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
+26 -9
View File
@@ -102,9 +102,9 @@ class TestYearWindowPagination:
async def test_a_middle_page_that_would_overshoot_is_also_clamped(
self, calendar_service: CalendarService
) -> None:
"""Same clamp, exercised one page before the very last year (the
`start_year=2034, years=4` case from real paging: 2034+3=2037 would
overshoot the 2026-2035 cache by two years)."""
"""Same clamp, exercised one page before the very last year (e.g. the
`start_year=max_year-1, years=4` case from real paging, which would
overshoot the cache's end by two years without the clamp)."""
bounds = cached_year_bounds()
assert bounds is not None
_, max_year = bounds
@@ -178,13 +178,30 @@ class TestNoDegradedDataAcrossTheServedRange:
async def test_red_day_count_is_consistent_across_every_served_year(
self, calendar_service: CalendarService
) -> None:
"""No year may be silently poorer than its neighbours (the fallback's
signature: 18 real red days in-cache vs. 13 once it kicks in).
The one legitimate exception in a ~30-year range: Pingstdagen
(Easter + 49 days) occasionally lands on Sveriges nationaldag (fixed
June 6), e.g. 2049 and 2055. That's 17 *distinct* red dates, not a
degradation — both holidays genuinely share one calendar date, and
the API itself reports 17 for that year. Assert the exception is
exactly this coincidence, by name, rather than loosely allowing any
two counts.
"""
by_year = await self._all_cached_years_red_days(calendar_service)
counts = {year: len(red_days) for year, red_days in by_year.items()}
# No year may be silently poorer than its neighbours (the fallback's
# signature: 18 real red days in-cache vs. 13 once it kicks in).
assert len(set(counts.values())) == 1, (
f"red-day counts differ across years, one may be degraded: {counts}"
)
for year, red_days in by_year.items():
names_by_date = {d: name.lower() for d, name in red_days.items()}
june_6_name = next(
(n for d, n in names_by_date.items() if d.month == 6 and d.day == 6), ""
)
pingst_meets_nationaldag = "pingstdagen" in june_6_name and "nationaldag" in june_6_name
expected = 17 if pingst_meets_nationaldag else 18
assert len(red_days) == expected, (
f"{year}: expected {expected} red dates "
f"({'Pingstdagen/Nationaldagen coincide' if pingst_meets_nationaldag else 'normal year'}), "
f"got {len(red_days)} — a year may be degraded: {sorted(red_days)}"
)
class TestOverrideMerging: