"""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 pytest import pytest_asyncio from httpx import AsyncClient from app.entities.parental_leave import ParentalLeaveFetch from app.entities.personal_data import PersonalData from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository from app.routers import fetch_data as fetch_data_router from app.scrapers.fk_scraper import PartnerDeduction from app.services.leave_snapshot import build_snapshot from tests.conftest import SeededIds @pytest_asyncio.fixture async def client(authed_client: AsyncClient) -> AsyncClient: """The shared `seeded_ids`-bound authenticated client (see `tests/conftest.py`) — required now that the routes reject ids outside the caller's workspace. See CLAUDE.md T1 authz work / audit §4. """ return authed_client 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), "sjukpenning_left": 310, "lagsta_niva_left": 70, "dubbeldagar_left": 60, "reserved_days_left": 90, "total_days": 480, } 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_left=91), ) assert response.status_code == 422 assert "reserved_days_left" in str(response.json()["detail"]) async def test_sjukpenning_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, total_days=240, sjukpenning_left=196, lagsta_niva_left=0, reserved_days_left=0, ), ) assert response.status_code == 422 assert "sjukpenning_left" in str(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 async def test_deduced_source_round_trips( self, client: AsyncClient, seeded_ids: SeededIds ) -> None: response = await client.post( "/api/parental-leave/manual", json=_manual_payload(seeded_ids, source="deduced"), ) assert response.status_code == 201 assert response.json()["source"] == "deduced" async def test_omitted_total_days_defaults_to_full_pool_for_a_sole_parent( self, client: AsyncClient, seeded_ids: SeededIds ) -> None: """`seeded_ids`'s workspace has exactly one member (the caller).""" payload = _manual_payload(seeded_ids, sjukpenning_left=390, lagsta_niva_left=90) del payload["total_days"] response = await client.post("/api/parental-leave/manual", json=payload) assert response.status_code == 201 assert response.json()["total_days"] == 480 async def test_omitted_total_days_defaults_to_half_pool_with_a_partner( self, client: AsyncClient, seeded_ids: SeededIds, personal_data_repo: SQLitePersonalDataRepository, ) -> None: await personal_data_repo.create( PersonalData( uuid=uuid4(), workspace_id=seeded_ids.workspace_id, name="Partner", created_at=datetime.now(), ) ) payload = _manual_payload(seeded_ids, sjukpenning_left=195, lagsta_niva_left=45) del payload["total_days"] response = await client.post("/api/parental-leave/manual", json=payload) assert response.status_code == 201 assert response.json()["total_days"] == 240 async def test_old_payload_shape_is_rejected( self, client: AsyncClient, seeded_ids: SeededIds ) -> None: """The pre-refactor payload shape (used-based fields, no per-tier "left") is missing the now-required `sjukpenning_left`/ `lagsta_niva_left`/`dubbeldagar_left` fields and must 422, not be silently reinterpreted. """ old_payload = { "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, "sjukpenning_total": 390, "sjukpenning_used": 80, "lagsta_niva_total": 90, "lagsta_niva_used": 20, } response = await client.post("/api/parental-leave/manual", json=old_payload) assert response.status_code == 422 async def test_legacy_field_names_alongside_a_valid_payload_are_ignored( self, client: AsyncClient, seeded_ids: SeededIds ) -> None: """Unknown extra fields (the old field names) don't break a request that is otherwise valid under the new schema — Pydantic drops them rather than erroring.""" payload = _manual_payload(seeded_ids) payload["days_used"] = 999 payload["sjukpenning_total"] = 999 payload["transferable_days_left"] = 999 response = await client.post("/api/parental-leave/manual", json=payload) assert response.status_code == 201 body = response.json() assert body["days_left"] == 380 assert body["tier_data"]["sjukpenning_total"] == 390 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, dubbeldagar_left=50) ) await client.post( "/api/parental-leave/manual", json=_manual_payload(seeded_ids, dubbeldagar_left=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["dubbeldagar_left"] 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, owner_user_id: UUID, child_name: str | None = None, ) -> str: captured["workspace_id"] = workspace_id captured["personal_data_uuid"] = personal_data_uuid captured["child_id"] = child_id captured["owner_user_id"] = owner_user_id captured["child_name"] = child_name 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, owner_user_id: UUID) -> 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, owner_user_id: UUID) -> 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" class TestPersistCallbackPartnerDeduction: """`start_fetch`'s `_persist` closure -- captured off a faked `start_session` and invoked directly, exactly as `_do_extract` would call it after a real scrape. Exercises the real dependency-injected repos through the app, not a bare unit call, so a wiring regression (wrong workspace_id/child_id) is caught too. """ async def _capture_persist_callback( self, client: AsyncClient, seeded_ids: SeededIds, monkeypatch: pytest.MonkeyPatch, ) -> Any: 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, owner_user_id: UUID, child_name: str | None = None, ) -> str: captured["persist_callback"] = persist_callback 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 return captured["persist_callback"] def _fetch_and_deduction( self, seeded_ids: SeededIds ) -> tuple[ParentalLeaveFetch, PartnerDeduction]: fetch = build_snapshot( personal_data_uuid=seeded_ids.personal_data_uuid, workspace_id=seeded_ids.workspace_id, child_id=seeded_ids.child_id, source="scraped", total_days=240, sjukpenning_left=60, lagsta_niva_left=40, dubbeldagar_left=20, ) deduction = PartnerDeduction( total_days=240, days_left=150, sjukpenning_left=130, lagsta_niva_left=20, dubbeldagar_left=20, ) return fetch, deduction async def test_two_members_creates_a_deduced_snapshot_for_the_other_member( self, client: AsyncClient, seeded_ids: SeededIds, personal_data_repo: SQLitePersonalDataRepository, monkeypatch: pytest.MonkeyPatch, ) -> None: partner = await personal_data_repo.create( PersonalData( uuid=uuid4(), workspace_id=seeded_ids.workspace_id, name="Partner", created_at=datetime.now(), ) ) persist_callback = await self._capture_persist_callback(client, seeded_ids, monkeypatch) fetch, deduction = self._fetch_and_deduction(seeded_ids) await persist_callback(fetch, deduction) response = await client.get(f"/api/parental-leave/{partner.uuid}/{seeded_ids.child_id}") assert response.status_code == 200 body = response.json() assert body is not None assert body["source"] == "deduced" assert body["days_left"] == 150 async def test_single_member_workspace_creates_only_the_callers_own_snapshot( self, client: AsyncClient, seeded_ids: SeededIds, monkeypatch: pytest.MonkeyPatch, ) -> None: persist_callback = await self._capture_persist_callback(client, seeded_ids, monkeypatch) fetch, deduction = self._fetch_and_deduction(seeded_ids) await persist_callback(fetch, deduction) response = await client.get( f"/api/parental-leave/{seeded_ids.personal_data_uuid}/{seeded_ids.child_id}/history" ) assert response.status_code == 200 assert len(response.json()) == 1 assert response.json()[0]["source"] == "scraped" async def test_partner_persist_failure_does_not_fail_the_session( self, client: AsyncClient, seeded_ids: SeededIds, personal_data_repo: SQLitePersonalDataRepository, monkeypatch: pytest.MonkeyPatch, ) -> None: await personal_data_repo.create( PersonalData( uuid=uuid4(), workspace_id=seeded_ids.workspace_id, name="Partner", created_at=datetime.now(), ) ) persist_callback = await self._capture_persist_callback(client, seeded_ids, monkeypatch) fetch, deduction = self._fetch_and_deduction(seeded_ids) async def failing_persist_partner_deduction(**kwargs: Any) -> None: raise RuntimeError("DB unavailable") monkeypatch.setattr( fetch_data_router, "persist_partner_deduction", failing_persist_partner_deduction ) await persist_callback(fetch, deduction) # must not raise # The caller's own snapshot is unaffected by the partner-persist failure. 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()["source"] == "scraped"