using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using StackExchange.Redis; [Collection("Database")] public class LiveCaptureIntegrationTests : IAsyncLifetime { private readonly ApiFixture _fixture; private HttpClient _client = null!; public LiveCaptureIntegrationTests(ApiFixture fixture) => _fixture = fixture; public async Task InitializeAsync() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await DbResetHelper.ResetAsync(db); await DataSeeder.SeedAsync(db); _client = _fixture.CreateClient(); } public Task DisposeAsync() => Task.CompletedTask; // ─────────────────────────────────────────────── // Happy path: normal observations, no alert // ─────────────────────────────────────────────── [Fact] public async Task RecordObservations_NormalValues_PromotesSynchronouslyWithNoAlert() { // Arrange — create patient and encounter via setup _client = await AuthHelper.LoginAsync(_fixture, "clinician1"); var (patientId, encounterId) = await CreateTestPatientAndEncounterAsync(); var request = new RecordObservationsRequest( Observations: new List { new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null), new("TEMP_C", 36.8m, "C", DateTimeOffset.UtcNow, null), new("SPO2", 98m, "%", DateTimeOffset.UtcNow, null) }, ClinicianAttestation: true, PasswordConfirm: "password" ); // Act var response = await _client.PostAsJsonAsync( $"/api/v1/live-capture/encounters/{encounterId}/observations", request); // Assert Assert.Equal(HttpStatusCode.Created, response.StatusCode); var body = await response.Content .ReadFromJsonAsync>(); Assert.True(body!.Success); Assert.Equal(encounterId, body.Data!.EncounterId); Assert.Equal(3, body.Data.Observations.Count); Assert.Equal(0, body.Data.CriticalAlertCount); // Verify all observations have live IDs foreach (var obs in body.Data.Observations) { Assert.NotEqual(Guid.Empty, obs.LiveObservationId); Assert.NotEqual(Guid.Empty, obs.DraftObservationId); Assert.Null(obs.CriticalAlert); } // Verify batch exists in database with Promoted status using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var batch = await db.DigitizationBatches.FindAsync(body.Data.BatchId); Assert.NotNull(batch); Assert.Equal(BatchStatus.Promoted, batch!.Status); Assert.Equal(BatchTrack.LiveCapture, batch.Track); Assert.True(batch.ClinicianAttestation); Assert.NotNull(batch.PromotedAt); } // ─────────────────────────────────────────────── // Critical path: alert fires BEFORE response returns // ─────────────────────────────────────────────── [Fact] public async Task RecordObservations_CriticalPotassium_AlertFiresBeforeResponseReturns() { // Arrange _client = await AuthHelper.LoginAsync(_fixture, "clinician1"); var (patientId, encounterId) = await CreateTestPatientAndEncounterAsync(); // Seed a critical threshold for potassium: // critical_low = 2.5, warning_low = 3.5, warning_high = 5.0, critical_high = 6.5 await SeedPotassiumThresholdAsync(); // Value of 2.1 is below critical_low of 2.5 — life-threatening var request = new RecordObservationsRequest( Observations: new List { new("POTASSIUM_MEQ_L", 2.1m, "mEq/L", DateTimeOffset.UtcNow, "Bedside iSTAT result") }, ClinicianAttestation: true, PasswordConfirm: "password" ); // Act var response = await _client.PostAsJsonAsync( $"/api/v1/live-capture/encounters/{encounterId}/observations", request); // Assert — response contains the alert inline Assert.Equal(HttpStatusCode.Created, response.StatusCode); var body = await response.Content .ReadFromJsonAsync>(); Assert.True(body!.Success); Assert.Equal(1, body.Data!.CriticalAlertCount); Assert.Single(body.Data.Observations); var obsResponse = body.Data.Observations[0]; Assert.NotNull(obsResponse.CriticalAlert); Assert.Equal("CRITICAL", obsResponse.CriticalAlert!.Severity); Assert.Equal("CRITICAL_LOW", obsResponse.CriticalAlert.ThresholdBound); Assert.Equal(2.5m, obsResponse.CriticalAlert.ThresholdValue); Assert.Contains("below critical low", obsResponse.CriticalAlert.Message); // Verify the alert exists in the database BEFORE we read anything else — // the alert was committed in the same transaction as the observation using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var alert = await db.ClinicalAlerts.FindAsync(obsResponse.CriticalAlert.AlertId); Assert.NotNull(alert); Assert.Equal(AlertStatus.Open, alert!.Status); Assert.Equal(AlertSeverity.Critical, alert.Severity); Assert.Equal(AlertType.CriticalPotassiumMeqL, alert.AlertType); Assert.Contains("below critical low", alert.Details); // Verify outbox event for alert was written in the same transaction var alertOutbox = await db.OutboxEvents .Where(e => e.EventType == "alert.generated" && e.AggregateId == obsResponse.CriticalAlert.AlertId) .FirstOrDefaultAsync(); Assert.NotNull(alertOutbox); Assert.Contains(obsResponse.CriticalAlert.AlertId.ToString(), alertOutbox!.PayloadJson); } // ─────────────────────────────────────────────── // Critical high: value above critical_high threshold // ─────────────────────────────────────────────── [Fact] public async Task RecordObservations_CriticalHighPotassium_AlertWithCorrectBound() { // Arrange _client = await AuthHelper.LoginAsync(_fixture, "clinician1"); var (_, encounterId) = await CreateTestPatientAndEncounterAsync(); await SeedPotassiumThresholdAsync(); // Value of 7.2 is above critical_high of 6.5 var request = new RecordObservationsRequest( Observations: new List { new("POTASSIUM_MEQ_L", 7.2m, "mEq/L", DateTimeOffset.UtcNow, null) }, ClinicianAttestation: true, PasswordConfirm: "password" ); // Act var response = await _client.PostAsJsonAsync( $"/api/v1/live-capture/encounters/{encounterId}/observations", request); // Assert Assert.Equal(HttpStatusCode.Created, response.StatusCode); var body = await response.Content .ReadFromJsonAsync>(); var alert = body!.Data!.Observations[0].CriticalAlert; Assert.NotNull(alert); Assert.Equal("CRITICAL_HIGH", alert!.ThresholdBound); Assert.Equal(6.5m, alert.ThresholdValue); Assert.Contains("above critical high", alert.Message); } // ─────────────────────────────────────────────── // Mixed batch: one critical, two normal // ─────────────────────────────────────────────── [Fact] public async Task RecordObservations_MixedBatch_OnlyCriticalObservationGetsAlert() { _client = await AuthHelper.LoginAsync(_fixture, "clinician1"); var (_, encounterId) = await CreateTestPatientAndEncounterAsync(); await SeedPotassiumThresholdAsync(); var request = new RecordObservationsRequest( Observations: new List { new("HEART_RATE", 80m, "bpm", DateTimeOffset.UtcNow, null), new("POTASSIUM_MEQ_L", 2.1m, "mEq/L", DateTimeOffset.UtcNow, null), new("TEMP_C", 37.0m, "C", DateTimeOffset.UtcNow, null) }, ClinicianAttestation: true, PasswordConfirm: "password" ); var response = await _client.PostAsJsonAsync( $"/api/v1/live-capture/encounters/{encounterId}/observations", request); Assert.Equal(HttpStatusCode.Created, response.StatusCode); var body = await response.Content .ReadFromJsonAsync>(); Assert.Equal(1, body!.Data!.CriticalAlertCount); Assert.Null(body.Data.Observations[0].CriticalAlert); // heart rate — normal Assert.NotNull(body.Data.Observations[1].CriticalAlert); // potassium — critical Assert.Null(body.Data.Observations[2].CriticalAlert); // temp — normal } // ─────────────────────────────────────────────── // Open encounter + vitals in one request // ─────────────────────────────────────────────── [Fact] public async Task OpenEncounterWithVitals_CreatesEncounterAndPromotesObservations() { _client = await AuthHelper.LoginAsync(_fixture, "clinician1"); var patientId = await CreateTestPatientAsync(); var request = new OpenEncounterWithVitalsRequest( PatientId: patientId, Department: "Outpatient Clinic", RoomBed: "OPD-3", AdmissionReason: "Follow-up consultation", Observations: new List { new("HEART_RATE", 68m, "bpm", DateTimeOffset.UtcNow, null), new("BP_SYSTOLIC", 120m, "mmHg", DateTimeOffset.UtcNow, null), new("BP_DIASTOLIC", 80m, "mmHg", DateTimeOffset.UtcNow, null) }, ClinicianAttestation: true, PasswordConfirm: "password" ); var response = await _client.PostAsJsonAsync( "/api/v1/live-capture/encounters", request); Assert.Equal(HttpStatusCode.Created, response.StatusCode); var body = await response.Content .ReadFromJsonAsync>(); Assert.True(body!.Success); Assert.NotEqual(Guid.Empty, body.Data!.EncounterId); Assert.Equal(3, body.Data.Observations.Count); Assert.Equal(0, body.Data.CriticalAlertCount); // Verify encounter was created in VigilCareClinical using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var encounter = await db.Encounters.FindAsync(body.Data.EncounterId); Assert.NotNull(encounter); Assert.Equal("active", encounter!.Status); Assert.Equal(Department.OutpatientClinic, encounter.Department); } // ─────────────────────────────────────────────── // Authorization: non-clinician role rejected // ─────────────────────────────────────────────── [Fact] public async Task RecordObservations_NonClinicianRole_Returns403() { _client = await AuthHelper.LoginAsync(_fixture, "entry1"); var request = new RecordObservationsRequest( Observations: new List { new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null) }, ClinicianAttestation: true, PasswordConfirm: "password" ); var response = await _client.PostAsJsonAsync( $"/api/v1/live-capture/encounters/{Guid.NewGuid()}/observations", request); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } // ─────────────────────────────────────────────── // Attestation: false attestation rejected // ─────────────────────────────────────────────── [Fact] public async Task RecordObservations_AttestationFalse_Returns422() { _client = await AuthHelper.LoginAsync(_fixture, "clinician1"); var (_, encounterId) = await CreateTestPatientAndEncounterAsync(); var request = new RecordObservationsRequest( Observations: new List { new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null) }, ClinicianAttestation: false, // Must be true PasswordConfirm: "password" ); var response = await _client.PostAsJsonAsync( $"/api/v1/live-capture/encounters/{encounterId}/observations", request); Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); var body = await response.Content .ReadFromJsonAsync>(); Assert.Equal("ATTESTATION_REQUIRED", body!.Error!.Code); } // ─────────────────────────────────────────────── // Attestation: wrong password rejected // ─────────────────────────────────────────────── [Fact] public async Task RecordObservations_WrongPassword_Returns422() { _client = await AuthHelper.LoginAsync(_fixture, "clinician1"); var (_, encounterId) = await CreateTestPatientAndEncounterAsync(); var request = new RecordObservationsRequest( Observations: new List { new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null) }, ClinicianAttestation: true, PasswordConfirm: "wrong-password" // Incorrect ); var response = await _client.PostAsJsonAsync( $"/api/v1/live-capture/encounters/{encounterId}/observations", request); Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); var body = await response.Content .ReadFromJsonAsync>(); Assert.Equal("PASSWORD_CONFIRM_INVALID", body!.Error!.Code); } // ─────────────────────────────────────────────── // Edge case: discharged encounter rejected // ─────────────────────────────────────────────── [Fact] public async Task RecordObservations_DischargedEncounter_Returns409() { _client = await AuthHelper.LoginAsync(_fixture, "clinician1"); var (_, encounterId) = await CreateTestPatientAndDischargedEncounterAsync(); var request = new RecordObservationsRequest( Observations: new List { new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null) }, ClinicianAttestation: true, PasswordConfirm: "password" ); var response = await _client.PostAsJsonAsync( $"/api/v1/live-capture/encounters/{encounterId}/observations", request); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); var body = await response.Content .ReadFromJsonAsync>(); Assert.Equal("ENCOUNTER_NOT_ACTIVE", body!.Error!.Code); } // ─────────────────────────────────────────────── // Edge case: duplicate active encounter // ─────────────────────────────────────────────── [Fact] public async Task OpenEncounterWithVitals_PatientHasActiveEncounter_Returns409() { _client = await AuthHelper.LoginAsync(_fixture, "clinician1"); var (patientId, _) = await CreateTestPatientAndEncounterAsync(); var request = new OpenEncounterWithVitalsRequest( PatientId: patientId, Department: Department.EmergencyDepartment.ToDbString(), RoomBed: "ER-1", AdmissionReason: "Chest pain", Observations: new List { new("HEART_RATE", 90m, "bpm", DateTimeOffset.UtcNow, null) }, ClinicianAttestation: true, PasswordConfirm: "password" ); var response = await _client.PostAsJsonAsync( "/api/v1/live-capture/encounters", request); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); var body = await response.Content .ReadFromJsonAsync>(); Assert.Equal("ACTIVE_ENCOUNTER_EXISTS", body!.Error!.Code); } // ─────────────────────────────────────────────── // Edge case: empty observations rejected // ─────────────────────────────────────────────── [Fact] public async Task RecordObservations_EmptyList_Returns422() { _client = await AuthHelper.LoginAsync(_fixture, "clinician1"); var (_, encounterId) = await CreateTestPatientAndEncounterAsync(); var request = new RecordObservationsRequest( Observations: new List(), ClinicianAttestation: true, PasswordConfirm: "password" ); var response = await _client.PostAsJsonAsync( $"/api/v1/live-capture/encounters/{encounterId}/observations", request); Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); var body = await response.Content .ReadFromJsonAsync>(); Assert.Equal("EMPTY_OBSERVATIONS", body!.Error!.Code); } // ─────────────────────────────────────────────── // Audit trail: verify digitization events written // ─────────────────────────────────────────────── [Fact] public async Task RecordObservations_WritesAttestationAndPromotionEvents() { _client = await AuthHelper.LoginAsync(_fixture, "clinician1"); var (_, encounterId) = await CreateTestPatientAndEncounterAsync(); var request = new RecordObservationsRequest( Observations: new List { new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null) }, ClinicianAttestation: true, PasswordConfirm: "password" ); var response = await _client.PostAsJsonAsync( $"/api/v1/live-capture/encounters/{encounterId}/observations", request); Assert.Equal(HttpStatusCode.Created, response.StatusCode); var body = await response.Content .ReadFromJsonAsync>(); using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var events = await db.DigitizationEvents .Where(e => e.BatchId == body!.Data!.BatchId) .OrderBy(e => e.OccurredAt) .ToListAsync(); Assert.Equal(2, events.Count); Assert.Equal(DigitizationEventType.LiveCaptureAttested, events[0].EventType); Assert.Equal(DigitizationEventType.Promoted, events[1].EventType); // Verify metadata contains clinician info Assert.Contains("clinicianName", events[0].MetadataJson!); Assert.Contains("LIVE_CAPTURE", events[0].MetadataJson!); } // ─────────────────────────────────────────────── // Helpers // ─────────────────────────────────────────────── private async Task CreateTestPatientAsync() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var patient = new Patient { Id = Guid.NewGuid(), Mrn = $"MRN-{Guid.NewGuid():N}"[..12], FullName = "Test Patient", DateOfBirth = new DateOnly(1985, 3, 15), Sex = "M", BloodType = BloodType.OPos, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }; db.Patients.Add(patient); await db.SaveChangesAsync(); return patient.Id; } private async Task<(Guid patientId, Guid encounterId)> CreateTestPatientAndEncounterAsync() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var patient = new Patient { Id = Guid.NewGuid(), Mrn = $"MRN-{Guid.NewGuid():N}"[..12], FullName = "Test Patient", DateOfBirth = new DateOnly(1985, 3, 15), Sex = "M", BloodType = BloodType.OPos, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }; var encounter = new Encounter { Id = Guid.NewGuid(), PatientId = patient.Id, Department = Department.InternalMedicine, RoomBed = "IM-201A", AdmissionReason = "Observation", Status = "active", AdmissionDate = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }; db.Patients.Add(patient); db.Encounters.Add(encounter); await db.SaveChangesAsync(); return (patient.Id, encounter.Id); } private async Task<(Guid patientId, Guid encounterId)> CreateTestPatientAndDischargedEncounterAsync() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var patient = new Patient { Id = Guid.NewGuid(), Mrn = $"MRN-{Guid.NewGuid():N}"[..12], FullName = "Discharged Patient", DateOfBirth = new DateOnly(1970, 7, 20), Sex = "F", BloodType = BloodType.ANeg, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }; var encounter = new Encounter { Id = Guid.NewGuid(), PatientId = patient.Id, Department = Department.Surgery, RoomBed = "SURG-105", AdmissionReason = "Post-op recovery", Status = "discharged", AdmissionDate = DateTimeOffset.UtcNow.AddDays(-3), CreatedAt = DateTimeOffset.UtcNow.AddDays(-3), UpdatedAt = DateTimeOffset.UtcNow }; db.Patients.Add(patient); db.Encounters.Add(encounter); await db.SaveChangesAsync(); return (patient.Id, encounter.Id); } private async Task SeedPotassiumThresholdAsync() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var redis = scope.ServiceProvider.GetRequiredService(); // Only seed if not already present var existing = await db.AlertThresholds .FirstOrDefaultAsync(t => t.ObservationCode == "POTASSIUM_MEQ_L"); if (existing is null) { var threshold = new AlertThreshold { Id = Guid.NewGuid(), ObservationCode = "POTASSIUM_MEQ_L", DisplayName = "Serum Potassium", Unit = "mEq/L", CriticalLow = 2.5m, WarningLow = 3.5m, WarningHigh = 5.0m, CriticalHigh = 6.5m, CreatedAt = DateTimeOffset.UtcNow }; db.AlertThresholds.Add(threshold); await db.SaveChangesAsync(); // Pre-warm Redis cache with ThresholdCacheEntry (same shape as LiveCaptureService) var cache = redis.GetDatabase(); await cache.StringSetAsync( "threshold:POTASSIUM_MEQ_L", JsonSerializer.Serialize(new ThresholdCacheEntry( threshold.ObservationCode, threshold.CriticalLow, threshold.WarningLow, threshold.WarningHigh, threshold.CriticalHigh)), TimeSpan.FromMinutes(30)); } } }