Files
F-r-ldraledigplannerare/backend/tests/unit/test_services/test_analytics_service.py
T
jacobh2 e4719cb9ae Rework balances around per-parent days left and deduce the partner from FK
Manual entry now takes one parent's sjukpenningnivå, lägstanivå and
dubbeldagar left (plus optional reserved days) and derives the rest in
build_snapshot, shared with the scraper. The scraper also reads the
family panel and exposes a partner deduction; with two parents it is
saved as a 'deduced' snapshot (migration 002). Parent count is the single
source of entitlement: one member gets 480 days and 96 savable at age 4,
two get 240 and 48 each. Workspaces are capped at two parents.

Also lands the in-flight squash of migrations 002–011 into the initial
schema and the removal of planning_mode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 12:27:29 +02:00

548 lines
22 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.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,
) -> AnalyticsService:
return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo, workspace_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,
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
)
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,
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
)
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,
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
)
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,
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
)
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,
) -> None:
from app.utils.errors import NotFoundError
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_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,
) -> None:
from app.utils.errors import NotFoundError
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_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,
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
)
# 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,
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
)
within_window = birth + relativedelta(months=1)
with pytest.raises(DubbeldagError):
await service.validate_dubbeldagar(child.id, within_window)
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,
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
)
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,
) -> None:
from app.utils.errors import NotFoundError
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_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,
) -> 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
)
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,
) -> 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
)
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,
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
)
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
)
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