using System.Net; using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using StackExchange.Redis; /// /// Tests for gap-analysis fixes: MRN sequence, sepsis bundle idempotency, /// trend alert exact match, order→bundle transaction, FHIR bundle rollback, /// patient update endpoint, new list endpoints, and new validators. /// [Collection("Integration")] public class GapAnalysisFixTests : IAsyncLifetime { private readonly ApiFixture _fixture; private readonly HttpClient _client; public GapAnalysisFixTests(ApiFixture fixture) { _fixture = fixture; _client = fixture.CreateClient(); } public async Task InitializeAsync() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await DbResetHelper.ResetAsync(db); var redis = scope.ServiceProvider.GetRequiredService(); await DataSeeder.SeedThresholdsOnlyAsync(db, redis); } public Task DisposeAsync() => Task.CompletedTask; // ------------------------------------------------------------------------- // P0 — MRN generation uses sequence (no duplicates on concurrent registration) // ------------------------------------------------------------------------- [Fact] public async Task MrnGeneration_SequentialRegistrations_ProduceUniqueMrns() { var resp1 = await _client.PostAsJsonAsync("/api/v1/patients", new RegisterPatientRequest("Alice", "One", new DateOnly(1990, 1, 1), "F")); var resp2 = await _client.PostAsJsonAsync("/api/v1/patients", new RegisterPatientRequest("Bob", "Two", new DateOnly(1991, 2, 2), "M")); resp1.StatusCode.Should().Be(HttpStatusCode.Created); resp2.StatusCode.Should().Be(HttpStatusCode.Created); var p1 = (await resp1.Content.ReadFromJsonAsync()) .GetProperty("data").GetProperty("mrn").GetString(); var p2 = (await resp2.Content.ReadFromJsonAsync()) .GetProperty("data").GetProperty("mrn").GetString(); p1.Should().StartWith("MRN-"); p2.Should().StartWith("MRN-"); p1.Should().NotBe(p2); } [Fact] public async Task MrnGeneration_FormatMatchesConfiguredPattern() { var resp = await _client.PostAsJsonAsync("/api/v1/patients", new RegisterPatientRequest("Format", "Test", new DateOnly(1985, 5, 5), "F")); resp.StatusCode.Should().Be(HttpStatusCode.Created); var mrn = (await resp.Content.ReadFromJsonAsync()) .GetProperty("data").GetProperty("mrn").GetString()!; mrn.Should().MatchRegex(@"^MRN-\d{6}$"); } // ------------------------------------------------------------------------- // P0 — Trend alert uses exact observation_code match // ------------------------------------------------------------------------- [Fact] public async Task TrendAlert_StoresObservationCode() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var detector = scope.ServiceProvider.GetRequiredService(); var patient = new Patient { Id = Guid.NewGuid(), Mrn = "MRN-TREND-OC", FirstName = "OC", LastName = "Test", DateOfBirth = new DateOnly(1970, 1, 1), Gender = "M", CreatedAt = DateTimeOffset.UtcNow }; var encounter = new Encounter { Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient, Status = EncounterStatus.Active, Department = Department.Icu, AttendingPhysician = "Dr. OC", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow }; db.Patients.Add(patient); db.Encounters.Add(encounter); await db.SaveChangesAsync(); var redis = scope.ServiceProvider.GetRequiredService(); foreach (var key in TrendCalculator.AllHistoryKeys(encounter.Id)) await redis.GetDatabase().KeyDeleteAsync(key); var baseTime = new DateTimeOffset(2026, 6, 20, 10, 0, 0, TimeSpan.Zero); await detector.ProcessObservationAsync( encounter.Id, patient.Id, "HEART_RATE", 72m, baseTime); var result = await detector.ProcessObservationAsync( encounter.Id, patient.Id, "HEART_RATE", 95m, baseTime.AddMinutes(10)); result.Outcome.Should().Be(TrendOutcome.RapidDeterioration); var alert = await db.ClinicalAlerts.SingleAsync(a => a.EncounterId == encounter.Id && a.AlertType == AlertType.RapidDeterioration); alert.ObservationCode.Should().Be("HEART_RATE"); } // ------------------------------------------------------------------------- // P1 — Order→Bundle spanning transaction // ------------------------------------------------------------------------- [Fact] public async Task OrderResult_UpdatesBundleElement_InSameTransaction() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var handler = scope.ServiceProvider.GetRequiredService(); var bundleService = scope.ServiceProvider.GetRequiredService(); var patient = new Patient { Id = Guid.NewGuid(), Mrn = "MRN-TX-001", FirstName = "Tx", LastName = "Test", DateOfBirth = new DateOnly(1960, 1, 1), Gender = "M", CreatedAt = DateTimeOffset.UtcNow }; var encounter = new Encounter { Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient, Status = EncounterStatus.Active, Department = Department.Icu, AttendingPhysician = "Dr. Tx", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow }; db.Patients.Add(patient); db.Encounters.Add(encounter); var alertId = Guid.NewGuid(); db.ClinicalAlerts.Add(new ClinicalAlert { Id = alertId, EncounterId = encounter.Id, PatientId = patient.Id, AlertType = AlertType.SofaSepsis, Severity = AlertSeverity.Critical, Details = "SOFA delta +2", Status = AlertStatus.Open, TriggeredAt = DateTimeOffset.UtcNow }); await db.SaveChangesAsync(); await handler.OnSepsisAlertCreatedAsync( encounter.Id, alertId, AlertType.SofaSepsis, CancellationToken.None); var element = await db.SepsisBundleElements .Include(e => e.Order) .FirstAsync(e => e.ElementType == SepsisBundleElementType.SerumLactate); var orderService = scope.ServiceProvider.GetRequiredService(); var order = element.Order!; order.Status = OrderStatus.InProgress; await db.SaveChangesAsync(); await orderService.RecordResultAsync(order.Id, new RecordOrderResultRequest("Lactate 1.5 mmol/L")); var updatedOrder = await db.Orders.AsNoTracking().FirstAsync(o => o.Id == order.Id); var updatedElement = await db.SepsisBundleElements.AsNoTracking() .FirstAsync(e => e.Id == element.Id); updatedOrder.Status.Should().Be(OrderStatus.Resulted); updatedElement.Status.Should().Be(SepsisBundleElementStatus.Completed); } // ------------------------------------------------------------------------- // P4 — Patient update endpoint (PATCH) // ------------------------------------------------------------------------- [Fact] public async Task PatientUpdate_PartialFields_UpdatesOnlyProvided() { var createResp = await _client.PostAsJsonAsync("/api/v1/patients", new RegisterPatientRequest("Original", "Name", new DateOnly(1990, 1, 1), "F")); createResp.StatusCode.Should().Be(HttpStatusCode.Created); var patientId = (await createResp.Content.ReadFromJsonAsync()) .GetProperty("data").GetProperty("id").GetGuid(); var patchResp = await _client.PatchAsJsonAsync($"/api/v1/patients/{patientId}", new { lastName = "Updated", allergies = "Penicillin" }); patchResp.StatusCode.Should().Be(HttpStatusCode.OK); var getResp = await _client.GetFromJsonAsync($"/api/v1/patients/{patientId}"); var data = getResp.GetProperty("data"); data.GetProperty("firstName").GetString().Should().Be("Original"); data.GetProperty("lastName").GetString().Should().Be("Updated"); data.GetProperty("allergies").GetString().Should().Be("Penicillin"); } [Fact] public async Task PatientUpdate_NonExistent_Returns404() { var resp = await _client.PatchAsJsonAsync($"/api/v1/patients/{Guid.NewGuid()}", new { firstName = "Ghost" }); resp.StatusCode.Should().Be(HttpStatusCode.NotFound); } [Fact] public async Task PatientUpdate_CreatesAuditLog() { var createResp = await _client.PostAsJsonAsync("/api/v1/patients", new RegisterPatientRequest("Audit", "Before", new DateOnly(1985, 3, 3), "M")); var patientId = (await createResp.Content.ReadFromJsonAsync()) .GetProperty("data").GetProperty("id").GetGuid(); await _client.PatchAsJsonAsync($"/api/v1/patients/{patientId}", new { lastName = "After" }); var auditResp = await _client.GetFromJsonAsync( $"/api/v1/audit-logs?entityType=Patient&entityId={patientId}"); var logs = auditResp.GetProperty("data").GetProperty("items"); logs.EnumerateArray().Should().Contain(log => log.GetProperty("action").GetString() == "PATIENT_UPDATED"); } // ------------------------------------------------------------------------- // P4 — Sepsis bundle list endpoint // ------------------------------------------------------------------------- [Fact] public async Task SepsisBundleList_ReturnsPagedResults() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var handler = scope.ServiceProvider.GetRequiredService(); var patient = new Patient { Id = Guid.NewGuid(), Mrn = "MRN-LIST-001", FirstName = "List", LastName = "Test", DateOfBirth = new DateOnly(1970, 1, 1), Gender = "F", CreatedAt = DateTimeOffset.UtcNow }; var encounter = new Encounter { Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient, Status = EncounterStatus.Active, Department = Department.Icu, AttendingPhysician = "Dr. List", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow }; db.Patients.Add(patient); db.Encounters.Add(encounter); var alertId = Guid.NewGuid(); db.ClinicalAlerts.Add(new ClinicalAlert { Id = alertId, EncounterId = encounter.Id, PatientId = patient.Id, AlertType = AlertType.SofaSepsis, Severity = AlertSeverity.Critical, Details = "SOFA delta +2", Status = AlertStatus.Open, TriggeredAt = DateTimeOffset.UtcNow }); await db.SaveChangesAsync(); await handler.OnSepsisAlertCreatedAsync( encounter.Id, alertId, AlertType.SofaSepsis, CancellationToken.None); var resp = await _client.GetFromJsonAsync( "/api/v1/sepsis-bundles?status=IN_PROGRESS&page=1&pageSize=10"); var data = resp.GetProperty("data"); data.GetProperty("totalCount").GetInt32().Should().BeGreaterThanOrEqualTo(1); data.GetProperty("items").EnumerateArray().Should().Contain(b => b.GetProperty("complianceStatus").GetString() == "IN_PROGRESS"); } // ------------------------------------------------------------------------- // P4 — qSOFA history endpoint // ------------------------------------------------------------------------- [Fact] public async Task QsofaHistory_ReturnsAlertRecords() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var patient = new Patient { Id = Guid.NewGuid(), Mrn = "MRN-QH-001", FirstName = "QH", LastName = "Test", DateOfBirth = new DateOnly(1975, 1, 1), Gender = "F", CreatedAt = DateTimeOffset.UtcNow }; var encounter = new Encounter { Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient, Status = EncounterStatus.Active, Department = Department.Icu, AttendingPhysician = "Dr. QH", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow }; db.Patients.Add(patient); db.Encounters.Add(encounter); db.ClinicalAlerts.Add(new ClinicalAlert { Id = Guid.NewGuid(), EncounterId = encounter.Id, PatientId = patient.Id, AlertType = AlertType.QsofaScreen, Severity = AlertSeverity.Warning, Details = "qSOFA >= 2", Status = AlertStatus.Open, TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30) }); db.ClinicalAlerts.Add(new ClinicalAlert { Id = Guid.NewGuid(), EncounterId = encounter.Id, PatientId = patient.Id, AlertType = AlertType.QsofaScreen, Severity = AlertSeverity.Warning, Details = "qSOFA >= 2", Status = AlertStatus.Resolved, TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-10), ResolvedAt = DateTimeOffset.UtcNow }); await db.SaveChangesAsync(); var resp = await _client.GetFromJsonAsync( $"/api/v1/encounters/{encounter.Id}/qsofa/history?limit=10"); var data = resp.GetProperty("data"); data.GetProperty("items").GetArrayLength().Should().Be(2); } // ------------------------------------------------------------------------- // P4 — Reconciliation alerts endpoint // ------------------------------------------------------------------------- [Fact] public async Task ReconciliationAlerts_ListWithFilter() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var patient = new Patient { Id = Guid.NewGuid(), Mrn = "MRN-RECON-001", FirstName = "Recon", LastName = "Test", DateOfBirth = new DateOnly(1980, 1, 1), Gender = "F", CreatedAt = DateTimeOffset.UtcNow }; var encounter = new Encounter { Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient, Status = EncounterStatus.Active, Department = Department.Icu, AttendingPhysician = "Dr. Recon", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow }; db.Patients.Add(patient); db.Encounters.Add(encounter); db.ReconciliationAlerts.Add(new ReconciliationAlert { Id = Guid.NewGuid(), CheckType = ReconciliationCheckType.UnacknowledgedCriticalAlert, EncounterId = encounter.Id, PatientId = patient.Id, Details = "Test reconciliation alert", CreatedAt = DateTimeOffset.UtcNow }); await db.SaveChangesAsync(); var resp = await _client.GetFromJsonAsync( "/api/v1/reconciliation-alerts?resolved=false&page=1&pageSize=10"); var data = resp.GetProperty("data"); data.GetProperty("totalCount").GetInt32().Should().BeGreaterThanOrEqualTo(1); data.GetProperty("items").EnumerateArray().Should().Contain(a => a.GetProperty("details").GetString()!.Contains("Test reconciliation alert")); } [Fact] public async Task ReconciliationAlerts_FilterByCheckType() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var patient = new Patient { Id = Guid.NewGuid(), Mrn = "MRN-RECON-002", FirstName = "Recon2", LastName = "Test", DateOfBirth = new DateOnly(1980, 1, 1), Gender = "M", CreatedAt = DateTimeOffset.UtcNow }; var encounter = new Encounter { Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient, Status = EncounterStatus.Active, Department = Department.Icu, AttendingPhysician = "Dr. Recon2", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow }; db.Patients.Add(patient); db.Encounters.Add(encounter); db.ReconciliationAlerts.Add(new ReconciliationAlert { Id = Guid.NewGuid(), CheckType = ReconciliationCheckType.PendingOrderNoResult, EncounterId = encounter.Id, Details = "Pending order check", CreatedAt = DateTimeOffset.UtcNow }); await db.SaveChangesAsync(); var resp = await _client.GetFromJsonAsync( "/api/v1/reconciliation-alerts?checkType=PENDING_ORDER_NO_RESULT"); var items = resp.GetProperty("data").GetProperty("items"); items.GetArrayLength().Should().BeGreaterThanOrEqualTo(1); } // ------------------------------------------------------------------------- // P2 — New validators return 400 for invalid input // ------------------------------------------------------------------------- [Fact] public async Task TransitionStatusValidator_LongDiagnosis_Returns400() { var patientId = await CreatePatientAsync(); var encounterId = await CreateEncounterAsync(patientId); var resp = await _client.PatchAsJsonAsync( $"/api/v1/encounters/{encounterId}/status", new { status = "Discharged", dischargeDiagnosis = new string('x', 501) }); resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); } [Fact] public async Task UpdatePatientValidator_LongFirstName_Returns400() { var patientId = await CreatePatientAsync(); var resp = await _client.PatchAsJsonAsync( $"/api/v1/patients/{patientId}", new { firstName = new string('x', 101) }); resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); } // ------------------------------------------------------------------------- // P2 — Outbox retry columns exist // ------------------------------------------------------------------------- [Fact] public async Task OutboxEvent_HasRetryColumns() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var ev = new OutboxEvent { Id = Guid.NewGuid(), Topic = "test.retry", Payload = "{}", PartitionKey = "test", CreatedAt = DateTimeOffset.UtcNow, RetryCount = 3, LastError = "Connection refused", FailedAt = DateTimeOffset.UtcNow }; db.OutboxEvents.Add(ev); await db.SaveChangesAsync(); var loaded = await db.OutboxEvents.AsNoTracking().FirstAsync(e => e.Id == ev.Id); loaded.RetryCount.Should().Be(3); loaded.LastError.Should().Be("Connection refused"); loaded.FailedAt.Should().NotBeNull(); } // ------------------------------------------------------------------------- // P0 — ClinicalAlert.ObservationCode column persists // ------------------------------------------------------------------------- [Fact] public async Task ClinicalAlert_ObservationCode_Persists() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var patient = new Patient { Id = Guid.NewGuid(), Mrn = "MRN-OC-001", FirstName = "OC", LastName = "Persist", DateOfBirth = new DateOnly(1980, 1, 1), Gender = "F", CreatedAt = DateTimeOffset.UtcNow }; var encounter = new Encounter { Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient, Status = EncounterStatus.Active, Department = Department.Icu, AttendingPhysician = "Dr. OC", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow }; db.Patients.Add(patient); db.Encounters.Add(encounter); var alert = new ClinicalAlert { Id = Guid.NewGuid(), EncounterId = encounter.Id, PatientId = patient.Id, AlertType = AlertType.RapidDeterioration, Severity = AlertSeverity.Warning, Details = "Test", ObservationCode = "SPO2", Status = AlertStatus.Open, TriggeredAt = DateTimeOffset.UtcNow }; db.ClinicalAlerts.Add(alert); await db.SaveChangesAsync(); var loaded = await db.ClinicalAlerts.AsNoTracking().FirstAsync(a => a.Id == alert.Id); loaded.ObservationCode.Should().Be("SPO2"); } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- private async Task CreatePatientAsync() { var resp = await _client.PostAsJsonAsync("/api/v1/patients", new RegisterPatientRequest("Gap", "Test", new DateOnly(1990, 1, 1), "F")); resp.EnsureSuccessStatusCode(); var body = await resp.Content.ReadFromJsonAsync(); return body.GetProperty("data").GetProperty("id").GetGuid(); } private async Task CreateEncounterAsync(Guid patientId) { var resp = await _client.PostAsJsonAsync( $"/api/v1/patients/{patientId}/encounters", new OpenEncounterRequest(EncounterType.Inpatient, Department.Icu, "Dr. Gap")); resp.EnsureSuccessStatusCode(); var body = await resp.Content.ReadFromJsonAsync(); return body.GetProperty("data").GetProperty("id").GetGuid(); } }