"""Integration tests for the book-close calendar + period lifecycle HTTP surface. Exercises the full /api/v1/finance/close router with real auth + RBAC + DB: preview vs materialised run, anchor edits, manual override, the close lifecycle (open → in_progress → soft_closed → closed → reopen) with the frozen-snapshot guarantee, calendar config + holiday/makeup CRUD, and the restore-defaults seed. """ from __future__ import annotations import pytest from httpx import AsyncClient async def _restore_defaults(client: AsyncClient, finance_headers) -> None: r = await client.post("/api/v1/finance/close/restore-defaults", headers=finance_headers) assert r.status_code == 200, r.text @pytest.mark.asyncio async def test_preview_then_create_run(client: AsyncClient, finance_headers): await _restore_defaults(client, finance_headers) # No run yet → preview window from default anchors. June 2026 closes Jul 6. r = await client.get("/api/v1/finance/close/runs/2026-06", headers=finance_headers) assert r.status_code == 200, r.text data = r.json()["data"] assert data["run"] is None assert data["label"] == "June 2026" assert data["preview"]["close_date"] == "2026-07-06" assert data["preview"]["anchors"]["Day+5"] == "2026-07-06" assert data["preview"]["anchors"]["Day+8"] == "2026-07-09" assert data["preview"]["anchors"]["Day-3"] == "2026-06-28" # Sat Jul 4 is worked. assert "2026-07-04" in data["preview"]["weekend_work_dates"] # Materialise the run. r = await client.post("/api/v1/finance/close/runs/2026-06", headers=finance_headers) assert r.status_code == 200, r.text run = r.json()["data"]["run"] assert run["status"] == "open" assert run["window"]["close_date"] == "2026-07-06" # Idempotent: second create returns the same run. r2 = await client.post("/api/v1/finance/close/runs/2026-06", headers=finance_headers) assert r2.status_code == 200 assert r2.json()["data"]["run"]["period"] == "2026-06" @pytest.mark.asyncio async def test_anchor_edit_recomputes(client: AsyncClient, finance_headers): await _restore_defaults(client, finance_headers) await client.post("/api/v1/finance/close/runs/2026-06", headers=finance_headers) # Push Day+1 anchor to Jul 2 → WD1 becomes Jul 2, close shifts a day later. r = await client.patch( "/api/v1/finance/close/runs/2026-06/anchors", headers=finance_headers, json={"anchor_plus_1": "2026-07-02"}, ) assert r.status_code == 200, r.text run = r.json()["data"]["run"] assert run["anchor_plus_1"] == "2026-07-02" assert run["window"]["anchors"]["Day+1"] == "2026-07-02" @pytest.mark.asyncio async def test_override_requires_reason_and_clears(client: AsyncClient, finance_headers): await _restore_defaults(client, finance_headers) await client.post("/api/v1/finance/close/runs/2026-06", headers=finance_headers) # Override without a reason → 422-ish validation error. r = await client.post( "/api/v1/finance/close/runs/2026-06/override", headers=finance_headers, json={"override_close_date": "2026-07-10", "reason": ""}, ) assert r.status_code >= 400 # With a reason → close_date follows the override. r = await client.post( "/api/v1/finance/close/runs/2026-06/override", headers=finance_headers, json={"override_close_date": "2026-07-10", "reason": "extended for system maintenance"}, ) assert r.status_code == 200, r.text run = r.json()["data"]["run"] assert run["override_close_date"] == "2026-07-10" assert run["window"]["close_date"] == "2026-07-10" # Computed date is still surfaced alongside the override. assert run["window"]["computed_close_date"] == "2026-07-06" # Clear it. r = await client.post( "/api/v1/finance/close/runs/2026-06/override", headers=finance_headers, json={"override_close_date": None}, ) assert r.json()["data"]["run"]["override_close_date"] is None assert r.json()["data"]["run"]["window"]["close_date"] == "2026-07-06" @pytest.mark.asyncio async def test_lifecycle_and_frozen_snapshot(client: AsyncClient, finance_headers): await _restore_defaults(client, finance_headers) await client.post("/api/v1/finance/close/runs/2026-06", headers=finance_headers) async def set_status(s): return await client.post( "/api/v1/finance/close/runs/2026-06/status", headers=finance_headers, json={"status": s}, ) assert (await set_status("in_progress")).json()["data"]["run"]["status"] == "in_progress" r = await set_status("soft_closed") run = r.json()["data"]["run"] assert run["status"] == "soft_closed" assert run["frozen_close_date"] == "2026-07-06" # Change the global target workday to 3 — the FROZEN run must not move. await client.put( "/api/v1/finance/close/config", headers=finance_headers, json={"target_workday": 3}, ) r = await client.get("/api/v1/finance/close/runs/2026-06", headers=finance_headers) assert r.json()["data"]["run"]["window"]["close_date"] == "2026-07-06" # frozen # Hard close, then reopen → frozen snapshot cleared, live config applies (WD3 = Jul 3). assert (await set_status("closed")).json()["data"]["run"]["status"] == "closed" r = await set_status("in_progress") # reopen run = r.json()["data"]["run"] assert run["status"] == "in_progress" assert run["frozen_close_date"] is None assert run["window"]["close_date"] == "2026-07-03" # live WD3 now @pytest.mark.asyncio async def test_invalid_transition_rejected(client: AsyncClient, finance_headers): await _restore_defaults(client, finance_headers) await client.post("/api/v1/finance/close/runs/2026-06", headers=finance_headers) # open → bogus status is rejected by schema. r = await client.post( "/api/v1/finance/close/runs/2026-06/status", headers=finance_headers, json={"status": "nope"}, ) assert r.status_code >= 400 @pytest.mark.asyncio async def test_override_blocked_on_frozen_run(client: AsyncClient, finance_headers): """A soft/hard-closed run is frozen — its close date can't be overridden (that would defeat the freeze invariant). Reopen first.""" await _restore_defaults(client, finance_headers) await client.post("/api/v1/finance/close/runs/2026-06", headers=finance_headers) await client.post("/api/v1/finance/close/runs/2026-06/status", headers=finance_headers, json={"status": "closed"}) r = await client.post("/api/v1/finance/close/runs/2026-06/override", headers=finance_headers, json={"override_close_date": "2026-07-10", "reason": "x"}) assert r.status_code >= 400 @pytest.mark.asyncio async def test_config_target_workday(client: AsyncClient, finance_headers): await _restore_defaults(client, finance_headers) await client.put( "/api/v1/finance/close/config", headers=finance_headers, json={"target_workday": 3}, ) r = await client.get("/api/v1/finance/close/runs/2026-06", headers=finance_headers) assert r.json()["data"]["preview"]["close_date"] == "2026-07-03" @pytest.mark.asyncio async def test_holiday_and_makeup_crud(client: AsyncClient, finance_headers): await _restore_defaults(client, finance_headers) # Default CN holidays present for 2026 (golden week etc.). r = await client.get("/api/v1/finance/close/holidays?year=2026", headers=finance_headers) assert r.status_code == 200 assert len(r.json()["data"]) > 10 # Add a manual holiday, then remove it. r = await client.post( "/api/v1/finance/close/holidays", headers=finance_headers, json={"day": "2026-12-25", "country": "US", "name": "Christmas"}, ) assert r.status_code == 200, r.text hid = r.json()["data"]["id"] assert r.json()["data"]["source"] == "manual" r = await client.delete(f"/api/v1/finance/close/holidays/{hid}", headers=finance_headers) assert r.status_code == 200 # Add a makeup workday. r = await client.post( "/api/v1/finance/close/makeup", headers=finance_headers, json={"day": "2026-11-01", "note": "test makeup"}, ) assert r.status_code == 200, r.text @pytest.mark.asyncio async def test_admin_edited_default_holiday_survives_restore(client: AsyncClient, finance_headers): """Re-adding/naming an existing default holiday claims it as manual so a later restore-defaults (which deletes only source='default') keeps the edit.""" await _restore_defaults(client, finance_headers) # 2026-01-01 is a seeded default CN holiday. Re-add it with a name. r = await client.post( "/api/v1/finance/close/holidays", headers=finance_headers, json={"day": "2026-01-01", "country": "CN", "name": "元旦 (admin note)"}, ) assert r.status_code == 200, r.text assert r.json()["data"]["source"] == "manual" # Restore defaults again — the admin-edited row must NOT be wiped. await _restore_defaults(client, finance_headers) r = await client.get("/api/v1/finance/close/holidays?year=2026", headers=finance_headers) assert r.status_code == 200 rows = r.json()["data"] edited = [h for h in rows if h["day"] == "2026-01-01" and h["country"] == "CN"] assert len(edited) == 1, edited assert edited[0]["name"] == "元旦 (admin note)" assert edited[0]["source"] == "manual" @pytest.mark.asyncio async def test_rbac(client: AsyncClient, employee_headers, auditor_headers, finance_headers): await _restore_defaults(client, finance_headers) # Employee cannot view finance close. r = await client.get("/api/v1/finance/close/runs/2026-06", headers=employee_headers) assert r.status_code == 403 # Auditor can view but cannot mutate. r = await client.get("/api/v1/finance/close/runs/2026-06", headers=auditor_headers) assert r.status_code == 200 r = await client.post("/api/v1/finance/close/runs/2026-06", headers=auditor_headers) assert r.status_code == 403