Rework balances around per-parent days left and deduce the partner from FK
Manual entry now takes one parent's sjukpenningnivå, lägstanivå and dubbeldagar left (plus optional reserved days) and derives the rest in build_snapshot, shared with the scraper. The scraper also reads the family panel and exposes a partner deduction; with two parents it is saved as a 'deduced' snapshot (migration 002). Parent count is the single source of entitlement: one member gets 480 days and 96 savable at age 4, two get 240 and 48 each. Workspaces are capped at two parents. Also lands the in-flight squash of migrations 002–011 into the initial schema and the removal of planning_mode. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -28,14 +28,20 @@ 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
|
||||
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
|
||||
|
||||
|
||||
@@ -53,16 +59,11 @@ def _manual_payload(seeded_ids: SeededIds, **overrides: object) -> dict:
|
||||
"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_left": 310,
|
||||
"lagsta_niva_left": 70,
|
||||
"dubbeldagar_left": 60,
|
||||
"sjukpenning_total": 390,
|
||||
"sjukpenning_used": 80,
|
||||
"lagsta_niva_total": 90,
|
||||
"lagsta_niva_used": 20,
|
||||
"reserved_days_left": 90,
|
||||
"total_days": 480,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
@@ -87,22 +88,28 @@ class TestSaveManualEntry:
|
||||
) -> None:
|
||||
response = await client.post(
|
||||
"/api/parental-leave/manual",
|
||||
json=_manual_payload(seeded_ids, reserved_days_total=90, reserved_days_left=91),
|
||||
json=_manual_payload(seeded_ids, reserved_days_left=91),
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "reserved_days_left" in response.json()["detail"]
|
||||
assert "reserved_days_left" in str(response.json()["detail"])
|
||||
|
||||
async def test_days_used_exceeding_total_days_returns_422(
|
||||
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=100, days_used=150),
|
||||
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 "days_used" in response.json()["detail"]
|
||||
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
|
||||
@@ -117,6 +124,96 @@ class TestSaveManualEntry:
|
||||
|
||||
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(
|
||||
@@ -147,10 +244,10 @@ class TestGetLatestAndHistory:
|
||||
self, client: AsyncClient, seeded_ids: SeededIds
|
||||
) -> None:
|
||||
await client.post(
|
||||
"/api/parental-leave/manual", json=_manual_payload(seeded_ids, days_used=50)
|
||||
"/api/parental-leave/manual", json=_manual_payload(seeded_ids, dubbeldagar_left=50)
|
||||
)
|
||||
await client.post(
|
||||
"/api/parental-leave/manual", json=_manual_payload(seeded_ids, days_used=60)
|
||||
"/api/parental-leave/manual", json=_manual_payload(seeded_ids, dubbeldagar_left=60)
|
||||
)
|
||||
|
||||
response = await client.get(
|
||||
@@ -159,7 +256,7 @@ class TestGetLatestAndHistory:
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert len(body) == 2
|
||||
assert [row["days_used"] for row in body] == [60, 50]
|
||||
assert [row["dubbeldagar_left"] for row in body] == [60, 50]
|
||||
|
||||
|
||||
class TestScraperEndpointsDisabled:
|
||||
@@ -287,3 +384,146 @@ class TestScraperEndpointsEnabled:
|
||||
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,
|
||||
) -> 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"
|
||||
|
||||
Reference in New Issue
Block a user