From f2496ecf9d160fb17950efe817a8a7c3c5f57f00 Mon Sep 17 00:00:00 2001 From: Reed Date: Fri, 20 Feb 2026 23:45:34 -0800 Subject: [PATCH 1/6] feat(intelligence): add generic policy fallback for unsupported procedure codes Co-Authored-By: Claude Opus 4.6 --- apps/intelligence/src/api/analyze.py | 30 +++---- .../src/policies/generic_policy.py | 76 ++++++++++++++++ apps/intelligence/src/tests/test_analyze.py | 87 +++++++++++++++++-- .../src/tests/test_generic_policy.py | 42 +++++++++ 4 files changed, 209 insertions(+), 26 deletions(-) create mode 100644 apps/intelligence/src/policies/generic_policy.py create mode 100644 apps/intelligence/src/tests/test_generic_policy.py diff --git a/apps/intelligence/src/api/analyze.py b/apps/intelligence/src/api/analyze.py index 143ddc7..c3c394b 100644 --- a/apps/intelligence/src/api/analyze.py +++ b/apps/intelligence/src/api/analyze.py @@ -13,6 +13,7 @@ from src.models.pa_form import PAFormResponse from src.parsers.pdf_parser import parse_pdf from src.policies.example_policy import EXAMPLE_POLICY +from src.policies.generic_policy import build_generic_policy from src.reasoning.evidence_extractor import extract_evidence from src.reasoning.form_generator import generate_form_data @@ -30,6 +31,13 @@ class AnalyzeRequest(BaseModel): clinical_data: dict[str, Any] +def resolve_policy(procedure_code: str) -> dict[str, Any]: + """Return the specific policy for known procedures, or a generic fallback.""" + if procedure_code in SUPPORTED_PROCEDURE_CODES: + return {**EXAMPLE_POLICY, "procedure_codes": [procedure_code]} + return build_generic_policy(procedure_code) + + @router.post("", response_model=PAFormResponse) async def analyze(request: AnalyzeRequest) -> PAFormResponse: """ @@ -37,13 +45,6 @@ async def analyze(request: AnalyzeRequest) -> PAFormResponse: Uses LLM to extract evidence from clinical data and generate PA form. """ - # Check if procedure is supported - if request.procedure_code not in SUPPORTED_PROCEDURE_CODES: - raise HTTPException( - status_code=400, - detail=f"Procedure code {request.procedure_code} not supported", - ) - # Parse clinical data into structured format bundle = ClinicalBundle.from_dict(request.patient_id, request.clinical_data) @@ -55,8 +56,8 @@ async def analyze(request: AnalyzeRequest) -> PAFormResponse: detail="patient.birth_date is required", ) - # Load policy with requested procedure code - policy = {**EXAMPLE_POLICY, "procedure_codes": [request.procedure_code]} + # Load specific policy or fall back to generic + policy = resolve_policy(request.procedure_code) # Extract evidence using LLM evidence = await extract_evidence(bundle, policy) @@ -87,13 +88,6 @@ async def analyze_with_documents( except json.JSONDecodeError as e: raise HTTPException(status_code=400, detail=f"Invalid clinical data JSON: {e}") - # Check if procedure is supported - if procedure_code not in SUPPORTED_PROCEDURE_CODES: - raise HTTPException( - status_code=400, - detail=f"Procedure code {procedure_code} not supported", - ) - # Parse clinical data into structured format bundle = ClinicalBundle.from_dict(patient_id, clinical_data_dict) @@ -112,8 +106,8 @@ async def analyze_with_documents( detail="patient.birth_date is required", ) - # Load policy with requested procedure code - policy = {**EXAMPLE_POLICY, "procedure_codes": [procedure_code]} + # Load specific policy or fall back to generic + policy = resolve_policy(procedure_code) # Extract evidence using LLM evidence = await extract_evidence(bundle, policy) diff --git a/apps/intelligence/src/policies/generic_policy.py b/apps/intelligence/src/policies/generic_policy.py new file mode 100644 index 0000000..47efa77 --- /dev/null +++ b/apps/intelligence/src/policies/generic_policy.py @@ -0,0 +1,76 @@ +"""Generic policy builder for unsupported procedure codes. + +When a procedure code is not in the specific policy set, we fall back to +a generic policy that uses broad criteria the LLM can evaluate against +any clinical documentation. +""" + +from typing import Any + + +def build_generic_policy(procedure_code: str) -> dict[str, Any]: + """Build a generic prior authorization policy for any procedure code. + + The generic policy uses broad criteria (medical necessity, diagnosis + validation, clinical documentation) that the LLM can evaluate against + any clinical data, regardless of procedure type. + """ + return { + "policy_id": f"generic-{procedure_code}", + "policy_name": f"Generic Prior Authorization - {procedure_code}", + "payer": "Generic", + "procedure_codes": [procedure_code], + "diagnosis_codes": { + "primary": [], + "supporting": [], + }, + "criteria": [ + { + "id": "medical_necessity", + "description": "The requested procedure is medically necessary based on the clinical documentation", + "evidence_patterns": [ + r"medically necessary", + r"medical necessity", + r"clinically indicated", + r"recommended.*procedure", + r"required.*treatment", + ], + "required": True, + }, + { + "id": "diagnosis_present", + "description": "Valid ICD-10 diagnosis code present supporting the procedure", + "evidence_patterns": [ + r"[A-Z]\d{2}\.?\d{0,4}", + r"diagnosis", + r"diagnosed with", + ], + "required": True, + }, + { + "id": "clinical_documentation", + "description": "Sufficient clinical documentation supports the request", + "evidence_patterns": [ + r"clinical.*documentation", + r"medical records", + r"chart.*notes", + r"history.*physical", + r"assessment.*plan", + ], + "required": True, + }, + ], + "form_field_mappings": { + "patient_name": "PatientName", + "patient_dob": "PatientDOB", + "member_id": "MemberID", + "diagnosis_primary": "PrimaryDiagnosis", + "diagnosis_secondary": "SecondaryDiagnosis", + "procedure_code": "ProcedureCode", + "clinical_summary": "ClinicalJustification", + "provider_name": "OrderingProviderName", + "provider_npi": "OrderingProviderNPI", + "facility_name": "FacilityName", + "date_of_service": "RequestedDateOfService", + }, + } diff --git a/apps/intelligence/src/tests/test_analyze.py b/apps/intelligence/src/tests/test_analyze.py index bb2491d..8c6044c 100644 --- a/apps/intelligence/src/tests/test_analyze.py +++ b/apps/intelligence/src/tests/test_analyze.py @@ -52,19 +52,20 @@ async def test_analyze_extracts_patient_info(valid_request: AnalyzeRequest) -> N @pytest.mark.asyncio -async def test_analyze_rejects_unsupported_procedure() -> None: - """Stub should reject unsupported procedure codes.""" +async def test_analyze_unsupported_procedure_uses_generic_fallback() -> None: + """Unsupported procedure codes now fall back to generic policy instead of 400.""" request = AnalyzeRequest( patient_id="test", procedure_code="99999", clinical_data={"patient": {"name": "Test", "birth_date": "1980-01-01"}}, ) - - with pytest.raises(HTTPException) as exc_info: - await analyze(request) - - assert exc_info.value.status_code == 400 - assert "not supported" in exc_info.value.detail + mock_llm = AsyncMock(return_value="The criterion is MET based on the evidence.") + with ( + patch("src.reasoning.evidence_extractor.chat_completion", mock_llm), + patch("src.reasoning.form_generator.chat_completion", mock_llm), + ): + result = await analyze(request) + assert result.procedure_code == "99999" @pytest.mark.asyncio @@ -92,3 +93,73 @@ async def test_analyze_builds_field_mappings(valid_request: AnalyzeRequest) -> N assert "PatientDOB" in result.field_mappings assert "ProcedureCode" in result.field_mappings assert result.field_mappings["PatientName"] == "John Doe" + + +@pytest.mark.asyncio +async def test_analyze_unsupported_procedure_uses_generic_policy(): + """POST /analyze with unsupported procedure returns 200, not 400.""" + request = AnalyzeRequest( + patient_id="test-123", + procedure_code="27447", # Total Knee Replacement - not in SUPPORTED_PROCEDURE_CODES + clinical_data={ + "patient": {"name": "Test Patient", "birth_date": "1968-03-15", "member_id": "MEM001"}, + "conditions": [{"code": "M17.11", "display": "Primary OA Right Knee"}], + "observations": [], + "procedures": [], + }, + ) + mock_llm = AsyncMock(return_value="The criterion is MET based on the clinical evidence provided.") + with ( + patch("src.reasoning.evidence_extractor.chat_completion", mock_llm), + patch("src.reasoning.form_generator.chat_completion", mock_llm), + ): + result = await analyze(request) + assert result.procedure_code == "27447" + + +@pytest.mark.asyncio +async def test_analyze_generic_policy_returns_valid_response(): + """Generic policy response has all required PAFormResponse fields.""" + request = AnalyzeRequest( + patient_id="test-456", + procedure_code="43239", # Upper GI Endoscopy + clinical_data={ + "patient": {"name": "Jane Doe", "birth_date": "1975-07-22", "member_id": "MEM002"}, + "conditions": [{"code": "K21.0", "display": "GERD with Esophagitis"}], + "observations": [], + "procedures": [], + }, + ) + mock_llm = AsyncMock(return_value="The criterion is MET based on the evidence.") + with ( + patch("src.reasoning.evidence_extractor.chat_completion", mock_llm), + patch("src.reasoning.form_generator.chat_completion", mock_llm), + ): + result = await analyze(request) + assert result.patient_name is not None + assert result.confidence_score >= 0 + assert len(result.supporting_evidence) > 0 + + +@pytest.mark.asyncio +async def test_analyze_known_procedure_still_uses_specific_policy(): + """72148 (MRI Lumbar) still uses the specific MRI policy, not generic.""" + request = AnalyzeRequest( + patient_id="test-789", + procedure_code="72148", + clinical_data={ + "patient": {"name": "Bob Smith", "birth_date": "1982-11-08", "member_id": "MEM003"}, + "conditions": [{"code": "M54.5", "display": "Low Back Pain"}], + "observations": [], + "procedures": [], + }, + ) + mock_llm = AsyncMock(return_value="The criterion is MET based on the evidence.") + with ( + patch("src.reasoning.evidence_extractor.chat_completion", mock_llm), + patch("src.reasoning.form_generator.chat_completion", mock_llm), + ): + result = await analyze(request) + # MRI policy has conservative_therapy criterion; generic doesn't + evidence_ids = [e.criterion_id for e in result.supporting_evidence] + assert "conservative_therapy" in evidence_ids diff --git a/apps/intelligence/src/tests/test_generic_policy.py b/apps/intelligence/src/tests/test_generic_policy.py new file mode 100644 index 0000000..588c553 --- /dev/null +++ b/apps/intelligence/src/tests/test_generic_policy.py @@ -0,0 +1,42 @@ +"""Tests for generic policy builder.""" +import pytest +from src.policies.generic_policy import build_generic_policy + + +def test_build_generic_policy_returns_valid_structure(): + """Policy has all required keys matching EXAMPLE_POLICY structure.""" + policy = build_generic_policy("27447") + assert "policy_id" in policy + assert "criteria" in policy + assert "form_field_mappings" in policy + assert "procedure_codes" in policy + assert "diagnosis_codes" in policy + + +def test_build_generic_policy_includes_medical_necessity_criterion(): + """Generic policy always includes medical necessity criterion.""" + policy = build_generic_policy("27447") + criterion_ids = [c["id"] for c in policy["criteria"]] + assert "medical_necessity" in criterion_ids + + +def test_build_generic_policy_includes_diagnosis_criterion(): + """Generic policy always includes diagnosis validation criterion.""" + policy = build_generic_policy("27447") + criterion_ids = [c["id"] for c in policy["criteria"]] + assert "diagnosis_present" in criterion_ids + + +def test_build_generic_policy_uses_procedure_code(): + """Procedure code is set in the policy.""" + policy = build_generic_policy("27447") + assert "27447" in policy["procedure_codes"] + + +def test_build_generic_policy_criteria_have_required_fields(): + """Each criterion has id, description, and required fields.""" + policy = build_generic_policy("99999") + for criterion in policy["criteria"]: + assert "id" in criterion + assert "description" in criterion + assert "required" in criterion From a904b803da834fddf5da77e2d1f025c12f9f9238 Mon Sep 17 00:00:00 2001 From: Reed Date: Fri, 20 Feb 2026 23:47:39 -0800 Subject: [PATCH 2/6] feat(gateway): add MockDataService.ApplyAnalysisResult for intelligence integration Co-Authored-By: Claude Opus 4.6 --- .../Services/MockDataServiceTests.cs | 106 ++++++++++++++++++ .../Gateway.API/Services/MockDataService.cs | 27 +++++ 2 files changed, 133 insertions(+) diff --git a/apps/gateway/Gateway.API.Tests/Services/MockDataServiceTests.cs b/apps/gateway/Gateway.API.Tests/Services/MockDataServiceTests.cs index 8225d48..0c6e72c 100644 --- a/apps/gateway/Gateway.API.Tests/Services/MockDataServiceTests.cs +++ b/apps/gateway/Gateway.API.Tests/Services/MockDataServiceTests.cs @@ -365,6 +365,112 @@ public async Task BootstrapRequest_PA003_HasUnmetCriteria() } } + // ── ApplyAnalysisResult ───────────────────────────────────────────── + + [Test] + public async Task ApplyAnalysisResult_UpdatesStatus_ToReady() + { + var svc = CreateService(); + var existing = svc.GetPARequests().First(); + + var result = svc.ApplyAnalysisResult( + existing.Id, + clinicalSummary: "AI analysis summary", + confidence: 85, + criteria: [new CriterionModel { Met = true, Label = "test", Reason = "test reason" }]); + + await Assert.That(result).IsNotNull(); + await Assert.That(result!.Status).IsEqualTo("ready"); + } + + [Test] + public async Task ApplyAnalysisResult_UpdatesConfidence_FromScore() + { + var svc = CreateService(); + var existing = svc.GetPARequests().First(); + + var result = svc.ApplyAnalysisResult(existing.Id, "summary", 92, []); + + await Assert.That(result).IsNotNull(); + await Assert.That(result!.Confidence).IsEqualTo(92); + } + + [Test] + public async Task ApplyAnalysisResult_UpdatesClinicalSummary() + { + var svc = CreateService(); + var existing = svc.GetPARequests().First(); + var summary = "Patient presents with chronic low back pain. AI recommends approval."; + + var result = svc.ApplyAnalysisResult(existing.Id, summary, 85, []); + + await Assert.That(result).IsNotNull(); + await Assert.That(result!.ClinicalSummary).IsEqualTo(summary); + } + + [Test] + public async Task ApplyAnalysisResult_UpdatesCriteria_FromEvidence() + { + var svc = CreateService(); + var existing = svc.GetPARequests().First(); + var criteria = new List + { + new() { Met = true, Label = "conservative_therapy", Reason = "6 weeks PT completed" }, + new() { Met = false, Label = "imaging_required", Reason = "No prior imaging found" }, + new() { Met = null, Label = "diagnosis_present", Reason = "Needs verification" }, + }; + + var result = svc.ApplyAnalysisResult(existing.Id, "summary", 75, criteria); + + await Assert.That(result).IsNotNull(); + await Assert.That(result!.Criteria.Count).IsEqualTo(3); + await Assert.That(result.Criteria[0].Met).IsTrue(); + await Assert.That(result.Criteria[1].Met).IsFalse(); + await Assert.That(result.Criteria[2].Met).IsNull(); + } + + [Test] + public async Task ApplyAnalysisResult_SetsReadyAtTimestamp() + { + var svc = CreateService(); + var existing = svc.GetPARequests().First(); + var before = DateTime.UtcNow; + + var result = svc.ApplyAnalysisResult(existing.Id, "summary", 85, []); + + await Assert.That(result).IsNotNull(); + await Assert.That(result!.ReadyAt).IsNotNull(); + var readyAt = DateTimeOffset.Parse(result.ReadyAt!); + await Assert.That(readyAt.UtcDateTime).IsGreaterThanOrEqualTo(before); + } + + [Test] + public async Task ApplyAnalysisResult_NonExistentId_ReturnsNull() + { + var svc = CreateService(); + + var result = svc.ApplyAnalysisResult("NONEXISTENT", "summary", 85, []); + + await Assert.That(result).IsNull(); + } + + [Test] + public async Task ApplyAnalysisResult_UpdatesAreVisibleViaGetPARequest() + { + var svc = CreateService(); + var existing = svc.GetPARequests().First(); + + svc.ApplyAnalysisResult(existing.Id, "Updated summary", 90, [ + new CriterionModel { Met = true, Label = "test_criterion", Reason = "test" } + ]); + + var fetched = svc.GetPARequest(existing.Id); + await Assert.That(fetched).IsNotNull(); + await Assert.That(fetched!.ClinicalSummary).IsEqualTo("Updated summary"); + await Assert.That(fetched.Confidence).IsEqualTo(90); + await Assert.That(fetched.Status).IsEqualTo("ready"); + } + // ── Success Rate ──────────────────────────────────────────────────── [Test] diff --git a/apps/gateway/Gateway.API/Services/MockDataService.cs b/apps/gateway/Gateway.API/Services/MockDataService.cs index a5c1fd1..a6b91f4 100644 --- a/apps/gateway/Gateway.API/Services/MockDataService.cs +++ b/apps/gateway/Gateway.API/Services/MockDataService.cs @@ -258,6 +258,33 @@ public PARequestModel CreatePARequest(PatientModel patient, string procedureCode return req; } + public PARequestModel? ApplyAnalysisResult( + string id, + string clinicalSummary, + int confidence, + IReadOnlyList criteria) + { + lock (_lock) + { + var idx = _paRequests.FindIndex(r => r.Id == id); + if (idx < 0) return null; + + var existing = _paRequests[idx]; + var readyAt = DateTime.UtcNow.ToString("O"); + var updated = existing with + { + Status = "ready", + Confidence = confidence, + ClinicalSummary = clinicalSummary, + Criteria = criteria.ToList(), + ReadyAt = readyAt, + UpdatedAt = readyAt, + }; + _paRequests[idx] = updated; + return updated; + } + } + public PARequestModel? UpdatePARequest(string id, string? diagnosis, string? diagnosisCode, string? serviceDate, string? placeOfService, string? clinicalSummary, IReadOnlyList? criteria) { lock (_lock) From 26b365a309a92d58aa1ef6561dd1e994e0ffdfdf Mon Sep 17 00:00:00 2001 From: Reed Date: Fri, 20 Feb 2026 23:49:26 -0800 Subject: [PATCH 3/6] test(dashboard): expand component tests for real Intelligence data shapes Co-Authored-By: Claude Opus 4.6 --- .../src/api/__tests__/graphqlService.test.ts | 61 ++++++++ .../__tests__/EvidencePanel.test.tsx | 110 +++++++++++++ .../__tests__/PARequestCard.test.tsx | 148 ++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 apps/dashboard/src/components/__tests__/PARequestCard.test.tsx diff --git a/apps/dashboard/src/api/__tests__/graphqlService.test.ts b/apps/dashboard/src/api/__tests__/graphqlService.test.ts index 9c7c767..bb5e73c 100644 --- a/apps/dashboard/src/api/__tests__/graphqlService.test.ts +++ b/apps/dashboard/src/api/__tests__/graphqlService.test.ts @@ -14,7 +14,9 @@ import { graphqlClient } from '../graphqlClient'; import { useApprovePARequest, useDenyPARequest, + useProcessPARequest, useConnectionStatus, + QUERY_KEYS, } from '../graphqlService'; const mockRequest = vi.mocked(graphqlClient.request); @@ -75,6 +77,65 @@ describe('useDenyPARequest', () => { }); }); +describe('useProcessPARequest', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call processPARequest mutation with correct id', async () => { + mockRequest.mockResolvedValueOnce({ + processPARequest: { id: 'PA-001', status: 'ready' }, + }); + + const { result } = renderHook(() => useProcessPARequest(), { + wrapper: createWrapper(), + }); + + result.current.mutate('PA-001'); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockRequest).toHaveBeenCalledOnce(); + const callArgs = mockRequest.mock.calls[0]; + expect(callArgs[0]).toContain('processPARequest'); + expect(callArgs[1]).toEqual({ id: 'PA-001' }); + }); + + it('useProcessPARequest_InvalidatesCorrectQueries_OnSuccess', async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + // Seed the cache with data for each query key we expect to be invalidated + queryClient.setQueryData(QUERY_KEYS.paRequests, []); + queryClient.setQueryData(QUERY_KEYS.paRequest('PA-002'), { id: 'PA-002' }); + queryClient.setQueryData(QUERY_KEYS.paStats, { ready: 0 }); + queryClient.setQueryData(QUERY_KEYS.activity, []); + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + mockRequest.mockResolvedValueOnce({ + processPARequest: { id: 'PA-002', status: 'ready' }, + }); + + const { result } = renderHook(() => useProcessPARequest(), { wrapper }); + + // Spy on invalidateQueries + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + result.current.mutate('PA-002'); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // Verify all four query keys are invalidated + const invalidatedKeys = invalidateSpy.mock.calls.map(call => call[0]?.queryKey); + expect(invalidatedKeys).toContainEqual(QUERY_KEYS.paRequests); + expect(invalidatedKeys).toContainEqual(QUERY_KEYS.paRequest('PA-002')); + expect(invalidatedKeys).toContainEqual(QUERY_KEYS.paStats); + expect(invalidatedKeys).toContainEqual(QUERY_KEYS.activity); + }); +}); + describe('useConnectionStatus', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/dashboard/src/components/__tests__/EvidencePanel.test.tsx b/apps/dashboard/src/components/__tests__/EvidencePanel.test.tsx index 997202a..1d1bcb8 100644 --- a/apps/dashboard/src/components/__tests__/EvidencePanel.test.tsx +++ b/apps/dashboard/src/components/__tests__/EvidencePanel.test.tsx @@ -117,4 +117,114 @@ describe('EvidencePanel', () => { expect(screen.getByTestId('evidence-skeleton')).toBeInTheDocument(); }); }); + + describe('Intelligence-shaped evidence', () => { + it('EvidencePanel_WithIntelligenceEvidence_DisplaysCriterionIdAsLabel', () => { + const intelligenceEvidence: EvidenceItem[] = [ + { + criterionId: 'conservative_therapy', + status: 'MET', + evidence: 'Patient completed 8 weeks of physical therapy and NSAID treatment', + source: 'Clinical Notes - 2026-01-15', + confidence: 0.95, + }, + { + criterionId: 'medical_necessity', + status: 'MET', + evidence: 'MRI indicated for evaluation of persistent symptoms', + source: 'Order Entry - 2026-01-20', + confidence: 0.88, + }, + ]; + render(); + + expect(screen.getByText('Conservative Therapy')).toBeInTheDocument(); + expect(screen.getByText('Medical Necessity')).toBeInTheDocument(); + }); + + it('EvidencePanel_WithUnclearStatus_ShowsDistinctBadge', () => { + const evidence: EvidenceItem[] = [ + { + criterionId: 'diagnosis_present', + status: 'UNCLEAR', + evidence: 'Diagnosis code needs verification', + source: 'System', + confidence: 0.5, + }, + ]; + render(); + + const badge = screen.getByText('Unclear'); + expect(badge).toBeInTheDocument(); + // UNCLEAR uses warning styling + expect(badge.className).toMatch(/bg-warning/); + expect(badge.className).toMatch(/text-warning/); + // Should NOT use MET (success) or NOT_MET (destructive) styling + expect(badge.className).not.toMatch(/bg-success/); + expect(badge.className).not.toMatch(/text-success/); + expect(badge.className).not.toMatch(/bg-destructive/); + expect(badge.className).not.toMatch(/text-destructive/); + }); + + it('EvidencePanel_WithAllThreeStatuses_ShowsCorrectSummaryBreakdown', () => { + const mixedEvidence: EvidenceItem[] = [ + { + criterionId: 'conservative_therapy', + status: 'MET', + evidence: 'PT completed', + source: 'Notes', + confidence: 0.95, + }, + { + criterionId: 'diagnosis_present', + status: 'UNCLEAR', + evidence: 'Needs verification', + source: 'System', + confidence: 0.5, + }, + { + criterionId: 'imaging_prior', + status: 'NOT_MET', + evidence: 'No prior imaging found', + source: 'Radiology', + confidence: 0.9, + }, + ]; + render(); + + expect(screen.getByText('3 criteria analyzed')).toBeInTheDocument(); + }); + + it('EvidencePanel_WithLowConfidence_ShowsWarningColor', () => { + const evidence: EvidenceItem[] = [ + { + criterionId: 'medical_necessity', + status: 'MET', + evidence: 'Some evidence found', + source: 'Notes', + confidence: 0.35, + }, + ]; + render(); + + // 35% confidence should show destructive color (below 0.5 threshold) + expect(screen.getByText(/35%/)).toBeInTheDocument(); + }); + + it('EvidencePanel_WithBorderlineConfidence_ShowsCorrectColor', () => { + const evidence: EvidenceItem[] = [ + { + criterionId: 'conservative_therapy', + status: 'MET', + evidence: 'Partial documentation', + source: 'Notes', + confidence: 0.5, + }, + ]; + render(); + + // 50% is at the boundary — should show warning color + expect(screen.getByText(/50%/)).toBeInTheDocument(); + }); + }); }); diff --git a/apps/dashboard/src/components/__tests__/PARequestCard.test.tsx b/apps/dashboard/src/components/__tests__/PARequestCard.test.tsx new file mode 100644 index 0000000..b329d23 --- /dev/null +++ b/apps/dashboard/src/components/__tests__/PARequestCard.test.tsx @@ -0,0 +1,148 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { PARequestCard, type PARequest } from '../PARequestCard'; + +// Mock @tanstack/react-router Link component +vi.mock('@tanstack/react-router', () => ({ + Link: ({ children, to, params }: { children: React.ReactNode; to: string; params?: Record }) => ( + + {children} + + ), +})); + +function createMockPARequest(overrides: Partial = {}): PARequest { + return { + id: 'PA-001', + patientName: 'Jane Smith', + patientId: 'MRN-12345', + procedureCode: '72148', + procedureName: 'MRI Lumbar Spine', + payer: 'Blue Cross Blue Shield', + currentStep: 'process', + createdAt: new Date().toISOString(), + encounterId: 'ENC-001', + ...overrides, + }; +} + +describe('PARequestCard', () => { + describe('rendering', () => { + it('PARequestCard_WithValidRequest_DisplaysPatientInfo', () => { + const request = createMockPARequest(); + render(); + + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + expect(screen.getByText(/MRN-12345/)).toBeInTheDocument(); + }); + + it('PARequestCard_WithValidRequest_DisplaysProcedure', () => { + const request = createMockPARequest(); + render(); + + expect(screen.getByText('72148')).toBeInTheDocument(); + expect(screen.getByText('MRI Lumbar Spine')).toBeInTheDocument(); + }); + + it('PARequestCard_WithValidRequest_DisplaysPayer', () => { + const request = createMockPARequest(); + render(); + + expect(screen.getByText('Blue Cross Blue Shield')).toBeInTheDocument(); + }); + }); + + describe('confidence display', () => { + it('PARequestCard_WithHighConfidence_DisplaysPercentage', () => { + const request = createMockPARequest({ confidenceScore: 0.92 }); + render(); + + expect(screen.getByText(/92%/)).toBeInTheDocument(); + }); + + it('PARequestCard_WithMediumConfidence_DisplaysPercentage', () => { + const request = createMockPARequest({ confidenceScore: 0.65 }); + render(); + + expect(screen.getByText(/65%/)).toBeInTheDocument(); + }); + + it('PARequestCard_WithLowConfidence_DisplaysReviewBadge', () => { + const request = createMockPARequest({ confidenceScore: 0.42 }); + render(); + + // Low confidence (< 0.5) shows "Review" text in a badge (data-slot="badge") + const badges = screen.getAllByText('Review'); + const confidenceBadge = badges.find(el => el.getAttribute('data-slot') === 'badge'); + expect(confidenceBadge).toBeDefined(); + }); + + it('PARequestCard_WithUndefinedConfidence_ShowsNoConfidenceBadge', () => { + const request = createMockPARequest({ confidenceScore: undefined }); + render(); + + // No confidence percentage badge should render + expect(screen.queryByText(/\d+%/)).not.toBeInTheDocument(); + // The only "Review" text should be from the WorkflowProgress step, not a badge + const reviewElements = screen.queryAllByText('Review'); + const confidenceBadge = reviewElements.find(el => el.getAttribute('data-slot') === 'badge'); + expect(confidenceBadge).toBeUndefined(); + }); + + it('PARequestCard_WithRealConfidenceRange_DisplaysCorrectly', () => { + // Test with various realistic confidence values from Intelligence + const request = createMockPARequest({ confidenceScore: 0.78 }); + render(); + + expect(screen.getByText(/78%/)).toBeInTheDocument(); + }); + }); + + describe('workflow states', () => { + it('PARequestCard_InProcessingState_ShowsProcessingIndicator', () => { + const request = createMockPARequest({ currentStep: 'process' }); + render(); + + expect(screen.getByText('Processing...')).toBeInTheDocument(); + }); + + it('PARequestCard_InDeliverState_ShowsReviewButton', () => { + const request = createMockPARequest({ currentStep: 'deliver' }); + render(); + + expect(screen.getByText('Review & Confirm')).toBeInTheDocument(); + }); + + it('PARequestCard_InReviewCompletedState_ShowsSubmittedMessage', () => { + const request = createMockPARequest({ + currentStep: 'review', + stepStatuses: { review: 'completed' }, + }); + render(); + + expect(screen.getByText('Submitted to athenahealth')).toBeInTheDocument(); + }); + }); + + describe('attention flag', () => { + it('PARequestCard_RequiresAttention_HasWarningRing', () => { + const request = createMockPARequest({ requiresAttention: true }); + const { container } = render(); + + const card = container.firstChild as HTMLElement; + expect(card.className).toMatch(/ring-warning/); + }); + }); + + describe('callbacks', () => { + it('PARequestCard_OnReviewClick_CallsCallback', () => { + const onReview = vi.fn(); + const request = createMockPARequest({ currentStep: 'deliver' }); + render(); + + fireEvent.click(screen.getByText('Review & Confirm')); + + expect(onReview).toHaveBeenCalledWith('PA-001'); + }); + }); +}); From 4c881f83b83eb0a7bc89bb8a0631b47b5fff9575 Mon Sep 17 00:00:00 2001 From: Reed Date: Fri, 20 Feb 2026 23:54:08 -0800 Subject: [PATCH 4/6] feat(gateway): replace IntelligenceClient stub with real HTTP implementation Replace the hardcoded stub IntelligenceClient with a real HTTP client that calls the Python Intelligence service at /api/analyze. Add snake_case DTO serialization bridge, DI wiring with HttpClientFactory, and mock the IntelligenceClient in integration test bootstraps. Co-Authored-By: Claude Opus 4.6 --- .../DependencyExtensionsTests.cs | 52 ++++- .../EncounterProcessingAlbaBootstrap.cs | 34 ++- .../Integration/GatewayAlbaBootstrap.cs | 33 ++- .../IntelligenceClientSerializationTests.cs | 180 +++++++++++++++ .../Services/IntelligenceClientTests.cs | 209 ++++++++++++++++++ .../Gateway.API/DependencyExtensions.cs | 18 +- .../Services/IntelligenceClient.cs | 136 +++++++----- 7 files changed, 594 insertions(+), 68 deletions(-) create mode 100644 apps/gateway/Gateway.API.Tests/Services/IntelligenceClientSerializationTests.cs create mode 100644 apps/gateway/Gateway.API.Tests/Services/IntelligenceClientTests.cs diff --git a/apps/gateway/Gateway.API.Tests/DependencyExtensionsTests.cs b/apps/gateway/Gateway.API.Tests/DependencyExtensionsTests.cs index ccd801b..afa71ab 100644 --- a/apps/gateway/Gateway.API.Tests/DependencyExtensionsTests.cs +++ b/apps/gateway/Gateway.API.Tests/DependencyExtensionsTests.cs @@ -6,9 +6,11 @@ namespace Gateway.API.Tests; +using Gateway.API.Configuration; using Gateway.API.Contracts; using Gateway.API.Data; using Gateway.API.Services; +using Gateway.API.Services.Decorators; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -79,6 +81,52 @@ public async Task AddGatewayPersistence_RegistersPostgresStores() await Assert.That(patientRegistry).IsTypeOf(); } + [Test] + public async Task AddIntelligenceClient_RegistersHttpClientForIntelligenceClient() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + var config = CreateTestConfiguration(); + services.AddSingleton(config); + services.AddHybridCache(); + services.AddOptions() + .BindConfiguration(CachingSettings.SectionName); + + // Act + services.AddIntelligenceClient(); + var provider = services.BuildServiceProvider(); + + using var scope = provider.CreateScope(); + var client = scope.ServiceProvider.GetService(); + + // Assert + await Assert.That(client).IsNotNull(); + } + + [Test] + public async Task AddIntelligenceClient_AppliesCachingDecorator() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + var config = CreateTestConfiguration(); + services.AddSingleton(config); + services.AddHybridCache(); + services.AddOptions() + .BindConfiguration(CachingSettings.SectionName); + + // Act + services.AddIntelligenceClient(); + var provider = services.BuildServiceProvider(); + + using var scope = provider.CreateScope(); + var client = scope.ServiceProvider.GetService(); + + // Assert - Resolved service should be the caching decorator + await Assert.That(client).IsTypeOf(); + } + private static IConfiguration CreateTestConfiguration() { var configValues = new Dictionary @@ -88,7 +136,9 @@ private static IConfiguration CreateTestConfiguration() ["Document:MaxSizeMb"] = "10", ["Caching:Enabled"] = "false", ["Caching:Duration"] = "00:05:00", - ["Caching:LocalCacheDuration"] = "00:01:00" + ["Caching:LocalCacheDuration"] = "00:01:00", + ["Intelligence:BaseUrl"] = "http://localhost:8000", + ["Intelligence:TimeoutSeconds"] = "30" }; return new ConfigurationBuilder() .AddInMemoryCollection(configValues) diff --git a/apps/gateway/Gateway.API.Tests/Integration/EncounterProcessingAlbaBootstrap.cs b/apps/gateway/Gateway.API.Tests/Integration/EncounterProcessingAlbaBootstrap.cs index 44300c3..8120adc 100644 --- a/apps/gateway/Gateway.API.Tests/Integration/EncounterProcessingAlbaBootstrap.cs +++ b/apps/gateway/Gateway.API.Tests/Integration/EncounterProcessingAlbaBootstrap.cs @@ -124,7 +124,39 @@ public async Task InitializeAsync() services.RemoveAll(); services.AddSingleton(mockAggregator); - // IntelligenceClient is already a stub - no replacement needed + // Replace IntelligenceClient with mock returning test PA form data + var mockIntelligenceClient = Substitute.For(); + mockIntelligenceClient + .AnalyzeAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(new PAFormData + { + PatientName = "Test Patient", + PatientDob = "1990-01-01", + MemberId = "MEM-TEST", + DiagnosisCodes = ["M54.5"], + ProcedureCode = callInfo.ArgAt(1), + ClinicalSummary = "Test clinical summary", + SupportingEvidence = + [ + new EvidenceItem + { + CriterionId = "diagnosis_present", + Status = "MET", + Evidence = "Test evidence", + Source = "Test", + Confidence = 0.95 + } + ], + Recommendation = "APPROVE", + ConfidenceScore = 0.95, + FieldMappings = new Dictionary + { + ["PatientName"] = "Test Patient" + } + })); + services.RemoveAll(); + services.AddSingleton(mockIntelligenceClient); + // PdfFormStamper is already a stub - no replacement needed // Remove the Aspire Redis registration that would fail without a real connection diff --git a/apps/gateway/Gateway.API.Tests/Integration/GatewayAlbaBootstrap.cs b/apps/gateway/Gateway.API.Tests/Integration/GatewayAlbaBootstrap.cs index a441532..017d57e 100644 --- a/apps/gateway/Gateway.API.Tests/Integration/GatewayAlbaBootstrap.cs +++ b/apps/gateway/Gateway.API.Tests/Integration/GatewayAlbaBootstrap.cs @@ -123,7 +123,38 @@ public async Task InitializeAsync() services.RemoveAll(); services.AddSingleton(mockAggregator); - // IntelligenceClient is already a stub - no replacement needed + // Replace IntelligenceClient with mock returning test PA form data + var mockIntelligenceClient = Substitute.For(); + mockIntelligenceClient + .AnalyzeAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(new PAFormData + { + PatientName = "Test Patient", + PatientDob = "1990-01-01", + MemberId = "MEM-TEST", + DiagnosisCodes = ["M54.5"], + ProcedureCode = callInfo.ArgAt(1), + ClinicalSummary = "Test clinical summary", + SupportingEvidence = + [ + new EvidenceItem + { + CriterionId = "diagnosis_present", + Status = "MET", + Evidence = "Test evidence", + Source = "Test", + Confidence = 0.95 + } + ], + Recommendation = "APPROVE", + ConfidenceScore = 0.95, + FieldMappings = new Dictionary + { + ["PatientName"] = "Test Patient" + } + })); + services.RemoveAll(); + services.AddSingleton(mockIntelligenceClient); }); }).ConfigureAwait(false); } diff --git a/apps/gateway/Gateway.API.Tests/Services/IntelligenceClientSerializationTests.cs b/apps/gateway/Gateway.API.Tests/Services/IntelligenceClientSerializationTests.cs new file mode 100644 index 0000000..6de0bf1 --- /dev/null +++ b/apps/gateway/Gateway.API.Tests/Services/IntelligenceClientSerializationTests.cs @@ -0,0 +1,180 @@ +using System.Text.Json; +using Gateway.API.Models; +using Gateway.API.Services; + +namespace Gateway.API.Tests.Services; + +/// +/// Tests for IntelligenceClient serialization: verifying that BuildAnalyzeRequest() +/// correctly maps C# models to the snake_case DTO that the Python Intelligence service expects. +/// +public class IntelligenceClientSerializationTests +{ + private static ClinicalBundle CreateFullBundle() + { + return new ClinicalBundle + { + PatientId = "patient-123", + Patient = new PatientInfo + { + Id = "patient-123", + GivenName = "Donna", + FamilyName = "Sandbox", + BirthDate = new DateOnly(1968, 3, 15), + Gender = "female", + MemberId = "ATH60178" + }, + Conditions = + [ + new ConditionInfo + { + Id = "cond-1", + Code = "M54.5", + CodeSystem = "http://hl7.org/fhir/sid/icd-10-cm", + Display = "Low back pain", + ClinicalStatus = "active" + } + ], + Observations = + [ + new ObservationInfo + { + Id = "obs-1", + Code = "72166-2", + CodeSystem = "http://loinc.org", + Display = "Smoking status", + Value = "Never smoker", + Unit = "N/A" + } + ], + Procedures = + [ + new ProcedureInfo + { + Id = "proc-1", + Code = "99213", + CodeSystem = "http://www.ama-assn.org/go/cpt", + Display = "Office visit", + Status = "completed" + } + ] + }; + } + + [Test] + public async Task BuildAnalyzeRequest_MapsPatientFullName_ToNameField() + { + // Arrange + var bundle = CreateFullBundle(); + + // Act + var dto = IntelligenceClient.BuildAnalyzeRequest(bundle, "72148"); + + // Assert + await Assert.That(dto.ClinicalData.Patient).IsNotNull(); + await Assert.That(dto.ClinicalData.Patient!.Name).IsEqualTo("Donna Sandbox"); + } + + [Test] + public async Task BuildAnalyzeRequest_MapsPatientBirthDate_ToIsoString() + { + // Arrange + var bundle = CreateFullBundle(); + + // Act + var dto = IntelligenceClient.BuildAnalyzeRequest(bundle, "72148"); + + // Assert + await Assert.That(dto.ClinicalData.Patient!.BirthDate).IsEqualTo("1968-03-15"); + } + + [Test] + public async Task BuildAnalyzeRequest_MapsConditions_ToSnakeCaseKeys() + { + // Arrange + var bundle = CreateFullBundle(); + + // Act + var dto = IntelligenceClient.BuildAnalyzeRequest(bundle, "72148"); + + // Assert + await Assert.That(dto.ClinicalData.Conditions).HasCount().EqualTo(1); + var condition = dto.ClinicalData.Conditions[0]; + await Assert.That(condition.Code).IsEqualTo("M54.5"); + await Assert.That(condition.System).IsEqualTo("http://hl7.org/fhir/sid/icd-10-cm"); + await Assert.That(condition.Display).IsEqualTo("Low back pain"); + await Assert.That(condition.ClinicalStatus).IsEqualTo("active"); + } + + [Test] + public async Task BuildAnalyzeRequest_MapsObservations_WithAllFields() + { + // Arrange + var bundle = CreateFullBundle(); + + // Act + var dto = IntelligenceClient.BuildAnalyzeRequest(bundle, "72148"); + + // Assert + await Assert.That(dto.ClinicalData.Observations).HasCount().EqualTo(1); + var obs = dto.ClinicalData.Observations[0]; + await Assert.That(obs.Code).IsEqualTo("72166-2"); + await Assert.That(obs.System).IsEqualTo("http://loinc.org"); + await Assert.That(obs.Display).IsEqualTo("Smoking status"); + await Assert.That(obs.Value).IsEqualTo("Never smoker"); + await Assert.That(obs.Unit).IsEqualTo("N/A"); + } + + [Test] + public async Task BuildAnalyzeRequest_WithNullPatient_SetsNullPatientField() + { + // Arrange + var bundle = new ClinicalBundle + { + PatientId = "patient-123", + Patient = null, + Conditions = [], + Observations = [], + Procedures = [] + }; + + // Act + var dto = IntelligenceClient.BuildAnalyzeRequest(bundle, "72148"); + + // Assert + await Assert.That(dto.ClinicalData.Patient).IsNull(); + await Assert.That(dto.PatientId).IsEqualTo("patient-123"); + await Assert.That(dto.ProcedureCode).IsEqualTo("72148"); + } + + [Test] + public async Task SerializeRequest_UsesSnakeCaseNaming() + { + // Arrange + var bundle = CreateFullBundle(); + var dto = IntelligenceClient.BuildAnalyzeRequest(bundle, "72148"); + + // Act + var json = JsonSerializer.Serialize(dto, IntelligenceClient.SnakeCaseSerializerOptions); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // Assert - top-level keys are snake_case + await Assert.That(root.TryGetProperty("patient_id", out _)).IsTrue(); + await Assert.That(root.TryGetProperty("procedure_code", out _)).IsTrue(); + await Assert.That(root.TryGetProperty("clinical_data", out _)).IsTrue(); + + // Assert - nested patient keys are snake_case + var clinicalData = root.GetProperty("clinical_data"); + var patient = clinicalData.GetProperty("patient"); + await Assert.That(patient.TryGetProperty("name", out _)).IsTrue(); + await Assert.That(patient.TryGetProperty("birth_date", out _)).IsTrue(); + await Assert.That(patient.TryGetProperty("gender", out _)).IsTrue(); + await Assert.That(patient.TryGetProperty("member_id", out _)).IsTrue(); + + // Assert - condition keys are snake_case + var conditions = clinicalData.GetProperty("conditions"); + var firstCondition = conditions[0]; + await Assert.That(firstCondition.TryGetProperty("clinical_status", out _)).IsTrue(); + } +} diff --git a/apps/gateway/Gateway.API.Tests/Services/IntelligenceClientTests.cs b/apps/gateway/Gateway.API.Tests/Services/IntelligenceClientTests.cs new file mode 100644 index 0000000..618e6b3 --- /dev/null +++ b/apps/gateway/Gateway.API.Tests/Services/IntelligenceClientTests.cs @@ -0,0 +1,209 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Gateway.API.Models; +using Gateway.API.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Gateway.API.Tests.Services; + +/// +/// Tests for IntelligenceClient HTTP behavior: verifying that it +/// calls the correct endpoint, serializes/deserializes correctly, and handles errors. +/// +public class IntelligenceClientTests +{ + private static (IntelligenceClient client, MockHttpHandler handler) CreateClient( + Func> responseFactory) + { + var handler = new MockHttpHandler(responseFactory); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://test-intelligence:8000") }; + var logger = NullLogger.Instance; + return (new IntelligenceClient(httpClient, logger), handler); + } + + private static ClinicalBundle CreateTestBundle() + { + return new ClinicalBundle + { + PatientId = "patient-123", + Patient = new PatientInfo + { + Id = "patient-123", + GivenName = "Donna", + FamilyName = "Sandbox", + BirthDate = new DateOnly(1968, 3, 15), + Gender = "female", + MemberId = "ATH60178" + }, + Conditions = + [ + new ConditionInfo + { + Id = "cond-1", + Code = "M54.5", + Display = "Low back pain", + ClinicalStatus = "active" + } + ], + Observations = + [ + new ObservationInfo + { + Id = "obs-1", + Code = "72166-2", + Display = "Smoking status", + Value = "Never smoker" + } + ], + Procedures = + [ + new ProcedureInfo + { + Id = "proc-1", + Code = "99213", + Display = "Office visit", + Status = "completed" + } + ] + }; + } + + private static readonly string MockResponseJson = """ + { + "patient_name": "Donna Sandbox", + "patient_dob": "1968-03-15", + "member_id": "ATH60178", + "diagnosis_codes": ["M54.5"], + "procedure_code": "72148", + "clinical_summary": "AI-generated clinical summary for MRI lumbar spine.", + "supporting_evidence": [ + { + "criterion_id": "conservative_therapy", + "status": "MET", + "evidence": "Patient completed 8 weeks of physical therapy", + "source": "Clinical notes", + "confidence": 0.95 + } + ], + "recommendation": "APPROVE", + "confidence_score": 0.92, + "field_mappings": {"PatientName": "Donna Sandbox"} + } + """; + + [Test] + public async Task AnalyzeAsync_PostsToAnalyzeEndpoint_WithCorrectPayload() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + string? capturedBody = null; + + var (client, _) = CreateClient(async request => + { + capturedRequest = request; + capturedBody = await request.Content!.ReadAsStringAsync(); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(MockResponseJson, Encoding.UTF8, "application/json") + }; + }); + + var bundle = CreateTestBundle(); + + // Act + await client.AnalyzeAsync(bundle, "72148"); + + // Assert - Correct URL and method + await Assert.That(capturedRequest).IsNotNull(); + await Assert.That(capturedRequest!.Method).IsEqualTo(HttpMethod.Post); + await Assert.That(capturedRequest.RequestUri!.AbsolutePath).IsEqualTo("/api/analyze"); + + // Assert - Body contains snake_case keys + await Assert.That(capturedBody).IsNotNull(); + using var doc = JsonDocument.Parse(capturedBody!); + var root = doc.RootElement; + await Assert.That(root.TryGetProperty("patient_id", out _)).IsTrue(); + await Assert.That(root.TryGetProperty("procedure_code", out _)).IsTrue(); + await Assert.That(root.GetProperty("patient_id").GetString()).IsEqualTo("patient-123"); + await Assert.That(root.GetProperty("procedure_code").GetString()).IsEqualTo("72148"); + } + + [Test] + public async Task AnalyzeAsync_DeserializesResponse_ToPAFormData() + { + // Arrange + var (client, _) = CreateClient(_ => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(MockResponseJson, Encoding.UTF8, "application/json") + })); + + var bundle = CreateTestBundle(); + + // Act + var result = await client.AnalyzeAsync(bundle, "72148"); + + // Assert + await Assert.That(result.PatientName).IsEqualTo("Donna Sandbox"); + await Assert.That(result.PatientDob).IsEqualTo("1968-03-15"); + await Assert.That(result.MemberId).IsEqualTo("ATH60178"); + await Assert.That(result.DiagnosisCodes).Contains("M54.5"); + await Assert.That(result.ProcedureCode).IsEqualTo("72148"); + await Assert.That(result.ClinicalSummary).IsEqualTo("AI-generated clinical summary for MRI lumbar spine."); + await Assert.That(result.Recommendation).IsEqualTo("APPROVE"); + await Assert.That(result.ConfidenceScore).IsEqualTo(0.92); + await Assert.That(result.SupportingEvidence).HasCount().EqualTo(1); + await Assert.That(result.SupportingEvidence[0].CriterionId).IsEqualTo("conservative_therapy"); + await Assert.That(result.SupportingEvidence[0].Status).IsEqualTo("MET"); + await Assert.That(result.SupportingEvidence[0].Confidence).IsEqualTo(0.95); + await Assert.That(result.FieldMappings["PatientName"]).IsEqualTo("Donna Sandbox"); + } + + [Test] + public async Task AnalyzeAsync_WhenServiceReturns400_ThrowsHttpRequestException() + { + // Arrange + var (client, _) = CreateClient(_ => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("{\"detail\":\"Invalid request\"}", Encoding.UTF8, "application/json") + })); + + var bundle = CreateTestBundle(); + + // Act & Assert + await Assert.That(() => client.AnalyzeAsync(bundle, "72148")) + .Throws(); + } + + [Test] + public async Task AnalyzeAsync_WhenServiceReturns500_ThrowsHttpRequestException() + { + // Arrange + var (client, _) = CreateClient(_ => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("{\"detail\":\"Internal error\"}", Encoding.UTF8, "application/json") + })); + + var bundle = CreateTestBundle(); + + // Act & Assert + await Assert.That(() => client.AnalyzeAsync(bundle, "72148")) + .Throws(); + } +} + +internal sealed class MockHttpHandler : DelegatingHandler +{ + private readonly Func> _handler; + + public MockHttpHandler(Func> handler) + { + _handler = handler; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => _handler(request); +} diff --git a/apps/gateway/Gateway.API/DependencyExtensions.cs b/apps/gateway/Gateway.API/DependencyExtensions.cs index 93e6c09..f02f294 100644 --- a/apps/gateway/Gateway.API/DependencyExtensions.cs +++ b/apps/gateway/Gateway.API/DependencyExtensions.cs @@ -206,17 +206,21 @@ public static IServiceCollection AddFhirClients(this IServiceCollection services /// Adds the Intelligence client to the dependency injection container. /// Wraps with caching decorator (decorator checks Enabled setting at runtime). /// - /// - /// STUB: Currently registers a stub implementation that returns mock data. - /// Production will add HttpClient configuration for the Intelligence service. - /// /// The service collection. /// The service collection for chaining. public static IServiceCollection AddIntelligenceClient(this IServiceCollection services) { - // STUB: Register stub implementation without HTTP client - // Production will use: services.AddHttpClient(...) - services.AddScoped(); + services.AddOptions() + .BindConfiguration(IntelligenceOptions.SectionName) + .Validate(o => !string.IsNullOrEmpty(o.BaseUrl), "Intelligence BaseUrl is required"); + + services.AddHttpClient() + .ConfigureHttpClient((sp, client) => + { + var options = sp.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds); + }); // Apply caching decorator (checks Enabled setting at runtime) services.Decorate(); diff --git a/apps/gateway/Gateway.API/Services/IntelligenceClient.cs b/apps/gateway/Gateway.API/Services/IntelligenceClient.cs index 8c2b2d5..dc2a6ef 100644 --- a/apps/gateway/Gateway.API/Services/IntelligenceClient.cs +++ b/apps/gateway/Gateway.API/Services/IntelligenceClient.cs @@ -1,86 +1,106 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; using Gateway.API.Contracts; using Gateway.API.Models; namespace Gateway.API.Services; /// -/// STUB: Intelligence client that returns mock PA analysis data. -/// Production implementation will call the Intelligence service HTTP API. +/// Intelligence client that calls the Python Intelligence service HTTP API +/// to analyze clinical data and generate prior authorization form data. /// public sealed class IntelligenceClient : IIntelligenceClient { + private readonly HttpClient _httpClient; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// + /// HTTP client configured with Intelligence service base URL. /// Logger for diagnostic output. - public IntelligenceClient(ILogger logger) + public IntelligenceClient(HttpClient httpClient, ILogger logger) { + _httpClient = httpClient; _logger = logger; } /// - public Task AnalyzeAsync( + public async Task AnalyzeAsync( ClinicalBundle clinicalBundle, string procedureCode, CancellationToken cancellationToken = default) { _logger.LogInformation( - "STUB: Returning mock analysis for ProcedureCode={ProcedureCode}", - procedureCode); - - var patientName = clinicalBundle.Patient?.FullName ?? "Unknown Patient"; - var patientDob = clinicalBundle.Patient?.BirthDate?.ToString("yyyy-MM-dd") ?? "Unknown"; - var memberId = clinicalBundle.Patient?.MemberId ?? "Unknown"; - var diagnosisCodes = clinicalBundle.Conditions - .Select(c => c.Code) - .Where(c => !string.IsNullOrEmpty(c)) - .DefaultIfEmpty("M54.5") - .ToList(); - - var result = new PAFormData + "Calling Intelligence service for PatientId={PatientId}, ProcedureCode={ProcedureCode}", + clinicalBundle.PatientId, procedureCode); + + var requestDto = BuildAnalyzeRequest(clinicalBundle, procedureCode); + + var response = await _httpClient.PostAsJsonAsync( + "/api/analyze", + requestDto, + SnakeCaseSerializerOptions, + cancellationToken); + + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + + if (result is null) { - PatientName = patientName, - PatientDob = patientDob, - MemberId = memberId, - DiagnosisCodes = diagnosisCodes!, - ProcedureCode = procedureCode, - ClinicalSummary = "STUB: Mock clinical summary for demo purposes. " + - "Production will generate AI-powered clinical justification.", - SupportingEvidence = - [ - new EvidenceItem - { - CriterionId = "diagnosis_present", - Status = "MET", - Evidence = "STUB: Qualifying diagnosis code found", - Source = "Stub implementation", - Confidence = 0.95 - }, - new EvidenceItem - { - CriterionId = "conservative_therapy", - Status = "MET", - Evidence = "STUB: Conservative therapy documented", - Source = "Stub implementation", - Confidence = 0.90 - } - ], - Recommendation = "APPROVE", - ConfidenceScore = 0.95, - FieldMappings = new Dictionary - { - ["PatientName"] = patientName, - ["PatientDOB"] = patientDob, - ["MemberID"] = memberId, - ["PrimaryDiagnosis"] = diagnosisCodes.FirstOrDefault() ?? "M54.5", - ["ProcedureCode"] = procedureCode, - ["ClinicalJustification"] = "STUB: Clinical justification", - ["RequestedDateOfService"] = DateTime.Today.ToString("yyyy-MM-dd") - } - }; - - return Task.FromResult(result); + throw new InvalidOperationException("Intelligence service returned null response"); + } + + _logger.LogInformation( + "Intelligence analysis complete: Recommendation={Recommendation}, Confidence={Confidence}", + result.Recommendation, result.ConfidenceScore); + + return result; + } + + internal static readonly JsonSerializerOptions SnakeCaseSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + internal static AnalyzeRequestDto BuildAnalyzeRequest(ClinicalBundle bundle, string procedureCode) + { + var patient = bundle.Patient is not null + ? new PatientDto( + Name: bundle.Patient.FullName, + BirthDate: bundle.Patient.BirthDate?.ToString("yyyy-MM-dd"), + Gender: bundle.Patient.Gender, + MemberId: bundle.Patient.MemberId) + : null; + + var conditions = bundle.Conditions.Select(c => new ConditionDto( + Code: c.Code, System: c.CodeSystem, Display: c.Display, ClinicalStatus: c.ClinicalStatus)).ToList(); + + var observations = bundle.Observations.Select(o => new ObservationDto( + Code: o.Code, System: o.CodeSystem, Display: o.Display, Value: o.Value, Unit: o.Unit)).ToList(); + + var procedures = bundle.Procedures.Select(p => new ProcedureDto( + Code: p.Code, System: p.CodeSystem, Display: p.Display, Status: p.Status)).ToList(); + + return new AnalyzeRequestDto( + PatientId: bundle.PatientId, + ProcedureCode: procedureCode, + ClinicalData: new ClinicalDataDto(patient, conditions, observations, procedures)); } } + +internal sealed record AnalyzeRequestDto(string PatientId, string ProcedureCode, ClinicalDataDto ClinicalData); + +internal sealed record ClinicalDataDto( + PatientDto? Patient, + List Conditions, + List Observations, + List Procedures); + +internal sealed record PatientDto(string Name, string? BirthDate, string? Gender, string? MemberId); +internal sealed record ConditionDto(string Code, string? System, string? Display, string? ClinicalStatus); +internal sealed record ObservationDto(string Code, string? System, string? Display, string? Value, string? Unit); +internal sealed record ProcedureDto(string Code, string? System, string? Display, string? Status); From deadf1ac5534780b69bfd78ac97b9c25480f5dd1 Mon Sep 17 00:00:00 2001 From: Reed Date: Sat, 21 Feb 2026 13:45:15 -0800 Subject: [PATCH 5/6] feat(gateway): wire ProcessPARequest mutation through FHIR + Intelligence pipeline Co-Authored-By: Claude Opus 4.6 --- .../GraphQL/ProcessPARequestMutationTests.cs | 219 ++++++++++++++++++ .../Gateway.API/GraphQL/Mutations/Mutation.cs | 34 ++- 2 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 apps/gateway/Gateway.API.Tests/GraphQL/ProcessPARequestMutationTests.cs diff --git a/apps/gateway/Gateway.API.Tests/GraphQL/ProcessPARequestMutationTests.cs b/apps/gateway/Gateway.API.Tests/GraphQL/ProcessPARequestMutationTests.cs new file mode 100644 index 0000000..d75a6c1 --- /dev/null +++ b/apps/gateway/Gateway.API.Tests/GraphQL/ProcessPARequestMutationTests.cs @@ -0,0 +1,219 @@ +using Gateway.API.Contracts; +using Gateway.API.GraphQL.Models; +using Gateway.API.GraphQL.Mutations; +using Gateway.API.Models; +using Gateway.API.Services; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Gateway.API.Tests.GraphQL; + +[Category("Unit")] +public sealed class ProcessPARequestMutationTests +{ + private readonly MockDataService _mockData = new(); + private readonly IFhirDataAggregator _fhirAggregator = Substitute.For(); + private readonly IIntelligenceClient _intelligenceClient = Substitute.For(); + private readonly Mutation _sut = new(); + + private static ClinicalBundle CreateTestBundle(string patientId = "60178") => new() + { + PatientId = patientId, + Patient = new PatientInfo + { + Id = patientId, + GivenName = "Donna", + FamilyName = "Sandbox", + BirthDate = new DateOnly(1968, 3, 15), + MemberId = "ATH60178" + }, + Conditions = [new ConditionInfo { Id = "cond-1", Code = "M54.5", Display = "Low Back Pain", ClinicalStatus = "active" }], + }; + + private static PAFormData CreateTestFormData( + string recommendation = "APPROVE", + double confidence = 0.85, + string procedureCode = "72148") => new() + { + PatientName = "Donna Sandbox", + PatientDob = "1968-03-15", + MemberId = "ATH60178", + DiagnosisCodes = ["M54.5"], + ProcedureCode = procedureCode, + ClinicalSummary = "AI-generated clinical summary for testing.", + SupportingEvidence = + [ + new EvidenceItem + { + CriterionId = "conservative_therapy", + Status = "MET", + Evidence = "Patient completed 8 weeks of PT", + Source = "Clinical Notes", + Confidence = 0.95 + }, + new EvidenceItem + { + CriterionId = "failed_treatment", + Status = "NOT_MET", + Evidence = "No documentation of treatment failure", + Source = "Chart Review", + Confidence = 0.70 + }, + new EvidenceItem + { + CriterionId = "diagnosis_present", + Status = "UNCLEAR", + Evidence = "Diagnosis code needs verification", + Source = "System", + Confidence = 0.50 + } + ], + Recommendation = recommendation, + ConfidenceScore = confidence, + FieldMappings = new Dictionary { ["PatientName"] = "Donna Sandbox" } + }; + + [Test] + public async Task ProcessPARequest_CallsFhirAggregator_WithPatientId() + { + var paRequest = _mockData.GetPARequests().First(); + _fhirAggregator.AggregateClinicalDataAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(CreateTestBundle(paRequest.PatientId)); + _intelligenceClient.AnalyzeAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(CreateTestFormData()); + + await _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None); + + await _fhirAggregator.Received(1).AggregateClinicalDataAsync( + paRequest.PatientId, Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ProcessPARequest_CallsIntelligenceClient_WithBundleAndProcedureCode() + { + var paRequest = _mockData.GetPARequests().First(); + var bundle = CreateTestBundle(paRequest.PatientId); + _fhirAggregator.AggregateClinicalDataAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(bundle); + _intelligenceClient.AnalyzeAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(CreateTestFormData(procedureCode: paRequest.ProcedureCode)); + + await _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None); + + await _intelligenceClient.Received(1).AnalyzeAsync( + Arg.Is(b => b.PatientId == paRequest.PatientId), + paRequest.ProcedureCode, + Arg.Any()); + } + + [Test] + public async Task ProcessPARequest_MapsEvidenceToMet_WhenStatusIsMET() + { + var paRequest = _mockData.GetPARequests().First(); + SetupMocks(paRequest.PatientId, paRequest.ProcedureCode); + + var result = await _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None); + + await Assert.That(result).IsNotNull(); + var metCriterion = result!.Criteria.First(c => c.Label == "conservative_therapy"); + await Assert.That(metCriterion.Met).IsTrue(); + } + + [Test] + public async Task ProcessPARequest_MapsEvidenceToNotMet_WhenStatusIsNOT_MET() + { + var paRequest = _mockData.GetPARequests().First(); + SetupMocks(paRequest.PatientId, paRequest.ProcedureCode); + + var result = await _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None); + + await Assert.That(result).IsNotNull(); + var notMetCriterion = result!.Criteria.First(c => c.Label == "failed_treatment"); + await Assert.That(notMetCriterion.Met).IsFalse(); + } + + [Test] + public async Task ProcessPARequest_MapsEvidenceToNull_WhenStatusIsUNCLEAR() + { + var paRequest = _mockData.GetPARequests().First(); + SetupMocks(paRequest.PatientId, paRequest.ProcedureCode); + + var result = await _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None); + + await Assert.That(result).IsNotNull(); + var unclearCriterion = result!.Criteria.First(c => c.Label == "diagnosis_present"); + await Assert.That(unclearCriterion.Met).IsNull(); + } + + [Test] + public async Task ProcessPARequest_SetsConfidence_FromScoreTimes100() + { + var paRequest = _mockData.GetPARequests().First(); + SetupMocks(paRequest.PatientId, paRequest.ProcedureCode, confidence: 0.85); + + var result = await _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None); + + await Assert.That(result).IsNotNull(); + await Assert.That(result!.Confidence).IsEqualTo(85); + } + + [Test] + public async Task ProcessPARequest_ReturnsNull_WhenPARequestNotFound() + { + var result = await _sut.ProcessPARequest("NONEXISTENT", _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None); + + await Assert.That(result).IsNull(); + // Should NOT call any services + await _fhirAggregator.DidNotReceive().AggregateClinicalDataAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ProcessPARequest_WhenFhirAggregatorThrows_PropagatesException() + { + var paRequest = _mockData.GetPARequests().First(); + _fhirAggregator.AggregateClinicalDataAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new HttpRequestException("FHIR service unavailable")); + + var act = () => _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None); + + await Assert.That(act).ThrowsExactly(); + } + + [Test] + public async Task ProcessPARequest_WhenIntelligenceThrowsHttpRequestException_PropagatesException() + { + var paRequest = _mockData.GetPARequests().First(); + _fhirAggregator.AggregateClinicalDataAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(CreateTestBundle(paRequest.PatientId)); + _intelligenceClient.AnalyzeAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new HttpRequestException("Intelligence service returned 500")); + + var act = () => _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None); + + await Assert.That(act).ThrowsExactly(); + } + + [Test] + public async Task ProcessPARequest_WhenCancelled_ThrowsOperationCanceledException() + { + var paRequest = _mockData.GetPARequests().First(); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + _fhirAggregator.AggregateClinicalDataAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new OperationCanceledException()); + + var act = () => _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, cts.Token); + + await Assert.That(act).ThrowsExactly(); + } + + private void SetupMocks(string patientId, string procedureCode, double confidence = 0.85) + { + _fhirAggregator.AggregateClinicalDataAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(CreateTestBundle(patientId)); + _intelligenceClient.AnalyzeAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(CreateTestFormData(confidence: confidence, procedureCode: procedureCode)); + } +} diff --git a/apps/gateway/Gateway.API/GraphQL/Mutations/Mutation.cs b/apps/gateway/Gateway.API/GraphQL/Mutations/Mutation.cs index cef1a90..ee5b86a 100644 --- a/apps/gateway/Gateway.API/GraphQL/Mutations/Mutation.cs +++ b/apps/gateway/Gateway.API/GraphQL/Mutations/Mutation.cs @@ -1,3 +1,4 @@ +using Gateway.API.Contracts; using Gateway.API.GraphQL.Inputs; using Gateway.API.GraphQL.Models; using Gateway.API.Services; @@ -40,9 +41,38 @@ public PARequestModel CreatePARequest(CreatePARequestInput input, [Service] Mock criteria); } - public async Task ProcessPARequest(string id, [Service] MockDataService mockData, CancellationToken cancellationToken) + public async Task ProcessPARequest( + string id, + [Service] MockDataService mockData, + [Service] IFhirDataAggregator fhirAggregator, + [Service] IIntelligenceClient intelligenceClient, + CancellationToken ct) { - return await mockData.ProcessPARequestAsync(id, cancellationToken); + var paRequest = mockData.GetPARequest(id); + if (paRequest is null) return null; + + var clinicalBundle = await fhirAggregator.AggregateClinicalDataAsync( + paRequest.PatientId, cancellationToken: ct); + + var formData = await intelligenceClient.AnalyzeAsync( + clinicalBundle, paRequest.ProcedureCode, ct); + + var criteria = formData.SupportingEvidence.Select(e => new CriterionModel + { + Met = e.Status switch + { + "MET" => true, + "NOT_MET" => false, + _ => null + }, + Label = e.CriterionId, + Reason = e.Evidence + }).ToList(); + + var confidence = (int)(formData.ConfidenceScore * 100); + + return mockData.ApplyAnalysisResult(id, + formData.ClinicalSummary, confidence, criteria); } public PARequestModel? SubmitPARequest(string id, [Service] MockDataService mockData, int addReviewTimeSeconds = 0) From 0f70bc7748ca9d129628afafb09b018bc505803f Mon Sep 17 00:00:00 2001 From: Reed Date: Sat, 21 Feb 2026 13:50:27 -0800 Subject: [PATCH 6/6] test(gateway): add ProcessPARequest integration tests with Alba Co-Authored-By: Claude Opus 4.6 --- .../ProcessPARequestAlbaBootstrap.cs | 233 ++++++++++++++++++ .../ProcessPARequestIntegrationTests.cs | 173 +++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 apps/gateway/Gateway.API.Tests/Integration/ProcessPARequestAlbaBootstrap.cs create mode 100644 apps/gateway/Gateway.API.Tests/Integration/ProcessPARequestIntegrationTests.cs diff --git a/apps/gateway/Gateway.API.Tests/Integration/ProcessPARequestAlbaBootstrap.cs b/apps/gateway/Gateway.API.Tests/Integration/ProcessPARequestAlbaBootstrap.cs new file mode 100644 index 0000000..ebe386b --- /dev/null +++ b/apps/gateway/Gateway.API.Tests/Integration/ProcessPARequestAlbaBootstrap.cs @@ -0,0 +1,233 @@ +// ============================================================================= +// +// Copyright (c) Levelup Software. All rights reserved. +// +// ============================================================================= + +using Alba; +using Gateway.API.Contracts; +using Gateway.API.Data; +using Gateway.API.Models; +using Gateway.API.Services.Polling; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using NSubstitute; +using StackExchange.Redis; +using TUnit.Core.Interfaces; + +namespace Gateway.API.Tests.Integration; + +/// +/// Alba bootstrap for ProcessPARequest integration tests. +/// Provides mocked FHIR aggregator and Intelligence client to exercise the full +/// GraphQL mutation through the ASP.NET pipeline. +/// +public sealed class ProcessPARequestAlbaBootstrap : IAsyncInitializer, IAsyncDisposable +{ + /// + /// Test API key for integration tests. + /// + public const string TestApiKey = "test-api-key"; + + /// + /// Gets the Alba host for making HTTP requests. + /// + public IAlbaHost Host { get; private set; } = null!; + + /// + public async Task InitializeAsync() + { + Host = await AlbaHost.For(config => + { + config.ConfigureAppConfiguration((context, configBuilder) => + { + configBuilder.AddInMemoryCollection(new Dictionary + { + ["ApiKey:ValidApiKeys:0"] = TestApiKey, + ["Athena:ClientId"] = "test-client-id", + ["Athena:ClientSecret"] = "test-client-secret", + ["Athena:FhirBaseUrl"] = "https://api.test.athenahealth.com/fhir/r4", + ["Athena:TokenEndpoint"] = "https://api.test.athenahealth.com/oauth2/v1/token", + ["Athena:PollingIntervalSeconds"] = "30", + ["Intelligence:BaseUrl"] = "http://localhost:8000", + ["Intelligence:TimeoutSeconds"] = "30", + ["ClinicalQuery:ObservationLookbackMonths"] = "12", + ["ClinicalQuery:ProcedureLookbackMonths"] = "24", + ["ConnectionStrings:authscript"] = "Host=localhost;Database=test;Username=test;Password=test" + }); + }); + + config.ConfigureServices(services => + { + // Replace Aspire's DbContext with in-memory provider + var dbContextDescriptors = services.Where(d => + d.ServiceType == typeof(GatewayDbContext) || + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType.FullName?.Contains("GatewayDbContext") == true || + d.ImplementationType?.FullName?.Contains("GatewayDbContext") == true).ToList(); + foreach (var descriptor in dbContextDescriptors) + { + services.Remove(descriptor); + } + + services.RemoveAll(typeof(Microsoft.EntityFrameworkCore.Internal.IDbContextPool)); + services.RemoveAll(typeof(Microsoft.EntityFrameworkCore.Internal.IScopedDbContextLease)); + + var databaseName = $"GatewayTest_{Guid.NewGuid()}"; + services.AddDbContext(options => + options.UseInMemoryDatabase(databaseName)); + + // Remove AthenaPollingService + services.RemoveAll(); + var hostedServiceDescriptor = services.FirstOrDefault(d => + d.ServiceType == typeof(IHostedService) && + d.ImplementationFactory?.Method.ReturnType == typeof(AthenaPollingService)); + if (hostedServiceDescriptor != null) + { + services.Remove(hostedServiceDescriptor); + } + + // Mock IFhirTokenProvider + var mockTokenProvider = Substitute.For(); + mockTokenProvider + .GetTokenAsync(Arg.Any()) + .Returns(Task.FromResult("test-access-token")); + services.RemoveAll(); + services.AddSingleton(mockTokenProvider); + + // Mock IFhirClient + services.RemoveAll(); + services.AddSingleton(Substitute.For()); + + // Mock IFhirDataAggregator with clinical data for patient "60178" (Donna Sandbox) + var mockAggregator = Substitute.For(); + mockAggregator + .AggregateClinicalDataAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(CreateTestClinicalBundle())); + services.RemoveAll(); + services.AddSingleton(mockAggregator); + + // Mock IIntelligenceClient with realistic PA form data + var mockIntelligenceClient = Substitute.For(); + mockIntelligenceClient + .AnalyzeAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(CreateTestPAFormData())); + services.RemoveAll(); + services.AddSingleton(mockIntelligenceClient); + + // Remove Redis (not available in tests) + services.RemoveAll(); + services.AddSingleton(sp => null); + }); + }).ConfigureAwait(false); + } + + /// + public async ValueTask DisposeAsync() + { + if (Host != null) + { + await Host.DisposeAsync().ConfigureAwait(false); + } + } + + private static ClinicalBundle CreateTestClinicalBundle() + { + return new ClinicalBundle + { + PatientId = "60178", + Patient = new PatientInfo + { + Id = "60178", + GivenName = "Donna", + FamilyName = "Sandbox", + BirthDate = new DateOnly(1968, 3, 15), + Gender = "female", + MemberId = "ATH60178" + }, + Conditions = + [ + new ConditionInfo + { + Id = "cond-001", + Code = "M54.5", + Display = "Low back pain", + ClinicalStatus = "active", + OnsetDate = DateOnly.FromDateTime(DateTime.UtcNow.AddMonths(-6)) + } + ], + Observations = + [ + new ObservationInfo + { + Id = "obs-001", + Code = "72166-2", + Display = "Smoking status", + Value = "Never smoker" + } + ], + Procedures = + [ + new ProcedureInfo + { + Id = "proc-001", + Code = "97110", + Display = "Therapeutic exercises", + PerformedDate = DateOnly.FromDateTime(DateTime.UtcNow.AddMonths(-2)) + } + ], + Documents = [], + ServiceRequests = [] + }; + } + + private static PAFormData CreateTestPAFormData() => new() + { + PatientName = "Donna Sandbox", + PatientDob = "1968-03-15", + MemberId = "ATH60178", + DiagnosisCodes = ["M54.5"], + ProcedureCode = "72148", + ClinicalSummary = "Patient presents with chronic low back pain. Conservative therapy including 8 weeks of physical therapy and NSAID therapy has been attempted without adequate relief. MRI lumbar spine is medically indicated.", + SupportingEvidence = + [ + new EvidenceItem + { + CriterionId = "conservative_therapy", + Status = "MET", + Evidence = "8 weeks of physical therapy and NSAID therapy documented", + Source = "Clinical Notes", + Confidence = 0.95 + }, + new EvidenceItem + { + CriterionId = "failed_treatment", + Status = "MET", + Evidence = "Persistent pain rated 7/10 despite conservative therapy", + Source = "Progress Notes", + Confidence = 0.90 + }, + new EvidenceItem + { + CriterionId = "diagnosis_present", + Status = "MET", + Evidence = "Valid ICD-10 code M54.5 (Low Back Pain) documented", + Source = "Problem List", + Confidence = 0.99 + } + ], + Recommendation = "APPROVE", + ConfidenceScore = 0.92, + FieldMappings = new Dictionary + { + ["PatientName"] = "Donna Sandbox", + ["PatientDOB"] = "1968-03-15", + ["MemberID"] = "ATH60178", + ["ProcedureCode"] = "72148" + } + }; +} diff --git a/apps/gateway/Gateway.API.Tests/Integration/ProcessPARequestIntegrationTests.cs b/apps/gateway/Gateway.API.Tests/Integration/ProcessPARequestIntegrationTests.cs new file mode 100644 index 0000000..6e9687b --- /dev/null +++ b/apps/gateway/Gateway.API.Tests/Integration/ProcessPARequestIntegrationTests.cs @@ -0,0 +1,173 @@ +// ============================================================================= +// +// Copyright (c) Levelup Software. All rights reserved. +// +// ============================================================================= + +using System.Text.Json; +using Alba; + +namespace Gateway.API.Tests.Integration; + +/// +/// Integration tests for the ProcessPARequest GraphQL mutation. +/// Exercises the full mutation through the ASP.NET pipeline with mocked +/// FHIR and Intelligence external dependencies. +/// +[Category("Integration")] +[ClassDataSource(Shared = SharedType.PerTestSession)] +public sealed class ProcessPARequestIntegrationTests +{ + private readonly ProcessPARequestAlbaBootstrap _fixture; + + public ProcessPARequestIntegrationTests(ProcessPARequestAlbaBootstrap fixture) + { + _fixture = fixture; + } + + [Test] + public async Task ProcessPARequest_ViaGraphQL_ReturnsReadyStatusWithAnalysis() + { + // Arrange - Get a pre-seeded PA request ID (PA-001 uses patient 60178, procedure 72148) + var listResult = await _fixture.Host.Scenario(s => + { + s.Post.Json(new + { + query = "{ paRequests { id status patientId procedureCode } }" + }).ToUrl("/api/graphql"); + s.StatusCodeShouldBe(200); + }).ConfigureAwait(false); + + var listBody = listResult.ReadAsText(); + using var listDoc = JsonDocument.Parse(listBody); + var requests = listDoc.RootElement.GetProperty("data").GetProperty("paRequests"); + + // Find PA-001 (Donna Sandbox, procedure 72148, status "ready") + string paRequestId = "PA-001"; + var found = false; + foreach (var req in requests.EnumerateArray()) + { + if (req.GetProperty("id").GetString() == paRequestId) + { + found = true; + break; + } + } + + await Assert.That(found).IsTrue(); + + // Act - Process the PA request via GraphQL mutation + var processResult = await _fixture.Host.Scenario(s => + { + s.Post.Json(new + { + query = @"mutation($id: String!) { + processPARequest(id: $id) { + id + status + confidence + clinicalSummary + criteria { met label reason } + } + }", + variables = new { id = paRequestId } + }).ToUrl("/api/graphql"); + s.StatusCodeShouldBe(200); + }).ConfigureAwait(false); + + // Assert + var processBody = processResult.ReadAsText(); + using var processDoc = JsonDocument.Parse(processBody); + var data = processDoc.RootElement.GetProperty("data").GetProperty("processPARequest"); + + // ID should match + var returnedId = data.GetProperty("id").GetString(); + await Assert.That(returnedId).IsEqualTo(paRequestId); + + // Status should be "ready" after processing + var status = data.GetProperty("status").GetString(); + await Assert.That(status).IsEqualTo("ready"); + + // Confidence should be 92 (0.92 * 100) + var confidence = data.GetProperty("confidence").GetInt32(); + await Assert.That(confidence).IsEqualTo(92); + + // Clinical summary should come from the mock Intelligence response (not contain "STUB") + var clinicalSummary = data.GetProperty("clinicalSummary").GetString(); + await Assert.That(clinicalSummary).IsNotNull(); + await Assert.That(clinicalSummary!).DoesNotContain("STUB"); + await Assert.That(clinicalSummary).Contains("chronic low back pain"); + + // Criteria should have 3 items from the mock evidence + var criteria = data.GetProperty("criteria"); + await Assert.That(criteria.GetArrayLength()).IsEqualTo(3); + + // Verify first criterion (conservative_therapy -> MET) + var firstCriterion = criteria[0]; + await Assert.That(firstCriterion.GetProperty("met").GetBoolean()).IsTrue(); + await Assert.That(firstCriterion.GetProperty("label").GetString()).IsEqualTo("conservative_therapy"); + await Assert.That(firstCriterion.GetProperty("reason").GetString()).Contains("physical therapy"); + } + + [Test] + public async Task ProcessPARequest_WithInvalidId_ReturnsNull() + { + // Act + var result = await _fixture.Host.Scenario(s => + { + s.Post.Json(new + { + query = @"mutation($id: String!) { + processPARequest(id: $id) { + id + status + } + }", + variables = new { id = "NONEXISTENT-ID" } + }).ToUrl("/api/graphql"); + s.StatusCodeShouldBe(200); + }).ConfigureAwait(false); + + // Assert - processPARequest should return null for non-existent ID + var body = result.ReadAsText(); + using var doc = JsonDocument.Parse(body); + var data = doc.RootElement.GetProperty("data").GetProperty("processPARequest"); + await Assert.That(data.ValueKind).IsEqualTo(JsonValueKind.Null); + } + + [Test] + public async Task ProcessPARequest_CriteriaMapping_AllMetStatusesMappedCorrectly() + { + // Arrange - Use PA-002 (different patient, still pre-seeded) + var paRequestId = "PA-002"; + + // Act + var result = await _fixture.Host.Scenario(s => + { + s.Post.Json(new + { + query = @"mutation($id: String!) { + processPARequest(id: $id) { + id + criteria { met label reason } + } + }", + variables = new { id = paRequestId } + }).ToUrl("/api/graphql"); + s.StatusCodeShouldBe(200); + }).ConfigureAwait(false); + + // Assert - All 3 evidence items have status "MET" -> met: true + var body = result.ReadAsText(); + using var doc = JsonDocument.Parse(body); + var data = doc.RootElement.GetProperty("data").GetProperty("processPARequest"); + var criteria = data.GetProperty("criteria"); + + await Assert.That(criteria.GetArrayLength()).IsEqualTo(3); + + foreach (var criterion in criteria.EnumerateArray()) + { + await Assert.That(criterion.GetProperty("met").GetBoolean()).IsTrue(); + } + } +}