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