97a7200c1a
The scraper never read "överlåtbara dagar" from Försäkringskassan. It only visited ansok-och-planera-foraldrapenning#/valjdagar and derived the reserved/transferable split in build_snapshot, assuming transferable days are spent before reserved ones. On real data that gave 49 for the user and 0 for the partner where FK states 129 and 71. The assumption cannot be repaired: reserved-first, transferable-first, proportional and cap-based models were all checked against real figures and none reproduces FK's accounting (reserved-first fits the partner but is 10 days off for the user). FK publishes the real numbers per parent per tier on the min-foraldrapenning page, which the scraper had never visited. - Scrape the "Dagar ni kan föra över" panel after the valjdagar extraction, summing sjukpenningnivå + lägstanivå per parent. The totals are not literals on the page. Parse only direct-child <p> elements: the first group embeds a tooltip with decoy "45 dagar"/"90 dagar" text. - build_snapshot accepts the reported value and derives reserved days from it; the old arithmetic remains only as a fallback. An implausible FK value degrades to that fallback rather than failing the fetch. - Record provenance in transferable_days_reported (migration 006) and badge derived values as estimates in the dashboard breakdown. Drop the footnote's claim that at most 150 days are transferable — real data contradicts it as a per-family figure. - Select the child's section by name when FK lists several; never guess. Verified against a live BankID scrape: scraped 240/101/139 with 129 transferable, deduced partner 240/169/71 with 71, both flagged as reported. Incidental fixes found along the way: - The fetch router never forwarded the partner's transferable figure to persist_partner_deduction, so the partner's stored snapshot would have kept the flawed split even once the parse landed. - 'deduced' snapshots were labelled "Manually entered" by a two-way ternary over a three-value union. Replaced with a compiler-checked exhaustive map. - Alembic resolved its database from alembic.ini alone, ignoring APP_SQLITE_PATH, so migrations silently targeted the developer's real database even when pointed elsewhere. env.py now resolves it, preferring -x sqlalchemy.url, then a real APP_SQLITE_PATH, then the ini value. - backend/captures/ was not ignored: the root pattern was anchored as /captures/ despite a comment saying scraper dumps must never be tracked. - CLAUDE.md quoted test counts and coverage percentages that had gone stale, which made a correct verification report look suspect. Gate thresholds kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
533 lines
20 KiB
Python
533 lines
20 KiB
Python
"""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"
|