Cover the balance entry path and three unguarded rules

Entering a parent's Försäkringskassan balances had no tests at all, despite
being how every number in the planner originates and despite it becoming the
first thing a new user does. The scraper endpoints are pinned per test rather
than read from the ambient configuration, because a local .env sets the flag
opposite to the shipped default and a test that trusts it would pass here and
fail in CI.

Also covered: the hard expiry at twelve, whose rejection path nothing reached
and whose boundary turns out to be inclusive; and the dubbeldagar count cap,
which now drives past the limit and asserts the days beyond it fall back to a
single parent. Two weak assertions were strengthened, including one that
derived its expectation from the same response it was checking and so could
not have failed on a shared error.
This commit is contained in:
2026-09-17 19:46:03 +02:00
parent 9d548da7d6
commit 0fc71101ae
6 changed files with 732 additions and 2 deletions
+43 -2
View File
@@ -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
# ---------------------------------------------------------------------------
@@ -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"
+1
View File
@@ -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."""
@@ -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
@@ -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)
@@ -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 == []