test: placeholder tests

This commit is contained in:
Benexl
2025-07-07 00:41:24 +03:00
parent cdad70e40d
commit f51ceaacd7
18 changed files with 385 additions and 17 deletions

View File

@@ -4,11 +4,11 @@ from fastapi import FastAPI
from requests import post
from thefuzz import fuzz
from ..AnimeProvider import AnimeProvider
from ..BaseAnimeProvider import BaseAnimeProvider
from ..Utility.data import anime_normalizer
app = FastAPI()
anime_provider = AnimeProvider("allanime", "true", "true")
anime_provider = BaseAnimeProvider("allanime", "true", "true")
ANILIST_ENDPOINT = "https://graphql.anilist.co"

View File

@@ -175,7 +175,7 @@ def download(
from rich.progress import Progress
from thefuzz import fuzz
from ....AnimeProvider import AnimeProvider
from ....BaseAnimeProvider import BaseAnimeProvider
from ....libs.anime_provider.types import Anime
from ....libs.fzf import fzf
from ....Utility.data import anime_normalizer
@@ -187,7 +187,7 @@ def download(
move_preferred_subtitle_lang_to_top,
)
anime_provider = AnimeProvider(config.provider)
anime_provider = BaseAnimeProvider(config.provider)
anilist_anime_info = None
translation_type = config.translation_type

View File

@@ -152,7 +152,7 @@ def download(
from rich.progress import Progress
from thefuzz import fuzz
from ...AnimeProvider import AnimeProvider
from ...BaseAnimeProvider import BaseAnimeProvider
from ...libs.anime_provider.types import Anime
from ...libs.fzf import fzf
from ...Utility.data import anime_normalizer
@@ -166,7 +166,7 @@ def download(
force_ffmpeg |= hls_use_mpegts or hls_use_h264
anime_provider = AnimeProvider(config.provider)
anime_provider = BaseAnimeProvider(config.provider)
anilist_anime_info = None
translation_type = config.translation_type

View File

@@ -131,9 +131,9 @@ def grab(
print(json.dumps(chapter_info))
else:
from ...AnimeProvider import AnimeProvider
from ...BaseAnimeProvider import BaseAnimeProvider
anime_provider = AnimeProvider(config.provider)
anime_provider = BaseAnimeProvider(config.provider)
grabbed_animes = []
for anime_title in anime_titles:

View File

@@ -151,13 +151,13 @@ def search(config: "Config", anime_titles: str, episode_range: str):
_manga_viewer()
else:
from ...AnimeProvider import AnimeProvider
from ...BaseAnimeProvider import BaseAnimeProvider
from ...libs.anime_provider.types import Anime
from ...Utility.data import anime_normalizer
from ..utils.mpv import run_mpv
from ..utils.utils import filter_by_quality, move_preferred_subtitle_lang_to_top
anime_provider = AnimeProvider(config.provider)
anime_provider = BaseAnimeProvider(config.provider)
anilist_anime_info = None
print(f"[green bold]Streaming:[/] {anime_titles}")

View File

@@ -1 +0,0 @@

View File

@@ -68,7 +68,7 @@ class Session:
logger.debug("Initializing session components...")
self.selector: BaseSelector = create_selector(self.config)
self.provider: AnimeProvider = create_provider(self.config.general.provider)
self.provider: BaseAnimeProvider = create_provider(self.config.general.provider)
self.player: BasePlayer = create_player(self.config.stream.player, self.config)
# Instantiate and use the API factory

View File

@@ -1,3 +1,3 @@
from .anime import AnimeProvider
from .anime import BaseAnimeProvider
__all__ = ["AnimeProvider"]
__all__ = ["BaseAnimeProvider"]

View File

@@ -1,3 +1,3 @@
from .provider import PROVIDERS_AVAILABLE, SERVERS_AVAILABLE, AnimeProvider
from .provider import PROVIDERS_AVAILABLE, SERVERS_AVAILABLE, BaseAnimeProvider
__all__ = ["SERVERS_AVAILABLE", "PROVIDERS_AVAILABLE", "AnimeProvider"]
__all__ = ["SERVERS_AVAILABLE", "PROVIDERS_AVAILABLE", "BaseAnimeProvider"]

View File

@@ -18,7 +18,7 @@ class BaseAnimeProvider(ABC):
super().__init_subclass__(**kwargs)
if not hasattr(cls, "HEADERS"):
raise TypeError(
f"Subclasses of AnimeProvider must define a 'HEADERS' class attribute."
f"Subclasses of BaseAnimeProvider must define a 'HEADERS' class attribute."
)
def __init__(self, client: Client) -> None:

View File

@@ -39,5 +39,11 @@ dev-dependencies = [
"pyinstaller>=6.11.1",
"pyright>=1.1.384",
"pytest>=8.3.3",
"pytest-httpx>=0.35.0",
"ruff>=0.6.9",
]
[tool.pytest.ini_options]
markers = [
"integration: marks tests as integration tests that require a live network connection",
]

View File

View File

View File

@@ -0,0 +1,37 @@
{
"data": {
"Page": {
"pageInfo": {
"total": 1,
"currentPage": 1,
"hasNextPage": false,
"perPage": 1
},
"media": [
{
"id": 21,
"idMal": 21,
"title": {
"romaji": "ONE PIECE",
"english": "ONE PIECE",
"native": "ONE PIECE"
},
"status": "RELEASING",
"episodes": null,
"averageScore": 87,
"popularity": 250000,
"favourites": 220000,
"genres": [
"Action",
"Adventure",
"Fantasy"
],
"coverImage": {
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/nx21-tXMN3Y20wTlH.jpg"
},
"mediaListEntry": null
}
]
}
}
}

View File

@@ -0,0 +1,43 @@
{
"data": {
"Page": {
"pageInfo": {
"total": 1,
"currentPage": 1,
"hasNextPage": false,
"perPage": 1
},
"mediaList": [
{
"media": {
"id": 16498,
"idMal": 16498,
"title": {
"romaji": "Shingeki no Kyojin",
"english": "Attack on Titan",
"native": "進撃の巨人"
},
"status": "FINISHED",
"episodes": 25,
"averageScore": 85,
"popularity": 300000,
"favourites": 200000,
"genres": [
"Action",
"Drama",
"Mystery"
],
"coverImage": {
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx16498-C6FPmWm59CyP.jpg"
},
"mediaListEntry": {
"status": "CURRENT",
"progress": 10,
"score": 9.0
}
}
}
]
}
}
}

View File

@@ -0,0 +1,181 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from fastanime.libs.api.anilist.api import AniListApi
from fastanime.libs.api.base import ApiSearchParams, UserListParams
from fastanime.libs.api.types import MediaItem, MediaSearchResult, UserProfile
from httpx import Response
if TYPE_CHECKING:
from fastanime.core.config import AnilistConfig
from httpx import Client
from pytest_httpx import HTTPXMock
# --- Fixtures ---
@pytest.fixture
def mock_anilist_config() -> AnilistConfig:
"""Provides a default AnilistConfig instance for tests."""
from fastanime.core.config import AnilistConfig
return AnilistConfig()
@pytest.fixture
def mock_data_path() -> Path:
"""Provides the path to the mock_data directory."""
return Path(__file__).parent / "mock_data"
@pytest.fixture
def anilist_client(
mock_anilist_config: AnilistConfig, httpx_mock: HTTPXMock
) -> AniListApi:
"""
Provides an instance of AniListApi with a mocked HTTP client.
Note: We pass the httpx_mock fixture which is the mocked client.
"""
return AniListApi(config=mock_anilist_config, client=httpx_mock)
# --- Test Cases ---
def test_search_media_success(
anilist_client: AniListApi, httpx_mock: HTTPXMock, mock_data_path: Path
):
"""
GIVEN a search query for 'one piece'
WHEN search_media is called
THEN it should return a MediaSearchResult with one correctly mapped MediaItem.
"""
# ARRANGE: Load mock response and configure the mock HTTP client.
mock_response_json = json.loads(
(mock_data_path / "search_one_piece.json").read_text()
)
httpx_mock.add_response(url="https://graphql.anilist.co", json=mock_response_json)
params = ApiSearchParams(query="one piece")
# ACT
result = anilist_client.search_media(params)
# ASSERT
assert result is not None
assert isinstance(result, MediaSearchResult)
assert len(result.media) == 1
one_piece = result.media[0]
assert isinstance(one_piece, MediaItem)
assert one_piece.id == 21
assert one_piece.title.english == "ONE PIECE"
assert one_piece.status == "RELEASING"
assert "Action" in one_piece.genres
assert one_piece.average_score == 8.7 # Mapper should convert 87 -> 8.7
def test_fetch_user_list_success(
anilist_client: AniListApi, httpx_mock: HTTPXMock, mock_data_path: Path
):
"""
GIVEN an authenticated client
WHEN fetch_user_list is called for the 'CURRENT' list
THEN it should return a MediaSearchResult with a correctly mapped MediaItem
that includes user-specific progress.
"""
# ARRANGE
mock_response_json = json.loads(
(mock_data_path / "user_list_watching.json").read_text()
)
httpx_mock.add_response(url="https://graphql.anilist.co", json=mock_response_json)
# Simulate being logged in
anilist_client.user_profile = UserProfile(id=12345, name="testuser")
params = UserListParams(status="CURRENT")
# ACT
result = anilist_client.fetch_user_list(params)
# ASSERT
assert result is not None
assert isinstance(result, MediaSearchResult)
assert len(result.media) == 1
attack_on_titan = result.media[0]
assert isinstance(attack_on_titan, MediaItem)
assert attack_on_titan.id == 16498
assert attack_on_titan.title.english == "Attack on Titan"
# Assert that user-specific data was mapped correctly
assert attack_on_titan.user_list_status is not None
assert attack_on_titan.user_list_status.status == "CURRENT"
assert attack_on_titan.user_list_status.progress == 10
assert attack_on_titan.user_list_status.score == 9.0
def test_update_list_entry_sends_correct_mutation(
anilist_client: AniListApi, httpx_mock: HTTPXMock
):
"""
GIVEN an authenticated client
WHEN update_list_entry is called
THEN it should send a POST request with the correct GraphQL mutation and variables.
"""
# ARRANGE
httpx_mock.add_response(
url="https://graphql.anilist.co",
json={"data": {"SaveMediaListEntry": {"id": 54321}}},
)
anilist_client.token = "fake-token" # Simulate authentication
params = UpdateListEntryParams(media_id=16498, progress=11, status="CURRENT")
# ACT
success = anilist_client.update_list_entry(params)
# ASSERT
assert success is True
# Verify the request content
request = httpx_mock.get_request()
assert request is not None
assert request.method == "POST"
request_body = json.loads(request.content)
assert "SaveMediaListEntry" in request_body["query"]
assert request_body["variables"]["mediaId"] == 16498
assert request_body["variables"]["progress"] == 11
assert request_body["variables"]["status"] == "CURRENT"
assert (
"scoreRaw" not in request_body["variables"]
) # Ensure None values are excluded
def test_api_calls_fail_gracefully_on_http_error(
anilist_client: AniListApi, httpx_mock: HTTPXMock
):
"""
GIVEN the AniList API returns a 500 server error
WHEN any API method is called
THEN it should return None or False and log an error without crashing.
"""
# ARRANGE
httpx_mock.add_response(url="https://graphql.anilist.co", status_code=500)
# ACT & ASSERT
with pytest.logs("fastanime.libs.api.anilist.api", level="ERROR") as caplog:
search_result = anilist_client.search_media(ApiSearchParams(query="test"))
assert search_result is None
assert "AniList API request failed" in caplog.text
update_result = anilist_client.update_list_entry(
UpdateListEntryParams(media_id=1)
)
assert update_result is False # Mutations should return bool

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
import os
import pytest
from fastanime.core.config import AnilistConfig, AppConfig
from fastanime.libs.api.base import ApiSearchParams
from fastanime.libs.api.factory import create_api_client
from fastanime.libs.api.types import MediaItem, MediaSearchResult
from httpx import Client
# Mark the entire module as 'integration'. This test will only run if you explicitly ask for it.
pytestmark = pytest.mark.integration
@pytest.fixture(scope="module")
def live_api_client() -> AniListApi:
"""
Creates an API client that makes REAL network requests.
This fixture has 'module' scope so it's created only once for all tests in this file.
"""
# We create a dummy AppConfig to pass to the factory
# Note: For authenticated tests, you would load a real token from env vars here.
config = AppConfig()
return create_api_client("anilist", config)
def test_search_media_live(live_api_client: AniListApi):
"""
GIVEN a live connection to the AniList API
WHEN search_media is called with a common query
THEN it should return a valid and non-empty MediaSearchResult.
"""
# ARRANGE
params = ApiSearchParams(query="Cowboy Bebop", per_page=1)
# ACT
result = live_api_client.search_media(params)
# ASSERT
assert result is not None
assert isinstance(result, MediaSearchResult)
assert len(result.media) > 0
cowboy_bebop = result.media[0]
assert isinstance(cowboy_bebop, MediaItem)
assert cowboy_bebop.id == 1 # Cowboy Bebop's AniList ID
assert "Cowboy Bebop" in cowboy_bebop.title.english
assert "Action" in cowboy_bebop.genres
@pytest.mark.skipif(
not os.getenv("ANILIST_TOKEN"), reason="ANILIST_TOKEN environment variable not set"
)
def test_authenticated_fetch_user_list_live():
"""
GIVEN a valid ANILIST_TOKEN is set as an environment variable
WHEN fetching the user's 'CURRENT' list
THEN it should succeed and return a MediaSearchResult.
"""
# ARRANGE
# For authenticated tests, we create a client inside the test
# so we can configure it with a real token.
token = os.getenv("ANILIST_TOKEN")
config = AppConfig() # Dummy config
# Create a real client and authenticate it
from fastanime.libs.api.anilist.api import AniListApi
real_http_client = Client()
live_auth_client = AniListApi(config.anilist, real_http_client)
profile = live_auth_client.authenticate(token)
assert profile is not None, "Authentication failed with the provided ANILIST_TOKEN"
# ACT
from fastanime.libs.api.base import UserListParams
params = UserListParams(status="CURRENT", per_page=5)
result = live_auth_client.fetch_user_list(params)
# ASSERT
# We can't know the exact content, but we can check the structure.
assert result is not None
assert isinstance(result, MediaSearchResult)
# It's okay if the list is empty, but the call should succeed.
assert isinstance(result.media, list)

15
uv.lock generated
View File

@@ -381,6 +381,7 @@ dev = [
{ name = "pyinstaller" },
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-httpx" },
{ name = "ruff" },
]
@@ -413,6 +414,7 @@ dev = [
{ name = "pyinstaller", specifier = ">=6.11.1" },
{ name = "pyright", specifier = ">=1.1.384" },
{ name = "pytest", specifier = ">=8.3.3" },
{ name = "pytest-httpx", specifier = ">=0.35.0" },
{ name = "ruff", specifier = ">=0.6.9" },
]
@@ -1138,6 +1140,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
]
[[package]]
name = "pytest-httpx"
version = "0.35.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/89/5b12b7b29e3d0af3a4b9c071ee92fa25a9017453731a38f08ba01c280f4c/pytest_httpx-0.35.0.tar.gz", hash = "sha256:d619ad5d2e67734abfbb224c3d9025d64795d4b8711116b1a13f72a251ae511f", size = 54146, upload-time = "2024-11-28T19:16:54.237Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/ed/026d467c1853dd83102411a78126b4842618e86c895f93528b0528c7a620/pytest_httpx-0.35.0-py3-none-any.whl", hash = "sha256:ee11a00ffcea94a5cbff47af2114d34c5b231c326902458deed73f9c459fd744", size = 19442, upload-time = "2024-11-28T19:16:52.787Z" },
]
[[package]]
name = "python-dotenv"
version = "1.1.1"