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:
@@ -1,41 +1,41 @@
|
||||
"""Tests for `ManualFetchService` (backend/app/services/fetch_data_service.py).
|
||||
|
||||
This is the manual-entry path a parent's Försäkringskassan balances enter the
|
||||
app through — now the primary onboarding action. `git grep` found zero
|
||||
existing coverage for this service or its router before this file.
|
||||
app through — now the primary onboarding action. The service is a thin
|
||||
wrapper over the shared `app.services.leave_snapshot.build_snapshot`
|
||||
derivation (also used by the FK scraper), plus the "default `total_days` off
|
||||
the workspace's parent count" behaviour that is unique to manual entry (a
|
||||
scraped snapshot always carries FK's own total).
|
||||
|
||||
`test_fk_scraper.py::test_reserved_days_derived` already proves the
|
||||
`reserved_days_left <= reserved_days_total` domain rule holds for the
|
||||
scraper-side derivation; this file proves the equivalent manual-entry
|
||||
boundary — the `ManualFetchService.save_manual_entry` validation itself —
|
||||
which was previously untested.
|
||||
`test_leave_snapshot.py` covers `build_snapshot`'s own derivation/validation
|
||||
exhaustively; this file only proves the wrapper forwards correctly and gets
|
||||
the parent-count default right, plus basic persistence round-trips.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.entities.personal_data import PersonalData
|
||||
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
|
||||
from app.services.fetch_data_service import ManualFetchService
|
||||
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
|
||||
from app.services.fetch_data_service import ManualFetchService, persist_partner_deduction
|
||||
from app.utils.errors import ValidationError
|
||||
from tests.conftest import SeededIds
|
||||
|
||||
|
||||
def _valid_kwargs(seeded_ids: SeededIds, **overrides: object) -> dict:
|
||||
base = dict(
|
||||
base: dict[str, object] = dict(
|
||||
personal_data_uuid=seeded_ids.personal_data_uuid,
|
||||
workspace_id=seeded_ids.workspace_id,
|
||||
child_id=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,
|
||||
)
|
||||
base.update(overrides)
|
||||
return base
|
||||
@@ -44,39 +44,50 @@ def _valid_kwargs(seeded_ids: SeededIds, **overrides: object) -> dict:
|
||||
@pytest.fixture
|
||||
def manual_fetch_service(
|
||||
parental_leave_repo: SQLiteParentalLeaveRepository,
|
||||
personal_data_repo: SQLitePersonalDataRepository,
|
||||
) -> ManualFetchService:
|
||||
return ManualFetchService(parental_leave_repo)
|
||||
return ManualFetchService(parental_leave_repo, personal_data_repo)
|
||||
|
||||
|
||||
class TestSaveManualEntryValidation:
|
||||
async def test_days_used_exceeding_total_days_is_rejected(
|
||||
async def test_sjukpenning_left_exceeding_total_is_rejected(
|
||||
self,
|
||||
manual_fetch_service: ManualFetchService,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
kwargs = _valid_kwargs(seeded_ids, total_days=100, days_used=101)
|
||||
with pytest.raises(ValidationError, match="days_used cannot exceed total_days"):
|
||||
kwargs = _valid_kwargs(
|
||||
seeded_ids,
|
||||
total_days=240,
|
||||
sjukpenning_left=196,
|
||||
lagsta_niva_left=0,
|
||||
reserved_days_left=0,
|
||||
)
|
||||
with pytest.raises(ValidationError, match="sjukpenning_left"):
|
||||
await manual_fetch_service.save_manual_entry(**kwargs)
|
||||
|
||||
async def test_days_used_equal_to_total_days_is_accepted(
|
||||
async def test_sjukpenning_left_equal_to_total_is_accepted(
|
||||
self,
|
||||
manual_fetch_service: ManualFetchService,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
"""The boundary itself (0 days left) is valid, not an off-by-one reject."""
|
||||
kwargs = _valid_kwargs(seeded_ids, total_days=100, days_used=100)
|
||||
"""The boundary itself (that tier fully unused) is valid, not an off-by-one reject."""
|
||||
kwargs = _valid_kwargs(
|
||||
seeded_ids,
|
||||
total_days=240,
|
||||
sjukpenning_left=195,
|
||||
lagsta_niva_left=45,
|
||||
reserved_days_left=90,
|
||||
)
|
||||
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
|
||||
assert fetch.days_left == 0
|
||||
assert fetch.days_left == 240
|
||||
|
||||
async def test_reserved_days_left_exceeding_total_is_rejected(
|
||||
self,
|
||||
manual_fetch_service: ManualFetchService,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
kwargs = _valid_kwargs(seeded_ids, reserved_days_total=90, reserved_days_left=91)
|
||||
with pytest.raises(
|
||||
ValidationError, match="reserved_days_left cannot exceed reserved_days_total"
|
||||
):
|
||||
kwargs = _valid_kwargs(seeded_ids, reserved_days_left=91)
|
||||
with pytest.raises(ValidationError, match="reserved_days_left"):
|
||||
await manual_fetch_service.save_manual_entry(**kwargs)
|
||||
|
||||
async def test_reserved_days_left_equal_to_total_is_accepted(
|
||||
@@ -84,7 +95,7 @@ class TestSaveManualEntryValidation:
|
||||
manual_fetch_service: ManualFetchService,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
kwargs = _valid_kwargs(seeded_ids, reserved_days_total=90, reserved_days_left=90)
|
||||
kwargs = _valid_kwargs(seeded_ids, reserved_days_left=90)
|
||||
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
|
||||
assert fetch.reserved_days_left == 90
|
||||
|
||||
@@ -96,11 +107,8 @@ class TestSaveManualEntryValidation:
|
||||
kwargs = _valid_kwargs(
|
||||
seeded_ids,
|
||||
total_days=480,
|
||||
days_used=100,
|
||||
sjukpenning_total=390,
|
||||
sjukpenning_used=80,
|
||||
lagsta_niva_total=90,
|
||||
lagsta_niva_used=20,
|
||||
sjukpenning_left=310,
|
||||
lagsta_niva_left=70,
|
||||
)
|
||||
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
|
||||
|
||||
@@ -112,6 +120,68 @@ class TestSaveManualEntryValidation:
|
||||
assert fetch.created_at is not None
|
||||
assert fetch.updated_at is not None
|
||||
|
||||
async def test_deduced_source_round_trips(
|
||||
self,
|
||||
manual_fetch_service: ManualFetchService,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
kwargs = _valid_kwargs(seeded_ids, source="deduced")
|
||||
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
|
||||
assert fetch.source == "deduced"
|
||||
|
||||
|
||||
class TestSaveManualEntryTotalDaysDefault:
|
||||
"""`total_days` defaults to `rules.total_days_per_child // parent_count`
|
||||
when omitted — the whole point of Task 5/§"default pool follows parent
|
||||
count" for manual entry.
|
||||
"""
|
||||
|
||||
async def test_omitted_total_days_defaults_to_full_pool_for_a_sole_parent(
|
||||
self,
|
||||
manual_fetch_service: ManualFetchService,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
"""`seeded_ids`'s workspace has exactly one member."""
|
||||
kwargs = _valid_kwargs(seeded_ids, sjukpenning_left=390, lagsta_niva_left=90)
|
||||
del kwargs["total_days"]
|
||||
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
|
||||
assert fetch.total_days == 480
|
||||
|
||||
async def test_omitted_total_days_defaults_to_half_pool_with_a_partner(
|
||||
self,
|
||||
manual_fetch_service: ManualFetchService,
|
||||
personal_data_repo: SQLitePersonalDataRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
await personal_data_repo.create(
|
||||
PersonalData(
|
||||
uuid=uuid4(),
|
||||
workspace_id=seeded_ids.workspace_id,
|
||||
name="Partner",
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
kwargs = _valid_kwargs(seeded_ids, sjukpenning_left=195, lagsta_niva_left=45)
|
||||
del kwargs["total_days"]
|
||||
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
|
||||
assert fetch.total_days == 240
|
||||
|
||||
async def test_omitted_total_days_without_a_personal_data_repo_raises(
|
||||
self,
|
||||
parental_leave_repo: SQLiteParentalLeaveRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
"""A `ManualFetchService` built without a `personal_data_repo` (e.g. a
|
||||
direct-construction call site that always passes `total_days`
|
||||
explicitly) can't resolve the default and must fail loudly rather
|
||||
than silently guessing.
|
||||
"""
|
||||
service = ManualFetchService(parental_leave_repo)
|
||||
kwargs = _valid_kwargs(seeded_ids)
|
||||
del kwargs["total_days"]
|
||||
with pytest.raises(ValueError, match="total_days"):
|
||||
await service.save_manual_entry(**kwargs)
|
||||
|
||||
|
||||
class TestSaveManualEntryPersistence:
|
||||
async def test_snapshot_persists_and_is_readable_via_get_latest(
|
||||
@@ -143,16 +213,22 @@ class TestSaveManualEntryPersistence:
|
||||
manual_fetch_service: ManualFetchService,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
await manual_fetch_service.save_manual_entry(**_valid_kwargs(seeded_ids, days_used=50))
|
||||
await manual_fetch_service.save_manual_entry(**_valid_kwargs(seeded_ids, days_used=60))
|
||||
await manual_fetch_service.save_manual_entry(**_valid_kwargs(seeded_ids, days_used=70))
|
||||
await manual_fetch_service.save_manual_entry(
|
||||
**_valid_kwargs(seeded_ids, dubbeldagar_left=50)
|
||||
)
|
||||
await manual_fetch_service.save_manual_entry(
|
||||
**_valid_kwargs(seeded_ids, dubbeldagar_left=40)
|
||||
)
|
||||
await manual_fetch_service.save_manual_entry(
|
||||
**_valid_kwargs(seeded_ids, dubbeldagar_left=30)
|
||||
)
|
||||
|
||||
history = await manual_fetch_service.list_history(
|
||||
seeded_ids.personal_data_uuid, seeded_ids.child_id
|
||||
)
|
||||
|
||||
assert len(history) == 3
|
||||
assert [h.days_used for h in history] == [70, 60, 50]
|
||||
assert [h.dubbeldagar_left for h in history] == [30, 40, 50]
|
||||
|
||||
async def test_list_history_with_no_snapshots_returns_empty_list(
|
||||
self,
|
||||
@@ -161,3 +237,133 @@ class TestSaveManualEntryPersistence:
|
||||
) -> None:
|
||||
history = await manual_fetch_service.list_history(uuid4(), uuid4())
|
||||
assert history == []
|
||||
|
||||
|
||||
class TestPersistPartnerDeduction:
|
||||
"""`persist_partner_deduction` — called by the FK scraper router's `_persist`
|
||||
after `_deduce_partner` produces a `PartnerDeduction` for the family panel.
|
||||
"""
|
||||
|
||||
async def test_creates_a_deduced_snapshot_for_the_sole_other_member(
|
||||
self,
|
||||
parental_leave_repo: SQLiteParentalLeaveRepository,
|
||||
personal_data_repo: SQLitePersonalDataRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
partner = await personal_data_repo.create(
|
||||
PersonalData(
|
||||
uuid=uuid4(),
|
||||
workspace_id=seeded_ids.workspace_id,
|
||||
name="Partner",
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
|
||||
await persist_partner_deduction(
|
||||
personal_data_repo=personal_data_repo,
|
||||
parental_leave_repo=parental_leave_repo,
|
||||
workspace_id=seeded_ids.workspace_id,
|
||||
child_id=seeded_ids.child_id,
|
||||
caller_personal_data_uuid=seeded_ids.personal_data_uuid,
|
||||
total_days=240,
|
||||
sjukpenning_left=130,
|
||||
lagsta_niva_left=20,
|
||||
dubbeldagar_left=20,
|
||||
)
|
||||
|
||||
snapshot = await parental_leave_repo.get_latest_by_parent(partner.uuid, seeded_ids.child_id)
|
||||
assert snapshot is not None
|
||||
assert snapshot.source == "deduced"
|
||||
assert snapshot.days_left == 150
|
||||
assert snapshot.tier_data.sjukpenning_left == 130
|
||||
assert snapshot.tier_data.lagsta_niva_left == 20
|
||||
|
||||
async def test_sole_member_workspace_persists_nothing(
|
||||
self,
|
||||
parental_leave_repo: SQLiteParentalLeaveRepository,
|
||||
personal_data_repo: SQLitePersonalDataRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
"""`seeded_ids`'s workspace has exactly one member (the caller) — there
|
||||
is no "other member" to attribute a deduced snapshot to."""
|
||||
await persist_partner_deduction(
|
||||
personal_data_repo=personal_data_repo,
|
||||
parental_leave_repo=parental_leave_repo,
|
||||
workspace_id=seeded_ids.workspace_id,
|
||||
child_id=seeded_ids.child_id,
|
||||
caller_personal_data_uuid=seeded_ids.personal_data_uuid,
|
||||
total_days=240,
|
||||
sjukpenning_left=130,
|
||||
lagsta_niva_left=20,
|
||||
dubbeldagar_left=20,
|
||||
)
|
||||
|
||||
history = await parental_leave_repo.list_history(
|
||||
seeded_ids.personal_data_uuid, seeded_ids.child_id
|
||||
)
|
||||
assert history == []
|
||||
|
||||
async def test_more_than_one_other_member_persists_nothing(
|
||||
self,
|
||||
parental_leave_repo: SQLiteParentalLeaveRepository,
|
||||
personal_data_repo: SQLitePersonalDataRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
"""Ambiguous "which member is the partner" — never guess."""
|
||||
for name in ("Partner A", "Partner B"):
|
||||
await personal_data_repo.create(
|
||||
PersonalData(
|
||||
uuid=uuid4(),
|
||||
workspace_id=seeded_ids.workspace_id,
|
||||
name=name,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
|
||||
await persist_partner_deduction(
|
||||
personal_data_repo=personal_data_repo,
|
||||
parental_leave_repo=parental_leave_repo,
|
||||
workspace_id=seeded_ids.workspace_id,
|
||||
child_id=seeded_ids.child_id,
|
||||
caller_personal_data_uuid=seeded_ids.personal_data_uuid,
|
||||
total_days=240,
|
||||
sjukpenning_left=130,
|
||||
lagsta_niva_left=20,
|
||||
dubbeldagar_left=20,
|
||||
)
|
||||
|
||||
history = await parental_leave_repo.list_history(
|
||||
seeded_ids.personal_data_uuid, seeded_ids.child_id
|
||||
)
|
||||
assert history == []
|
||||
|
||||
async def test_repo_failure_is_swallowed_not_raised(
|
||||
self,
|
||||
personal_data_repo: SQLitePersonalDataRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
"""The caller's own snapshot is already saved by the time this runs —
|
||||
a DB error persisting the partner's snapshot must never fail the
|
||||
scraper session."""
|
||||
await personal_data_repo.create(
|
||||
PersonalData(
|
||||
uuid=uuid4(),
|
||||
workspace_id=seeded_ids.workspace_id,
|
||||
name="Partner",
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
failing_repo = AsyncMock()
|
||||
failing_repo.create.side_effect = RuntimeError("DB unavailable")
|
||||
|
||||
await persist_partner_deduction(
|
||||
personal_data_repo=personal_data_repo,
|
||||
parental_leave_repo=failing_repo,
|
||||
workspace_id=seeded_ids.workspace_id,
|
||||
child_id=seeded_ids.child_id,
|
||||
caller_personal_data_uuid=seeded_ids.personal_data_uuid,
|
||||
total_days=240,
|
||||
sjukpenning_left=130,
|
||||
lagsta_niva_left=20,
|
||||
dubbeldagar_left=20,
|
||||
) # must not raise
|
||||
|
||||
Reference in New Issue
Block a user