"""Tests for `app.services.leave_snapshot.build_snapshot`. This is the ONE shared derivation both the FK scraper (`app.scrapers.fk_scraper._extract_parental_leave`) and manual entry (`app.services.fetch_data_service.ManualFetchService.save_manual_entry`) go through — see its module docstring. `test_fk_scraper.py` proves the scraper still gets identical results after the move; this file is the exhaustive, direct unit-level coverage of the derivation and validation itself. """ from uuid import uuid4 import pytest from app.services.leave_snapshot import build_snapshot from app.utils.errors import ValidationError def _ids() -> dict: return dict(personal_data_uuid=uuid4(), workspace_id=uuid4(), child_id=uuid4()) class TestTotalsByParentCount: def test_240_total_splits_into_195_and_45(self) -> None: """Two-parent share: 195 sjukpenning + 45 lägstanivå = 240.""" fetch = build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=195, lagsta_niva_left=45, dubbeldagar_left=60, reserved_days_left=90, ) assert fetch.tier_data.sjukpenning_total == 195 assert fetch.tier_data.lagsta_niva_total == 45 assert fetch.total_days == 240 assert fetch.days_left == 240 assert fetch.days_used == 0 def test_480_total_splits_into_390_and_90(self) -> None: """Sole-parent share: the full 390 + 90 = 480.""" fetch = build_snapshot( **_ids(), source="manual", total_days=480, sjukpenning_left=390, lagsta_niva_left=90, dubbeldagar_left=60, reserved_days_left=90, ) assert fetch.tier_data.sjukpenning_total == 390 assert fetch.tier_data.lagsta_niva_total == 90 assert fetch.total_days == 480 assert fetch.days_left == 480 def test_days_used_and_tier_used_are_derived(self) -> None: fetch = build_snapshot( **_ids(), source="manual", total_days=480, sjukpenning_left=310, lagsta_niva_left=70, dubbeldagar_left=60, reserved_days_left=90, ) assert fetch.days_left == 380 assert fetch.days_used == 100 assert fetch.tier_data.sjukpenning_used == 80 assert fetch.tier_data.lagsta_niva_used == 20 class TestReservedTransferableSplit: def test_explicit_reserved_days_left_is_used_as_given(self) -> None: fetch = build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=150, lagsta_niva_left=20, dubbeldagar_left=60, reserved_days_left=60, ) assert fetch.reserved_days_total == 90 assert fetch.reserved_days_left == 60 # days_left = 170; transferable_days_left = days_left - reserved_days_left assert fetch.transferable_days_left == 170 - 60 def test_reserved_days_left_derived_transferable_first(self) -> None: """240 total, 100 days_left, none given explicitly -> the classic "transferable spent first" derivation: reserved untouched until the 150 transferable days are gone. """ fetch = build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=60, lagsta_niva_left=40, dubbeldagar_left=60, ) assert fetch.days_left == 100 assert fetch.reserved_days_total == 90 assert fetch.reserved_days_left == 90 assert fetch.transferable_days_left == 10 def test_reserved_days_left_derived_once_transferable_is_exhausted(self) -> None: """240 total, only 30 days_left -> transferable (150) is long gone, so all 30 remaining days must be reserved ones.""" fetch = build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=30, lagsta_niva_left=0, dubbeldagar_left=60, ) assert fetch.days_left == 30 assert fetch.reserved_days_left == 30 assert fetch.transferable_days_left == 0 class TestTransferableDaysReported: """`transferable_days_left` as FK's own reported figure (vs. the transferable-first fallback) — ground-truth numbers from real FK data, see the task that added this: user 129 reported -> reserved 10; partner 71 reported -> reserved 0. """ def test_fk_reported_transferable_user_figures(self) -> None: fetch = build_snapshot( **_ids(), source="scraped", total_days=240, sjukpenning_left=94, lagsta_niva_left=45, dubbeldagar_left=0, transferable_days_left=129, ) assert fetch.days_left == 139 assert fetch.reserved_days_left == 10 assert fetch.transferable_days_left == 129 assert fetch.transferable_days_reported is True def test_fk_reported_transferable_partner_figures(self) -> None: fetch = build_snapshot( **_ids(), source="scraped", total_days=240, sjukpenning_left=26, lagsta_niva_left=45, dubbeldagar_left=0, transferable_days_left=71, ) assert fetch.days_left == 71 assert fetch.reserved_days_left == 0 assert fetch.transferable_days_left == 71 assert fetch.transferable_days_reported is True def test_both_reserved_and_transferable_overrides_raises(self) -> None: with pytest.raises(ValidationError, match="mutually exclusive") as exc_info: build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=94, lagsta_niva_left=45, dubbeldagar_left=0, reserved_days_left=10, transferable_days_left=129, ) assert exc_info.value.message_key == "parentalLeave.conflictingTransferableOverride" def test_implausible_reported_transferable_raises(self) -> None: """days_left is 139; a reported transferable of 200 would make reserved_days_left negative (139 - 200 = -61) — the existing negative-reserved-days validator must catch it.""" with pytest.raises(ValidationError, match="negative") as exc_info: build_snapshot( **_ids(), source="scraped", total_days=240, sjukpenning_left=94, lagsta_niva_left=45, dubbeldagar_left=0, transferable_days_left=200, ) assert exc_info.value.message_key == "parentalLeave.negativeReservedDaysLeft" def test_derived_fallback_sets_transferable_days_reported_false(self) -> None: fetch = build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=60, lagsta_niva_left=40, dubbeldagar_left=60, ) assert fetch.reserved_days_left == 90 assert fetch.transferable_days_left == 10 assert fetch.transferable_days_reported is False def test_explicit_reserved_days_left_also_not_reported(self) -> None: """A caller-supplied `reserved_days_left` is a user estimate too — only `transferable_days_left` counts as FK-reported.""" fetch = build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=150, lagsta_niva_left=20, dubbeldagar_left=60, reserved_days_left=60, ) assert fetch.transferable_days_reported is False class TestNegativeAndOverTotalGuards: def test_negative_sjukpenning_left_raises(self) -> None: with pytest.raises(ValidationError, match="negative"): build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=-1, lagsta_niva_left=45, dubbeldagar_left=60, reserved_days_left=90, ) def test_negative_lagsta_niva_left_raises(self) -> None: with pytest.raises(ValidationError, match="negative"): build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=195, lagsta_niva_left=-1, dubbeldagar_left=60, reserved_days_left=90, ) def test_sjukpenning_left_exceeding_total_raises(self) -> None: with pytest.raises(ValidationError, match="sjukpenning_left"): build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=196, lagsta_niva_left=0, dubbeldagar_left=60, reserved_days_left=0, ) def test_lagsta_niva_left_exceeding_total_raises(self) -> None: with pytest.raises(ValidationError, match="lagsta_niva_left"): build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=0, lagsta_niva_left=46, dubbeldagar_left=60, reserved_days_left=0, ) def test_negative_explicit_reserved_days_left_raises(self) -> None: with pytest.raises(ValidationError, match="negative"): build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=195, lagsta_niva_left=45, dubbeldagar_left=60, reserved_days_left=-1, ) def test_reserved_days_left_exceeding_total_raises(self) -> None: with pytest.raises(ValidationError, match="reserved_days_left"): build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=195, lagsta_niva_left=45, dubbeldagar_left=60, reserved_days_left=91, ) def test_reserved_days_left_equal_to_total_is_accepted(self) -> None: fetch = build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=195, lagsta_niva_left=45, dubbeldagar_left=60, reserved_days_left=90, ) assert fetch.reserved_days_left == 90 def test_explicit_reserved_days_left_exceeding_days_left_raises(self) -> None: """reserved_days_left (90) > days_left (10) -> transferable_days_left would go negative; that must be rejected, not silently persisted.""" with pytest.raises(ValidationError, match="negative"): build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=10, lagsta_niva_left=0, dubbeldagar_left=60, reserved_days_left=90, ) class TestDubbeldagarBound: def test_negative_dubbeldagar_left_raises(self) -> None: with pytest.raises(ValidationError, match="negative"): build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=195, lagsta_niva_left=45, dubbeldagar_left=-1, reserved_days_left=90, ) def test_dubbeldagar_left_exceeding_max_raises(self) -> None: with pytest.raises(ValidationError, match="dubbeldagar_left"): build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=195, lagsta_niva_left=45, dubbeldagar_left=61, reserved_days_left=90, ) def test_dubbeldagar_left_at_max_is_accepted(self) -> None: fetch = build_snapshot( **_ids(), source="manual", total_days=240, sjukpenning_left=195, lagsta_niva_left=45, dubbeldagar_left=60, reserved_days_left=90, ) assert fetch.dubbeldagar_left == 60 class TestSource: @pytest.mark.parametrize("source", ["scraped", "manual", "deduced"]) def test_valid_sources_are_accepted(self, source: str) -> None: fetch = build_snapshot( **_ids(), source=source, total_days=240, sjukpenning_left=195, lagsta_niva_left=45, dubbeldagar_left=60, reserved_days_left=90, ) assert fetch.source == source def test_invalid_source_raises(self) -> None: with pytest.raises(ValidationError, match="source"): build_snapshot( **_ids(), source="guessed", total_days=240, sjukpenning_left=195, lagsta_niva_left=45, dubbeldagar_left=60, reserved_days_left=90, )