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
310 lines
13 KiB
Python
310 lines
13 KiB
Python
"""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=<max_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 (e.g. the
|
|
`start_year=max_year-1, years=4` case from real paging, which would
|
|
overshoot the cache's end by two years without the clamp)."""
|
|
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:
|
|
"""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).
|
|
|
|
The one legitimate exception in a ~30-year range: Pingstdagen
|
|
(Easter + 49 days) occasionally lands on Sveriges nationaldag (fixed
|
|
June 6), e.g. 2049 and 2055. That's 17 *distinct* red dates, not a
|
|
degradation — both holidays genuinely share one calendar date, and
|
|
the API itself reports 17 for that year. Assert the exception is
|
|
exactly this coincidence, by name, rather than loosely allowing any
|
|
two counts.
|
|
"""
|
|
by_year = await self._all_cached_years_red_days(calendar_service)
|
|
for year, red_days in by_year.items():
|
|
names_by_date = {d: name.lower() for d, name in red_days.items()}
|
|
june_6_name = next(
|
|
(n for d, n in names_by_date.items() if d.month == 6 and d.day == 6), ""
|
|
)
|
|
pingst_meets_nationaldag = "pingstdagen" in june_6_name and "nationaldag" in june_6_name
|
|
expected = 17 if pingst_meets_nationaldag else 18
|
|
assert len(red_days) == expected, (
|
|
f"{year}: expected {expected} red dates "
|
|
f"({'Pingstdagen/Nationaldagen coincide' if pingst_meets_nationaldag else 'normal year'}), "
|
|
f"got {len(red_days)} — a year may be degraded: {sorted(red_days)}"
|
|
)
|
|
|
|
|
|
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
|