Files
F-r-ldraledigplannerare/backend/tests/unit/test_services/test_fetch_data_service.py
T
jacobh2 e4719cb9ae 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>
2026-09-19 12:27:29 +02:00

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