"""API-level test for `GET /api/calendar/days`. Exercises the actual router + dependency graph (not just the service), per the `test_plan_router.py` pattern: §4.2's boundary-mapping gotcha has bitten this codebase before (fields computed by a service but dropped in the wire model), so a service-only test is not enough for a new endpoint. """ from datetime import date, datetime from uuid import uuid4 import aiosqlite import pytest_asyncio from httpx import ASGITransport, AsyncClient from app.dependencies import get_connection, get_current_user from app.entities.day_override import DayOverride from app.entities.personal_data import PersonalData from app.entities.user import User from app.entities.workspace import Workspace from app.main import app from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverrideRepository from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository @pytest_asyncio.fixture async def client(connection: aiosqlite.Connection) -> AsyncClient: """An httpx client against the real app: `get_connection` is overridden to hand back the test's in-memory connection, and `get_current_user` is overridden with a fixed fake user so the test doesn't need a real JWT. """ app.dependency_overrides[get_connection] = lambda: connection app.dependency_overrides[get_current_user] = lambda: User( id=uuid4(), personal_data_uuid=uuid4(), email="test@example.com", hashed_password="not-a-real-hash", created_at=datetime.now(), ) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: yield ac app.dependency_overrides.pop(get_connection, None) app.dependency_overrides.pop(get_current_user, None) class TestGetCalendarDays: async def test_returns_a_four_year_window_with_statutory_days( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: workspace_repo = SQLiteWorkspaceRepository(connection) ws = await workspace_repo.create( Workspace(id=uuid4(), name="Familj", created_at=datetime.now()) ) response = await client.get( "/api/calendar/days", params={"workspace_id": str(ws.id), "start_year": 2026}, ) assert response.status_code == 200 body = response.json() assert body["start_year"] == 2026 assert body["end_year"] == 2029 by_date = {d["date"]: d for d in body["days"]} christmas = by_date["2026-12-25"] assert christmas["day_type"] == "red_day" assert christmas["is_free"] is True assert christmas["is_override"] is False async def test_pagination_years_param_is_capped_at_four( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: workspace_repo = SQLiteWorkspaceRepository(connection) ws = await workspace_repo.create( Workspace(id=uuid4(), name="Familj", created_at=datetime.now()) ) response = await client.get( "/api/calendar/days", params={"workspace_id": str(ws.id), "start_year": 2026, "years": 5}, ) assert response.status_code == 422, "the full cache must never be returned in one call" async def test_out_of_range_start_year_is_rejected( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: workspace_repo = SQLiteWorkspaceRepository(connection) ws = await workspace_repo.create( Workspace(id=uuid4(), name="Familj", created_at=datetime.now()) ) response = await client.get( "/api/calendar/days", params={"workspace_id": str(ws.id), "start_year": 1999}, ) assert response.status_code == 422 async def test_a_window_reaching_the_cache_end_is_clamped_not_overshot( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: """Regression test for the real bug: `start_year=&years=4` used to return 200 with `end_year = max_year + 3`, silently serving years past the cache from the degraded `holidays`-library fallback (missing Julafton/Nyårsafton/Midsommarafton). It must now clamp to the cache and report the ACTUAL served range. """ workspace_repo = SQLiteWorkspaceRepository(connection) ws = await workspace_repo.create( Workspace(id=uuid4(), name="Familj", created_at=datetime.now()) ) # Discover max_year from a normal response rather than hardcoding it. bounds_response = await client.get( "/api/calendar/days", params={"workspace_id": str(ws.id), "start_year": 2026}, ) max_year = bounds_response.json()["max_year"] assert max_year is not None response = await client.get( "/api/calendar/days", params={"workspace_id": str(ws.id), "start_year": max_year, "years": 4}, ) assert response.status_code == 200 body = response.json() assert body["start_year"] == max_year assert body["end_year"] == max_year, "end_year must report the clamped range, not overshoot" years_seen = {int(d["date"][:4]) for d in body["days"]} assert years_seen == {max_year}, "no day may come from beyond the cache" async def test_workspace_day_overrides_are_merged_into_the_response( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: workspace_repo = SQLiteWorkspaceRepository(connection) personal_data_repo = SQLitePersonalDataRepository(connection) day_override_repo = SQLiteDayOverrideRepository(connection) ws = await workspace_repo.create( Workspace(id=uuid4(), name="Familj", created_at=datetime.now()) ) await personal_data_repo.create( PersonalData(uuid=uuid4(), workspace_id=ws.id, name="Alex", created_at=datetime.now()) ) override_date = date(2026, 4, 30) # Valborgsmässoafton: not a default day off (§6) await day_override_repo.create( DayOverride( id=uuid4(), workspace_id=ws.id, date=override_date, is_working_day=False, created_at=datetime.now(), updated_at=datetime.now(), ) ) response = await client.get( "/api/calendar/days", params={"workspace_id": str(ws.id), "start_year": 2026}, ) assert response.status_code == 200 by_date = {d["date"]: d for d in response.json()["days"]} valborg = by_date["2026-04-30"] assert valborg["day_type"] == "working_day", "Valborg is not a statutory day off (§6)" assert valborg["is_override"] is True assert valborg["override_is_working_day"] is False assert valborg["is_free"] is True, ( "the workspace override marks it free despite the default" )