"""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 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