"""Leave-availability External API — POST /api/v1/open/hr/absences/lookup. The HR data surface gated by the standalone API-client credential (``helix_api_``). Pins: * scope gates the endpoint; a client without ``hr.absences:read`` → 403 * tri-state coverage: covered (has leave accounts) / no_data (resolved but no accounts — the overseas case) / unknown_person (email unresolved) * only spans overlapping the window are returned; approved + pending + pending-cancellation count as "out"; rejected/cancelled excluded; a NULL status is conservatively kept as "unknown" * output is coarse — date span + category(leave) + status + day count; the raw leave type and reason are never on the wire * input guards: to366d → 422, empty emails → 422, >200 emails → 422 * meta carries as_of / from / to / window_days """ from __future__ import annotations import uuid from datetime import date import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.api_clients.models import ApiClient from app.api_clients.scopes import ( SCOPE_CLOUD_SUBSCRIPTIONS_READ, SCOPE_HR_ABSENCES_READ, ) from app.api_clients.tokens import ApiClientTokenService from app.domains.hr.leave_models import HrLeaveAbsence, HrLeaveAccount from app.identity.person_models import IdentityPerson pytestmark = pytest.mark.asyncio URL = "/api/v1/open/hr/absences/lookup" def _headers(token: str) -> dict: return {"Authorization": f"Bearer {token}"} async def _mk_person( db: AsyncSession, *, email: str, name: str = "Person", status: str = "active", ) -> IdentityPerson: p = IdentityPerson( id=uuid.uuid4(), work_email=email, display_name=name, employment_status=status, source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:10]}", ) db.add(p) await db.flush() return p async def _mk_account(db: AsyncSession, person: IdentityPerson) -> None: db.add(HrLeaveAccount( id=uuid.uuid4(), external_code=f"acct-{uuid.uuid4().hex[:12]}", person_id=str(person.id), account_type="CHN_ANNL", )) await db.flush() async def _mk_absence( db: AsyncSession, person: IdentityPerson, *, start: date, end: date, status: str | None = "APPROVED", time_type: str = "CHN_ANNL", days: str | None = None, ) -> None: db.add(HrLeaveAbsence( id=uuid.uuid4(), external_code=f"et-{uuid.uuid4().hex[:12]}", person_id=str(person.id), hris_person_id=person.hris_person_id, start_date=start, end_date=end, time_type=time_type, quantity_days=days, approval_status=status, )) await db.flush() async def _mk_client(db: AsyncSession, *, scopes: list[str]) -> str: owner = await _mk_person(db, email=f"co-{uuid.uuid4().hex[:8]}@pingcap.com") client = ApiClient( id=uuid.uuid4(), name="Oncall Scheduler", slug=f"oncall-{uuid.uuid4().hex[:6]}", owner_person_id=owner.id, scopes=scopes, ) db.add(client) await db.flush() await db.refresh(client) _row, plaintext = await ApiClientTokenService(db).mint( client=client, name="t", expires_in_days=None, created_by=None, ) await db.commit() return plaintext async def test_scope_required(client, db_session): # A client holding an unrelated scope is rejected. token = await _mk_client(db_session, scopes=[SCOPE_CLOUD_SUBSCRIPTIONS_READ]) r = await client.post( URL, headers=_headers(token), json={"emails": ["a@pingcap.com"], "from": "2026-08-10", "to": "2026-08-17"}, ) assert r.status_code == 403, r.text async def test_covered_person_on_leave(client, db_session): p = await _mk_person(db_session, email="zhangsan@pingcap.com") await _mk_account(db_session, p) await _mk_absence( db_session, p, start=date(2026, 8, 12), end=date(2026, 8, 14), status="APPROVED", days="3", ) token = await _mk_client(db_session, scopes=[SCOPE_HR_ABSENCES_READ]) r = await client.post( URL, headers=_headers(token), json={"emails": ["ZhangSan@pingcap.com"], "from": "2026-08-10", "to": "2026-08-17"}, ) assert r.status_code == 200, r.text body = r.json() row = body["data"][0] assert row["email"] == "zhangsan@pingcap.com" assert row["coverage"] == "covered" assert row["unavailable_spans"] == [{ "start": "2026-08-12", "end": "2026-08-14", "days": 3.0, "category": "leave", "status": "APPROVED", }] # no raw leave type / reason leaks onto the wire assert "CHN_ANNL" not in r.text and "time_type" not in r.text assert body["meta"]["window_days"] == 8 assert body["meta"]["from"] == "2026-08-10" assert body["meta"]["as_of"] is not None async def test_no_data_when_no_accounts(client, db_session): # Resolved person but no leave accounts (the overseas case) → no_data, # NOT "available". await _mk_person(db_session, email="overseas@pingcap.com") token = await _mk_client(db_session, scopes=[SCOPE_HR_ABSENCES_READ]) r = await client.post( URL, headers=_headers(token), json={"emails": ["overseas@pingcap.com"], "from": "2026-08-10", "to": "2026-08-17"}, ) assert r.status_code == 200, r.text row = r.json()["data"][0] assert row["coverage"] == "no_data" assert row["unavailable_spans"] == [] async def test_unknown_person(client, db_session): token = await _mk_client(db_session, scopes=[SCOPE_HR_ABSENCES_READ]) r = await client.post( URL, headers=_headers(token), json={"emails": ["ghost@pingcap.com"], "from": "2026-08-10", "to": "2026-08-17"}, ) assert r.status_code == 200, r.text row = r.json()["data"][0] assert row["coverage"] == "unknown_person" assert row["unavailable_spans"] == [] async def test_status_filtering(client, db_session): p = await _mk_person(db_session, email="multi@pingcap.com") await _mk_account(db_session, p) # pending + pending_cancellation + null → included; rejected/cancelled → not await _mk_absence(db_session, p, start=date(2026, 8, 11), end=date(2026, 8, 11), status="PENDING") await _mk_absence(db_session, p, start=date(2026, 8, 12), end=date(2026, 8, 12), status="PENDING_CANCELLATION") await _mk_absence(db_session, p, start=date(2026, 8, 13), end=date(2026, 8, 13), status=None) await _mk_absence(db_session, p, start=date(2026, 8, 14), end=date(2026, 8, 14), status="REJECTED") await _mk_absence(db_session, p, start=date(2026, 8, 15), end=date(2026, 8, 15), status="CANCELLED") token = await _mk_client(db_session, scopes=[SCOPE_HR_ABSENCES_READ]) r = await client.post( URL, headers=_headers(token), json={"emails": ["multi@pingcap.com"], "from": "2026-08-10", "to": "2026-08-20"}, ) assert r.status_code == 200, r.text spans = r.json()["data"][0]["unavailable_spans"] statuses = {s["status"] for s in spans} assert statuses == {"PENDING", "PENDING_CANCELLATION", "unknown"} assert len(spans) == 3 async def test_window_excludes_outside_spans(client, db_session): p = await _mk_person(db_session, email="win@pingcap.com") await _mk_account(db_session, p) # Entirely before the window. await _mk_absence(db_session, p, start=date(2026, 7, 1), end=date(2026, 7, 3)) # Overlapping the window edge. await _mk_absence(db_session, p, start=date(2026, 8, 9), end=date(2026, 8, 11)) token = await _mk_client(db_session, scopes=[SCOPE_HR_ABSENCES_READ]) r = await client.post( URL, headers=_headers(token), json={"emails": ["win@pingcap.com"], "from": "2026-08-10", "to": "2026-08-17"}, ) spans = r.json()["data"][0]["unavailable_spans"] assert len(spans) == 1 assert spans[0]["start"] == "2026-08-09" async def test_half_day(client, db_session): p = await _mk_person(db_session, email="half@pingcap.com") await _mk_account(db_session, p) await _mk_absence( db_session, p, start=date(2026, 8, 12), end=date(2026, 8, 12), days="0.5", ) token = await _mk_client(db_session, scopes=[SCOPE_HR_ABSENCES_READ]) r = await client.post( URL, headers=_headers(token), json={"emails": ["half@pingcap.com"], "from": "2026-08-10", "to": "2026-08-17"}, ) assert r.json()["data"][0]["unavailable_spans"][0]["days"] == 0.5 async def test_input_guards(client, db_session): token = await _mk_client(db_session, scopes=[SCOPE_HR_ABSENCES_READ]) # to before from → 422 r = await client.post( URL, headers=_headers(token), json={"emails": ["a@pingcap.com"], "from": "2026-08-17", "to": "2026-08-10"}, ) assert r.status_code == 422, r.text # window too wide → 422 r = await client.post( URL, headers=_headers(token), json={"emails": ["a@pingcap.com"], "from": "2026-01-01", "to": "2027-06-01"}, ) assert r.status_code == 422, r.text # empty emails → 422 (min_length) r = await client.post( URL, headers=_headers(token), json={"emails": [], "from": "2026-08-10", "to": "2026-08-17"}, ) assert r.status_code == 422, r.text async def test_order_and_dedup_of_input_emails(client, db_session): p = await _mk_person(db_session, email="dup@pingcap.com") await _mk_account(db_session, p) token = await _mk_client(db_session, scopes=[SCOPE_HR_ABSENCES_READ]) r = await client.post( URL, headers=_headers(token), json={ "emails": ["dup@pingcap.com", "DUP@pingcap.com", "ghost@pingcap.com"], "from": "2026-08-10", "to": "2026-08-17", }, ) data = r.json()["data"] # deduped case-insensitively → 2 rows assert [d["email"] for d in data] == ["dup@pingcap.com", "ghost@pingcap.com"]