Spaces:
Sleeping
Sleeping
| """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 | |
| 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 | |
| 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 | |