claimflow-api / tests /test_agent_flow.py
Minifigures's picture
feat: ClaimFlow API demo backend
ceea9e1 verified
Raw
History Blame Contribute Delete
20.8 kB
"""Insurance-agent portal flow: queue -> dossier -> draft email -> atomic decision.
The decision endpoint carries the assessment's hard invariant: the final decision and
the claimant notification email are ONE action, so every claim in a terminal state must
have a Notification row — even when the agent submits empty email fields (server-side
template fallback). Artifacts are built via the ORM with realistic payloads produced by
the deterministic keyless fallback generators, exactly as the stage wiring would.
"""
import json
from datetime import date
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.passwords import hash_password
from app.claimguard import audit
from app.config import Settings
from app.llm import fallbacks
from app.llm.schemas import ADJUDICATION_NOTICE, ADVISORY_NOTICE, DIAGNOSTIC_DISCLAIMER
from app.ml.base import ImagingAnalysis
from app.models import (
AdjudicationSummary,
ArtifactStatus,
AuditEvent,
Claim,
ClaimAction,
ClaimHistory,
ClaimState,
Decision,
DiagnosticReport,
Document,
DocumentKind,
Modality,
Notification,
NotificationStatus,
Recommendation,
RecommendationNote,
Role,
User,
)
from app.rag.store import get_adjudicated_cases_collection, get_client
from tests.conftest import DEMO_PASSWORD, login
CLAIM_REF = "CLM-AGENT-0001"
# date desc order: physio (rejected) -> dental (approved) -> imaging (approved)
HISTORY_ROWS = [
dict(
claim_type="physiotherapy",
procedure_code="P-200",
diagnosis_code="M54.5",
modality=None,
billed_amount=95.0,
outcome="rejected",
date_of_service=date(2026, 1, 9),
decided_at=date(2026, 1, 30),
),
dict(
claim_type="dental",
procedure_code="D-110",
diagnosis_code="K02.9",
modality=None,
billed_amount=180.0,
outcome="approved",
date_of_service=date(2025, 11, 2),
decided_at=date(2025, 11, 20),
),
dict(
claim_type="imaging_diagnostics",
procedure_code="73100",
diagnosis_code="S62.10",
modality="xray",
billed_amount=350.0,
outcome="approved",
date_of_service=date(2024, 7, 14),
decided_at=date(2024, 8, 1),
),
]
# ------------------------------------------------------- payloads from the fallback generators
def _report_payload() -> dict:
analysis = ImagingAnalysis(
modality="xray",
modality_confidence=0.97,
modality_probs={"xray": 0.97, "ct": 0.02, "mri": 0.01},
authenticity_verdict="authentic",
authenticity_risk=0.05,
signals=[],
quality_flags=[],
backend="stub",
)
payload = fallbacks.fallback_diagnostic_report(analysis, declared_modality="xray").model_dump()
# system-injected sections, exactly as stage 1 wiring adds them post-parse
payload["authenticity"] = {"verdict": "authentic", "risk_score": 0.05, "signals": []}
payload["classifier"] = {"modality": "xray", "confidence": 0.97}
payload["disclaimer"] = DIAGNOSTIC_DISCLAIMER
return payload
def _note_payload() -> dict:
payload = fallbacks.fallback_recommendation(
{
"claim": {
"claim_type": "imaging_diagnostics",
"procedure_code": "73100",
"diagnosis_code": "S62.10",
"incident_date": "2026-05-20",
"amount_claimed": 420.5,
},
"diagnostic_report": {
"modality": "xray",
"authenticity_verdict": "authentic",
"authenticity_risk": 0.05,
"requires_mandatory_review": False,
"impression": "No acute abnormality.",
},
"uploads": [],
"modality_for_procedure": "xray",
}
).model_dump()
payload["advisory_notice"] = ADVISORY_NOTICE
payload["documents_reviewed"] = []
return payload
def _summary_payload() -> dict:
payload = fallbacks.fallback_adjudication(
"SUPPORTS_CLAIM",
{"total": 3, "approved": 2, "rejected": 1, "recent_12mo": 1, "prior_rejections": 1},
[],
"authentic",
).model_dump()
payload["similar_case_outcomes"] = []
payload["advisory_notice"] = ADJUDICATION_NOTICE
return payload
# ----------------------------------------------------------------------------- fixtures
@pytest.fixture()
def case(session: Session, users: dict[str, User]) -> Claim:
"""A dossier-ready claim in ADJUDICATION with COMPLETE artifacts and member history."""
claimant = users[Role.CLAIMANT.value]
claim = Claim(
claim_ref=CLAIM_REF,
claimant_id=claimant.id,
claim_type="imaging_diagnostics",
description="Wrist X-ray after a fall on ice",
procedure_code="73100",
diagnosis_code="S62.10",
incident_date=date(2026, 5, 20),
amount_claimed=420.5,
state=ClaimState.ADJUDICATION,
)
session.add(claim)
session.flush()
session.add(
Document(
claim_id=claim.id,
uploader_id=claimant.id,
kind=DocumentKind.IMAGING,
modality=Modality.XRAY,
filename="wrist_xray.png",
mime="image/png",
size_bytes=1024,
sha256="0" * 64,
storage_path="/nonexistent/wrist_xray.png", # dossier never reads the file
)
)
session.add(
DiagnosticReport(
claim_id=claim.id,
status=ArtifactStatus.COMPLETE,
payload_json=json.dumps(_report_payload()),
generated_by="stub-analyzer",
modality="xray",
modality_confidence=0.97,
authenticity_verdict="authentic",
authenticity_risk=0.05,
)
)
session.add(
RecommendationNote(
claim_id=claim.id,
status=ArtifactStatus.COMPLETE,
payload_json=json.dumps(_note_payload()),
generated_by="fallback_template",
fallback_reason="no_api_key",
recommendation=Recommendation.SUPPORTS_CLAIM,
confidence=0.0,
)
)
session.add(
AdjudicationSummary(
claim_id=claim.id,
status=ArtifactStatus.COMPLETE,
payload_json=json.dumps(_summary_payload()),
generated_by="fallback_template",
fallback_reason="no_api_key",
recommendation_lean="LEAN_APPROVE",
confidence=0.0,
)
)
for row in HISTORY_ROWS:
session.add(ClaimHistory(member_id=claimant.member_id, **row))
session.commit()
return claim
@pytest.fixture()
def fr_case(session: Session, users: dict[str, User]) -> Claim:
"""A second claimant with French/formal preferences and her ADJUDICATION claim."""
fr_user = User(
email="claimant2@demo.ca",
password_hash=hash_password(DEMO_PASSWORD),
role=Role.CLAIMANT,
full_name="Marie Tremblay",
member_id="MBR-2002",
preferred_language="fr",
preferred_tone="formal",
)
session.add(fr_user)
session.flush()
claim = Claim(
claim_ref="CLM-AGENT-0002",
claimant_id=fr_user.id,
claim_type="imaging",
state=ClaimState.ADJUDICATION,
)
session.add(claim)
session.commit()
return claim
def _decision_body(action: str, **overrides: object) -> dict:
body: dict = {"action": action, "note": None, "email_subject": "", "email_body_text": ""}
body.update(overrides)
return body
def _assert_terminal_claims_have_notifications(session: Session) -> None:
"""THE invariant: a claim in a terminal state always has a Notification row."""
terminal = session.scalars(
select(Claim).where(Claim.state.in_([ClaimState.APPROVED, ClaimState.REJECTED]))
).all()
assert terminal, "expected at least one decided claim"
for claim in terminal:
notification_id = session.scalar(
select(Notification.id).where(Notification.claim_id == claim.id).limit(1)
)
assert notification_id is not None, f"terminal claim {claim.id} has no notification"
# -------------------------------------------------------------------------------- queue
def test_queue_lists_adjudication_claim_and_blocks_claimant(
as_agent: TestClient, case: Claim
) -> None:
resp = as_agent.get("/api/agent/queue")
assert resp.status_code == 200, resp.text
[item] = resp.json()
assert item["claim_id"] == case.id
assert item["claim_ref"] == CLAIM_REF
assert item["claim_type"] == "imaging_diagnostics"
assert item["claimant"] == "Casey Claimant"
assert item["state"] == "ADJUDICATION"
assert item["submitted_at"]
assert item["summary_status"] == "complete"
assert item["recommendation_lean"] == "LEAN_APPROVE"
assert item["confidence"] == 0.0
assert item["requires_mandatory_review"] is False
assert item["specialist_recommendation"] == "SUPPORTS_CLAIM"
login(as_agent, "claimant@demo.ca")
assert as_agent.get("/api/agent/queue").status_code == 403
# ------------------------------------------------------------------------------ dossier
def test_dossier_shape_is_complete(as_agent: TestClient, case: Claim) -> None:
resp = as_agent.get(f"/api/agent/cases/{case.id}/dossier")
assert resp.status_code == 200, resp.text
dossier = resp.json()
claim = dossier["claim"]
assert claim["id"] == case.id
assert claim["claim_ref"] == CLAIM_REF
assert claim["state"] == "ADJUDICATION"
assert claim["procedure_code"] == "73100"
assert claim["amount_claimed"] == 420.5
assert dossier["claimant"] == {
"full_name": "Casey Claimant",
"member_id": "MBR-1001",
"preferred_language": "en",
"preferred_tone": "plain_language",
}
[doc] = dossier["documents"]
assert doc["filename"] == "wrist_xray.png"
assert doc["kind"] == "imaging"
assert doc["modality"] == "xray"
report = dossier["diagnostic_report"]
assert report["status"] == "complete"
assert report["modality"] == "xray"
assert report["authenticity_verdict"] == "authentic"
assert report["payload"]["authenticity"] == {
"verdict": "authentic",
"risk_score": 0.05,
"signals": [],
}
assert report["payload"]["disclaimer"] == DIAGNOSTIC_DISCLAIMER
note = dossier["recommendation_note"]
assert note["status"] == "complete"
assert note["recommendation"] == "SUPPORTS_CLAIM"
assert note["payload"]["recommendation"] == "SUPPORTS_CLAIM"
assert note["payload"]["advisory_notice"] == ADVISORY_NOTICE
summary = dossier["adjudication_summary"]
assert summary["status"] == "complete"
assert summary["recommendation_lean"] == "LEAN_APPROVE"
payload = summary["payload"]
assert payload["similar_case_outcomes"] == []
assert payload["risk_factors"] == []
assert payload["recommendation_lean"] == "LEAN_APPROVE"
assert payload["advisory_notice"] == ADJUDICATION_NOTICE
history = dossier["claim_history"]
assert [row["outcome"] for row in history] == ["rejected", "approved", "approved"]
assert [row["date_of_service"] for row in history] == [
"2026-01-09",
"2025-11-02",
"2024-07-14",
]
assert dossier["history_stats"] == {"total": 3, "approved": 2, "rejected": 1}
assert dossier["timeline"] == [] # claim built via ORM: no Decision rows yet
assert dossier["notifications"] == []
assert as_agent.get("/api/agent/cases/999999/dossier").status_code == 404
def test_dossier_outside_adjudication_or_terminal_is_409(
as_agent: TestClient, session: Session, users: dict[str, User]
) -> None:
claim = Claim(
claim_ref="CLM-AGENT-0009",
claimant_id=users[Role.CLAIMANT.value].id,
claim_type="imaging",
state=ClaimState.SUBMITTED,
)
session.add(claim)
session.commit()
assert as_agent.get(f"/api/agent/cases/{claim.id}/dossier").status_code == 409
# -------------------------------------------------------------------------- draft email
def test_draft_email_keyless_falls_back_and_audits_without_body(
as_agent: TestClient, case: Claim, session: Session
) -> None:
resp = as_agent.post(f"/api/agent/cases/{case.id}/draft-email", json={"decision": "APPROVED"})
assert resp.status_code == 200, resp.text
draft = resp.json()
assert draft["generated_by"] == "fallback_template"
assert draft["fallback_reason"] == "no_api_key"
assert CLAIM_REF in draft["subject"]
assert "Casey" in draft["greeting"]
assert len(draft["body_paragraphs"]) >= 1
assert draft["closing"]
session.expire_all()
event = session.scalar(select(AuditEvent).where(AuditEvent.event_type == "email.drafted"))
assert event is not None
assert event.claim_id == case.id
assert event.actor_role == "insurance_agent"
assert json.loads(event.payload_json) == {
"decision": "APPROVED",
"generated_by": "fallback_template",
"fallback_reason": "no_api_key",
}
# The audit log stays PII-free: no greeting/body text (it carries the first name).
assert "Casey" not in event.payload_json
valid, _ = audit.verify_chain(session)
assert valid is True
def test_draft_email_respects_french_preference(as_agent: TestClient, fr_case: Claim) -> None:
resp = as_agent.post(
f"/api/agent/cases/{fr_case.id}/draft-email", json={"decision": "REJECTED"}
)
assert resp.status_code == 200, resp.text
draft = resp.json()
assert draft["generated_by"] == "fallback_template"
assert "Décision concernant votre demande" in draft["subject"] # fr + formal template
assert "Marie" in draft["greeting"]
assert any("30 jours" in p for p in draft["body_paragraphs"])
def test_draft_email_outside_adjudication_is_409(
as_agent: TestClient, session: Session, users: dict[str, User]
) -> None:
claim = Claim(
claim_ref="CLM-AGENT-0010",
claimant_id=users[Role.CLAIMANT.value].id,
claim_type="imaging",
state=ClaimState.SPECIALIST_REVIEW,
)
session.add(claim)
session.commit()
resp = as_agent.post(f"/api/agent/cases/{claim.id}/draft-email", json={"decision": "APPROVED"})
assert resp.status_code == 409
# ----------------------------------------------------------------------------- decision
def test_decision_approve_notifies_audits_and_indexes_precedent(
as_agent: TestClient,
case: Claim,
session: Session,
settings: Settings,
users: dict[str, User],
) -> None:
collection = get_adjudicated_cases_collection(get_client(settings))
count_before = collection.count()
resp = as_agent.post(
f"/api/agent/cases/{case.id}/decision",
json=_decision_body(
"approve",
note="evidence supports the claim",
email_subject=f"Good news about your claim {CLAIM_REF}",
email_body_text="Hi Casey,\n\nYour claim was approved.\n\nThanks,\nClaimFlow",
),
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["state"] == "APPROVED"
assert body["notification_status"] == "logged"
assert body["case_ref"] is not None and body["case_ref"].startswith("CASE-")
session.expire_all()
claim = session.get(Claim, case.id)
assert claim is not None and claim.state is ClaimState.APPROVED
# the decision and the notification are one action: both rows exist post-commit
notification = session.scalar(select(Notification).where(Notification.claim_id == case.id))
assert notification is not None
assert notification.id == body["notification_id"]
assert notification.status is NotificationStatus.LOGGED # console provider terminal state
assert notification.provider == "console"
assert notification.subject == f"Good news about your claim {CLAIM_REF}"
assert "approved" in notification.body_text
decision = session.scalar(select(Decision).where(Decision.claim_id == case.id))
assert decision is not None
assert decision.action is ClaimAction.APPROVE
assert decision.from_state is ClaimState.ADJUDICATION
assert decision.to_state is ClaimState.APPROVED
assert decision.note == "evidence supports the claim"
assert decision.actor_id == users[Role.INSURANCE_AGENT.value].id
event_types = set(
session.scalars(select(AuditEvent.event_type).where(AuditEvent.claim_id == case.id)).all()
)
assert {"workflow.transition", "decision.approve", "email.sent"} <= event_types
approve_event = session.scalar(
select(AuditEvent).where(AuditEvent.event_type == "decision.approve")
)
assert approve_event is not None
assert approve_event.actor_user_id == users[Role.INSURANCE_AGENT.value].id
assert approve_event.actor_role == "insurance_agent"
assert json.loads(approve_event.payload_json) == {"note_present": True}
# precedent indexed into the adjudicated-cases collection
assert collection.count() == count_before + 1
_assert_terminal_claims_have_notifications(session)
valid, _ = audit.verify_chain(session)
assert valid is True
# terminal claims stay readable for post-decision review
dossier = as_agent.get(f"/api/agent/cases/{case.id}/dossier")
assert dossier.status_code == 200
reviewed = dossier.json()
assert reviewed["claim"]["state"] == "APPROVED"
assert len(reviewed["notifications"]) == 1
assert len(reviewed["timeline"]) == 1
def test_decision_reject_with_empty_email_uses_server_fallback(
as_agent: TestClient, case: Claim, session: Session
) -> None:
resp = as_agent.post(
f"/api/agent/cases/{case.id}/decision", json=_decision_body("reject")
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["state"] == "REJECTED"
assert body["notification_status"] == "logged"
session.expire_all()
claim = session.get(Claim, case.id)
assert claim is not None and claim.state is ClaimState.REJECTED
# empty subject/body never block the decision: the template fallback fills both
notification = session.scalar(select(Notification).where(Notification.claim_id == case.id))
assert notification is not None
assert notification.id == body["notification_id"]
assert CLAIM_REF in notification.subject
assert "Casey" in notification.body_text
assert "30 days" in notification.body_text # rejection copy carries the appeal window
reject_event = session.scalar(
select(AuditEvent).where(AuditEvent.event_type == "decision.reject")
)
assert reject_event is not None
assert json.loads(reject_event.payload_json) == {"note_present": False}
_assert_terminal_claims_have_notifications(session)
valid, _ = audit.verify_chain(session)
assert valid is True
def test_double_decision_is_409(as_agent: TestClient, case: Claim, session: Session) -> None:
assert (
as_agent.post(
f"/api/agent/cases/{case.id}/decision", json=_decision_body("approve")
).status_code
== 200
)
second = as_agent.post(f"/api/agent/cases/{case.id}/decision", json=_decision_body("reject"))
assert second.status_code == 409
assert "terminal state" in second.json()["detail"]
session.expire_all()
claim = session.get(Claim, case.id)
assert claim is not None and claim.state is ClaimState.APPROVED # first decision stands
notifications = session.scalars(
select(Notification).where(Notification.claim_id == case.id)
).all()
assert len(notifications) == 1 # the rejected second attempt sent nothing
def test_non_agent_roles_cannot_use_agent_endpoints(
client: TestClient, case: Claim, session: Session, users: dict[str, User]
) -> None:
for email in ("claimant@demo.ca", "imaging@demo.ca", "specialist@demo.ca"):
login(client, email)
assert client.get(f"/api/agent/cases/{case.id}/dossier").status_code == 403
assert (
client.post(
f"/api/agent/cases/{case.id}/draft-email", json={"decision": "APPROVED"}
).status_code
== 403
)
assert (
client.post(
f"/api/agent/cases/{case.id}/decision", json=_decision_body("approve")
).status_code
== 403
)
session.expire_all()
claim = session.get(Claim, case.id)
assert claim is not None and claim.state is ClaimState.ADJUDICATION # untouched
assert session.scalar(select(Notification).where(Notification.claim_id == case.id)) is None