6a79435baf
A hand-rolled month-arithmetic module survived the move to dateutil with no caller left but its own tests, and two predicates alongside it were never wired up at all. An error import was also being deferred inside a function for a circular dependency that does not exist.
279 lines
11 KiB
Python
279 lines
11 KiB
Python
"""Tests for Swedish holiday utilities."""
|
|
|
|
import datetime
|
|
|
|
|
|
import pytest
|
|
|
|
from app.utils.holidays import (
|
|
UncachedYearError,
|
|
cached_year_bounds,
|
|
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))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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))
|
|
|
|
def test_nyarsafton_is_non_working(self) -> None:
|
|
assert is_non_working_day(datetime.date(2027, 12, 31))
|
|
|
|
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))
|
|
|
|
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)
|