"""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