6cd60887d5
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.
343 lines
14 KiB
Python
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 == []
|