mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-05 20:40:09 -08:00
test: placeholder tests
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from .anime import AnimeProvider
|
||||
from .anime import BaseAnimeProvider
|
||||
|
||||
__all__ = ["AnimeProvider"]
|
||||
__all__ = ["BaseAnimeProvider"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
0
tests/api/anilist/__init__.py
Normal file
0
tests/api/anilist/__init__.py
Normal file
0
tests/api/anilist/mock_data/__init__.py
Normal file
0
tests/api/anilist/mock_data/__init__.py
Normal file
37
tests/api/anilist/mock_data/search_one_piece.json
Normal file
37
tests/api/anilist/mock_data/search_one_piece.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
43
tests/api/anilist/mock_data/user_list_watching.json
Normal file
43
tests/api/anilist/mock_data/user_list_watching.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
181
tests/api/anilist/test_anilist_api.py
Normal file
181
tests/api/anilist/test_anilist_api.py
Normal 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
|
||||
87
tests/api/anilist/test_anilist_api_intergration.py
Normal file
87
tests/api/anilist/test_anilist_api_intergration.py
Normal 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
15
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user