0fc71101ae
Entering a parent's Försäkringskassan balances had no tests at all, despite being how every number in the planner originates and despite it becoming the first thing a new user does. The scraper endpoints are pinned per test rather than read from the ambient configuration, because a local .env sets the flag opposite to the shipped default and a test that trusts it would pass here and fail in CI. Also covered: the hard expiry at twelve, whose rejection path nothing reached and whose boundary turns out to be inclusive; and the dubbeldagar count cap, which now drives past the limit and asserts the days beyond it fall back to a single parent. Two weak assertions were strengthened, including one that derived its expectation from the same response it was checking and so could not have failed on a shared error.
301 lines
12 KiB
Python
301 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 `False` in `app/config.py` (the
|
|
checked-in, CI-facing configuration -- no `APP_SCRAPER_ENABLED` is set in
|
|
CI). On at least one contributor's machine, an untracked, gitignored
|
|
`backend/.env` sets `APP_SCRAPER_ENABLED=true` for manual scraper testing
|
|
(see `AGENTS.md`'s `npm run login` note) -- so `settings.scraper_enabled` at
|
|
*import time* is genuinely ambient and must not be asserted on directly (it
|
|
was `True` when this suite was drafted, on the machine that drafted it,
|
|
despite the shipped default being `False`).
|
|
|
|
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 what both the shipped default and CI exercise; the enabled path is
|
|
exercised here 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:
|
|
"""`settings.scraper_enabled = False` is the shipped default and what CI
|
|
exercises (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"
|