Fix flexible-wish double-booking, extend calendar cache to 2055, add journey tests

Second real bug found by driving the running app while the suites were green.

Flexible wishes were funded twice across propose -> apply -> re-propose. Because
planned_leave_day carries no wish_id, the engine could not tell that applied days
were that wish's own prior occurrence; it treated them as occupied space, routed
the candidate search around them, and funded the year again on shifted dates while
reporting weeks_funded 1.0. Applying twice turned "one week each spring" into 18
paid days instead of 9. Fixed wishes were unaffected and stay unaffected.

Fix (allocation_service.py), per the approved approach of counting already-applied
days inside the bounding range rather than adding a wish_id column:
- _Ledger._persisted_lookup snapshots existing days BEFORE this run folds in its
  own freshly-computed windows, so a fixed wish's new placement is never mistaken
  for a flexible wish's history
- _reuse_already_funded_window finds this wish's persisted dates within its bound
  and reuses the window containing them, via the ordinary fund_window path
- full funding therefore yields zero new days and partial funding tops up exactly
  the remainder, with no duplication and no false shortfall
- with no persisted dates, or if the reused window is no longer collision-free, it
  falls through to the original search, so an unsatisfied year is never frozen

Calendar cache extended 2026-2035 -> 2026-2055 (39KB -> 116KB) and the allocator
switched to strict, so an uncached year raises UncachedYearError -> ValidationError
-> HTTP 422 instead of silently treating Julafton, Nyarsafton and Midsommarafton as
working days. Also fixed normalize_day dropping the second name when two holidays
share a date (Nationaldagen was being lost). 2049 and 2055 legitimately have 17 red
days, not 18: Pingstdagen coincides with Nationaldagen.

New backend/tests/integration/test_user_journey.py drives the real app over HTTP
across seven journeys: the double-booking guard, the fixed-wish control, apply
idempotency, fixed-never-displaced-by-flexible, flexible wishes tracking Easter
per year, age-4 arithmetic self-consistency, and a recursive check that no Swedish
text reaches the wire now that everything is message_key + data.

New backend/scripts/seed_demo.py seeds a realistic workspace through the real
service layer for manual testing: demo@example.com / DemoPass123!

Backend 414 -> 435 tests. Frontend unchanged at 705.

