diff --git a/backend/tests/integration/test_user_journey.py b/backend/tests/integration/test_user_journey.py index 43d2483..0a52eab 100644 --- a/backend/tests/integration/test_user_journey.py +++ b/backend/tests/integration/test_user_journey.py @@ -34,6 +34,7 @@ from dateutil.easter import easter from dateutil.relativedelta import relativedelta from httpx import ASGITransport, AsyncClient +from app.config import settings from app.dependencies import get_connection from app.main import app from app.utils.holidays import cached_year_bounds @@ -687,6 +688,38 @@ class TestAge4ProjectionArithmeticIsSelfConsistent: async def test_expiring_equals_projected_minus_ceiling_clamped_at_zero( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: + """Independently-derived expected values, not internal consistency. + + The original version of this test re-derived its expected + `expiring_at_b4` from `projection["projected_left_at_b4"]` -- a field + of the SAME response it was checking -- so it only proved the two + response fields agree with each other, not that either is correct + (a shared bug in both would still pass; the test's own docstring + said as much). + + This version instead computes the expected numbers from the seeded + fixture and `settings.rules` directly, independent of the response. + The `/api/plan/propose` response's `age4_projections` come from + `allocation_service._Ledger._age4_projections`, not from + `AnalyticsService.get_days_left_at_age_4` -- that method always + passes `days_used=0` and derives `total_initial` from the + (parent, child) pool `AllocationInputLoader.load` built + (`allocation_service.py:1446-1475`). No wish is created in this + test, so nothing gets proposed and `planned_fp_before_b4` is 0. + With no `ParentalLeaveFetch` snapshot for this brand-new + registration, that pool falls back to `rules.sjukpenning_days // 2 + + rules.lagsta_niva_days // 2`, which is `rules.days_per_parent` + exactly (the "Bug A" default the two must agree on -- see + `test_planning_service_apply.py`'s + `test_no_snapshot_default_balance_matches_allocation_engine`) -- + making `projected_left_at_b4 == rules.days_per_parent`. The + workspace is a fresh single-member 'solo' workspace (never set to + 'couple' here), so the per-CHILD cap + (`rules.max_savable_days_at_age_4`) applies via + `resolve_age4_savable_cap`, not the per-parent one -- making + `expiring_at_b4 == max(rules.days_per_parent - + rules.max_savable_days_at_age_4, 0)`. + """ headers, ws_id, pd_uuid = await _register(client, "age4-projection@example.com", "Alex") birth_date = date.today() - relativedelta(months=6) child_id = await _add_child(client, headers, ws_id, "Barn", birth_date) @@ -695,11 +728,19 @@ class TestAge4ProjectionArithmeticIsSelfConsistent: proposal = await _propose(client, headers, ws_id) + rules = settings.rules + expected_cap = rules.max_savable_days_at_age_4 + expected_projected_left = rules.days_per_parent + expected_expiring = max(expected_projected_left - expected_cap, 0) + expected_savable = min(expected_projected_left, expected_cap) + assert proposal["age4_projections"], "expected an age4 projection for the (parent, child)" for projection in proposal["age4_projections"]: assert projection["child_id"] == str(child_id) - expected = max(projection["projected_left_at_b4"] - 96, 0) - assert projection["expiring_at_b4"] == expected, projection + assert projection["savable_cap_at_b4"] == expected_cap, projection + assert projection["projected_left_at_b4"] == expected_projected_left, projection + assert projection["expiring_at_b4"] == expected_expiring, projection + assert projection["savable_after_b4"] == expected_savable, projection # --------------------------------------------------------------------------- diff --git a/backend/tests/unit/test_fetch_data_router.py b/backend/tests/unit/test_fetch_data_router.py new file mode 100644 index 0000000..ab48e60 --- /dev/null +++ b/backend/tests/unit/test_fetch_data_router.py @@ -0,0 +1,300 @@ +"""API-level tests for `/api/parental-leave` (manual entry, always available) +and `/api/fetch-data` (scraper session, gated by `settings.scraper_enabled`). + +`git grep -n "fetch_data_service\\|FetchDataService" backend/tests/` found no +coverage for either router before this file, despite manual entry being the +primary onboarding action. Follows the `test_calendar_router.py` / +`test_leave_wish_router.py` pattern: exercise the real app + dependency +graph, not just the service, so status codes and wire-shape regressions are +caught. + +`settings.scraper_enabled` defaults to `False` in `app/config.py` (the +checked-in, CI-facing configuration -- no `APP_SCRAPER_ENABLED` is set in +CI). On at least one contributor's machine, an untracked, gitignored +`backend/.env` sets `APP_SCRAPER_ENABLED=true` for manual scraper testing +(see `AGENTS.md`'s `npm run login` note) -- so `settings.scraper_enabled` at +*import time* is genuinely ambient and must not be asserted on directly (it +was `True` when this suite was drafted, on the machine that drafted it, +despite the shipped default being `False`). + +The scraper-session tests below therefore use `monkeypatch` to pin +`app.routers.fetch_data.settings.scraper_enabled` to a known value for the +duration of each test, rather than asserting on (or branching on) whatever a +contributor's local `.env` happens to set. This is deliberately more robust +than reading the live value once and assuming it holds — the disabled path +is what both the shipped default and CI exercise; the enabled path is +exercised here only far enough to prove the guard flips (`start_session` is +faked out, never a real Playwright session -- see the module docstring in +`app/scrapers/fk_scraper.py`; actually driving it here would launch a real, +non-headless browser as a fire-and-forget background task, which is unsafe +to do from a router test). +""" + +from datetime import datetime +from typing import Any +from uuid import UUID, uuid4 + +import aiosqlite +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from app.dependencies import get_connection, get_current_user +from app.entities.user import User +from app.main import app +from app.routers import fetch_data as fetch_data_router +from tests.conftest import SeededIds + + +@pytest_asyncio.fixture +async def client(connection: aiosqlite.Connection) -> AsyncClient: + """An httpx client against the real app: `get_connection` and + `get_current_user` are overridden to use the test's in-memory connection + and a fixed fake user. + """ + app.dependency_overrides[get_connection] = lambda: connection + app.dependency_overrides[get_current_user] = lambda: User( + id=uuid4(), + personal_data_uuid=uuid4(), + email="test@example.com", + hashed_password="not-a-real-hash", + created_at=datetime.now(), + ) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + yield ac + app.dependency_overrides.pop(get_connection, None) + app.dependency_overrides.pop(get_current_user, None) + + +def _manual_payload(seeded_ids: SeededIds, **overrides: object) -> dict: + base: dict[str, object] = { + "personal_data_uuid": str(seeded_ids.personal_data_uuid), + "workspace_id": str(seeded_ids.workspace_id), + "child_id": str(seeded_ids.child_id), + "total_days": 480, + "days_used": 100, + "reserved_days_total": 90, + "reserved_days_left": 90, + "transferable_days_left": 150, + "dubbeldagar_left": 60, + "sjukpenning_total": 390, + "sjukpenning_used": 80, + "lagsta_niva_total": 90, + "lagsta_niva_used": 20, + } + base.update(overrides) + return base + + +class TestSaveManualEntry: + async def test_valid_payload_returns_201_with_persisted_shape( + self, client: AsyncClient, seeded_ids: SeededIds + ) -> None: + response = await client.post("/api/parental-leave/manual", json=_manual_payload(seeded_ids)) + + assert response.status_code == 201 + body = response.json() + assert body["source"] == "manual" + assert body["days_left"] == 380 + assert body["tier_data"]["sjukpenning_left"] == 310 + assert body["tier_data"]["lagsta_niva_left"] == 70 + assert body["child_id"] == str(seeded_ids.child_id) + + async def test_reserved_days_left_exceeding_total_returns_422( + self, client: AsyncClient, seeded_ids: SeededIds + ) -> None: + response = await client.post( + "/api/parental-leave/manual", + json=_manual_payload(seeded_ids, reserved_days_total=90, reserved_days_left=91), + ) + + assert response.status_code == 422 + assert "reserved_days_left" in response.json()["detail"] + + async def test_days_used_exceeding_total_days_returns_422( + self, client: AsyncClient, seeded_ids: SeededIds + ) -> None: + response = await client.post( + "/api/parental-leave/manual", + json=_manual_payload(seeded_ids, total_days=100, days_used=150), + ) + + assert response.status_code == 422 + assert "days_used" in response.json()["detail"] + + async def test_out_of_range_field_is_rejected_by_schema_before_the_service( + self, client: AsyncClient, seeded_ids: SeededIds + ) -> None: + """`dubbeldagar_left` is capped at 60 in `ManualFetchRequest` itself + (Pydantic, not the service) -- a value above that never reaches + `ManualFetchService.save_manual_entry` at all.""" + response = await client.post( + "/api/parental-leave/manual", + json=_manual_payload(seeded_ids, dubbeldagar_left=61), + ) + + assert response.status_code == 422 + + +class TestGetLatestAndHistory: + async def test_get_latest_with_no_snapshot_returns_null( + self, client: AsyncClient, seeded_ids: SeededIds + ) -> None: + response = await client.get( + f"/api/parental-leave/{seeded_ids.personal_data_uuid}/{seeded_ids.child_id}" + ) + assert response.status_code == 200 + assert response.json() is None + + async def test_get_latest_after_manual_save_returns_the_snapshot( + self, client: AsyncClient, seeded_ids: SeededIds + ) -> None: + create_response = await client.post( + "/api/parental-leave/manual", json=_manual_payload(seeded_ids) + ) + assert create_response.status_code == 201 + created_id = create_response.json()["id"] + + response = await client.get( + f"/api/parental-leave/{seeded_ids.personal_data_uuid}/{seeded_ids.child_id}" + ) + assert response.status_code == 200 + assert response.json()["id"] == created_id + + async def test_get_history_returns_all_snapshots_newest_first( + self, client: AsyncClient, seeded_ids: SeededIds + ) -> None: + await client.post( + "/api/parental-leave/manual", json=_manual_payload(seeded_ids, days_used=50) + ) + await client.post( + "/api/parental-leave/manual", json=_manual_payload(seeded_ids, days_used=60) + ) + + response = await client.get( + f"/api/parental-leave/{seeded_ids.personal_data_uuid}/{seeded_ids.child_id}/history" + ) + assert response.status_code == 200 + body = response.json() + assert len(body) == 2 + assert [row["days_used"] for row in body] == [60, 50] + + +class TestScraperEndpointsDisabled: + """`settings.scraper_enabled = False` is the shipped default and what CI + exercises (see the module docstring for why this is pinned via + `monkeypatch` rather than asserted on ambiently). + """ + + async def test_start_returns_501_when_scraper_disabled( + self, client: AsyncClient, seeded_ids: SeededIds, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", False) + response = await client.post( + "/api/fetch-data/start", + json={ + "workspace_id": str(seeded_ids.workspace_id), + "personal_data_uuid": str(seeded_ids.personal_data_uuid), + "child_id": str(seeded_ids.child_id), + }, + ) + assert response.status_code == 501 + assert "APP_SCRAPER_ENABLED" in response.json()["detail"] + + async def test_poll_returns_501_when_scraper_disabled( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", False) + response = await client.get("/api/fetch-data/poll", params={"session_id": "does-not-exist"}) + assert response.status_code == 501 + + async def test_cancel_returns_501_when_scraper_disabled( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", False) + response = await client.post( + "/api/fetch-data/cancel", json={"session_id": "does-not-exist"} + ) + assert response.status_code == 501 + + +class TestScraperEndpointsEnabled: + """With the guard open, the routes must delegate to `_scraper_service` + and return its result -- `_scraper_service.start_session` is replaced + with a fake so no real Playwright session (a real, possibly non-headless + browser launched as a fire-and-forget background task) is ever started + from a test. + """ + + async def test_start_returns_200_and_delegates_to_the_scraper_service( + self, client: AsyncClient, seeded_ids: SeededIds, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", True) + + captured: dict[str, Any] = {} + + async def fake_start_session( + *, workspace_id: UUID, personal_data_uuid: UUID, child_id: UUID, persist_callback: Any + ) -> str: + captured["workspace_id"] = workspace_id + captured["personal_data_uuid"] = personal_data_uuid + captured["child_id"] = child_id + return "fake-session-id" + + monkeypatch.setattr(fetch_data_router._scraper_service, "start_session", fake_start_session) + + response = await client.post( + "/api/fetch-data/start", + json={ + "workspace_id": str(seeded_ids.workspace_id), + "personal_data_uuid": str(seeded_ids.personal_data_uuid), + "child_id": str(seeded_ids.child_id), + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["session_id"] == "fake-session-id" + assert body["status"] == "launching" + assert body["workspace_id"] == str(seeded_ids.workspace_id) + assert captured["child_id"] == seeded_ids.child_id + + async def test_poll_returns_200_with_the_scraper_services_result( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", True) + + async def fake_poll_session(session_id: str) -> dict: + assert session_id == "some-session-id" + return {"status": "awaiting_scan"} + + monkeypatch.setattr(fetch_data_router._scraper_service, "poll_session", fake_poll_session) + + response = await client.get( + "/api/fetch-data/poll", params={"session_id": "some-session-id"} + ) + + assert response.status_code == 200 + assert response.json() == {"status": "awaiting_scan"} + + async def test_cancel_returns_200_and_delegates_to_the_scraper_service( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(fetch_data_router.settings, "scraper_enabled", True) + + cancelled: dict[str, Any] = {} + + async def fake_cancel_session(session_id: str) -> None: + cancelled["session_id"] = session_id + + monkeypatch.setattr( + fetch_data_router._scraper_service, "cancel_session", fake_cancel_session + ) + + response = await client.post( + "/api/fetch-data/cancel", json={"session_id": "some-session-id"} + ) + + assert response.status_code == 200 + assert response.json() == {"status": "cancelled"} + assert cancelled["session_id"] == "some-session-id" diff --git a/backend/tests/unit/test_holidays.py b/backend/tests/unit/test_holidays.py index e5a50b4..09e9f1b 100644 --- a/backend/tests/unit/test_holidays.py +++ b/backend/tests/unit/test_holidays.py @@ -132,6 +132,7 @@ class TestGetHolidayName: """Get name of New Year's Day.""" name = get_holiday_name(datetime.date(2026, 1, 1)) assert name is not None + assert "Nyår" in name def test_midsummer_name(self) -> None: """Get name of Midsummer.""" diff --git a/backend/tests/unit/test_planning_service_apply.py b/backend/tests/unit/test_planning_service_apply.py index 59542f0..3c262b9 100644 --- a/backend/tests/unit/test_planning_service_apply.py +++ b/backend/tests/unit/test_planning_service_apply.py @@ -12,6 +12,9 @@ property this test file cares about most. from datetime import date, datetime, timedelta from uuid import uuid4 +from dateutil.relativedelta import relativedelta + +from app.config import settings from app.entities.leave_wish import LeaveWish from app.entities.parental_leave import ParentalLeaveFetch, TierData from app.entities.planned_leave import PlannedLeaveDay @@ -500,6 +503,135 @@ class TestApplyProposedDaysValidation: assert rows == [] +class TestApplyProposedDaysEligibleRange: + """`PlanningService._validate_eligible_range` (planning_service.py:748-775) + rejects any planned day before the child's birth date or after + `birth_date + LeaveRules.hard_expiry_age_years` (the Swedish 12-year hard + expiry on FP days). + + The upper bound is INCLUSIVE: the source only rejects `d.date > + hard_expiry`, so the exact 12th-birthday date itself is still eligible + -- confirmed by `test_hard_expiry_boundary_date_itself_is_inclusive_and_accepted` + below, which would fail if that boundary were ever tightened to `>=`. + + `seeded_ids`' child has a fixed `birth_date` of 2024-01-01 + (`tests/conftest.py`), which these tests rely on directly. + """ + + _BIRTH_DATE = date(2024, 1, 1) + + async def test_day_after_the_hard_expiry_is_rejected( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + hard_expiry = self._BIRTH_DATE + relativedelta(years=settings.rules.hard_expiry_age_years) + day_after = hard_expiry + timedelta(days=1) + + result = await planning_service.apply_proposed_days( + ws, [ProposedDayInput(day_after, parent, child, "sjukpenning")] + ) + + assert result.accepted is False + assert any( + r.message_key == "planApply.rejection.outsideEligibleWindow" for r in result.rejections + ) + rows = await planned_leave_repo.list(parent_personal_data_uuid=parent) + assert rows == [] + + async def test_day_before_the_birth_date_is_rejected( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + day_before = self._BIRTH_DATE - timedelta(days=1) + + result = await planning_service.apply_proposed_days( + ws, [ProposedDayInput(day_before, parent, child, "sjukpenning")] + ) + + assert result.accepted is False + assert any( + r.message_key == "planApply.rejection.outsideEligibleWindow" for r in result.rejections + ) + rows = await planned_leave_repo.list(parent_personal_data_uuid=parent) + assert rows == [] + + async def test_hard_expiry_boundary_date_itself_is_inclusive_and_accepted( + self, + planning_service: PlanningService, + seeded_ids: SeededIds, + ) -> None: + """The 12th-birthday date exactly equal to `birth_date + + hard_expiry_age_years` is still an eligible day -- the source rejects + strictly-greater dates only, so this is the INCLUSIVE boundary.""" + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + hard_expiry = self._BIRTH_DATE + relativedelta(years=settings.rules.hard_expiry_age_years) + + result = await planning_service.apply_proposed_days( + ws, [ProposedDayInput(hard_expiry, parent, child, "sjukpenning")] + ) + + assert result.accepted is True, result.rejections + + async def test_birth_date_itself_is_inclusive_and_accepted( + self, + planning_service: PlanningService, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + + result = await planning_service.apply_proposed_days( + ws, [ProposedDayInput(self._BIRTH_DATE, parent, child, "sjukpenning")] + ) + + assert result.accepted is True, result.rejections + + async def test_rejected_batch_writes_nothing_even_with_a_valid_day_included( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + """All-or-nothing: one out-of-window day in the batch must sink the + whole batch, including the otherwise-valid day alongside it.""" + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + hard_expiry = self._BIRTH_DATE + relativedelta(years=settings.rules.hard_expiry_age_years) + day_after = hard_expiry + timedelta(days=1) + valid_day = ProposedDayInput(date(2026, 6, 15), parent, child, "sjukpenning") + invalid_day = ProposedDayInput(day_after, parent, child, "sjukpenning") + + result = await planning_service.apply_proposed_days(ws, [valid_day, invalid_day]) + + assert result.accepted is False + rows = await planned_leave_repo.list(parent_personal_data_uuid=parent) + assert rows == [] + + class TestApplyProposedDaysDeleteKeys: """`delete_keys` folds explicit deletions into the same validated batch as diff --git a/backend/tests/unit/test_services/test_allocation_service.py b/backend/tests/unit/test_services/test_allocation_service.py index f2d28ee..31b1964 100644 --- a/backend/tests/unit/test_services/test_allocation_service.py +++ b/backend/tests/unit/test_services/test_allocation_service.py @@ -20,6 +20,7 @@ around a real holiday (Easter 2027 / Jul 2027-2028) instead. from datetime import date, timedelta from uuid import UUID, uuid4 +from app.config import settings from app.entities.leave_wish import LeaveWish from app.entities.planned_leave import PlannedLeaveDay from app.services.allocation_service import ( @@ -1976,3 +1977,95 @@ class TestPinnedDayIsNeverRetypedAndDebitsThePool: budget_after = proposal.balances_after.vacation_after[(parent.uuid, 2027)] assert budget_after.already_used == 1 assert budget_after.available == 24 + + +# --------------------------------------------------------------------------- +# Dubbeldagar count cap (settings.rules.max_dubbeldagar) reached mid-window +# -> the allocator falls back to a parent swap for the leftover days +# (allocation_service.py:979, `if used >= _MAX_DUBBELDAGAR`). +# +# TestCase10DubbeldagOutsideWindow above covers the *15-month window* edge +# (every day outside the window falls back to swap); TestDubbeldagSuccess in +# test_allocation_service_branches.py covers a short, uncapped success case. +# Neither drives the *count* cap itself to exhaustion -- this does, with a +# single `parent_mode='both'` wish long enough that its window contains more +# eligible weekdays than `max_dubbeldagar`, so `_fund_both`'s running `used` +# counter crosses the cap partway through and pushes every date after that +# point into `leftover_for_swap`. +# +# NOTE on the fallback's signature: `_fund_both` only ever routes a +# *non-free* (spendable weekday) date into `leftover_for_swap` -- every +# free/weekend/red day is funded for both parents directly inside +# `_fund_both`'s own loop, before the leftover list is even built (see the +# `is_free_day(...)` branch above the cap check in `allocation_service.py`). +# `REASON_SWAP_FIRST_HALF`/`REASON_SWAP_SECOND_HALF` are therefore never the +# right signal here: `_fund_swap` -> `_fund_solo` only stamps that reason on +# the *free-day* branch it takes for its half, and a cap-triggered leftover +# never contains a free day to stamp. The real, unambiguous signature of +# "funded as a parent swap rather than dubbeldagar" for this path is that +# each leftover date is funded for exactly ONE parent (an `_assign_tier3` +# tier day, e.g. `fp_interior`) instead of BOTH parents with +# `is_dubbeldag=True` -- confirmed against a live run of this exact scenario +# before writing the assertions below. +# --------------------------------------------------------------------------- + + +class TestCase23DubbeldagCountCapFallsBackToSwap: + def test_days_past_the_count_cap_are_funded_solo_not_as_dubbeldagar(self) -> None: + cap = settings.rules.max_dubbeldagar + # 15mo cutoff: 2024-04-01 -- well after the window below, so the + # *window* edge (Case 10) plays no part here; only the count cap can. + child = _child(birth_date=date(2023, 1, 1)) + parent_a = _parent(name="A") + parent_b = _parent(name="B") + wish = _wish( + child_id=child.id, + start=(1, 1), + end=(6, 30), # ~124 spendable weekdays -- comfortably more than the cap + weeks=26, + first_year=2023, + parent_mode="both", + ) + alloc_input = _build_input( + today=date(2023, 1, 1), + horizon_end=date(2023, 12, 31), + planning_mode="couple", + children=[child], + parents=[parent_a, parent_b], + pools={ + (parent_a.uuid, child.id): DayPool(300, 90, cap), + (parent_b.uuid, child.id): DayPool(300, 90, cap), + }, + vacation={}, + wishes=[wish], + ) + + proposal = Allocator().propose(alloc_input) + + dubbel_days = [d for d in proposal.days if d.is_dubbeldag] + dubbel_dates_a = { + d.date for d in dubbel_days if d.parent_personal_data_uuid == parent_a.uuid + } + dubbel_dates_b = { + d.date for d in dubbel_days if d.parent_personal_data_uuid == parent_b.uuid + } + # Both parents get a dubbeldag entry for exactly the same dates, and + # the running count stops growing once it hits the cap -- not before, + # not after. + assert dubbel_dates_a == dubbel_dates_b + assert len(dubbel_dates_a) == cap + + # Non-free, non-dubbeldag spendable days -- the leftover the cap + # pushed into `_fund_swap`. + leftover_days = [d for d in proposal.days if d.day_type != "free" and not d.is_dubbeldag] + assert leftover_days, "expected leftover-past-the-cap days to fall back to a parent swap" + + leftover_parents_by_date: dict[date, set] = {} + for d in leftover_days: + leftover_parents_by_date.setdefault(d.date, set()).add(d.parent_personal_data_uuid) + # Swap-funded: exactly one parent per leftover date -- never both, which + # is what would make it a dubbeldag instead. + assert all(len(parents) == 1 for parents in leftover_parents_by_date.values()) + # The fallback only ever kicks in for dates after the cap was reached + # (`used` is monotonically increasing within the window). + assert min(leftover_parents_by_date) > max(dubbel_dates_a) diff --git a/backend/tests/unit/test_services/test_fetch_data_service.py b/backend/tests/unit/test_services/test_fetch_data_service.py new file mode 100644 index 0000000..a5e5c6f --- /dev/null +++ b/backend/tests/unit/test_services/test_fetch_data_service.py @@ -0,0 +1,163 @@ +"""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. `git grep` found zero +existing coverage for this service or its router before this file. + +`test_fk_scraper.py::test_reserved_days_derived` already proves the +`reserved_days_left <= reserved_days_total` domain rule holds for the +scraper-side derivation; this file proves the equivalent manual-entry +boundary — the `ManualFetchService.save_manual_entry` validation itself — +which was previously untested. +""" + +from uuid import uuid4 + +import pytest + +from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository +from app.services.fetch_data_service import ManualFetchService +from app.utils.errors import ValidationError +from tests.conftest import SeededIds + + +def _valid_kwargs(seeded_ids: SeededIds, **overrides: object) -> dict: + base = dict( + personal_data_uuid=seeded_ids.personal_data_uuid, + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + total_days=480, + days_used=100, + reserved_days_total=90, + reserved_days_left=90, + transferable_days_left=150, + dubbeldagar_left=60, + sjukpenning_total=390, + sjukpenning_used=80, + lagsta_niva_total=90, + lagsta_niva_used=20, + ) + base.update(overrides) + return base + + +@pytest.fixture +def manual_fetch_service( + parental_leave_repo: SQLiteParentalLeaveRepository, +) -> ManualFetchService: + return ManualFetchService(parental_leave_repo) + + +class TestSaveManualEntryValidation: + async def test_days_used_exceeding_total_days_is_rejected( + self, + manual_fetch_service: ManualFetchService, + seeded_ids: SeededIds, + ) -> None: + kwargs = _valid_kwargs(seeded_ids, total_days=100, days_used=101) + with pytest.raises(ValidationError, match="days_used cannot exceed total_days"): + await manual_fetch_service.save_manual_entry(**kwargs) + + async def test_days_used_equal_to_total_days_is_accepted( + self, + manual_fetch_service: ManualFetchService, + seeded_ids: SeededIds, + ) -> None: + """The boundary itself (0 days left) is valid, not an off-by-one reject.""" + kwargs = _valid_kwargs(seeded_ids, total_days=100, days_used=100) + fetch = await manual_fetch_service.save_manual_entry(**kwargs) + assert fetch.days_left == 0 + + 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_total=90, reserved_days_left=91) + with pytest.raises( + ValidationError, match="reserved_days_left cannot exceed reserved_days_total" + ): + 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_total=90, 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, + days_used=100, + sjukpenning_total=390, + sjukpenning_used=80, + lagsta_niva_total=90, + lagsta_niva_used=20, + ) + 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 + + +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, days_used=50)) + await manual_fetch_service.save_manual_entry(**_valid_kwargs(seeded_ids, days_used=60)) + await manual_fetch_service.save_manual_entry(**_valid_kwargs(seeded_ids, days_used=70)) + + history = await manual_fetch_service.list_history( + seeded_ids.personal_data_uuid, seeded_ids.child_id + ) + + assert len(history) == 3 + assert [h.days_used for h in history] == [70, 60, 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 == []