system-prompts-and-models-o.../dealix/tests/governance/test_approvals.py
2026-05-01 14:03:52 +03:00

185 lines
5.9 KiB
Python

"""Unit tests for ApprovalGate — fake Redis, no network."""
from __future__ import annotations
import time
import pytest
from dealix.governance.approvals import (
OUTBOUND_THRESHOLD,
PENDING_TTL_SECONDS,
RISK_THRESHOLD,
ApprovalDecision,
ApprovalGate,
ApprovalStatus,
)
class FakeRedis:
"""Minimal in-memory Redis compatible with ApprovalGate's async calls."""
def __init__(self) -> None:
self.kv: dict[str, str] = {}
self.z: dict[str, dict[str, float]] = {}
self.expiries: dict[str, float] = {}
async def set(self, key: str, value: str, ex: int | None = None) -> None:
self.kv[key] = value
if ex:
self.expiries[key] = time.time() + ex
async def get(self, key: str) -> str | None:
if key in self.expiries and time.time() > self.expiries[key]:
self.kv.pop(key, None)
return None
return self.kv.get(key)
async def zadd(self, key: str, mapping: dict[str, float]) -> int:
bucket = self.z.setdefault(key, {})
added = 0
for member, score in mapping.items():
if member not in bucket:
added += 1
bucket[member] = score
return added
async def zrem(self, key: str, *members: str) -> int:
bucket = self.z.get(key, {})
removed = 0
for m in members:
if m in bucket:
bucket.pop(m)
removed += 1
return removed
async def zrevrange(self, key: str, start: int, end: int) -> list[str]:
bucket = self.z.get(key, {})
ordered = sorted(bucket.items(), key=lambda kv: kv[1], reverse=True)
# end is inclusive in Redis; Python slice end is exclusive.
return [m for m, _ in ordered[start : end + 1]]
async def zcard(self, key: str) -> int:
return len(self.z.get(key, {}))
@pytest.fixture
def gate() -> ApprovalGate:
return ApprovalGate(FakeRedis())
@pytest.mark.asyncio
async def test_auto_approves_below_thresholds(gate: ApprovalGate) -> None:
req = await gate.request(
action="enrichment_task",
payload={"recipients": 5},
risk_score=0.1,
)
assert req.status == ApprovalStatus.AUTO_APPROVED
@pytest.mark.asyncio
async def test_requires_approval_when_action_critical(gate: ApprovalGate) -> None:
req = await gate.request(
action="outbound_email_campaign",
payload={"recipients": 3},
risk_score=0.0,
)
assert req.status == ApprovalStatus.PENDING
assert "CRITICAL_ACTIONS" in req.reason
@pytest.mark.asyncio
async def test_requires_approval_over_recipient_threshold(gate: ApprovalGate) -> None:
req = await gate.request(
action="notify",
payload={"recipients": OUTBOUND_THRESHOLD + 1},
)
assert req.status == ApprovalStatus.PENDING
assert "recipients" in req.reason
@pytest.mark.asyncio
async def test_requires_approval_over_risk_threshold(gate: ApprovalGate) -> None:
req = await gate.request(
action="enrichment_task",
payload={},
risk_score=RISK_THRESHOLD + 0.01,
)
assert req.status == ApprovalStatus.PENDING
assert "risk_score" in req.reason
@pytest.mark.asyncio
async def test_requires_approval_over_amount(gate: ApprovalGate) -> None:
req = await gate.request(action="refund", payload={"amount_sar": 10000})
assert req.status == ApprovalStatus.PENDING
@pytest.mark.asyncio
async def test_decide_approve(gate: ApprovalGate) -> None:
req = await gate.request(action="outbound_email_campaign", payload={"recipients": 100})
assert req.status == ApprovalStatus.PENDING
decided = await gate.decide(
ApprovalDecision(request_id=req.id, approved=True, decided_by="sami", note="ok")
)
assert decided is not None
assert decided.status == ApprovalStatus.APPROVED
assert decided.decided_by == "sami"
stats = await gate.stats()
assert stats["pending"] == 0
@pytest.mark.asyncio
async def test_decide_reject(gate: ApprovalGate) -> None:
req = await gate.request(action="outbound_whatsapp_broadcast", payload={"recipients": 200})
decided = await gate.decide(
ApprovalDecision(request_id=req.id, approved=False, decided_by="sami")
)
assert decided.status == ApprovalStatus.REJECTED
@pytest.mark.asyncio
async def test_decide_idempotent(gate: ApprovalGate) -> None:
req = await gate.request(action="outbound_email_campaign", payload={"recipients": 100})
await gate.decide(ApprovalDecision(request_id=req.id, approved=True, decided_by="sami"))
# second decide should NOT change status
second = await gate.decide(
ApprovalDecision(request_id=req.id, approved=False, decided_by="sami2")
)
assert second.status == ApprovalStatus.APPROVED
assert second.decided_by == "sami"
@pytest.mark.asyncio
async def test_list_pending_excludes_decided(gate: ApprovalGate) -> None:
r1 = await gate.request(action="outbound_email_campaign", payload={"recipients": 100})
r2 = await gate.request(action="outbound_email_campaign", payload={"recipients": 200})
await gate.decide(ApprovalDecision(request_id=r1.id, approved=True, decided_by="sami"))
pending = await gate.list_pending()
ids = {r.id for r in pending}
assert r1.id not in ids
assert r2.id in ids
@pytest.mark.asyncio
async def test_expires_after_ttl(gate: ApprovalGate) -> None:
req = await gate.request(action="outbound_email_campaign", payload={"recipients": 100})
# fast-forward by mutating expires_at on the stored record
key = gate._key(req.id)
raw = await gate.r.get(key)
import json as _json
d = _json.loads(raw)
d["expires_at"] = time.time() - 10
await gate.r.set(key, _json.dumps(d), ex=PENDING_TTL_SECONDS + 3600)
refreshed = await gate.get(req.id)
assert refreshed.status == ApprovalStatus.EXPIRED
@pytest.mark.asyncio
async def test_get_returns_none_for_unknown(gate: ApprovalGate) -> None:
assert await gate.get("does-not-exist") is None