"""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=&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 (the `start_year=2034, years=4` case from real paging: 2034+3=2037 would overshoot the 2026-2035 cache by two years).""" 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: by_year = await self._all_cached_years_red_days(calendar_service) counts = {year: len(red_days) for year, red_days in by_year.items()} # 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). assert len(set(counts.values())) == 1, ( f"red-day counts differ across years, one may be degraded: {counts}" ) 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