"""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 assert "Nyår" in name 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)