"""External API: the public-holiday calendar surface. The contract that matters to an integrator — and the one a refactor could break silently — is the coverage tri-state. A scheduler asking about a country Helix has never been told about must get ``no_data``, not an empty holiday list that reads as "everybody is working". """ from __future__ import annotations import uuid from datetime import date, timedelta import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from app.api_clients.models import ApiClient from app.api_clients.scopes import SCOPE_HR_ABSENCES_READ, SCOPE_HR_HOLIDAYS_READ from app.api_clients.tokens import ApiClientTokenService from app.domains.hr import holiday_service as svc from app.domains.hr.holiday_models import MAKEUP_WORKDAY, HrPublicHolidayCalendar from app.identity.person_models import IdentityPerson pytestmark = pytest.mark.asyncio URL = "/api/v1/open/hr/holidays" def _headers(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} async def _mk_token(db: AsyncSession, *, scopes: list[str]) -> str: owner = IdentityPerson( id=uuid.uuid4(), work_email=f"co-{uuid.uuid4().hex[:8]}@pingcap.com", display_name="Client Owner", employment_status="active", source_system="successfactors", hris_person_id=f"sf-cli-{uuid.uuid4().hex[:10]}", ) db.add(owner) await db.flush() client_row = ApiClient( id=uuid.uuid4(), name="Oncall Scheduler", slug=f"oncall-{uuid.uuid4().hex[:6]}", owner_person_id=owner.id, scopes=scopes, ) db.add(client_row) await db.flush() await db.refresh(client_row) _row, plaintext = await ApiClientTokenService(db).mint( client=client_row, name="t", expires_in_days=None, created_by=None, ) await db.commit() return plaintext async def _seed( db: AsyncSession, *, country: str, name_en: str, days: list[date], makeup: list[date] | None = None, label: str | None = None, year: int | None = None, ) -> str: resolved_year = year or days[0].year if await svc.get_calendar(db, country=country, year=resolved_year) is None: db.add(HrPublicHolidayCalendar( country=country, year=resolved_year, label=label or country, source="seed", )) await db.flush() key, _rows = await svc.create_group( db, country=country, name_en=name_en, name_local=None, days=days, makeup_days=makeup or [], source="seed", ) await db.commit() return key async def test_scope_is_required(client: AsyncClient, db_session: AsyncSession): token = await _mk_token(db_session, scopes=[SCOPE_HR_ABSENCES_READ]) resp = await client.get(f"{URL}?year=2026", headers=_headers(token)) assert resp.status_code == 403 async def test_no_token_is_rejected(client: AsyncClient): assert (await client.get(f"{URL}?year=2026")).status_code in (401, 403) async def test_returns_holidays_and_makeup_workdays( client: AsyncClient, db_session: AsyncSession, ): await _seed( db_session, country="CN", name_en="Labor Day Holiday", days=[date(2026, 5, d) for d in (1, 2, 3, 4, 5)], makeup=[date(2026, 5, 9)], label="China", ) token = await _mk_token(db_session, scopes=[SCOPE_HR_HOLIDAYS_READ]) resp = await client.get( f"{URL}?country=CN&from=2026-05-01&to=2026-05-31", headers=_headers(token), ) assert resp.status_code == 200, resp.text body = resp.json() assert body["success"] is True entry = body["data"][0] assert entry["country"] == "CN" assert entry["country_name"] == "China" assert entry["coverage"] == "covered" assert entry["years_covered"] == [2026] assert len(entry["holidays"]) == 6 makeup = [h for h in entry["holidays"] if h["day_type"] == MAKEUP_WORKDAY] assert [h["date"] for h in makeup] == ["2026-05-09"] # Freshness stamp so a caller can judge how current the calendar is. assert body["meta"]["as_of"] is not None assert body["meta"]["from"] == "2026-05-01" async def test_unmaintained_country_is_no_data_not_an_empty_list( client: AsyncClient, db_session: AsyncSession, ): await _seed( db_session, country="CN", name_en="Labor Day", days=[date(2026, 5, 1)], ) token = await _mk_token(db_session, scopes=[SCOPE_HR_HOLIDAYS_READ]) resp = await client.get(f"{URL}?country=CN,PT&year=2026", headers=_headers(token)) by_country = {row["country"]: row for row in resp.json()["data"]} assert by_country["CN"]["coverage"] == "covered" assert by_country["PT"]["coverage"] == "no_data" assert by_country["PT"]["holidays"] == [] async def test_unknown_country_code_is_reported_not_a_422( client: AsyncClient, db_session: AsyncSession, ): """One bad code must not hide the good ones in the same call.""" await _seed( db_session, country="CN", name_en="Labor Day", days=[date(2026, 5, 1)], ) token = await _mk_token(db_session, scopes=[SCOPE_HR_HOLIDAYS_READ]) resp = await client.get( f"{URL}?country=CN,Freedonia&year=2026", headers=_headers(token), ) assert resp.status_code == 200 by_country = {row["country"]: row["coverage"] for row in resp.json()["data"]} assert by_country == {"CN": "covered", "Freedonia": "unknown_country"} async def test_a_year_outside_the_maintained_range_is_no_data( client: AsyncClient, db_session: AsyncSession, ): """Maintained through 2026 does not mean 2027 has no holidays.""" await _seed( db_session, country="CN", name_en="Labor Day", days=[date(2026, 5, 1)], ) token = await _mk_token(db_session, scopes=[SCOPE_HR_HOLIDAYS_READ]) resp = await client.get(f"{URL}?country=CN&year=2027", headers=_headers(token)) entry = resp.json()["data"][0] assert entry["coverage"] == "no_data" assert entry["years_covered"] == [] async def test_country_codes_accept_alpha3_and_names( client: AsyncClient, db_session: AsyncSession, ): await _seed( db_session, country="JP", name_en="Marine Day", days=[date(2026, 7, 20)], label="Japan", ) token = await _mk_token(db_session, scopes=[SCOPE_HR_HOLIDAYS_READ]) for value in ("JPN", "japan", "jp"): resp = await client.get( f"{URL}?country={value}&year=2026", headers=_headers(token), ) entry = resp.json()["data"][0] assert entry["country"] == "JP", value assert entry["coverage"] == "covered", value async def test_omitting_country_returns_every_maintained_one( client: AsyncClient, db_session: AsyncSession, ): await _seed( db_session, country="CN", name_en="Labor Day", days=[date(2026, 5, 1)], ) await _seed( db_session, country="JP", name_en="Marine Day", days=[date(2026, 7, 20)], ) token = await _mk_token(db_session, scopes=[SCOPE_HR_HOLIDAYS_READ]) resp = await client.get(f"{URL}?year=2026", headers=_headers(token)) assert {row["country"] for row in resp.json()["data"]} == {"CN", "JP"} async def test_input_guards(client: AsyncClient, db_session: AsyncSession): token = await _mk_token(db_session, scopes=[SCOPE_HR_HOLIDAYS_READ]) # year together with an explicit window is ambiguous. both = await client.get( f"{URL}?year=2026&from=2026-01-01", headers=_headers(token), ) assert both.status_code == 422 # reversed window reversed_window = await client.get( f"{URL}?from=2026-05-10&to=2026-05-01", headers=_headers(token), ) assert reversed_window.status_code == 422 # window wider than the cap too_wide = await client.get( f"{URL}?from=2020-01-01&to=2030-01-01", headers=_headers(token), ) assert too_wide.status_code == 422 # too many countries many = ",".join(f"c{i}" for i in range(80)) assert ( await client.get(f"{URL}?country={many}&year=2026", headers=_headers(token)) ).status_code == 422 async def test_default_window_starts_today( client: AsyncClient, db_session: AsyncSession, ): """No from/to means "from today", so a caller gets what is still ahead.""" today = date.today() await _seed( db_session, country="CN", name_en="Past Holiday", days=[today - timedelta(days=40)], year=(today - timedelta(days=40)).year, ) await _seed( db_session, country="CN", name_en="Future Holiday", days=[today + timedelta(days=40)], year=(today + timedelta(days=40)).year, ) token = await _mk_token(db_session, scopes=[SCOPE_HR_HOLIDAYS_READ]) resp = await client.get(f"{URL}?country=CN", headers=_headers(token)) names = {h["name"] for h in resp.json()["data"][0]["holidays"]} assert "Future Holiday" in names assert "Past Holiday" not in names async def test_countries_discovery_endpoint( client: AsyncClient, db_session: AsyncSession, ): await _seed( db_session, country="CN", name_en="Labor Day Holiday", days=[date(2026, 5, 1), date(2026, 5, 2)], label="China", ) token = await _mk_token(db_session, scopes=[SCOPE_HR_HOLIDAYS_READ]) resp = await client.get(f"{URL}/countries?year=2026", headers=_headers(token)) assert resp.status_code == 200 rows = resp.json()["data"] assert rows[0]["country"] == "CN" assert rows[0]["country_name"] == "China" assert rows[0]["holiday_days"] == 2 async def test_holiday_endpoints_appear_in_the_curated_openapi_spec(): """A new /open route that is not listed in the curated spec is invisible.""" from app.api_clients.open_meta_router import _curated_spec paths = _curated_spec()["paths"] assert "/api/v1/open/hr/holidays" in paths assert "/api/v1/open/hr/holidays/countries" in paths def test_new_scope_is_registered_in_the_catalog(): from app.api_clients.scopes import KNOWN_SCOPES, catalog_payload, validate_scopes assert SCOPE_HR_HOLIDAYS_READ in KNOWN_SCOPES assert validate_scopes([SCOPE_HR_HOLIDAYS_READ]) == [SCOPE_HR_HOLIDAYS_READ] entry = next( row for row in catalog_payload() if row["key"] == SCOPE_HR_HOLIDAYS_READ ) assert entry == { "key": "hr.holidays:read", "desc_id": "hr_holidays_read", "kind": "read", }