249e277696
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
315 lines
12 KiB
Python
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)
|