Still not verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
2026-09-14 07:38:07 +02:00
parent 28c632ca36
commit 249e277696
15 changed files with 4828 additions and 53 deletions
@@ -15,6 +15,7 @@ import pytest
from app.entities.child import Child
from app.entities.day_override import DayOverride
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
@@ -29,7 +30,7 @@ from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedL
from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository
from app.services.allocation_service import AllocationInputLoader, Allocator, PlanningEngineService
from app.utils.errors import NotFoundError
from app.utils.errors import NotFoundError, ValidationError
def _make_loader(
@@ -391,3 +392,118 @@ class TestAllocationInputLoader:
date(2027, 12, 24): True,
date(2027, 2, 2): False,
}
async def test_load_costs_julafton_correctly_for_a_far_future_horizon(
self,
workspace_repo: SQLiteWorkspaceRepository,
child_repo: SQLiteChildRepository,
personal_data_repo: SQLitePersonalDataRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
) -> None:
"""Before the cache was extended to ~30 years, a horizon reaching the
late 2040s ran past the old 10-year cache (2026-2035) and the loader
silently fell back to the live `holidays` library, which reports
Julafton/Nyårsafton/Midsommarafton as ordinary working days. A child
born in 2044 pushes the horizon (their 4th birthday) to 2048 — assert
the resulting `holidays` map still carries all three as real red
days that far out, proving the extended cache (not a silent
fallback) served it, and that the allocator would cost them
correctly rather than charging a needless semesterdag."""
now = datetime.now()
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Future", created_at=now))
await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="Parent", created_at=now)
)
await child_repo.create(
Child(
id=uuid4(),
workspace_id=ws.id,
name="Barn",
birth_date=date(2044, 1, 1),
created_at=now,
)
)
loader = _make_loader(
workspace_repo,
child_repo,
personal_data_repo,
parental_leave_repo,
vacation_repo,
planned_leave_repo,
leave_wish_repo,
)
alloc_input = await loader.load(ws.id, today=date(2027, 1, 1))
assert alloc_input.horizon_end.year == 2048
julafton = alloc_input.holidays.get(date(2048, 12, 24))
assert julafton is not None and "julafton" in julafton.lower()
nyarsafton = alloc_input.holidays.get(date(2048, 12, 31))
assert nyarsafton is not None and "nyårsafton" in nyarsafton.lower()
midsommarafton = [
n for d, n in alloc_input.holidays.items() if "midsommarafton" in n.lower()
]
assert midsommarafton, "Midsommarafton must still be a real free day in 2048"
async def test_load_raises_validation_error_when_horizon_exceeds_the_cache(
self,
workspace_repo: SQLiteWorkspaceRepository,
child_repo: SQLiteChildRepository,
personal_data_repo: SQLitePersonalDataRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
) -> None:
"""A wish with a far-out `last_year` and no children yet (so there's
no age-12 hard cap to save it) can still in principle push the
horizon past even a ~30-year cache. Rather than silently falling
back to the live `holidays` library (the original bug) or silently
clamping the horizon (which would contradict the §6 decision to plan
past B4 and show the squeeze), the loader must raise a
`ValidationError` — the same domain error `CalendarService` uses for
an out-of-range `start_year` — so the router can turn it into a 422
the UI can explain."""
now = datetime.now()
ws = await workspace_repo.create(Workspace(id=uuid4(), name="FarFuture", created_at=now))
await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="Parent", created_at=now)
)
await leave_wish_repo.create(
LeaveWish(
id=uuid4(),
workspace_id=ws.id,
child_id=None,
name="Sommar",
window_start_month=6,
window_start_day=1,
window_end_month=6,
window_end_day=30,
target_weeks=4,
recurrence="yearly",
first_year=2027,
last_year=2200,
priority=1,
parent_mode="either",
parent_personal_data_uuid=None,
)
)
loader = _make_loader(
workspace_repo,
child_repo,
personal_data_repo,
parental_leave_repo,
vacation_repo,
planned_leave_repo,
leave_wish_repo,
)
with pytest.raises(ValidationError):
await loader.load(ws.id, today=date(2027, 1, 1))
@@ -21,6 +21,7 @@ from datetime import date, timedelta
from uuid import UUID, uuid4
from app.entities.leave_wish import LeaveWish
from app.entities.planned_leave import PlannedLeaveDay
from app.services.allocation_service import (
REASON_DUBBELDAG,
REASON_FP_LAGSTA_STRETCH,
@@ -152,6 +153,26 @@ def _build_input(
)
def _planned(
*, parent_uuid: UUID, child_id: UUID, on: date, day_type: str = "vacation"
) -> PlannedLeaveDay:
"""A persisted `planned_leave_day` row — the "already applied, before
this `propose()` run" building block for the §fix (double-booking on
flexible re-propose) test cases below. Only the fields
`AllocationInput.existing_planned` actually reads
(`parent_personal_data_uuid`, `child_id`, `date`, `day_type`) matter.
"""
return PlannedLeaveDay(
id=uuid4(),
workspace_id=uuid4(),
child_id=child_id,
parent_personal_data_uuid=parent_uuid,
date=on,
day_type=day_type,
)
FEB_2027 = ((2, 1), (2, 28)) # 28 days, 20 spendable, no real holidays nearby
AUG_2027 = ((8, 1), (8, 31)) # 31 days, 22 spendable, no real holidays nearby
@@ -1650,3 +1671,224 @@ class TestCase24WindowModeDefaultsToFixed:
proposal_explicit = _run(explicit_wish)
assert proposal_default == proposal_explicit
# ---------------------------------------------------------------------------
# §fix — double-booking on flexible re-propose (2026-09-14 handoff).
#
# A flexible wish-year that already has enough planned days inside its
# bounding range must be treated as funded, proposing nothing new, instead
# of the engine routing a *fresh* candidate search around the applied dates
# and funding the same wish-year a second time on shifted dates. All four
# scenarios reuse TestCase15's exact setup (VAR_2027 bound, weeks=1, Easter
# 2027-03-23..29 as the argmin-cost candidate: 4 free days + 3 spendable
# days 03-23/24/25, all funded via vacation) so the "already funded"/"top
# up"/"still may move" behaviour is verified against a known-good baseline.
# ---------------------------------------------------------------------------
class TestCase25FlexibleAlreadyFundedYearProposesNothingNew:
def test_fully_applied_year_adds_zero_new_paid_days(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id,
name="Vårlov",
start=VAR_2027[0],
end=VAR_2027[1],
weeks=1,
first_year=2027,
window_mode="flexible",
)
# The exact 3 spendable days TestCase15 funds when this wish-year is
# searched fresh -- simulating a prior propose -> apply cycle.
existing = [
_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, d))
for d in (23, 24, 25)
]
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_goal="balanced",
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[wish],
existing_planned=existing,
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.strategy != STRATEGY_UNPLACED
assert outcome.shortfall_days == 0
assert outcome.weeks_funded == outcome.weeks_wished == 1
# Reused the exact prior window, not a freshly searched one.
assert outcome.start_date == date(2027, 3, 23)
assert outcome.end_date == date(2027, 3, 29)
assert outcome.cost_breakdown["vacation"] == 3
assert outcome.cost_breakdown["free"] == 4
new_paid_days = [d for d in proposal.days if d.wish_id == wish.id and d.day_type != "free"]
assert new_paid_days == [], f"expected zero new paid days, got {new_paid_days}"
class TestCase26FlexiblePartialFundingTopsUpRemainder:
def test_partially_applied_year_tops_up_only_the_remainder(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id,
name="Vårlov",
start=VAR_2027[0],
end=VAR_2027[1],
weeks=1,
first_year=2027,
window_mode="flexible",
)
# Only ONE of the winning window's 3 spendable days was ever applied.
existing = [_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23))]
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_goal="balanced",
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[wish],
existing_planned=existing,
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
# Topped up to full funding in the SAME window, not a false shortfall.
assert outcome.shortfall_days == 0
assert outcome.weeks_funded == outcome.weeks_wished == 1
assert outcome.start_date == date(2027, 3, 23)
assert outcome.end_date == date(2027, 3, 29)
assert outcome.cost_breakdown["vacation"] == 3
new_paid_dates = {
d.date for d in proposal.days if d.wish_id == wish.id and d.day_type != "free"
}
# Exactly the remainder -- never re-proposes the already-applied day,
# never duplicates the whole week.
assert new_paid_dates == {date(2027, 3, 24), date(2027, 3, 25)}
class TestCase27FlexibleDeterminismWithExistingPlannedDays:
def test_repeated_runs_with_a_partially_applied_flexible_wish_are_identical(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id,
name="Vårlov",
start=VAR_2027[0],
end=VAR_2027[1],
weeks=1,
first_year=2027,
window_mode="flexible",
)
existing = [_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23))]
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_goal="balanced",
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[wish],
existing_planned=existing,
)
allocator = Allocator()
proposal_1 = allocator.propose(alloc_input)
proposal_2 = allocator.propose(alloc_input)
assert proposal_1 == proposal_2
class TestCase28FlexibleWishStillMovesWhenItsYearIsNotAlreadySatisfied:
"""§10.3's "still free to move" decision must survive the fix: a
partially-applied flexible occurrence whose reused window is no longer
available (because a higher-priority fixed wish now claims one of its
still-unfunded days) must NOT be frozen to that window -- it searches
again and lands somewhere collision-free, exactly like an untouched
flexible wish would.
"""
def test_prior_window_abandoned_when_a_fixed_wish_claims_part_of_it(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
# Only 2027-03-23 was ever applied for "Vårlov" -- the other two
# spendable days of the Easter window (03-24, 03-25) were proposed
# last time but never applied.
existing = [_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23))]
# A brand-new fixed wish now claims 2027-03-25 -- one of the Easter
# window's still-open days -- and, per §3.9/§3.11.2, always wins.
blocker = _wish(
child_id=child.id,
name="Fixed",
start=(3, 25),
end=(3, 25),
weeks=1,
first_year=2027,
window_mode="fixed",
)
flexible = _wish(
child_id=child.id,
name="Vårlov",
start=VAR_2027[0],
end=VAR_2027[1],
weeks=1,
first_year=2027,
window_mode="flexible",
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_goal="balanced",
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[blocker, flexible],
existing_planned=existing,
)
proposal = Allocator().propose(alloc_input)
by_name = {o.wish_name: o for o in proposal.outcomes}
fixed_outcome = by_name["Fixed"]
flex_outcome = by_name["Vårlov"]
# Fixed always wins its exact requested date.
assert fixed_outcome.start_date == fixed_outcome.end_date == date(2027, 3, 25)
# The flexible wish was NOT frozen to the original Easter window
# (03-23..03-29) -- that window is no longer collision-free, so it
# moved rather than silently overlapping the fixed wish's day.
assert (flex_outcome.start_date, flex_outcome.end_date) != (
date(2027, 3, 23),
date(2027, 3, 29),
)
assert flex_outcome.strategy != STRATEGY_UNPLACED
assert flex_outcome.shortfall_days == 0
flex_dates = {
flex_outcome.start_date + timedelta(days=i)
for i in range((flex_outcome.end_date - flex_outcome.start_date).days + 1)
}
# Still honours the previously-applied day (it's inside the new
# window's span) but never re-claims the fixed wish's date.
assert date(2027, 3, 23) in flex_dates
assert date(2027, 3, 25) not in flex_dates