Files
F-r-ldraledigplannerare/backend/tests/unit/test_planning_service_sync.py
T
jacobh2 6cd60887d5 Add a plan sync endpoint that recomputes, persists and reports the delta
The calendar is becoming the plan: a user edits a period and the schedule
updates immediately, with no separate propose and apply steps. POST
/api/plan/sync does both and returns a per-date diff against a snapshot taken
before any write, so the interface can say what actually moved rather than
guessing from coarse counters.

Pinned days now mean something. A day the user hand-edited keeps its type and
its parent: the loader hides unpinned wish-attributed rows from the allocator
so a re-plan is free to move them, while pinned rows stay visible and the
existing occupancy check refuses to touch them. Their cost is debited off the
pool before any window is funded, following the precedent already set for
preschool planning days.

Propose and apply are untouched. The allocator stays pure.
2026-09-17 17:58:47 +02:00

343 lines
14 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 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 == []