Files
F-r-ldraledigplannerare/backend/tests/unit/test_services/test_analytics_service.py
T
jacobh2 1a02bdcdbf Let vacation years go back to the child's birth year
Settings only offered vacation-day rows from the current year forward,
while analytics ran its balance series from the child's birth year. Every
year in the gap had no vacation_days row, so the series assumed a full
statutory 25 earned with no use and banked earned - floor of it forward —
phantom saved days the user had no row to correct.

Settings now offers editable rows back to the birth year, and a past year
with no row assumes only the floor was earned, so it banks nothing. A past
year the user did enter is untouched by that rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:49:56 +02:00

1363 lines
53 KiB
Python

"""Tests for the core calculation engine (days-left-at-age-4).
These tests cover the headline feature and must cover edge cases:
leap years, unborn child (due date), no FK data yet, full planned calendar.
"""
from datetime import date, datetime
from uuid import uuid4
import pytest
from dateutil.relativedelta import relativedelta
from app.entities.child import Child
from app.entities.parental_leave import ParentalLeaveFetch, TierData
from app.entities.planned_leave import PlannedLeaveDay
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository
from app.services.analytics_service import AnalyticsService
from tests.conftest import SeededIds
def _make_child(workspace_id, birth_date: date) -> Child:
return Child(
id=uuid4(),
workspace_id=workspace_id,
name="TestChild",
birth_date=birth_date,
created_at=datetime.now(),
)
def _make_fetch(
personal_data_uuid, workspace_id, child_id, total=240, used=100
) -> ParentalLeaveFetch:
now = datetime.now()
tier = TierData(
sjukpenning_total=390,
sjukpenning_used=used,
sjukpenning_left=390 - used,
lagsta_niva_total=90,
lagsta_niva_used=0,
lagsta_niva_left=90,
)
return ParentalLeaveFetch(
id=uuid4(),
personal_data_uuid=personal_data_uuid,
workspace_id=workspace_id,
child_id=child_id,
source="manual",
fetched_at=now,
total_days=total,
days_used=used,
days_left=total - used,
reserved_days_total=90,
reserved_days_left=90,
transferable_days_left=50,
dubbeldagar_left=60,
tier_data=tier,
created_at=now,
updated_at=now,
)
async def _make_service(
child_repo: SQLiteChildRepository,
leave_repo: SQLiteParentalLeaveRepository,
planned_repo: SQLitePlannedLeaveRepository,
pd_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> AnalyticsService:
return AnalyticsService(
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
)
class TestAge4Projection:
async def test_no_planned_days_projection(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
# Use a birth date far in the future so today < B4
birth = date.today() + relativedelta(years=2)
child = _make_child(ws, birth)
await child_repo.create(child)
fetch = _make_fetch(pd_uuid, ws, child.id, total=240, used=100)
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
assert proj.days_used == 100
assert proj.planned_days_before_b4 == 0
assert proj.projected_left_at_b4 == 140 # 240 - 100
assert proj.savable_after_b4 == 96
assert proj.expiring_at_b4 == 44 # 140 - 96
assert proj.savable_cap_at_b4 == 96 # solo workspace -> per-child ceiling
async def test_no_fetch_data_assumes_full_entitlement(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date.today() + relativedelta(years=2)
child = _make_child(ws, birth)
await child_repo.create(child)
# No FK data entered
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
# `seeded_ids`'s workspace has exactly one member -> a sole parent's
# default entitlement is the FULL pool (480), not a partnered
# parent's half-share (240) -- see `default_tier_pool_share`.
assert proj.total_days == 480 # default entitlement
assert proj.days_used == 0
assert proj.projected_left_at_b4 == 480
assert proj.savable_after_b4 == 96
assert proj.expiring_at_b4 == 384 # 480 - 96
async def test_leap_year_birthday(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
# Child born 2024-02-29 (leap day); 4th birthday must be 2028-02-29
birth = date(2024, 2, 29)
child = _make_child(ws, birth)
await child_repo.create(child)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
# dateutil.relativedelta gives 2028-02-29 (2028 is a leap year)
assert proj.fourth_birthday == date(2028, 2, 29)
async def test_all_days_used_nothing_left(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date.today() + relativedelta(years=2)
child = _make_child(ws, birth)
await child_repo.create(child)
fetch = _make_fetch(pd_uuid, ws, child.id, total=240, used=240)
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
assert proj.projected_left_at_b4 == 0
assert proj.savable_after_b4 == 0
assert proj.expiring_at_b4 == 0
async def test_child_not_found_raises(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
from app.utils.errors import NotFoundError
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
with pytest.raises(NotFoundError):
await service.get_days_left_at_age_4(uuid4(), uuid4())
class TestValidateDubbeldagar:
async def test_child_not_found_raises(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
from app.utils.errors import NotFoundError
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
with pytest.raises(NotFoundError):
await service.validate_dubbeldagar(uuid4(), date.today())
async def test_outside_window_raises(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
from app.utils.errors import DubbeldagError
ws = seeded_ids.workspace_id
birth = date(2024, 1, 1)
child = _make_child(ws, birth)
await child_repo.create(child)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
# Date beyond the 15-month window
beyond_window = birth + relativedelta(months=16)
with pytest.raises(DubbeldagError):
await service.validate_dubbeldagar(child.id, beyond_window)
async def test_quota_exceeded_raises(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
from app.config import settings
from app.entities.planned_leave import PlannedLeaveDay
from app.utils.errors import DubbeldagError
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date(2024, 1, 1)
child = _make_child(ws, birth)
await child_repo.create(child)
# Fill up the dubbeldagar quota
for i in range(settings.rules.max_dubbeldagar):
day = birth + relativedelta(days=i)
await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=ws,
child_id=child.id,
parent_personal_data_uuid=pd_uuid,
date=day,
day_type="sjukpenning",
is_dubbeldag=True,
)
)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
within_window = birth + relativedelta(months=1)
with pytest.raises(DubbeldagError):
await service.validate_dubbeldagar(child.id, within_window)
async def test_allocator_and_validator_agree_when_cap_changes_at_runtime(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Regression: `allocation_service` used to snapshot
`settings.rules.max_dubbeldagar` into a module-level constant at
import time, while `AnalyticsService.validate_dubbeldagar` always
read the setting live. If the setting changed after import (e.g. a
rule-year config reload) the two could disagree: the allocator would
keep proposing dubbeldagar past a newly-lowered cap that the
validator would then reject. Both must now read the SAME live
value.
Lowers the cap to 2 at runtime -- well after `allocation_service`
was imported, so a stale snapshot would still show the higher
default -- and proves the allocator stops proposing dubbeldagar at
exactly the new cap, and that the validator independently agrees
the quota is exhausted at that same count.
"""
from app.config import settings
from app.entities.leave_wish import LeaveWish
from app.entities.personal_data import PersonalData
from app.entities.workspace import Workspace
from app.services.allocation_service import AllocationInputLoader, Allocator
from app.utils.errors import DubbeldagError
monkeypatch.setattr(
settings, "rules", settings.rules.model_copy(update={"max_dubbeldagar": 2})
)
now = datetime.now()
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent_a = await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="A", created_at=now)
)
parent_b = await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="B", created_at=now)
)
birth = date(2027, 1, 1)
child = _make_child(ws.id, birth)
await child_repo.create(child)
# Huge untouched pools so the FP pool itself never becomes the
# limiting factor -- only the (lowered) dubbeldagar quota should bind.
await parental_leave_repo.create(
_make_fetch(parent_a.uuid, ws.id, child.id, total=300, used=0)
)
await parental_leave_repo.create(
_make_fetch(parent_b.uuid, ws.id, child.id, total=300, used=0)
)
await leave_wish_repo.create(
LeaveWish(
id=uuid4(),
workspace_id=ws.id,
child_id=child.id,
name="Test",
window_start_month=2,
window_start_day=1,
window_end_month=2,
window_end_day=28, # clean month, 20 spendable weekdays -- well over the cap
target_weeks=4,
first_year=2027,
priority=1,
parent_mode="both",
parent_personal_data_uuid=None,
wants_dubbeldag=True,
)
)
loader = AllocationInputLoader(
workspace_repo=workspace_repo,
child_repo=child_repo,
personal_data_repo=personal_data_repo,
parental_leave_repo=parental_leave_repo,
vacation_repo=vacation_repo,
planned_leave_repo=planned_leave_repo,
leave_wish_repo=leave_wish_repo,
)
alloc_input = await loader.load(ws.id, today=date(2027, 1, 1))
proposal = Allocator().propose(alloc_input)
dubbel_dates = {d.date for d in proposal.days if d.is_dubbeldag}
# Allocator must honour the LOWERED live cap, not a higher
# import-time snapshot -- this is the headline regression.
assert len(dubbel_dates) == 2
# Persist exactly what the allocator proposed, then ask the
# validator (which always reads the setting live) whether one MORE
# dubbeldag is still allowed -- it must agree the quota is exhausted
# at the same count the allocator stopped at.
for d in dubbel_dates:
await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=ws.id,
child_id=child.id,
parent_personal_data_uuid=parent_a.uuid,
date=d,
day_type="sjukpenning",
is_dubbeldag=True,
)
)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
with pytest.raises(DubbeldagError):
await service.validate_dubbeldagar(child.id, date(2027, 2, 27))
class TestGetOverview:
async def test_overview_single_parent(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date.today() + relativedelta(years=2)
child = _make_child(ws, birth)
await child_repo.create(child)
fetch = _make_fetch(pd_uuid, ws, child.id, total=240, used=80)
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
overview = await service.get_overview(ws, child.id)
assert overview.workspace_id == ws
assert len(overview.parent_projections) == 1
assert overview.combined_days_used == 80
assert overview.combined_days_left == 160 # 240 - 80
assert overview.combined_expiring_at_b4 == 64 # 160 - 96
async def test_overview_no_parents_raises(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
from app.utils.errors import NotFoundError
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
with pytest.raises(NotFoundError):
await service.get_overview(uuid4(), uuid4())
class TestCoupleWorkspacePerParentCap:
"""Headline regression: a two-parent `couple` workspace must cap each
parent's own savable balance at 48 (not 96), so the combined savable
figure across both parents tops out at 96 total — never 192. This is
the bug this task exists to fix: the age-4 ceiling is per CHILD, not
per PARENT pool.
"""
async def test_two_parent_couple_caps_48_each_96_combined(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
from app.entities.personal_data import PersonalData
from app.entities.workspace import Workspace
now = datetime.now()
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent_a = await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="A", created_at=now)
)
parent_b = await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="B", created_at=now)
)
birth = date.today() + relativedelta(years=2)
child = _make_child(ws.id, birth)
await child_repo.create(child)
# Both parents sit on a huge untouched pool -> both should be
# clamped to the per-parent cap, never the per-child one.
fetch_a = _make_fetch(parent_a.uuid, ws.id, child.id, total=240, used=0)
fetch_b = _make_fetch(parent_b.uuid, ws.id, child.id, total=240, used=0)
await parental_leave_repo.create(fetch_a)
await parental_leave_repo.create(fetch_b)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj_a = await service.get_days_left_at_age_4(parent_a.uuid, child.id)
proj_b = await service.get_days_left_at_age_4(parent_b.uuid, child.id)
assert proj_a.savable_cap_at_b4 == 48
assert proj_b.savable_cap_at_b4 == 48
assert proj_a.savable_after_b4 == 48
assert proj_b.savable_after_b4 == 48
assert proj_a.expiring_at_b4 == 192 # 240 - 48
assert proj_b.expiring_at_b4 == 192
overview = await service.get_overview(ws.id, child.id)
combined_savable = sum(p.savable_after_b4 for p in overview.parent_projections)
assert combined_savable == 96 # never 192 — this is the headline regression
assert overview.combined_expiring_at_b4 == 384 # (240-48) * 2
async def test_single_member_workspace_falls_back_to_96(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
"""Safety belt: a workspace with only one member so far (partner not
added yet) must still use the per-child (96) figure — there is no
second parent to divide it with.
"""
from app.entities.personal_data import PersonalData
from app.entities.workspace import Workspace
now = datetime.now()
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent = await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="A", created_at=now)
)
birth = date.today() + relativedelta(years=2)
child = _make_child(ws.id, birth)
await child_repo.create(child)
fetch = _make_fetch(parent.uuid, ws.id, child.id, total=240, used=0)
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(parent.uuid, child.id)
assert proj.savable_cap_at_b4 == 96
assert proj.savable_after_b4 == 96
assert proj.expiring_at_b4 == 144 # 240 - 96
class TestSingleParentWorkspaceCap:
"""The age-4 cap is purely a function of parent count (no separate
"planning mode" flag exists): a workspace with a single member gets the
full per-child figure (96); `TestCoupleWorkspacePerParentCap` above
covers the two-parent 48/48 split.
"""
async def test_genuine_single_parent_workspace_gets_96(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date.today() + relativedelta(years=2)
child = _make_child(ws, birth)
await child_repo.create(child)
fetch = _make_fetch(pd_uuid, ws, child.id, total=240, used=0)
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
assert proj.savable_cap_at_b4 == 96
assert proj.savable_after_b4 == 96
class TestAgreesWithPlanProposalForTheSameCommittedState:
"""Bug A regression: the Dashboard (`AnalyticsService`) and the Plan tab's
propose-time projection (`allocation_service._Ledger.build_age4_projections`,
via `Allocator`/`AllocationInputLoader`) must compute the same
`expiring_at_b4` for a parent with no FK snapshot yet and no leave wishes
(so the proposal adds nothing new) — i.e. the *committed* state.
Both surfaces derive their no-snapshot default through the same shared
`app.utils.tip_predicates.default_tier_pool_share(rules, parent_count)`
helper, so they can never disagree again: a genuinely SOLE parent (this
workspace has exactly one member, "Sam") is entitled to the WHOLE
child's combined tier totals (`rules.sjukpenning_days` +
`rules.lagsta_niva_days` == 390 + 90 == 480) since there's no partner to
split the pool with; a partnered parent would instead get their fair
half-share (195 + 45 == 240). This test pins the two surfaces together.
"""
async def test_no_snapshot_no_wishes_matches_dashboard(
self,
workspace_repo: SQLiteWorkspaceRepository,
child_repo: SQLiteChildRepository,
personal_data_repo: SQLitePersonalDataRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
) -> None:
from app.entities.personal_data import PersonalData
from app.entities.workspace import Workspace
from app.services.allocation_service import AllocationInputLoader, Allocator
now = datetime.now()
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent = await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=ws.id,
name="Sam",
salary_type="monthly",
employment_status="employed",
created_at=now,
)
)
birth = date.today() + relativedelta(years=2)
child = _make_child(ws.id, birth)
await child_repo.create(child)
# Deliberately no ParentalLeaveFetch and no leave wishes for `parent`
# -> both surfaces must fall back to default entitlement.
# -- Dashboard side --
analytics = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
dashboard_proj = await analytics.get_days_left_at_age_4(parent.uuid, child.id)
# -- Plan-tab side --
loader = AllocationInputLoader(
workspace_repo=workspace_repo,
child_repo=child_repo,
personal_data_repo=personal_data_repo,
parental_leave_repo=parental_leave_repo,
vacation_repo=vacation_repo,
planned_leave_repo=planned_leave_repo,
leave_wish_repo=leave_wish_repo,
)
alloc_input = await loader.load(ws.id, today=date.today())
proposal = Allocator().propose(alloc_input)
assert proposal.days == [] # no wishes -> nothing new proposed
plan_proj = next(
p
for p in proposal.age4_projections
if p.parent_personal_data_uuid == parent.uuid and p.child_id == child.id
)
assert dashboard_proj.projected_left_at_b4 == plan_proj.projected_left_at_b4
assert dashboard_proj.savable_after_b4 == plan_proj.savable_after_b4
assert dashboard_proj.expiring_at_b4 == plan_proj.expiring_at_b4
# Pin the actual figures too, so a future regression of either
# fallback (240 vs. 480) fails loudly rather than merely disagreeing.
assert dashboard_proj.projected_left_at_b4 == 480
assert dashboard_proj.expiring_at_b4 == 384
async def test_vacation_left_matches_plan_proposal_in_the_birth_year(
self,
workspace_repo: SQLiteWorkspaceRepository,
child_repo: SQLiteChildRepository,
personal_data_repo: SQLitePersonalDataRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
) -> None:
"""The birth year is the FIRST year of `year_balances`' series, so its
`carried_in` is read straight off the `vacation_days` row's
`saved_days_in` (no multi-year FIFO simulation has happened yet) —
exactly what `AllocationInputLoader.load`'s `VacationBudget` also
does. The two surfaces must therefore agree for that one year.
(A LATER year's `left` is not asserted here: analytics simulates a
FIFO carry-forward bank across the whole series, while the plan
proposal reads whatever `saved_days_in` the user entered for that
specific year directly — they only need to agree where analytics
hasn't yet layered its own simulation on top, i.e. the first year.)
"""
from app.entities.personal_data import PersonalData
from app.entities.vacation_days import VacationDays
from app.entities.workspace import Workspace
from app.services.allocation_service import AllocationInputLoader, Allocator
now = datetime.now()
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent = await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=ws.id,
name="Sam",
salary_type="monthly",
employment_status="employed",
created_at=now,
)
)
birth = date.today() + relativedelta(years=2)
child = _make_child(ws.id, birth)
await child_repo.create(child)
await vacation_repo.create(
VacationDays(
id=uuid4(),
personal_data_uuid=parent.uuid,
workspace_id=ws.id,
year=birth.year,
total_days=25,
saved_days_in=4,
)
)
analytics = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
dashboard_proj = await analytics.get_days_left_at_age_4(parent.uuid, child.id)
birth_year_balance = next(
yb for yb in dashboard_proj.year_balances if yb.year == birth.year
)
loader = AllocationInputLoader(
workspace_repo=workspace_repo,
child_repo=child_repo,
personal_data_repo=personal_data_repo,
parental_leave_repo=parental_leave_repo,
vacation_repo=vacation_repo,
planned_leave_repo=planned_leave_repo,
leave_wish_repo=leave_wish_repo,
)
alloc_input = await loader.load(ws.id, today=date.today())
proposal = Allocator().propose(alloc_input)
plan_vacation = proposal.balances_after.vacation_after[(parent.uuid, birth.year)]
assert birth_year_balance.vacation.left == plan_vacation.available
assert birth_year_balance.vacation.left == 29 # 25 + 4 saved_days_in
class TestVacationYearBalanceSeries:
"""Pure unit tests against `AnalyticsService._vacation_year_series` — the
FIFO carry-forward bank simulation documented on `ParentYearBalance`.
Every case here passes `current_year=years[0]` so its whole series is
"current year or later" — these tests exercise the FIFO mechanics
themselves, not the past-vs-current-year `earned` rule, which
`TestPastYearEarnedRule` below covers on its own.
"""
def test_no_rows_assumes_statutory_default_and_floor(self) -> None:
years = [2025, 2026, 2027]
result = AnalyticsService._vacation_year_series(
years, {}, {}, floor=20, current_year=years[0]
)
first = result[0]
assert first.earned == 25 # rules.statutory_vacation_days_per_year default
assert first.is_assumed is True
assert first.carried_in == 0
assert first.left == 25
assert first.carried_out == 5 # max(25 - floor(20), 0)
# `expiring` now reports EVERY lost day, not just age-based losses
# (the bug this predicate/service pair fixes): of the 25 unused
# days, only 5 (earned - floor) can be banked — the other 20 (the
# unused floor) are forfeited outright, with no age-expiry involved
# yet in year one.
assert first.expiring == 20
assert first.expiring_age == 0
assert first.expiring_over_cap == 20
def test_carry_chain_accumulates_across_years(self) -> None:
years = [2025, 2026, 2027]
result = AnalyticsService._vacation_year_series(
years, {}, {}, floor=20, current_year=years[0]
)
# Unused years keep saving 5/year (25 earned - 20 floor) and the
# prior years' saved days carry forward untouched.
assert [r.carried_in for r in result] == [0, 5, 10]
assert [r.carried_out for r in result] == [5, 10, 15]
assert [r.left for r in result] == [25, 30, 35]
def test_saved_days_expire_after_five_years(self) -> None:
from app.config import settings
carry_years = settings.rules.vacation_carry_forward_years
# Exactly carry_years + 1 years: only 2020's own batch can possibly
# have aged out by the last year in this series.
years = list(range(2020, 2020 + carry_years + 1))
result = AnalyticsService._vacation_year_series(
years, {}, {}, floor=20, current_year=years[0]
)
by_year = {r.year: r for r in result}
# 2020's 5 saved days must not still be sitting in `carried_in`
# `carry_years` years later, and must have been reported as
# `expiring_age` exactly once, in the year they lapsed. (`expiring`
# itself is nonzero every year here too, since the unused floor is
# an over-cap loss every year — isolate the age-based portion.)
age_expiring_years = [y for y, r in by_year.items() if r.expiring_age > 0]
assert age_expiring_years == [2020 + carry_years]
assert by_year[2020 + carry_years].expiring_age == 5
assert by_year[2020 + carry_years].expiring_over_cap == 20
assert by_year[2020 + carry_years].expiring == 25
def test_continuous_saving_expires_the_oldest_batch_every_year_thereafter(self) -> None:
"""Once the bank has been running >= carry_years, an unbroken
surplus (earned always above floor, nothing spent) ages out exactly
one batch per year from then on — steady state, not a one-off event.
"""
from app.config import settings
carry_years = settings.rules.vacation_carry_forward_years
years = list(range(2020, 2020 + carry_years + 3))
result = AnalyticsService._vacation_year_series(
years, {}, {}, floor=20, current_year=years[0]
)
by_year = {r.year: r for r in result}
for year in years[carry_years:]:
assert by_year[year].expiring_age == 5
assert by_year[year].expiring_over_cap == 20 # unused floor, every year
assert by_year[year].expiring == 25
# Carried-in plateaus at exactly carry_years batches of 5.
assert by_year[years[-1]].carried_in == 5 * carry_years
def test_saved_days_in_override_on_later_year(self) -> None:
from app.entities.vacation_days import VacationDays
years = [2025, 2026, 2027]
# No row for 2025 (the seed year); a later year's row overrides the
# computed carry-in per `ParentYearBalance`'s docstring.
rows = {
2026: VacationDays(
id=uuid4(),
personal_data_uuid=uuid4(),
workspace_id=uuid4(),
year=2026,
total_days=25,
saved_days_in=12,
),
}
result = AnalyticsService._vacation_year_series(
years, rows, {}, floor=20, current_year=years[0]
)
by_year = {r.year: r for r in result}
assert by_year[2025].carried_in == 0 # nothing entered/simulated yet
assert by_year[2026].carried_in == 12 # override wins over computed (5)
assert by_year[2026].is_assumed is False
# 2027 continues the FIFO simulation from the overridden figure.
assert by_year[2027].carried_in == 17 # 12 (override) + 5 (2026's own saveable)
def test_zero_saved_days_in_on_later_year_does_not_override(self) -> None:
from app.entities.vacation_days import VacationDays
years = [2025, 2026]
rows = {
2026: VacationDays(
id=uuid4(),
personal_data_uuid=uuid4(),
workspace_id=uuid4(),
year=2026,
total_days=25,
saved_days_in=0,
),
}
result = AnalyticsService._vacation_year_series(
years, rows, {}, floor=20, current_year=years[0]
)
by_year = {r.year: r for r in result}
# 2025 saved 5 (25 - floor 20) with no usage; a zero saved_days_in on
# 2026's row must not wipe that computed carry-in out.
assert by_year[2026].carried_in == 5
def test_expiring_breakdown_matches_dashboard_screenshot_case(self) -> None:
"""Real dashboard bug report: earned 30, floor 20, planned_use 15 →
left 15, carried_out 10 — 5 days genuinely lost, but the OLD
age-only `expiring` showed 0. `expiring` must report every lost day,
with `expiring_over_cap` isolating the over-cap portion."""
from app.entities.vacation_days import VacationDays
years = [2025]
rows = {
2025: VacationDays(
id=uuid4(),
personal_data_uuid=uuid4(),
workspace_id=uuid4(),
year=2025,
total_days=30,
saved_days_in=0,
),
}
use_by_year = {2025: 15}
result = AnalyticsService._vacation_year_series(
years, rows, use_by_year, floor=20, current_year=years[0]
)
balance = result[0]
assert balance.left == 15
assert balance.carried_out == 10
assert balance.expiring == 5
assert balance.expiring_age == 0
assert balance.expiring_over_cap == 5
def test_age_expiry_and_over_cap_loss_coincide_without_double_counting(self) -> None:
"""A bank entry aging out at the 5-year cap and a fresh over-cap
loss can both happen in the same year (earned 25, floor 20, never
used: every year loses the unused floor AND, from year
`carry_years` on, also ages out that year's oldest 5-day batch).
`expiring` must equal their sum exactly once, never double-counted.
"""
from app.config import settings
carry_years = settings.rules.vacation_carry_forward_years
years = list(range(2020, 2020 + carry_years + 1))
result = AnalyticsService._vacation_year_series(
years, {}, {}, floor=20, current_year=years[0]
)
last = result[-1]
assert last.expiring_age == 5
assert last.expiring_over_cap == 20
assert last.expiring == 25
assert last.expiring == last.expiring_age + last.expiring_over_cap
class TestPastYearEarnedRule:
"""`_vacation_year_series`'s `current_year` parameter — the fix for
Settings now offering editable rows back to the child's birth year
(`computeVacationYearRange`). Before this, a year before `current_year`
with no `vacation_days` row was indistinguishable from a future year:
it assumed the full statutory 25 and banked `25 - floor` forward even
though the row was never rendered for the user to correct — phantom
"saved" vacation. See `ParentYearBalance`'s docstring.
"""
def test_phantom_past_years_with_no_row_carry_nothing_forward(self) -> None:
years = [2020, 2021, 2022]
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20, current_year=2023)
by_year = {r.year: r for r in result}
for year in years:
assert by_year[year].earned == 20 # floor, not the statutory 25
assert by_year[year].is_assumed is True
assert by_year[year].carried_in == 0
assert by_year[year].carried_out == 0
assert [r.carried_in for r in result] == [0, 0, 0]
def test_entered_past_row_still_carries_its_surplus_forward(self) -> None:
"""A past year the user genuinely entered data for is untouched by
the phantom-year rule above: its `total_days` still earns normally,
and any real surplus it banks still carries into the next (also
past) year — only a phantom (no-row) year banks nothing, never an
entered one.
"""
from app.entities.vacation_days import VacationDays
years = [2020, 2021]
rows = {
2020: VacationDays(
id=uuid4(),
personal_data_uuid=uuid4(),
workspace_id=uuid4(),
year=2020,
total_days=30,
saved_days_in=0,
),
}
result = AnalyticsService._vacation_year_series(
years, rows, {}, floor=20, current_year=2023
)
by_year = {r.year: r for r in result}
assert by_year[2020].earned == 30 # the entered row, not the floor
assert by_year[2020].is_assumed is False
assert by_year[2020].carried_out == 10 # 30 - floor(20), genuinely saved
# 2021 has no row and is itself a past year, but 2020's REAL surplus
# must still show up here, untouched.
assert by_year[2021].earned == 20 # floor — 2021 itself has no row
assert by_year[2021].carried_in == 10
assert by_year[2021].carried_out == 10
class TestParentalLeaveYearBalanceSeries:
"""Pure unit tests against `AnalyticsService._pl_year_series`."""
def test_no_snapshot_deducts_from_birth_using_default_share(self) -> None:
birth = date(2025, 1, 1)
b4 = date(2029, 1, 1)
years = [2025, 2026, 2027, 2028, 2029]
pid, cid = uuid4(), uuid4()
rows = [
PlannedLeaveDay(
id=uuid4(),
workspace_id=uuid4(),
child_id=cid,
parent_personal_data_uuid=pid,
date=date(2026, 6, 1),
day_type="sjukpenning",
)
]
result = AnalyticsService._pl_year_series(
years, rows, birth, b4, savable_cap=96, latest_fetch=None, default_share=(390, 90)
)
by_year = {r.year: r for r in result}
assert by_year[2025].sjukpenning_left == 390 # nothing planned yet
assert by_year[2026].sjukpenning_used == 1
assert by_year[2026].sjukpenning_left == 389 # deducted from birth (no snapshot)
def test_snapshot_mid_series_only_deducts_post_snapshot_rows(self) -> None:
from app.entities.parental_leave import ParentalLeaveFetch, TierData
birth = date(2025, 1, 1)
b4 = date(2029, 1, 1)
years = [2025, 2026, 2027, 2028, 2029]
pid, cid = uuid4(), uuid4()
fetch = ParentalLeaveFetch(
id=uuid4(),
personal_data_uuid=pid,
workspace_id=uuid4(),
child_id=cid,
source="manual",
fetched_at=datetime(2026, 6, 1),
total_days=480,
days_used=10,
days_left=470,
reserved_days_total=0,
reserved_days_left=0,
transferable_days_left=0,
dubbeldagar_left=60,
tier_data=TierData(
sjukpenning_total=390,
sjukpenning_used=10,
sjukpenning_left=380,
lagsta_niva_total=90,
lagsta_niva_used=0,
lagsta_niva_left=90,
),
)
rows = [
# Before the snapshot — already reflected in `sjukpenning_left`
# above, must NOT be deducted again.
PlannedLeaveDay(
id=uuid4(),
workspace_id=uuid4(),
child_id=cid,
parent_personal_data_uuid=pid,
date=date(2026, 1, 1),
day_type="sjukpenning",
),
# After the snapshot — must be deducted.
PlannedLeaveDay(
id=uuid4(),
workspace_id=uuid4(),
child_id=cid,
parent_personal_data_uuid=pid,
date=date(2026, 7, 1),
day_type="sjukpenning",
),
]
result = AnalyticsService._pl_year_series(
years, rows, birth, b4, savable_cap=96, latest_fetch=fetch, default_share=None
)
by_year = {r.year: r for r in result}
assert by_year[2026].sjukpenning_used == 2 # both rows counted for display
assert by_year[2026].sjukpenning_left == 379 # only the post-snapshot row deducted
assert by_year[2027].sjukpenning_left == 379 # unchanged, carries forward
def test_age4_clamp_cuts_lagsta_first_in_b4_year(self) -> None:
from app.entities.parental_leave import ParentalLeaveFetch, TierData
birth = date(2025, 1, 1)
b4 = date(2029, 1, 1)
years = [2025, 2026, 2027, 2028, 2029]
pid, cid = uuid4(), uuid4()
fetch = ParentalLeaveFetch(
id=uuid4(),
personal_data_uuid=pid,
workspace_id=uuid4(),
child_id=cid,
source="manual",
fetched_at=datetime(2025, 1, 1),
total_days=480,
days_used=0,
days_left=480,
reserved_days_total=0,
reserved_days_left=0,
transferable_days_left=0,
dubbeldagar_left=60,
tier_data=TierData(
sjukpenning_total=390,
sjukpenning_used=0,
sjukpenning_left=390,
lagsta_niva_total=90,
lagsta_niva_used=0,
lagsta_niva_left=90,
),
)
result = AnalyticsService._pl_year_series(
years, [], birth, b4, savable_cap=48, latest_fetch=fetch, default_share=None
)
by_year = {r.year: r for r in result}
# Untouched pool (390 + 90 = 480) clamped to 48 at b4, lagsta cut first.
assert by_year[2029].lagsta_left == 0
assert by_year[2029].sjukpenning_left == 48
# Years before b4 are unaffected by the clamp.
assert by_year[2028].sjukpenning_left == 390
assert by_year[2028].lagsta_left == 90
class TestYearBalancesOnProjection:
"""`Age4Projection.year_balances` end-to-end through the real repos."""
async def test_series_spans_birth_year_through_b4_year(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date(2025, 3, 1)
child = _make_child(ws, birth)
await child_repo.create(child)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
assert [yb.year for yb in proj.year_balances] == [2025, 2026, 2027, 2028, 2029]
assert all(yb.personal_data_uuid == pd_uuid for yb in proj.year_balances)
assert all(yb.child_id == child.id for yb in proj.year_balances)
async def test_vacation_planned_use_counts_rows_across_all_children(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
"""Vacation is budgeted per (parent, year), not per child — see
`PlanningService._vacation_days_left`. A vacation day planned
against a DIFFERENT child of the same parent, in the same year,
must still reduce `planned_use` here.
"""
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date(2025, 3, 1)
child = _make_child(ws, birth)
await child_repo.create(child)
other_child = _make_child(ws, date(2020, 1, 1))
await child_repo.create(other_child)
from app.entities.planned_leave import PlannedLeaveDay
await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=ws,
child_id=other_child.id,
parent_personal_data_uuid=pd_uuid,
date=date(2025, 6, 1),
day_type="vacation",
)
)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
year_2025 = next(yb for yb in proj.year_balances if yb.year == 2025)
assert year_2025.vacation.planned_use == 1
class TestOverviewYearTotals:
async def test_combines_both_parents_per_year(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
from app.entities.personal_data import PersonalData
from app.entities.workspace import Workspace
now = datetime.now()
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent_a = await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="A", created_at=now)
)
parent_b = await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="B", created_at=now)
)
birth = date(2025, 1, 1)
child = _make_child(ws.id, birth)
await child_repo.create(child)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
overview = await service.get_overview(ws.id, child.id)
assert [yt.year for yt in overview.year_totals] == [2025, 2026, 2027, 2028, 2029]
birth_year_totals = overview.year_totals[0]
parent_a_proj = next(
p for p in overview.parent_projections if p.personal_data_uuid == parent_a.uuid
)
parent_b_proj = next(
p for p in overview.parent_projections if p.personal_data_uuid == parent_b.uuid
)
a_2025 = next(yb for yb in parent_a_proj.year_balances if yb.year == 2025)
b_2025 = next(yb for yb in parent_b_proj.year_balances if yb.year == 2025)
expected_pl_left = (
a_2025.parental_leave.sjukpenning_left
+ a_2025.parental_leave.lagsta_left
+ b_2025.parental_leave.sjukpenning_left
+ b_2025.parental_leave.lagsta_left
)
expected_vacation_left = a_2025.vacation.left + b_2025.vacation.left
assert birth_year_totals.pl_left == expected_pl_left
assert birth_year_totals.vacation_left == expected_vacation_left