Files
F-r-ldraledigplannerare/backend/tests/unit/test_holidays.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

315 lines
12 KiB
Python

"""Tests for Swedish holiday utilities."""
import datetime
import pytest
from app.utils.holidays import (
UncachedYearError,
cached_year_bounds,
get_holiday_name,
get_red_days,
get_swedish_holidays,
is_klamdag,
is_non_working_day,
is_swedish_holiday,
)
class TestGetSwedishHolidays:
"""Test get_swedish_holidays function."""
def test_returns_dict(self) -> None:
"""get_swedish_holidays returns a dictionary."""
result = get_swedish_holidays(2026)
assert isinstance(result, dict)
assert len(result) > 0
def test_keys_are_dates(self) -> None:
"""All keys are datetime.date objects."""
result = get_swedish_holidays(2026)
for key in result.keys():
assert isinstance(key, datetime.date)
def test_values_are_strings(self) -> None:
"""All values are strings."""
result = get_swedish_holidays(2026)
for value in result.values():
assert isinstance(value, str)
assert len(value) > 0
def test_christmas_2026(self) -> None:
"""Christmas 2026 (Dec 25) is in the holidays."""
result = get_swedish_holidays(2026)
christmas = datetime.date(2026, 12, 25)
assert christmas in result
assert "Jul" in result[christmas] # Holiday name contains "Jul"
def test_new_year_2026(self) -> None:
"""New Year's Day 2026 (Jan 1) is in the holidays."""
result = get_swedish_holidays(2026)
new_year = datetime.date(2026, 1, 1)
assert new_year in result
def test_midsummer_2026(self) -> None:
"""Midsummer 2026 (Midsommar) is in the holidays."""
result = get_swedish_holidays(2026)
# Midsummer is always a Saturday in mid-June
midsummer_2026 = datetime.date(2026, 6, 20)
assert midsummer_2026 in result
assert "Midsommar" in result[midsummer_2026]
def test_easter_2026(self) -> None:
"""Easter-related days (Långfredagen, Påskdagen) in 2026."""
result = get_swedish_holidays(2026)
# Easter 2026 is April 5; Good Friday would be April 3
good_friday = datetime.date(2026, 4, 3)
easter_sunday = datetime.date(2026, 4, 5)
assert good_friday in result
assert easter_sunday in result
def test_different_years(self) -> None:
"""Different years have different holiday sets."""
holidays_2025 = get_swedish_holidays(2025)
holidays_2026 = get_swedish_holidays(2026)
holidays_2027 = get_swedish_holidays(2027)
# All should have holidays
assert len(holidays_2025) > 0
assert len(holidays_2026) > 0
assert len(holidays_2027) > 0
def test_caching(self) -> None:
"""Calling get_swedish_holidays twice returns same object (cached)."""
result1 = get_swedish_holidays(2026)
result2 = get_swedish_holidays(2026)
# Should be the same object due to caching
assert result1 is result2
def test_random_day_not_holiday(self) -> None:
"""Random weekday is not a holiday."""
result = get_swedish_holidays(2026)
random_day = datetime.date(2026, 6, 8) # Random Monday
assert random_day not in result
class TestIsSwedishHoliday:
"""Test is_swedish_holiday function."""
def test_christmas_is_holiday(self) -> None:
"""Christmas is recognized as a holiday."""
assert is_swedish_holiday(datetime.date(2026, 12, 25))
def test_new_year_is_holiday(self) -> None:
"""New Year's Day is recognized as a holiday."""
assert is_swedish_holiday(datetime.date(2026, 1, 1))
def test_random_day_not_holiday(self) -> None:
"""Random day is not a holiday."""
assert not is_swedish_holiday(datetime.date(2026, 6, 8))
def test_monday_not_holiday(self) -> None:
"""Random Monday is not a holiday."""
assert not is_swedish_holiday(datetime.date(2026, 6, 1))
class TestGetHolidayName:
"""Test get_holiday_name function."""
def test_christmas_name(self) -> None:
"""Get name of Christmas."""
name = get_holiday_name(datetime.date(2026, 12, 25))
assert name is not None
assert "Jul" in name
def test_non_holiday_returns_none(self) -> None:
"""Non-holiday date returns None."""
name = get_holiday_name(datetime.date(2026, 6, 8))
assert name is None
def test_new_year_name(self) -> None:
"""Get name of New Year's Day."""
name = get_holiday_name(datetime.date(2026, 1, 1))
assert name is not None
def test_midsummer_name(self) -> None:
"""Get name of Midsummer."""
name = get_holiday_name(datetime.date(2026, 6, 20))
assert name is not None
assert "Midsommar" in name
# ---------------------------------------------------------------------------
# De facto days (julafton, midsommarafton, nyårsafton) — cached-year source
# ---------------------------------------------------------------------------
class TestDeFactoDaysAreNonWorking:
"""The calendar cache adds de facto days the `holidays` library doesn't
know about — these must count as free, closing the bug where the
allocator would spend a vacation day on julafton."""
def test_julafton_is_non_working(self) -> None:
assert is_non_working_day(datetime.date(2027, 12, 24))
assert is_swedish_holiday(datetime.date(2027, 12, 24))
name = get_holiday_name(datetime.date(2027, 12, 24))
assert name is not None
assert "Julafton" in name
def test_nyarsafton_is_non_working(self) -> None:
assert is_non_working_day(datetime.date(2027, 12, 31))
name = get_holiday_name(datetime.date(2027, 12, 31))
assert name is not None
assert "Nyårsafton" in name
def test_midsommarafton_is_non_working(self) -> None:
# Midsommardagen 2027 is Sat 2027-06-26 -> midsommarafton is Fri 6/25.
assert is_non_working_day(datetime.date(2027, 6, 25))
name = get_holiday_name(datetime.date(2027, 6, 25))
assert name is not None
assert "Midsommarafton" in name
def test_ordinary_weekday_is_still_working(self) -> None:
assert not is_non_working_day(datetime.date(2027, 3, 2)) # a plain Tuesday
# ---------------------------------------------------------------------------
# Klämdagar
# ---------------------------------------------------------------------------
class TestKlamdag:
def test_known_klamdag_2027_is_detected(self) -> None:
"""2027-05-06 (Thu) is Kristi himmelsfärdsdag; 2027-05-08 (Sat) is the
weekend; 2027-05-07 (Fri) is squeezed between them."""
assert is_klamdag(datetime.date(2027, 5, 7))
def test_julafton_itself_is_not_a_klamdag(self) -> None:
"""Julafton is a free day in its own right, not a working day
squeezed between two others."""
assert not is_klamdag(datetime.date(2027, 12, 24))
def test_ordinary_weekday_is_not_a_klamdag(self) -> None:
assert not is_klamdag(datetime.date(2027, 3, 2))
def test_klamdag_detected_for_uncached_fallback_year(self) -> None:
"""2060 is outside the cached range (2026-2055): 2060-01-02 (Fri) is
squeezed between Nyårsdagen (Thu 1/1) and the weekend (Sat 1/3).
`is_klamdag` is computed generically from `is_non_working_day`, so
this works even without cached klämdag data for the year."""
assert is_klamdag(datetime.date(2060, 1, 2))
# ---------------------------------------------------------------------------
# get_red_days — real red days, excluding ordinary Sundays
# ---------------------------------------------------------------------------
class TestGetRedDays:
def test_bare_sunday_excluded(self) -> None:
"""2027-01-03 is a plain Sunday with no holiday -> excluded."""
red_days = get_red_days(2027)
assert datetime.date(2027, 1, 3) not in red_days
def test_easter_sunday_survives_the_filter(self) -> None:
"""Påskdagen 2027 (2027-03-28) IS a Sunday, but must survive: the
filter matches the exact string "Söndag" only, not `"Söndag" in name`.
A substring filter would silently delete this (and Pingstdagen,
Nationaldagen, Annandag jul whenever they land on a Sunday)."""
red_days = get_red_days(2027)
easter_sunday = datetime.date(2027, 3, 28)
assert easter_sunday in red_days
assert "Påskdagen" in red_days[easter_sunday]
def test_julafton_included_as_a_real_red_day(self) -> None:
red_days = get_red_days(2027)
assert datetime.date(2027, 12, 24) in red_days
def test_only_bare_sundays_are_removed(self) -> None:
all_holidays = get_swedish_holidays(2027)
red_days = get_red_days(2027)
removed = set(all_holidays) - set(red_days)
assert all(all_holidays[d] == "Söndag" for d in removed)
# ---------------------------------------------------------------------------
# Fallback path for years outside the cached range (2026-2055)
# ---------------------------------------------------------------------------
class TestFallbackForUncachedYear:
def test_uncached_year_still_returns_formal_holidays(self) -> None:
"""2060 is outside the cache -> falls back to the `holidays` library:
formal röda dagar are still known."""
result = get_swedish_holidays(2060)
assert datetime.date(2060, 12, 25) in result # Juldagen
assert datetime.date(2060, 1, 1) in result # Nyårsdagen (+ Söndag combo)
def test_uncached_year_does_not_know_de_facto_days(self) -> None:
"""Documents the fallback's known limitation: no de facto-day data
exists outside the cached range, so julafton is NOT marked free."""
assert not is_non_working_day(datetime.date(2060, 12, 24))
def test_uncached_year_easter_sunday_survives_sondag_filter(self) -> None:
"""The exact-match trap applies to the fallback path too."""
red_days = get_red_days(2060)
# Easter 2060: verified against `holidays.Sweden(years=2060)`.
easter_sunday = next(
d for d, name in get_swedish_holidays(2060).items() if "Påskdagen" in name
)
assert easter_sunday in red_days
# ---------------------------------------------------------------------------
# `strict=True` — defense in depth for callers that must never silently hit
# the fallback (currently `CalendarService`; see the calendar-endpoint bug
# where years past the cache silently degraded to this same fallback).
# ---------------------------------------------------------------------------
class TestStrictMode:
def test_strict_raises_for_a_year_past_the_cache(self) -> None:
bounds = cached_year_bounds()
assert bounds is not None
_, max_year = bounds
with pytest.raises(UncachedYearError):
get_swedish_holidays(max_year + 1, strict=True)
def test_strict_raises_for_a_year_before_the_cache(self) -> None:
bounds = cached_year_bounds()
assert bounds is not None
min_year, _ = bounds
with pytest.raises(UncachedYearError):
get_swedish_holidays(min_year - 1, strict=True)
def test_strict_succeeds_and_matches_non_strict_for_a_cached_year(self) -> None:
bounds = cached_year_bounds()
assert bounds is not None
min_year, _ = bounds
assert get_swedish_holidays(min_year, strict=True) == get_swedish_holidays(min_year)
def test_strict_still_raises_even_if_the_year_was_already_cached_non_strict(self) -> None:
"""A prior non-strict call for an uncached year must not poison the
per-year memo cache so a later strict call is fooled into thinking
it's fine."""
bounds = cached_year_bounds()
assert bounds is not None
_, max_year = bounds
uncached_year = max_year + 5
get_swedish_holidays(uncached_year) # warms the non-strict cache
with pytest.raises(UncachedYearError):
get_swedish_holidays(uncached_year, strict=True)
def test_get_red_days_forwards_strict(self) -> None:
bounds = cached_year_bounds()
assert bounds is not None
_, max_year = bounds
with pytest.raises(UncachedYearError):
get_red_days(max_year + 1, strict=True)