"""PR3 (finance closed-loop) — finding triage + finance-controller authority.""" from __future__ import annotations from datetime import UTC, datetime, timedelta import pytest from app.core.rbac import can_manage_finance, can_operate_finance from app.domains.finance import finding_service from app.domains.finance.models import FinanceFindingAck def test_finance_controller_authority(): # Finance can operate (close/reopen, triage); employee cannot; finance # still cannot do global-admin-only finance mutations. assert can_operate_finance(["finance"]) is True assert can_operate_finance(["global_admin"]) is True assert can_operate_finance(["employee"]) is False assert can_manage_finance(["finance"]) is False # unchanged: global-only @pytest.mark.asyncio async def test_set_ack_upsert(db_session): db = db_session a = await finding_service.set_ack(db, kind="savings_seat", ref="waste:x@y:Plan", status="acknowledged", actor_id="p1") assert a.id is not None # Same (kind, ref) updates in place — no duplicate row. b = await finding_service.set_ack(db, kind="savings_seat", ref="waste:x@y:Plan", status="resolved", note="cancelled seat", actor_id="p2") assert b.id == a.id assert b.status == "resolved" assert b.note == "cancelled seat" @pytest.mark.asyncio async def test_snooze_requires_until(db_session): with pytest.raises(Exception): await finding_service.set_ack(db_session, kind="k", ref="r", status="snoozed") with pytest.raises(Exception): await finding_service.set_ack(db_session, kind="k", ref="r", status="bogus") def test_is_suppressed_rules(): now = datetime.now(UTC) future = FinanceFindingAck(kind="k", ref="r", status="snoozed", snooze_until=now + timedelta(days=3)) past = FinanceFindingAck(kind="k", ref="r", status="snoozed", snooze_until=now - timedelta(days=1)) resolved = FinanceFindingAck(kind="k", ref="r", status="resolved") acked = FinanceFindingAck(kind="k", ref="r", status="acknowledged") assert finding_service.is_suppressed(None, now) is False assert finding_service.is_suppressed(future, now) is True # snooze active → hidden assert finding_service.is_suppressed(past, now) is False # snooze elapsed → back assert finding_service.is_suppressed(resolved, now) is True # resolved → hidden assert finding_service.is_suppressed(acked, now) is False # acknowledged stays visible @pytest.mark.asyncio async def test_load_ack_map(db_session): db = db_session await finding_service.set_ack(db, kind="savings_seat", ref="a", status="acknowledged") await finding_service.set_ack(db, kind="savings_seat", ref="b", status="resolved") await finding_service.set_ack(db, kind="other_kind", ref="a", status="acknowledged") m = await finding_service.load_ack_map(db, "savings_seat") assert set(m) == {"a", "b"} assert m["b"].status == "resolved" @pytest.mark.asyncio async def test_set_ack_validation_raises_clean_422_not_500(db_session): """Regression (2026-07-03 audit): the three validation raises passed the human message as AppException's positional ``code`` and omitted the required ``message`` arg → TypeError → 500 instead of a clean 422. Every validation branch must now raise AppException with status 422. """ from app.core.exceptions import AppException # empty kind/ref with pytest.raises(AppException) as e1: await finding_service.set_ack( db_session, kind="", ref="", status="acknowledged", ) assert e1.value.status_code == 422 # bad status with pytest.raises(AppException) as e2: await finding_service.set_ack( db_session, kind="k", ref="r", status="bogus", ) assert e2.value.status_code == 422 # snoozed without snooze_until with pytest.raises(AppException) as e3: await finding_service.set_ack( db_session, kind="k", ref="r", status="snoozed", ) assert e3.value.status_code == 422