"""Tests for the NFO Management API endpoints. Covers the live endpoints in src/server/api/nfo.py: - GET /api/nfo/{key}/diagnostics - POST /api/nfo/{key}/repair - GET /api/nfo/{key}/validate - GET /api/nfo/{key}/content (re-introduced — used by Anime Settings 'View NFO XML') - GET /api/nfo/needs-repair - POST /api/nfo/batch/repair Note: Legacy endpoints referenced in v1.x (e.g. /api/nfo/{id}/check, /create, /update, /content, /missing, /batch/create) no longer exist in the codebase — they were replaced by the consolidated diagnostic, repair, validate, needs-repair, batch/repair endpoints and the new Anime Settings page (see tests/api/test_anime_settings_endpoints.py). Auth note: tests/conftest.py's autouse ``reset_auth_and_rate_limits`` fixture configures the master password with ``TestPass123!`` before every test. The per-file ``reset_auth`` autouse fixture that used to live here was removed because it wiped the conftest's setup and made any test that needed an authenticated client fail with a stale-hash login error. """ from unittest.mock import AsyncMock, Mock, patch import pytest from httpx import ASGITransport, AsyncClient from src.server.fastapi_app import app @pytest.fixture async def client(): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: yield ac async def _login(client: AsyncClient) -> str: """Log in with the master password configured by conftest and return the bearer token. Sets the ``Authorization`` header on the client as a side benefit so the caller can ``await client.get(...)`` immediately.""" resp = await client.post( "/api/auth/login", json={"password": "TestPass123!"}, ) assert resp.status_code == 200, resp.text token = resp.json()["access_token"] client.headers.update({"Authorization": f"Bearer {token}"}) return token class TestNFOAuthRequirements: """All NFO endpoints must require authentication.""" @pytest.mark.asyncio async def test_get_diagnostics_requires_auth(self, client): resp = await client.get("/api/nfo/any-key/diagnostics") assert resp.status_code in (401, 503) @pytest.mark.asyncio async def test_post_repair_requires_auth(self, client): resp = await client.post("/api/nfo/any-key/repair") assert resp.status_code in (401, 503) @pytest.mark.asyncio async def test_get_validate_requires_auth(self, client): resp = await client.get("/api/nfo/any-key/validate") assert resp.status_code in (401, 503) @pytest.mark.asyncio async def test_get_needs_repair_requires_auth(self, client): resp = await client.get("/api/nfo/needs-repair") assert resp.status_code in (401, 503) @pytest.mark.asyncio async def test_post_batch_repair_requires_auth(self, client): resp = await client.post( "/api/nfo/batch/repair", json=["key1", "key2"], ) assert resp.status_code in (401, 503) @pytest.mark.asyncio async def test_get_content_requires_auth(self, client): """GET /api/nfo/{key}/content (used by the Anime Settings page 'View NFO XML' button) must require authentication.""" resp = await client.get("/api/nfo/any-key/content") assert resp.status_code in (401, 503) class TestNFOContentEndpoint: """Behavioural tests for GET /api/nfo/{key}/content. Covers the success path and the two 404 cases (unknown series, missing tvshow.nfo) the Anime Settings page relies on.""" @pytest.fixture def mock_anime_service(self): """Replace the FastAPI get_anime_service dependency with a mock. Yields the mock so individual tests can configure ``list_series_with_filters``.""" from src.server.utils import dependencies as deps service = Mock() service.list_series_with_filters = AsyncMock(return_value=[]) app.dependency_overrides[deps.get_anime_service] = lambda: service yield service app.dependency_overrides.pop(deps.get_anime_service, None) @pytest.mark.asyncio async def test_returns_xml_for_series_with_nfo( self, client, tmp_path, monkeypatch, mock_anime_service ): """Happy path: existing tvshow.nfo is returned verbatim inside the JSON wrapper the JS uses (``data.content``).""" from src.config import settings as settings_module # Point settings.anime_directory at a temp dir monkeypatch.setattr( settings_module.settings, "anime_directory", str(tmp_path), raising=False, ) # Build a fake folder + tvshow.nfo on disk folder = "Naruto (2002)" series_dir = tmp_path / folder series_dir.mkdir() xml = ( "\n" "Naruto2002\n" ) (series_dir / "tvshow.nfo").write_text(xml, encoding="utf-8") mock_anime_service.list_series_with_filters = AsyncMock( return_value=[{"key": "naruto", "folder": folder}] ) await _login(client) resp = await client.get("/api/nfo/naruto/content") assert resp.status_code == 200, resp.text body = resp.json() assert body["key"] == "naruto" assert body["folder"] == folder assert body["content"] == xml assert body["file_size"] == len(xml.encode("utf-8")) assert "last_modified" in body @pytest.mark.asyncio async def test_404_when_series_unknown( self, client, tmp_path, monkeypatch, mock_anime_service ): from src.config import settings as settings_module monkeypatch.setattr( settings_module.settings, "anime_directory", str(tmp_path), raising=False, ) mock_anime_service.list_series_with_filters = AsyncMock(return_value=[]) await _login(client) resp = await client.get("/api/nfo/missing/content") assert resp.status_code == 404 assert "not found" in resp.json()["detail"].lower() @pytest.mark.asyncio async def test_404_when_nfo_file_missing( self, client, tmp_path, monkeypatch, mock_anime_service ): """Series exists with a configured folder but no tvshow.nfo yet.""" from src.config import settings as settings_module folder = "Empty" (tmp_path / folder).mkdir() monkeypatch.setattr( settings_module.settings, "anime_directory", str(tmp_path), raising=False, ) mock_anime_service.list_series_with_filters = AsyncMock( return_value=[{"key": "empty", "folder": folder}] ) await _login(client) resp = await client.get("/api/nfo/empty/content") assert resp.status_code == 404 assert "tvshow.nfo" in resp.json()["detail"].lower() class TestNFOEndpointModels: """Verify the response models use the renamed classes (regression test for the rename from NfoDiagnosticsResponse -> NfoSettingsResponse).""" def test_renamed_settings_response_model_exists(self): # Confirm the old names are gone from src.server import models from src.server.models.nfo import ( NfoRepairResponse, NfoSeriesSettings, NfoSettingsResponse, ) nfo_module = models.nfo assert hasattr(nfo_module, "NfoSettingsResponse") assert hasattr(nfo_module, "NfoSeriesSettings") assert hasattr(nfo_module, "NfoRepairResponse") # The diagnostic prefix should NOT be present anymore assert not hasattr(nfo_module, "NfoDiagnosticsResponse") assert not hasattr(nfo_module, "NfoSeriesDiagnostics")