diff --git a/fastanime/api/api.py b/fastanime/api/api.py index 97fca11..e148f75 100644 --- a/fastanime/api/api.py +++ b/fastanime/api/api.py @@ -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" diff --git a/fastanime/cli/commands/anilist/subcommands/download.py b/fastanime/cli/commands/anilist/subcommands/download.py index adc73a7..4910b54 100644 --- a/fastanime/cli/commands/anilist/subcommands/download.py +++ b/fastanime/cli/commands/anilist/subcommands/download.py @@ -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 diff --git a/fastanime/cli/commands/download.py b/fastanime/cli/commands/download.py index 136adb5..f9b9fdd 100644 --- a/fastanime/cli/commands/download.py +++ b/fastanime/cli/commands/download.py @@ -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 diff --git a/fastanime/cli/commands/grab.py b/fastanime/cli/commands/grab.py index 23eab9c..27892c3 100644 --- a/fastanime/cli/commands/grab.py +++ b/fastanime/cli/commands/grab.py @@ -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: diff --git a/fastanime/cli/commands/search.py b/fastanime/cli/commands/search.py index 2d322da..f644145 100644 --- a/fastanime/cli/commands/search.py +++ b/fastanime/cli/commands/search.py @@ -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}") diff --git a/fastanime/cli/constants.py b/fastanime/cli/constants.py deleted file mode 100644 index 8b13789..0000000 --- a/fastanime/cli/constants.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/fastanime/cli/interactive/session.py b/fastanime/cli/interactive/session.py index 662af00..e393270 100644 --- a/fastanime/cli/interactive/session.py +++ b/fastanime/cli/interactive/session.py @@ -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 diff --git a/fastanime/libs/providers/__init__.py b/fastanime/libs/providers/__init__.py index 0920eac..a43e14e 100644 --- a/fastanime/libs/providers/__init__.py +++ b/fastanime/libs/providers/__init__.py @@ -1,3 +1,3 @@ -from .anime import AnimeProvider +from .anime import BaseAnimeProvider -__all__ = ["AnimeProvider"] +__all__ = ["BaseAnimeProvider"] diff --git a/fastanime/libs/providers/anime/__init__.py b/fastanime/libs/providers/anime/__init__.py index b90fb93..ac2a6b7 100644 --- a/fastanime/libs/providers/anime/__init__.py +++ b/fastanime/libs/providers/anime/__init__.py @@ -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"] diff --git a/fastanime/libs/providers/anime/base.py b/fastanime/libs/providers/anime/base.py index 70786f4..14a7f25 100644 --- a/fastanime/libs/providers/anime/base.py +++ b/fastanime/libs/providers/anime/base.py @@ -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: diff --git a/pyproject.toml b/pyproject.toml index d3a1f72..3c932c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", +] diff --git a/tests/api/anilist/__init__.py b/tests/api/anilist/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/anilist/mock_data/__init__.py b/tests/api/anilist/mock_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/anilist/mock_data/search_one_piece.json b/tests/api/anilist/mock_data/search_one_piece.json new file mode 100644 index 0000000..5dc2e46 --- /dev/null +++ b/tests/api/anilist/mock_data/search_one_piece.json @@ -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 + } + ] + } + } +} diff --git a/tests/api/anilist/mock_data/user_list_watching.json b/tests/api/anilist/mock_data/user_list_watching.json new file mode 100644 index 0000000..ed4e03d --- /dev/null +++ b/tests/api/anilist/mock_data/user_list_watching.json @@ -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 + } + } + } + ] + } + } +} diff --git a/tests/api/anilist/test_anilist_api.py b/tests/api/anilist/test_anilist_api.py new file mode 100644 index 0000000..eb26317 --- /dev/null +++ b/tests/api/anilist/test_anilist_api.py @@ -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 diff --git a/tests/api/anilist/test_anilist_api_intergration.py b/tests/api/anilist/test_anilist_api_intergration.py new file mode 100644 index 0000000..20f0907 --- /dev/null +++ b/tests/api/anilist/test_anilist_api_intergration.py @@ -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) diff --git a/uv.lock b/uv.lock index f4ee309..804344c 100644 --- a/uv.lock +++ b/uv.lock @@ -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"