"""API-level tests for `/api/parental-leave` (manual entry, always available) and `/api/fetch-data` (scraper session, gated by `settings.scraper_enabled`). `git grep -n "fetch_data_service\\|FetchDataService" backend/tests/` found no coverage for either router before this file, despite manual entry being the primary onboarding action. Follows the `test_calendar_router.py` / `test_leave_wish_router.py` pattern: exercise the real app + dependency graph, not just the service, so status codes and wire-shape regressions are caught. `settings.scraper_enabled` defaults to `True` in `app/config.py` (the FK legal/ToS review completed 2026-06; see the field's docstring) but a contributor's untracked, gitignored `backend/.env` can still override it either way -- so `settings.scraper_enabled` at *import time* is genuinely ambient and must not be asserted on directly. The scraper-session tests below therefore use `monkeypatch` to pin `app.routers.fetch_data.settings.scraper_enabled` to a known value for the duration of each test, rather than asserting on (or branching on) whatever a contributor's local `.env` happens to set. This is deliberately more robust than reading the live value once and assuming it holds — the disabled path is exercised here to prove the guard still works for an environment that opts back out (e.g. one without Playwright chromium installed); the enabled path is exercised only far enough to prove the guard flips (`start_session` is faked out, never a real Playwright session -- see the module docstring in `app/scrapers/fk_scraper.py`; actually driving it here would launch a real, non-headless browser as a fire-and-forget background task, which is unsafe to do from a router test). """ from datetime import datetime from typing import Any from uuid import UUID, uuid4 import aiosqlite import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from app.dependencies import get_connection, get_current_user from app.entities.user import User from app.main import app from app.routers import fetch_data as fetch_data_router from tests.conftest import SeededIds @pytest_asyncio.fixture async def client(connection: aiosqlite.Connection) -> AsyncClient: """An httpx client against the real app: `get_connection` and `get_current_user` are overridden to use the test's in-memory connection and a fixed fake user. """ app.dependency_overrides[get_connection] = lambda: connection app.dependency_overrides[get_current_user] = lambda: User( id=uuid4(), personal_data_uuid=uuid4(), email="test@example.com", hashed_password="not-a-real-hash", created_at=datetime.now(), ) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: yield ac app.dependency_overrides.pop(get_connection, None) app.dependency_overrides.pop(get_current_user, None) def _manual_payload(seeded_ids: SeededIds, **overrides: object) -> dict: base: dict[str, object] = { "personal_data_uuid": str(seeded_ids.personal_data_uuid), "workspace_id": str(seeded_ids.workspace_id), "child_id": str(seeded_ids.child_id), "total_days": 480, "days_used": 100, "reserved_days_total": 90, "reserved_days_left": 90, "transferable_days_left": 150, "dubbeldagar_left": 60, "sjukpenning_total": 390, "sjukpenning_used": 80, "lagsta_niva_total": 90, "lagsta_niva_used": 20, } base.update(overrides) return base class TestSaveManualEntry: async def test_valid_payload_returns_201_with_persisted_shape( self, client: AsyncClient, seeded_ids: SeededIds ) -> None: response = await client.post("/api/parental-leave/manual", json=_manual_payload(seeded_ids)) assert response.status_code == 201 body = response.json() assert body["source"] == "manual" assert body["days_left"] == 380 assert body["tier_data"]["sjukpenning_left"] == 310 assert body["tier_data"]["lagsta_niva_left"] == 70 assert body["child_id"] == str(seeded_ids.child_id) async def test_reserved_days_left_exceeding_total_returns_422( self, client: AsyncClient, seeded_ids: SeededIds ) -> None: response = await client.post( "/api/parental-leave/manual", json=_manual_payload(seeded_ids, reserved_days_total=90, reserved_days_left=91), ) assert response.status_code == 422 assert "reserved_days_left" in response.json()["detail"] async def test_days_used_exceeding_total_days_returns_422( self, client: AsyncClient, seeded_ids: SeededIds ) -> None: response = await client.post( "/api/parental-leave/manual", json=_manual_payload(seeded_ids, total_days=100, days_used=150), ) assert response.status_code == 422 assert "days_used" in response.json()["detail"] async def test_out_of_range_field_is_rejected_by_schema_before_the_service( self, client: AsyncClient, seeded_ids: SeededIds ) -> None: """`dubbeldagar_left` is capped at 60 in `ManualFetchRequest` itself (Pydantic, not the service) -- a value above that never reaches `ManualFetchService.save_manual_entry` at all.""" response = await client.post( "/api/parental-leave/manual", json=_manual_payload(seeded_ids, dubbeldagar_left=61), ) assert response.status_code == 422 class TestGetLatestAndHistory: async def test_get_latest_with_no_snapshot_returns_null( self, client: AsyncClient, seeded_ids: SeededIds ) -> None: response = await client.get( f"/api/parental-leave/{seeded_ids.personal_data_uuid}/{seeded_ids.child_id}" ) assert response.status_code == 200 assert response.json() is None async def test_get_latest_after_manual_save_returns_the_snapshot( self, client: AsyncClient, seeded_ids: SeededIds ) -> None: create_response = await client.post( "/api/parental-leave/manual", json=_manual_payload(seeded_ids) ) assert create_response.status_code == 201 created_id = create_response.json()["id"] response = await client.get( f"/api/parental-leave/{seeded_ids.personal_data_uuid}/{seeded_ids.child_id}" ) assert response.status_code == 200 assert response.json()["id"] == created_id async def test_get_history_returns_all_snapshots_newest_first( self, client: AsyncClient, seeded_ids: SeededIds ) -> None: await client.post( "/api/parental-leave/manual", json=_manual_payload(seeded_ids, days_used=50) ) await client.post( "/api/parental-leave/manual", json=_manual_payload(seeded_ids, days_used=60) ) response = await client.get( f"/api/parental-leave/{seeded_ids.personal_data_uuid}/{seeded_ids.child_id}/history" ) assert response.status_code == 200 body = response.json() assert len(body) == 2 assert [row["days_used"] for row in body] == [60, 50] class TestScraperEndpointsDisabled: """Covers the disabled path (an environment that opts out via `APP_SCRAPER_ENABLED=false`, e.g. one without Playwright chromium installed) — see the module docstring for why this is pinned via `monkeypatch` rather than asserted on ambiently. """ async def test_start_returns_501_when_scraper_disabled( self, client: AsyncClient, seeded_ids: SeededIds, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", False) response = await client.post( "/api/fetch-data/start", json={ "workspace_id": str(seeded_ids.workspace_id), "personal_data_uuid": str(seeded_ids.personal_data_uuid), "child_id": str(seeded_ids.child_id), }, ) assert response.status_code == 501 assert "APP_SCRAPER_ENABLED" in response.json()["detail"] async def test_poll_returns_501_when_scraper_disabled( self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", False) response = await client.get("/api/fetch-data/poll", params={"session_id": "does-not-exist"}) assert response.status_code == 501 async def test_cancel_returns_501_when_scraper_disabled( self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", False) response = await client.post( "/api/fetch-data/cancel", json={"session_id": "does-not-exist"} ) assert response.status_code == 501 class TestScraperEndpointsEnabled: """With the guard open, the routes must delegate to `_scraper_service` and return its result -- `_scraper_service.start_session` is replaced with a fake so no real Playwright session (a real, possibly non-headless browser launched as a fire-and-forget background task) is ever started from a test. """ async def test_start_returns_200_and_delegates_to_the_scraper_service( self, client: AsyncClient, seeded_ids: SeededIds, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", True) captured: dict[str, Any] = {} async def fake_start_session( *, workspace_id: UUID, personal_data_uuid: UUID, child_id: UUID, persist_callback: Any ) -> str: captured["workspace_id"] = workspace_id captured["personal_data_uuid"] = personal_data_uuid captured["child_id"] = child_id return "fake-session-id" monkeypatch.setattr(fetch_data_router._scraper_service, "start_session", fake_start_session) response = await client.post( "/api/fetch-data/start", json={ "workspace_id": str(seeded_ids.workspace_id), "personal_data_uuid": str(seeded_ids.personal_data_uuid), "child_id": str(seeded_ids.child_id), }, ) assert response.status_code == 200 body = response.json() assert body["session_id"] == "fake-session-id" assert body["status"] == "launching" assert body["workspace_id"] == str(seeded_ids.workspace_id) assert captured["child_id"] == seeded_ids.child_id async def test_poll_returns_200_with_the_scraper_services_result( self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", True) async def fake_poll_session(session_id: str) -> dict: assert session_id == "some-session-id" return {"status": "awaiting_scan"} monkeypatch.setattr(fetch_data_router._scraper_service, "poll_session", fake_poll_session) response = await client.get( "/api/fetch-data/poll", params={"session_id": "some-session-id"} ) assert response.status_code == 200 assert response.json() == {"status": "awaiting_scan"} async def test_cancel_returns_200_and_delegates_to_the_scraper_service( self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", True) cancelled: dict[str, Any] = {} async def fake_cancel_session(session_id: str) -> None: cancelled["session_id"] = session_id monkeypatch.setattr( fetch_data_router._scraper_service, "cancel_session", fake_cancel_session ) response = await client.post( "/api/fetch-data/cancel", json={"session_id": "some-session-id"} ) assert response.status_code == 200 assert response.json() == {"status": "cancelled"} assert cancelled["session_id"] == "some-session-id"