Files
F-r-ldraledigplannerare/backend/tests/unit/test_planning_service_sync.py
T
jacobh2 c1aa4ae0b0 Credit days freed by the same sync before checking the balance
A sync deleted the rows a re-plan had dropped only after apply_proposed_days
had already validated, so capacity being freed in the very same call was
never counted. Shortening one period and lengthening another while near the
pool cap read as pure net-new demand and was refused outright, writing
nothing and losing the edit silently.

apply_proposed_days now takes the keys it should delete, credits those rows
back to their tier and to the dubbeldagar quota during the up-front checks,
and performs the deletions in its own write phase. Validation still runs
fully before any write, which is what SQLite's lack of a cross-request
transaction requires.
2026-09-17 18:12:31 +02:00

442 lines
18 KiB
Python

"""Tests for `PlanningService.sync_plan` (POST /api/plan/sync).
Covers the Task 4 contract: propose+apply in one step, a pinned
`planned_leave_day` is never retyped/overwritten by a re-plan even when the
fresh proposal would have chosen differently for that date, the `changes`
diff surfaces each of its four kinds, sync is idempotent on an unchanged
plan, and `/propose` stays read-only (persists nothing).
"""
from datetime import date, datetime
from uuid import uuid4
from app.entities.leave_wish import LeaveWish
from app.entities.parental_leave import ParentalLeaveFetch, TierData
from app.entities.personal_data import PersonalData
from app.entities.planned_leave import PlannedLeaveDay
from app.entities.vacation_days import VacationDays
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
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.planned_leave_repository import SQLitePlannedLeaveRepository
from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
from app.services.allocation_service import PlanningEngineService
from app.services.planning_service import PlanningService
from tests.conftest import SeededIds
def _make_wish(workspace_id, child_id, **overrides) -> LeaveWish:
base = dict(
id=uuid4(),
workspace_id=workspace_id,
child_id=child_id,
name="Test wish",
window_start_month=2,
window_start_day=1,
window_end_month=2,
window_end_day=7,
target_weeks=1,
recurrence="once",
first_year=2027,
last_year=None,
priority=1,
parent_mode="either",
parent_personal_data_uuid=None,
window_mode="fixed",
created_at=datetime.now(),
updated_at=datetime.now(),
)
base.update(overrides)
return LeaveWish(**base)
def _make_fetch(personal_data_uuid, workspace_id, child_id, fetched_at, sjuk_left, lagsta_left):
tier = TierData(
sjukpenning_total=390,
sjukpenning_used=390 - sjuk_left,
sjukpenning_left=sjuk_left,
lagsta_niva_total=90,
lagsta_niva_used=90 - lagsta_left,
lagsta_niva_left=lagsta_left,
)
return ParentalLeaveFetch(
id=uuid4(),
personal_data_uuid=personal_data_uuid,
workspace_id=workspace_id,
child_id=child_id,
source="manual",
fetched_at=fetched_at,
total_days=480,
days_used=480 - sjuk_left - lagsta_left,
days_left=sjuk_left + lagsta_left,
reserved_days_total=90,
reserved_days_left=90,
transferable_days_left=sjuk_left + lagsta_left,
dubbeldagar_left=60,
tier_data=tier,
created_at=fetched_at,
updated_at=fetched_at,
)
class TestSyncNeverOverwritesAPinnedDay:
async def test_pinned_day_survives_even_though_fresh_plan_would_differ(
self,
planning_service: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
# No vacation budget this year -> the monthly-salaried parent's
# tier-3 policy (vacation first) falls through to FP, sjukpenning
# preferred -- so a free interior day in this window would be
# funded as 'sjukpenning', never 'lagsta'.
await vacation_repo.create(
VacationDays(
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0
)
)
await leave_wish_repo.create(_make_wish(ws, child))
pinned_date = date(2027, 2, 3) # a Wednesday inside the wish's window
pinned = await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=ws,
child_id=child,
parent_personal_data_uuid=parent,
date=pinned_date,
day_type="lagsta", # deliberately NOT what a fresh plan would pick
is_pinned=True,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
result = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert result.apply_result.accepted is True
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
pinned_after = next(r for r in rows if r.date == pinned_date)
assert pinned_after.id == pinned.id
assert pinned_after.day_type == "lagsta"
assert pinned_after.is_pinned is True
# Every OTHER day in the window was free to be funded normally.
other_days = [r for r in rows if r.date != pinned_date]
assert other_days, "expected the rest of the window to be funded"
assert all(r.day_type == "sjukpenning" for r in other_days)
# The pinned date never shows up as a changed slot.
assert all(c.date != pinned_date for c in result.changes)
class TestSyncChangeKinds:
async def test_added_then_removed_when_the_wish_shrinks(
self,
planning_service: PlanningService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""A wish that still exists but now claims fewer dates: the dates it
drops (still `wish_id`-attributed, just no longer produced by this
run's batch) must be reported (and written) as 'removed' -- distinct
from a deleted wish, whose rows the DB itself detaches via
`ON DELETE SET NULL` (making them ad-hoc, permanently kept) before
`sync_plan` ever sees them.
"""
ws, child, _parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
wish = await leave_wish_repo.create(_make_wish(ws, child, name="A", window_end_day=7))
# -- first sync: pure 'added' (Feb 1-7) ---------------------------
result_1 = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert result_1.apply_result.accepted is True
assert result_1.changes, "expected at least one 'added' change"
assert {c.kind for c in result_1.changes} == {"added"}
# -- idempotent re-sync: no changes -------------------------------
result_2 = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert result_2.apply_result.accepted is True
assert result_2.changes == []
# -- shrink the window (Feb 1-4) -> the dropped tail is 'removed' -
wish.window_end_day = 4
await leave_wish_repo.update(wish.id, wish)
result_3 = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert result_3.apply_result.accepted is True
assert result_3.changes, "expected 'removed' changes once the window shrinks"
assert {c.kind for c in result_3.changes} == {"removed"}
assert all(c.date > date(2027, 2, 4) for c in result_3.changes)
async def test_retyped_when_the_underlying_pool_changes(
self,
planning_service: PlanningService,
personal_data_repo: SQLitePersonalDataRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
ws, child = seeded_ids.workspace_id, seeded_ids.child_id
# An hourly-salaried parent prefers FP (sjukpenning first) over
# vacation for tier-3 interior days, so this is deterministic
# regardless of any assumed vacation default.
parent = await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws, name="Hourly Parent", salary_type="hourly")
)
# `parent_mode="specific"` -- with a second (seeded) parent now also
# in the workspace, an "either" wish would default to whichever
# parent sorts first, not necessarily this one.
await leave_wish_repo.create(
_make_wish(ws, child, parent_mode="specific", parent_personal_data_uuid=parent.uuid)
)
await parental_leave_repo.create(
_make_fetch(
parent.uuid, ws, child, datetime(2027, 1, 1), sjuk_left=100, lagsta_left=100
)
)
result_1 = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert result_1.apply_result.accepted is True
assert any(c.new_day_type == "sjukpenning" for c in result_1.changes)
# sjukpenning now exhausted -> the SAME dates must fall back to lagsta.
await parental_leave_repo.create(
_make_fetch(parent.uuid, ws, child, datetime(2027, 1, 2), sjuk_left=0, lagsta_left=100)
)
result_2 = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert result_2.apply_result.accepted is True
assert result_2.changes, "expected 'retyped' changes once sjukpenning ran out"
assert {c.kind for c in result_2.changes} == {"retyped"}
assert all(
c.old_day_type == "sjukpenning" and c.new_day_type == "lagsta" for c in result_2.changes
)
async def test_reattributed_when_a_day_moves_to_a_different_wish(
self,
planning_service: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
await vacation_repo.create(
VacationDays(
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0
)
)
# `old_wish`'s own window doesn't cover `target_date` at all, so it
# never competes for that key -- it just needs to be a real, still-
# existing wish so the persisted row's `wish_id` stays non-null
# (unlike deleting it, which `ON DELETE SET NULL` would turn into an
# ad-hoc, permanently-kept row before `sync_plan` ever sees it).
old_wish = await leave_wish_repo.create(
_make_wish(
ws,
child,
name="Old",
window_start_month=3,
window_start_day=1,
window_end_month=3,
window_end_day=7,
)
)
new_wish = await leave_wish_repo.create(
_make_wish(
ws,
child,
name="New",
window_start_month=2,
window_start_day=3,
window_end_month=2,
window_end_day=3,
target_days=1,
)
)
target_date = date(2027, 2, 3)
# A row identical to what `new_wish` would compute for this date
# (same date/type), but still attributed to `old_wish`.
await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=ws,
child_id=child,
parent_personal_data_uuid=parent,
date=target_date,
day_type="sjukpenning",
wish_id=old_wish.id,
is_pinned=False,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
result = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert result.apply_result.accepted is True
by_date = {c.date: c for c in result.changes}
assert target_date in by_date
changed = by_date[target_date]
assert changed.kind == "reattributed"
assert changed.old_wish_id == old_wish.id
assert changed.new_wish_id == new_wish.id
assert changed.old_day_type == changed.new_day_type == "sjukpenning"
class TestSyncIdempotent:
async def test_unchanged_plan_produces_no_changes(
self,
planning_service: PlanningService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
ws, child, _parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
await leave_wish_repo.create(_make_wish(ws, child))
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert first.apply_result.accepted is True
second = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert second.apply_result.accepted is True
assert second.changes == []
class TestSyncCreditsFreedCapacityWithinSameCall:
"""Regression for the bug this change fixes: `sync_plan` used to delete
stale (shrunk-away) rows only AFTER `apply_proposed_days` had already
validated and written the fresh batch, so a wish shrinking never
credited the capacity it freed to a same-call growing wish -- a family
near their sjukpenning cap who shortened one window and lengthened
another would have the whole sync rejected with `insufficientBalance`
even though the freed days more than covered the new demand.
"""
async def test_shrinking_one_window_frees_capacity_for_a_new_one_in_the_same_sync(
self,
planning_service: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
# No vacation budget -> forces sjukpenning/lagsta funding, so this
# test only has to reason about the sjukpenning pool.
await vacation_repo.create(
VacationDays(
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0
)
)
await parental_leave_repo.create(
_make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=90)
)
summer = await leave_wish_repo.create(
_make_wish(
ws,
child,
name="Summer",
window_start_month=6,
window_start_day=1,
window_end_month=6,
window_end_day=30,
target_weeks=4,
)
)
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert first.apply_result.accepted is True
summer_rows = await planned_leave_repo.list_by_workspace_child(
ws, child, from_date=date(2027, 6, 1), to_date=date(2027, 6, 30)
)
sjuk_used = sum(1 for r in summer_rows if r.day_type == "sjukpenning")
assert sjuk_used > 10, "expected the 4-week summer window to fund several sjukpenning days"
# Pin the balance to exactly what's already spent -> zero slack left,
# simulating a family sitting right at their sjukpenning cap.
await parental_leave_repo.create(
_make_fetch(
parent, ws, child, datetime(2027, 1, 2), sjuk_left=sjuk_used, lagsta_left=90
)
)
# Shrink summer to one week and add a smaller winter wish elsewhere.
# The capacity freed by shrinking summer comfortably covers winter's
# demand, but only if the freed days are credited within this call.
summer.window_end_day = 7
summer.target_weeks = 1
await leave_wish_repo.update(summer.id, summer)
await leave_wish_repo.create(
_make_wish(
ws,
child,
name="Winter",
window_start_month=12,
window_start_day=1,
window_end_month=12,
window_end_day=7,
target_weeks=1,
)
)
second = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert second.apply_result.accepted is True, second.apply_result.rejections
assert not any(
r.message_key == "planApply.rejection.insufficientBalance"
for r in second.apply_result.rejections
)
winter_rows = await planned_leave_repo.list_by_workspace_child(
ws, child, from_date=date(2027, 12, 1), to_date=date(2027, 12, 7)
)
assert any(r.day_type == "sjukpenning" for r in winter_rows)
class TestProposeStaysReadOnly:
async def test_propose_never_writes_a_row(
self,
planning_engine_service: PlanningEngineService,
planned_leave_repo: SQLitePlannedLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
ws, child, _parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
await leave_wish_repo.create(_make_wish(ws, child))
proposal = await planning_engine_service.propose(ws, today=date(2027, 1, 1))
assert proposal.days, "expected the proposal to compute something"
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
assert rows == []