Files
F-r-ldraledigplannerare/backend/tests/unit/test_calendar_service.py
T
jacobh2 249e277696 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
2026-09-14 07:38:07 +02:00

310 lines
13 KiB
Python

"""Tests for `CalendarService` (§3.4 + §3.8: röda dagar / klämdagar API).
Covers: year-window pagination (fixed 4-year page, out-of-range rejection),
override merging (workspace DayOverrides layered on top of the statutory
calendar), and the exact-name holiday-matching trap (§4.2: Easter Sunday
comes back from the `holidays` library as the combined string
"Påskdagen; Söndag" — a naive `"Söndag" in name` filter would misclassify it
as a bare weekend).
"""
from datetime import date, datetime
from uuid import uuid4
import pytest
from app.entities.day_override import DayOverride
from app.services.calendar_service import MAX_WINDOW_YEARS, CalendarService
from app.services.day_override_service import DayOverrideService
from app.utils.errors import ValidationError
from app.utils.holidays import cached_year_bounds
from tests.conftest import SeededIds
@pytest.fixture
def calendar_service(day_override_service: DayOverrideService) -> CalendarService:
return CalendarService(day_override_service)
class TestYearWindowPagination:
async def test_default_window_is_four_years(self, calendar_service: CalendarService) -> None:
window = await calendar_service.get_calendar_window(workspace_id=uuid4(), start_year=2026)
assert window.start_year == 2026
assert window.end_year == 2029
years_seen = {d.date.year for d in window.days}
assert years_seen == {2026, 2027, 2028, 2029}
async def test_second_page_starts_where_first_left_off(
self, calendar_service: CalendarService
) -> None:
first = await calendar_service.get_calendar_window(workspace_id=uuid4(), start_year=2026)
second = await calendar_service.get_calendar_window(
workspace_id=uuid4(), start_year=first.end_year + 1
)
assert second.start_year == 2030
assert second.end_year == 2033
async def test_years_over_max_window_is_rejected(
self, calendar_service: CalendarService
) -> None:
with pytest.raises(ValidationError):
await calendar_service.get_calendar_window(
workspace_id=uuid4(), start_year=2026, years=MAX_WINDOW_YEARS + 1
)
async def test_start_year_outside_cache_is_rejected(
self, calendar_service: CalendarService
) -> None:
bounds = cached_year_bounds()
assert bounds is not None, "cache file must exist for this repo's test suite"
_, max_year = bounds
with pytest.raises(ValidationError):
await calendar_service.get_calendar_window(
workspace_id=uuid4(), start_year=max_year + 1
)
async def test_full_10_year_cache_is_never_returned_in_one_call(
self, calendar_service: CalendarService
) -> None:
bounds = cached_year_bounds()
assert bounds is not None
min_year, _ = bounds
window = await calendar_service.get_calendar_window(
workspace_id=uuid4(), start_year=min_year
)
years_seen = {d.date.year for d in window.days}
assert len(years_seen) == MAX_WINDOW_YEARS
async def test_window_reaching_max_year_is_clamped_not_rejected(
self, calendar_service: CalendarService
) -> None:
"""§ bug: `GET /days?start_year=<max_year>&years=4` used to happily
report `end_year = max_year + 3` and silently serve the extra years
from the degraded `holidays`-library fallback. The window must be
clamped to the cache instead, and `end_year` must report the ACTUAL
range served — never a range that wasn't returned.
"""
bounds = cached_year_bounds()
assert bounds is not None
_, max_year = bounds
window = await calendar_service.get_calendar_window(
workspace_id=uuid4(), start_year=max_year, years=MAX_WINDOW_YEARS
)
assert window.start_year == max_year
assert window.end_year == max_year, "must clamp end_year to the cache, not overshoot it"
years_seen = {d.date.year for d in window.days}
assert years_seen == {max_year}, "no day may come from a year beyond the cache"
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 (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
start_year = max_year - 1
window = await calendar_service.get_calendar_window(
workspace_id=uuid4(), start_year=start_year, years=MAX_WINDOW_YEARS
)
assert window.end_year == max_year
years_seen = {d.date.year for d in window.days}
assert years_seen == {start_year, max_year}
assert max(years_seen) <= max_year
class TestNoDegradedDataAcrossTheServedRange:
"""§4.2/§6: the fallback `holidays` library only knows formal röda dagar
and misses de facto days (Julafton, Nyårsafton, Midsommarafton). This is
exactly the regression that shipped: years past the cache silently lost
those days. Assert they're present for EVERY year the endpoint can ever
serve, and that no year is silently poorer than its neighbours.
"""
@staticmethod
async def _all_cached_years_red_days(
calendar_service: CalendarService,
) -> dict[int, dict[date, str]]:
bounds = cached_year_bounds()
assert bounds is not None
min_year, max_year = bounds
by_year: dict[int, dict[date, str]] = {}
start = min_year
while start <= max_year:
window = await calendar_service.get_calendar_window(
workspace_id=uuid4(), start_year=start
)
for day in window.days:
if day.day_type == "red_day":
by_year.setdefault(day.date.year, {})[day.date] = day.name or ""
start = window.end_year + 1
return by_year
async def test_julafton_nyarsafton_and_midsommarafton_are_red_days_every_year(
self, calendar_service: CalendarService
) -> None:
by_year = await self._all_cached_years_red_days(calendar_service)
bounds = cached_year_bounds()
assert bounds is not None
min_year, max_year = bounds
assert set(by_year) == set(range(min_year, max_year + 1)), (
"every cached year must be reachable via pagination"
)
for year, red_days in by_year.items():
names_by_date = {d: name.lower() for d, name in red_days.items()}
julafton = [d for d, n in names_by_date.items() if d.month == 12 and d.day == 24]
assert julafton and "julafton" in names_by_date[julafton[0]], (
f"{year}: Julafton (12-24) must be a red day"
)
nyarsafton = [d for d, n in names_by_date.items() if d.month == 12 and d.day == 31]
assert nyarsafton and "nyårsafton" in names_by_date[nyarsafton[0]], (
f"{year}: Nyårsafton (12-31) must be a red day"
)
midsommarafton = [d for d, n in names_by_date.items() if "midsommarafton" in n]
assert midsommarafton, f"{year}: Midsommarafton must be a red day"
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)
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:
async def test_worked_override_flips_a_statutory_red_day_to_not_free(
self,
calendar_service: CalendarService,
day_override_repo,
seeded_ids: SeededIds,
) -> None:
# 2026-12-25 (Juldagen) is a statutory red day; the workspace marks it
# a working day via an override.
christmas = date(2026, 12, 25)
await day_override_repo.create(
DayOverride(
id=uuid4(),
workspace_id=seeded_ids.workspace_id,
date=christmas,
is_working_day=True,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
window = await calendar_service.get_calendar_window(
workspace_id=seeded_ids.workspace_id, start_year=2026
)
by_date = {d.date: d for d in window.days}
christmas_day = by_date[christmas]
assert christmas_day.day_type == "red_day", "statutory classification must not change"
assert christmas_day.is_override is True
assert christmas_day.override_is_working_day is True
assert christmas_day.is_free is False, "the override must win over the statutory calendar"
async def test_off_override_flips_an_ordinary_working_day_to_free(
self,
calendar_service: CalendarService,
day_override_repo,
seeded_ids: SeededIds,
) -> None:
# 2026-01-02 is a bridge_day (klämdag) - an ordinary working day the
# employer happens to give off here.
ordinary_bridge_day = date(2026, 1, 2)
await day_override_repo.create(
DayOverride(
id=uuid4(),
workspace_id=seeded_ids.workspace_id,
date=ordinary_bridge_day,
is_working_day=False,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
window = await calendar_service.get_calendar_window(
workspace_id=seeded_ids.workspace_id, start_year=2026
)
by_date = {d.date: d for d in window.days}
day = by_date[ordinary_bridge_day]
assert day.day_type == "klamdag"
assert day.is_override is True
assert day.override_is_working_day is False
assert day.is_free is True
async def test_days_without_an_override_are_unaffected(
self, calendar_service: CalendarService, seeded_ids: SeededIds
) -> None:
window = await calendar_service.get_calendar_window(
workspace_id=seeded_ids.workspace_id, start_year=2026
)
assert all(not d.is_override and d.override_is_working_day is None for d in window.days)
class TestExactNameHolidayMatching:
"""§4.2: the `holidays` library combines a fixed holiday with "Söndag"
when it lands on a Sunday, e.g. "Påskdagen; Söndag". A naive
`"Söndag" in name` filter would wrongly classify these as bare weekends.
"""
async def test_easter_sunday_is_classified_as_red_day_even_when_it_reads_sondag(
self, calendar_service: CalendarService
) -> None:
# Easter Sunday always falls on a Sunday by definition, so in every
# cached year its cache/library name is combined with "Söndag".
window = await calendar_service.get_calendar_window(workspace_id=uuid4(), start_year=2026)
easter_sundays = [d for d in window.days if d.name and "påskdagen" in d.name.lower()]
assert easter_sundays, "expected an Easter Sunday in the 2026 window"
for day in easter_sundays:
assert day.day_type == "red_day"
assert day.name != "Söndag", "must carry the real holiday name, not the bare marker"
async def test_bare_sundays_are_never_classified_as_red_days(
self, calendar_service: CalendarService
) -> None:
window = await calendar_service.get_calendar_window(workspace_id=uuid4(), start_year=2026)
# 2026-01-04 is an ordinary Sunday with no coinciding holiday.
plain_sunday = date(2026, 1, 4)
by_date = {d.date: d for d in window.days}
day = by_date[plain_sunday]
assert day.day_type == "weekend"
assert day.name is None
assert day.is_free is True