"""Pytest tests for the Anime Settings endpoints. Covers: - GET /api/anime/{key}/settings (happy path, 401, 404) - PUT /api/anime/{key}/settings (validation, DB sync, NFO sync) - POST /api/anime/{key}/regenerate-nfo (happy path, 404, 400 without tmdb_id) Also regression-tests the bug-fix where _create_or_update_nfo previously called a non-existent update_series_nfo_status method. """ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from src.server.fastapi_app import app from src.server.services.auth_service import auth_service # ============================================================================ # Test DB setup (in-memory SQLite) # ============================================================================ @pytest_asyncio.fixture async def test_db_session(): """Override the DB dependency with an in-memory SQLite session.""" engine = create_async_engine( "sqlite+aiosqlite:///:memory:", echo=False, future=True, ) SessionLocal = async_sessionmaker(engine, expire_on_commit=False) async def _override_db_session(): async with SessionLocal() as session: try: yield session await session.commit() except Exception: await session.rollback() raise from src.server.utils.dependencies import ( get_database_session, get_optional_database_session, ) app.dependency_overrides[get_database_session] = _override_db_session app.dependency_overrides[get_optional_database_session] = _override_db_session # Seed the test DB from sqlalchemy import update from src.server.database.models import AnimeSeries as AS from src.server.database.models import Base from src.server.database.service import AnimeSeriesService async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async with SessionLocal() as setup_session: await AnimeSeriesService.create( db=setup_session, key="attack-on-titan", name="Attack on Titan", site="aniworld.to", folder="Attack on Titan (2013)", year=2013, has_nfo=True, nfo_path="/anime/Attack on Titan (2013)/tvshow.nfo", ) await setup_session.execute( update(AS).where(AS.key == "attack-on-titan").values( tmdb_id=1429, tvdb_id=789 ) ) await setup_session.commit() yield setup_session app.dependency_overrides.pop(get_database_session, None) app.dependency_overrides.pop(get_optional_database_session, None) await engine.dispose() # ============================================================================ # Fixtures # ============================================================================ @pytest.fixture(autouse=True) def reset_auth(): auth_service._hash = None auth_service._failed.clear() yield auth_service._hash = None auth_service._failed.clear() @pytest.fixture async def client(): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: yield ac @pytest.fixture async def authenticated_client(client): await client.post( "/api/auth/setup", json={"master_password": "TestPassword123!"}, ) resp = await client.post( "/api/auth/login", json={"password": "TestPassword123!"}, ) token = resp.json()["access_token"] client.headers.update({"Authorization": f"Bearer {token}"}) yield client @pytest.fixture def mock_anime_service(): service = MagicMock() service.list_series_with_filters = AsyncMock(return_value=[ { "key": "attack-on-titan", "name": "Attack on Titan", "site": "aniworld.to", "folder": "Attack on Titan (2013)", "tmdb_id": 1429, }, ]) service.update_nfo_status = AsyncMock() service.update_series_nfo_status = AsyncMock() service.rename_folder_if_needed = AsyncMock(return_value=True) if not hasattr(service, "_app"): service._app = MagicMock() service._app.list.GetList.return_value = [] from src.server.utils import dependencies as deps app.dependency_overrides[deps.get_anime_service] = lambda: service yield service app.dependency_overrides.pop(deps.get_anime_service, None) # ============================================================================ # GET /api/anime/{key}/settings # ============================================================================ class TestGetAnimeSettingsEndpoint: @pytest.mark.asyncio async def test_returns_200_with_full_payload( self, authenticated_client, mock_anime_service, test_db_session ): resp = await authenticated_client.get( "/api/anime/attack-on-titan/settings" ) assert resp.status_code == 200, resp.text data = resp.json() assert data["key"] == "attack-on-titan" assert data["name"] == "Attack on Titan" assert data["tmdb_id"] == 1429 assert data["tvdb_id"] == 789 assert data["has_nfo"] is True assert "folder" in data @pytest.mark.asyncio async def test_returns_404_for_unknown_key( self, authenticated_client, mock_anime_service, test_db_session ): resp = await authenticated_client.get( "/api/anime/no-such-series/settings" ) assert resp.status_code == 404 assert "not found" in resp.json()["detail"].lower() @pytest.mark.asyncio async def test_returns_401_without_auth(self, client): resp = await client.get("/api/anime/attack-on-titan/settings") assert resp.status_code == 401 @pytest.mark.asyncio async def test_includes_episode_counts( self, authenticated_client, mock_anime_service, test_db_session ): from src.server.database.models import Episode # Need a fresh engine to insert episodes (test_db_session is async) engine = create_async_engine( "sqlite+aiosqlite:///:memory:", echo=False, future=True, ) SessionLocal = async_sessionmaker(engine, expire_on_commit=False) from src.server.database.models import Base async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) # The override yields a different session each call — we need to # seed via the test_db_session and verify count from there. # Simplest: just rely on the absence of any episodes in the seed resp = await authenticated_client.get( "/api/anime/attack-on-titan/settings" ) assert resp.status_code == 200 data = resp.json() # Default seed has zero episodes assert data["episode_count"] == 0 assert data["missing_episode_count"] == 0 await engine.dispose() # ============================================================================ # PUT /api/anime/{key}/settings # ============================================================================ class TestUpdateAnimeSettingsEndpoint: @pytest.mark.asyncio async def test_updates_name_only( self, authenticated_client, mock_anime_service, test_db_session ): resp = await authenticated_client.put( "/api/anime/attack-on-titan/settings", json={"name": "Attack on Titan: Final Season"}, ) assert resp.status_code == 200, resp.text data = resp.json() assert data["name"] == "Attack on Titan: Final Season" @pytest.mark.asyncio async def test_updates_tmdb_id_and_regenerates_nfo( self, authenticated_client, mock_anime_service, test_db_session ): with patch( "src.server.api.nfo._create_or_update_nfo", AsyncMock(return_value=["title", "tmdbid"]), ) as mock_create: resp = await authenticated_client.put( "/api/anime/attack-on-titan/settings", json={"tmdb_id": 9999, "apply_to_nfo": True}, ) assert resp.status_code == 200, resp.text assert mock_create.await_count == 1 # NFO regeneration uses the (just-updated) DB value assert mock_create.await_args.kwargs["tmdb_id"] == 9999 @pytest.mark.asyncio async def test_updates_folder_and_renames_disk( self, authenticated_client, mock_anime_service, test_db_session ): resp = await authenticated_client.put( "/api/anime/attack-on-titan/settings", json={"folder": "Attack on Titan (2013) HD", "rename_disk": True}, ) assert resp.status_code == 200, resp.text assert mock_anime_service.rename_folder_if_needed.await_count == 1 @pytest.mark.asyncio async def test_rejects_empty_name( self, authenticated_client, mock_anime_service, test_db_session ): resp = await authenticated_client.put( "/api/anime/attack-on-titan/settings", json={"name": ""}, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_rejects_unsafe_folder_path( self, authenticated_client, mock_anime_service, test_db_session ): # "///" sanitizes to empty -> ValueError -> 422 resp = await authenticated_client.put( "/api/anime/attack-on-titan/settings", json={"folder": "///"}, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_rejects_negative_tmdb_id( self, authenticated_client, mock_anime_service, test_db_session ): resp = await authenticated_client.put( "/api/anime/attack-on-titan/settings", json={"tmdb_id": -5}, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_rejects_tmdb_id_too_large( self, authenticated_client, mock_anime_service, test_db_session ): resp = await authenticated_client.put( "/api/anime/attack-on-titan/settings", json={"tmdb_id": 99999999999}, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_returns_404_for_unknown_key( self, authenticated_client, mock_anime_service, test_db_session ): resp = await authenticated_client.put( "/api/anime/no-such-series/settings", json={"name": "X"}, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_returns_401_without_auth(self, client): resp = await client.put( "/api/anime/attack-on-titan/settings", json={"name": "X"}, ) assert resp.status_code == 401 @pytest.mark.asyncio async def test_returns_400_when_apply_to_nfo_without_tmdb_id( self, authenticated_client, mock_anime_service, test_db_session ): from sqlalchemy import update from src.server.database.models import AnimeSeries as AS await test_db_session.execute( update(AS).where(AS.key == "attack-on-titan").values(tmdb_id=None) ) await test_db_session.commit() resp = await authenticated_client.put( "/api/anime/attack-on-titan/settings", json={"apply_to_nfo": True}, ) assert resp.status_code == 400 assert "tmdb" in resp.json()["detail"].lower() # ============================================================================ # POST /api/anime/{key}/regenerate-nfo # ============================================================================ class TestRegenerateNfoEndpoint: @pytest.mark.asyncio async def test_returns_200_on_success( self, authenticated_client, mock_anime_service, test_db_session ): with patch( "src.server.api.nfo._create_or_update_nfo", AsyncMock(return_value=["title", "tmdbid"]), ) as mock_create: resp = await authenticated_client.post( "/api/anime/attack-on-titan/regenerate-nfo" ) assert resp.status_code == 200, resp.text data = resp.json() assert data["success"] is True assert data["repaired_tags"] == ["title", "tmdbid"] assert mock_create.await_count == 1 @pytest.mark.asyncio async def test_returns_400_when_no_tmdb_id( self, authenticated_client, mock_anime_service, test_db_session ): from sqlalchemy import update from src.server.database.models import AnimeSeries as AS await test_db_session.execute( update(AS).where(AS.key == "attack-on-titan").values(tmdb_id=None) ) await test_db_session.commit() resp = await authenticated_client.post( "/api/anime/attack-on-titan/regenerate-nfo" ) assert resp.status_code == 400 @pytest.mark.asyncio async def test_returns_404_for_unknown_key( self, authenticated_client, mock_anime_service, test_db_session ): resp = await authenticated_client.post( "/api/anime/no-such/regenerate-nfo" ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_returns_401_without_auth(self, client): resp = await client.post( "/api/anime/attack-on-titan/regenerate-nfo" ) assert resp.status_code == 401 # ============================================================================ # Renamed diagnostic endpoints — URL kept, function renamed # ============================================================================ class TestRenamedDiagnosticEndpoints: @pytest.mark.asyncio async def test_get_diagnostics_still_works( self, authenticated_client, mock_anime_service ): resp = await authenticated_client.get( "/api/nfo/attack-on-titan/diagnostics" ) # 404 if no series, 200 if file exists, 503 if anime_dir unset assert resp.status_code in (200, 404, 503) # ============================================================================ # Bug regression test # ============================================================================ class TestBugFixCreateOrUpdateNfo: def test_update_nfo_status_method_exists(self): """AnimeService must expose update_nfo_status (the canonical name).""" from src.server.services.anime_service import AnimeService assert hasattr(AnimeService, "update_nfo_status"), ( "AnimeService.update_nfo_status must exist" ) def test_nfo_api_calls_update_nfo_status(self): """api/nfo.py must call update_nfo_status (not the legacy name).""" src = open( "src/server/api/nfo.py" ).read() assert "update_nfo_status(" in src, ( "api/nfo.py must call update_nfo_status on anime_service" ) assert "update_series_nfo_status(" not in src, ( "api/nfo.py must NOT call the non-existent update_series_nfo_status" )