e4719cb9ae
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>
370 lines
14 KiB
Python
370 lines
14 KiB
Python
"""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. 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_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.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[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,
|
|
sjukpenning_left=310,
|
|
lagsta_niva_left=70,
|
|
dubbeldagar_left=60,
|
|
reserved_days_left=90,
|
|
)
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
@pytest.fixture
|
|
def manual_fetch_service(
|
|
parental_leave_repo: SQLiteParentalLeaveRepository,
|
|
personal_data_repo: SQLitePersonalDataRepository,
|
|
) -> ManualFetchService:
|
|
return ManualFetchService(parental_leave_repo, personal_data_repo)
|
|
|
|
|
|
class TestSaveManualEntryValidation:
|
|
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=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_sjukpenning_left_equal_to_total_is_accepted(
|
|
self,
|
|
manual_fetch_service: ManualFetchService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""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 == 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_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(
|
|
self,
|
|
manual_fetch_service: ManualFetchService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
kwargs = _valid_kwargs(seeded_ids, reserved_days_left=90)
|
|
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
|
|
assert fetch.reserved_days_left == 90
|
|
|
|
async def test_valid_payload_is_accepted_and_derived_fields_are_correct(
|
|
self,
|
|
manual_fetch_service: ManualFetchService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
kwargs = _valid_kwargs(
|
|
seeded_ids,
|
|
total_days=480,
|
|
sjukpenning_left=310,
|
|
lagsta_niva_left=70,
|
|
)
|
|
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
|
|
|
|
assert fetch.source == "manual"
|
|
assert fetch.days_left == 380
|
|
assert fetch.tier_data.sjukpenning_left == 310
|
|
assert fetch.tier_data.lagsta_niva_left == 70
|
|
assert fetch.id is not None
|
|
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(
|
|
self,
|
|
manual_fetch_service: ManualFetchService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
kwargs = _valid_kwargs(seeded_ids)
|
|
saved = await manual_fetch_service.save_manual_entry(**kwargs)
|
|
|
|
latest = await manual_fetch_service.get_latest(
|
|
seeded_ids.personal_data_uuid, seeded_ids.child_id
|
|
)
|
|
assert latest is not None
|
|
assert latest.id == saved.id
|
|
assert latest.total_days == saved.total_days
|
|
assert latest.tier_data.sjukpenning_left == saved.tier_data.sjukpenning_left
|
|
|
|
async def test_get_latest_with_no_snapshot_returns_none(
|
|
self,
|
|
manual_fetch_service: ManualFetchService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
result = await manual_fetch_service.get_latest(uuid4(), uuid4())
|
|
assert result is None
|
|
|
|
async def test_list_history_returns_all_snapshots_newest_first(
|
|
self,
|
|
manual_fetch_service: ManualFetchService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
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.dubbeldagar_left for h in history] == [30, 40, 50]
|
|
|
|
async def test_list_history_with_no_snapshots_returns_empty_list(
|
|
self,
|
|
manual_fetch_service: ManualFetchService,
|
|
seeded_ids: SeededIds,
|
|
) -> 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
|