Files
F-r-ldraledigplannerare/backend/tests/unit/test_fetch_data_router.py
T
jacobh2 a4fce16dd1 Give a new account a first run, and drop to three tabs
Registering used to land you on a bare form asking for a child, with no hint
what came next. A short wizard now walks through the child, whether you are
planning alone or together, and where the day balances come from: fetched from
Försäkringskassan, entered by hand, or skipped — and if skipped, the figures
carry a badge saying they are estimates until real ones arrive.

Fetching is the first option rather than a hidden one, so the scraper is on by
default and the script that used to force it on no longer needs to.

Planning and Tips lose their tabs. Authoring moved to the calendar and the
figures moved to the overview, so both pages were only a second way to reach
what is already on screen; their routes now redirect rather than dead-end. The
dead translations went with them.

Dates also follow the chosen language now instead of always formatting as
Swedish.
2026-09-17 22:05:54 +02:00

300 lines
12 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 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"