Files
F-r-ldraledigplannerare/backend/tests/unit/test_planning_service_sync.py
T
jacobh2 6d92b87bf3 Give every year of a repeating period its own row
A repeating period was a single row that the planner expanded into one
window per year at read time, so no year had anywhere to differ from the
others. Editing one year rewrote them all, and removing a year in the
middle had to split the wish in two. Neither survives contact with the
thing this app is for: Easter moves, and the cheapest week is not the
same week every year.

Each year is now its own row, linked by a series id. Editing one year
touches one row; editing the series re-projects the rest from it.
Removing a year is one delete. The weekday snap is worked out once, when
the rows are made, and stored, so a row now says what it means rather
than being re-derived on every read. Recurrence stops being a stored
field and becomes what it always was in practice — an instruction at
create and update time; whether a period repeats is whether it belongs
to a series.

Migration 004 rebuilds the table and expands the existing rows, keeping
each original row as its first year so the days already planned against
it still point somewhere. A Feb 29 anchor recurring into a common year
used to raise out of the allocator; it clamps now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 15:53:25 +02:00

986 lines
39 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 (
EVICTION_CHOICE_DROP_FUTURE_PL,
EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR,
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,
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 TestSyncEvictionPreview:
"""`sync_plan`'s eviction preview/resolution — the fix for the data-loss
bug where editing one wish silently deleted another wish's rows because
the shared pool went to the edited wish first (see this file's module
docstring / PLANNING_ENGINE_SPEC.md). Both scenarios below use two wishes
over the EXACT SAME window (Mon 11 - Fri 15 Jan 2027 -- five plain
workdays, no weekend, no Swedish red day in range) so a higher-priority
wish's fresh placement claims literally the same dates the lower-priority
wish already had persisted, guaranteeing full contention regardless of
the exact day-count the allocator computes.
"""
async def test_would_evict_returns_preview_and_mutates_nothing(
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 this year, no lagsta -> the only lever is sjukpenning,
# so a shortfall there can never be silently backstopped by anything
# else (it must show up as a genuine shortfall/eviction).
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=0)
)
existing = await leave_wish_repo.create(
_make_wish(
ws,
child,
name="Existing",
window_start_month=1,
window_start_day=11,
window_end_month=1,
window_end_day=15,
priority=5,
)
)
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert first.apply_result.accepted is True
rows_after_first = await planned_leave_repo.list_by_workspace_child(ws, child)
existing_count = len(rows_after_first)
assert existing_count > 0
assert all(r.wish_id == existing.id for r in rows_after_first)
assert first.eviction_preview.would_evict is False
# Shrink the shared pool to exactly what `existing` already spent (no
# slack), then a higher-priority wish over the SAME window steals it.
await parental_leave_repo.create(
_make_fetch(
parent, ws, child, datetime(2027, 1, 2), sjuk_left=existing_count, lagsta_left=0
)
)
await leave_wish_repo.create(
_make_wish(
ws,
child,
name="New",
window_start_month=2,
window_start_day=1,
window_end_month=2,
window_end_day=5,
priority=1,
)
)
second = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
# Nothing accepted, nothing changed, and the caller must be told why.
assert second.apply_result.accepted is False
assert second.changes == []
assert second.apply_result.rejections
assert (
second.apply_result.rejections[0].message_key
== "planSync.rejection.evictionChoiceRequired"
)
preview = second.eviction_preview
assert preview.would_evict is True
assert preview.candidates, "expected the evicted 'Existing' days to be listed"
assert all(c.wish_id == existing.id for c in preview.candidates)
assert all(c.wish_name == "Existing" for c in preview.candidates)
assert all(c.day_type == "sjukpenning" for c in preview.candidates)
assert all(
c.child_id == child and c.parent_personal_data_uuid == parent
for c in preview.candidates
)
assert len(preview.shortages) == 1
shortage = preview.shortages[0]
assert shortage.resource_kind == "parental_leave"
assert shortage.parent_personal_data_uuid == parent
assert shortage.tier == "sjukpenning"
assert shortage.child_id == child
assert shortage.year is None
assert shortage.days_evicted == len(preview.candidates)
# Requirement: mutates NOTHING, not even the harmless parts.
rows_after_second = await planned_leave_repo.list_by_workspace_child(ws, child)
assert {r.id for r in rows_after_second} == {r.id for r in rows_after_first}
async def test_shortfall_days_is_not_masked_when_a_higher_priority_wish_claims_the_same_dates(
self,
planning_service: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Regression for a real bug this eviction-preview work uncovered in
`_Ledger._fund_solo` (`app/services/allocation_service.py`): when a
higher-priority wish's placement lands on the EXACT SAME calendar
dates a lower-priority wish already occupied (as opposed to merely
exhausting a shared pool on different dates — the scenario the test
above covers), `_fund_solo` used to find those dates already in
`_existing_lookup` (via `record_days()`, folded in by the earlier-
processed wish this same run) and credit them toward the LATER
wish's own `cost`/`shortfall_days` as if THAT wish had gotten them
too — so a wish entirely crowded out on every one of its dates could
still report `shortfall_days == 0`. Fixed by only crediting cost for
dates present in `_existing_lookup` BEFORE this run's own
`record_days()` calls started (`_pre_run_occupied`); a same-run
claim by a different wish now correctly falls through unfunded.
NOTE — known, separately-scoped gap this test does NOT claim to fix:
because the two wishes here land on the identical (child, parent,
date) keys, `sync_plan`'s `stale_keys`/`_genuinely_evicted_rows`
(which only look at keys ABSENT from the fresh batch) never fire —
the row survives and is silently metadata-updated to the new wish's
`wish_id` (a 'reattributed' `PlanChange`, not a deletion), bypassing
`eviction_preview` entirely. That is a real, distinct hole in the
eviction-preview feature (same-date collisions between two DIFFERENT
wishes are not gated the way stale-key drops are) — flagged in this
session's handoff report as follow-up work, not fixed here. This
test only pins down that `shortfall_days`/`weeks_funded` — the
signal both the UI's "window funded" display AND
`_genuinely_evicted_rows` depend on — are no longer silently wrong.
"""
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
)
)
await parental_leave_repo.create(
_make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=0)
)
existing = await leave_wish_repo.create(
_make_wish(
ws,
child,
name="Existing",
window_start_month=1,
window_start_day=11,
window_end_month=1,
window_end_day=15,
priority=5,
)
)
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert first.apply_result.accepted is True
existing_count = len(await planned_leave_repo.list_by_workspace_child(ws, child))
await parental_leave_repo.create(
_make_fetch(
parent, ws, child, datetime(2027, 1, 2), sjuk_left=existing_count, lagsta_left=0
)
)
# SAME window as `existing` -- the higher-priority wish's placement
# lands on the literal same calendar dates, not merely a shared pool.
await leave_wish_repo.create(
_make_wish(
ws,
child,
name="New",
window_start_month=1,
window_start_day=11,
window_end_month=1,
window_end_day=15,
priority=1,
)
)
second = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
existing_outcome = next(o for o in second.proposal.outcomes if o.wish_id == existing.id)
assert existing_outcome.shortfall_days == existing_count, (
"the crowded-out wish's own outcome must report the real shortfall, "
"not silently claim the other wish's days as its own funded total"
)
assert existing_outcome.weeks_funded == 0.0
async def test_drop_future_pl_choice_frees_the_days_and_lets_the_edit_land(
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,
)
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=0)
)
existing = await leave_wish_repo.create(
_make_wish(
ws,
child,
name="Existing",
window_start_month=1,
window_start_day=11,
window_end_month=1,
window_end_day=15,
priority=5,
)
)
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert first.apply_result.accepted is True
existing_count = len(await planned_leave_repo.list_by_workspace_child(ws, child))
await parental_leave_repo.create(
_make_fetch(
parent, ws, child, datetime(2027, 1, 2), sjuk_left=existing_count, lagsta_left=0
)
)
new_wish = await leave_wish_repo.create(
_make_wish(
ws,
child,
name="New",
window_start_month=2,
window_start_day=1,
window_end_month=2,
window_end_day=5,
priority=1,
)
)
preview = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert preview.eviction_preview.would_evict is True
resolved = await planning_service.sync_plan(
ws, today=date(2027, 1, 1), eviction_choice=EVICTION_CHOICE_DROP_FUTURE_PL
)
assert resolved.apply_result.accepted is True, resolved.apply_result.rejections
rows_after = await planned_leave_repo.list_by_workspace_child(ws, child)
assert rows_after, "expected the new wish's days to have been written"
assert all(r.wish_id == new_wish.id for r in rows_after)
assert not any(r.wish_id == existing.id for r in rows_after)
async def test_wrong_choice_still_rejects_and_mutates_nothing(
self,
planning_service: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""The vacation-eviction option can't free a parental-leave shortage
-- `apply_proposed_days`'s own balance validation rejects the whole
batch exactly as it would with no choice at all, atomically.
"""
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
)
)
await parental_leave_repo.create(
_make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=0)
)
await leave_wish_repo.create(
_make_wish(
ws,
child,
name="Existing",
window_start_month=1,
window_start_day=11,
window_end_month=1,
window_end_day=15,
priority=5,
)
)
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert first.apply_result.accepted is True
rows_after_first = await planned_leave_repo.list_by_workspace_child(ws, child)
existing_count = len(rows_after_first)
await parental_leave_repo.create(
_make_fetch(
parent, ws, child, datetime(2027, 1, 2), sjuk_left=existing_count, lagsta_left=0
)
)
await leave_wish_repo.create(
_make_wish(
ws,
child,
name="New",
window_start_month=2,
window_start_day=1,
window_end_month=2,
window_end_day=5,
priority=1,
)
)
resolved = await planning_service.sync_plan(
ws,
today=date(2027, 1, 1),
eviction_choice=EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR,
)
assert resolved.apply_result.accepted is False
rows_after = await planned_leave_repo.list_by_workspace_child(ws, child)
assert {r.id for r in rows_after} == {r.id for r in rows_after_first}
async def test_drop_latest_vacation_choice_frees_vacation_days(
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 FP at all -> the ONLY way either wish's window can be funded is
# vacation, so a shortfall here can only ever be a vacation shortage.
await parental_leave_repo.create(
_make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=0, lagsta_left=0)
)
vacation_row = await vacation_repo.create(
VacationDays(
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=25
)
)
existing = await leave_wish_repo.create(
_make_wish(
ws,
child,
name="Existing",
window_start_month=1,
window_start_day=11,
window_end_month=1,
window_end_day=15,
priority=5,
)
)
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert first.apply_result.accepted is True
rows_after_first = await planned_leave_repo.list_by_workspace_child(ws, child)
existing_count = len(rows_after_first)
assert existing_count > 0
assert all(r.day_type == "vacation" for r in rows_after_first)
# Shrink the shared vacation budget to exactly what `existing` spent.
vacation_row.total_days = existing_count
await vacation_repo.update(vacation_row.id, vacation_row)
new_wish = await leave_wish_repo.create(
_make_wish(
ws,
child,
name="New",
window_start_month=2,
window_start_day=1,
window_end_month=2,
window_end_day=5,
priority=1,
)
)
preview = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert preview.eviction_preview.would_evict is True
assert all(c.day_type == "vacation" for c in preview.eviction_preview.candidates)
assert len(preview.eviction_preview.shortages) == 1
shortage = preview.eviction_preview.shortages[0]
assert shortage.resource_kind == "vacation"
assert shortage.year == 2027
assert shortage.tier is None
assert shortage.child_id is None
# The wrong option (PL) can't touch a vacation shortage.
wrong = await planning_service.sync_plan(
ws, today=date(2027, 1, 1), eviction_choice=EVICTION_CHOICE_DROP_FUTURE_PL
)
assert wrong.apply_result.accepted is False
rows_still_original = await planned_leave_repo.list_by_workspace_child(ws, child)
assert {r.id for r in rows_still_original} == {r.id for r in rows_after_first}
resolved = await planning_service.sync_plan(
ws,
today=date(2027, 1, 1),
eviction_choice=EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR,
)
assert resolved.apply_result.accepted is True, resolved.apply_result.rejections
rows_after = await planned_leave_repo.list_by_workspace_child(ws, child)
assert rows_after
assert all(r.wish_id == new_wish.id for r in rows_after)
assert not any(r.wish_id == existing.id for r in rows_after)
async def test_pinned_day_never_evicted_under_any_choice(
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,
)
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=0)
)
await leave_wish_repo.create(
_make_wish(
ws,
child,
name="Existing",
window_start_month=1,
window_start_day=11,
window_end_month=1,
window_end_day=15,
priority=5,
)
)
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert first.apply_result.accepted is True
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
assert rows
to_pin = rows[0]
pinned = await planning_service.update(to_pin.id, is_pinned=True)
assert pinned.is_pinned is True
await parental_leave_repo.create(
_make_fetch(parent, ws, child, datetime(2027, 1, 2), sjuk_left=len(rows), lagsta_left=0)
)
await leave_wish_repo.create(
_make_wish(
ws,
child,
name="New",
window_start_month=2,
window_start_day=1,
window_end_month=2,
window_end_day=5,
priority=1,
)
)
preview = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert preview.eviction_preview.would_evict is True
assert all(c.date != pinned.date for c in preview.eviction_preview.candidates)
resolved = await planning_service.sync_plan(
ws, today=date(2027, 1, 1), eviction_choice=EVICTION_CHOICE_DROP_FUTURE_PL
)
assert resolved.apply_result.accepted is True, resolved.apply_result.rejections
rows_after = await planned_leave_repo.list_by_workspace_child(ws, child)
survivor = next((r for r in rows_after if r.id == pinned.id), None)
assert survivor is not None, "the pinned day must never be evicted"
assert survivor.is_pinned is True
assert survivor.date == pinned.date
assert survivor.day_type == pinned.day_type
class TestSyncEvictionPreviewAbsentWhenWithinBudget:
async def test_plain_sync_has_no_preview_friction(
self,
planning_service: PlanningService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Requirement 5 — a save that fits within budget applies straight
through: no extra round trip, no empty-but-present friction either.
"""
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))
result = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert result.apply_result.accepted is True
assert result.eviction_preview.would_evict is False
assert result.eviction_preview.candidates == []
assert result.eviction_preview.shortages == []
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 == []