using System.Net; using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using StackExchange.Redis; [Collection("Integration")] public class ObservationIngestTests : IAsyncLifetime { private readonly ApiFixture _fixture; private readonly HttpClient _client; private Guid _patientId; private Guid _encounterId; public ObservationIngestTests(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); // Seed one patient, one active encounter, and the four thresholds var patient = new Patient { Id = Guid.NewGuid(), Mrn = "MRN-T001", FirstName = "Test", LastName = "Patient", 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. Test", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow }; db.Patients.Add(patient); db.Encounters.Add(encounter); db.AlertThresholds.AddRange( new AlertThreshold { Id = Guid.NewGuid(), ObservationCode = "HEART_RATE", DisplayName = "Heart Rate", Unit = "bpm", CriticalLow = 30, WarningLow = 50, WarningHigh = 100, CriticalHigh = 150, CreatedAt = DateTimeOffset.UtcNow }, 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 } ); await db.SaveChangesAsync(); // Pre-load thresholds into Redis (replicates what ThresholdCacheLoader does at startup) var redis = scope.ServiceProvider.GetRequiredService(); var cache = redis.GetDatabase(1); await cache.StringSetAsync("threshold:HEART_RATE", """{"ObservationCode":"HEART_RATE","CriticalLow":30,"WarningLow":50,"WarningHigh":100,"CriticalHigh":150}"""); await cache.StringSetAsync("threshold:POTASSIUM_MEQ_L", """{"ObservationCode":"POTASSIUM_MEQ_L","CriticalLow":2.5,"WarningLow":3.5,"WarningHigh":5.0,"CriticalHigh":6.5}"""); _patientId = patient.Id; _encounterId = encounter.Id; } public Task DisposeAsync() => Task.CompletedTask; // Test 1: normal observation — no alert created [Fact] public async Task NormalObservation_NoAlert_Created() { var resp = await _client.PostAsJsonAsync( $"/api/v1/encounters/{_encounterId}/observations", new BatchIngestRequest(new List { new("HEART_RATE", 78, "bpm", ObservationSource.Device, DateTimeOffset.UtcNow, null) })); resp.StatusCode.Should().Be(HttpStatusCode.Created); var body = await resp.Content.ReadFromJsonAsync(); body!.RootElement.GetProperty("data").GetProperty("alertGenerated").GetBoolean() .Should().BeFalse(); body.RootElement.GetProperty("data").GetProperty("duplicate").GetBoolean() .Should().BeFalse(); using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var alertCount = await db.ClinicalAlerts.CountAsync(); alertCount.Should().Be(0, "a normal value must not create an alert"); // Outbox event should still be written for the Kafka consumer (warning detection) var outboxCount = await db.OutboxEvents.CountAsync(); outboxCount.Should().Be(1, "observation.recorded outbox event must be written for every observation"); var outboxTopic = await db.OutboxEvents.Select(e => e.Topic).FirstAsync(); outboxTopic.Should().Be("observation.recorded"); } // Test 2: critical threshold breach — alert and outbox event in same transaction [Fact] public async Task CriticalBreach_AlertCreated_InSameTransaction() { // Potassium 2.1 mEq/L is below critical_low of 2.5 — immediately life-threatening var resp = await _client.PostAsJsonAsync( $"/api/v1/encounters/{_encounterId}/observations", new BatchIngestRequest(new List { new("POTASSIUM_MEQ_L", 2.1m, "mEq/L", ObservationSource.Lab, DateTimeOffset.UtcNow, "key-critical-001") })); resp.StatusCode.Should().Be(HttpStatusCode.Created); var body = await resp.Content.ReadFromJsonAsync(); body!.RootElement.GetProperty("data").GetProperty("alertGenerated").GetBoolean() .Should().BeTrue(); using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var alert = await db.ClinicalAlerts.SingleAsync(); alert.Severity.Should().Be(AlertSeverity.Critical); alert.Status.Should().Be(AlertStatus.Open); alert.EncounterId.Should().Be(_encounterId); alert.PatientId.Should().Be(_patientId); // Both outbox events must exist: alert.generated AND observation.recorded var topics = await db.OutboxEvents.Select(e => e.Topic).OrderBy(t => t).ToListAsync(); topics.Should().BeEquivalentTo(new[] { "alert.generated", "observation.recorded" }); } // Test 3: warning threshold breach — no alert created; only observation.recorded outbox event [Fact] public async Task WarningBreach_NoAlertCreated_OnlyObservationOutboxEvent() { // Heart rate 104 bpm is above warning_high (100) but below critical_high (150) var resp = await _client.PostAsJsonAsync( $"/api/v1/encounters/{_encounterId}/observations", new BatchIngestRequest(new List { new("HEART_RATE", 104, "bpm", ObservationSource.Device, DateTimeOffset.UtcNow, null) })); resp.StatusCode.Should().Be(HttpStatusCode.Created); using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var alertCount = await db.ClinicalAlerts.CountAsync(); alertCount.Should().Be(0, "warning detection is deferred to the Kafka consumer in Phase 3"); var topics = await db.OutboxEvents.Select(e => e.Topic).ToListAsync(); topics.Should().ContainSingle().Which.Should().Be("observation.recorded"); } // Test 4: duplicate idempotency key — returns 201 with original observation, no duplicate [Fact] public async Task DuplicateIdempotencyKey_Returns201_NoDuplicateRow() { var payload = new BatchIngestRequest(new List { new("HEART_RATE", 78, "bpm", ObservationSource.Device, DateTimeOffset.UtcNow, "device-key-abc123") }); var resp1 = await _client.PostAsJsonAsync( $"/api/v1/encounters/{_encounterId}/observations", payload); var resp2 = await _client.PostAsJsonAsync( $"/api/v1/encounters/{_encounterId}/observations", payload); resp1.StatusCode.Should().Be(HttpStatusCode.Created); resp2.StatusCode.Should().Be(HttpStatusCode.Created); var body2 = await resp2.Content.ReadFromJsonAsync(); body2!.RootElement.GetProperty("data").GetProperty("duplicate").GetBoolean() .Should().BeTrue("the second call must be identified as a duplicate"); using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var count = await db.Observations.CountAsync(); count.Should().Be(1, "only one observation row must exist despite two identical requests"); } // Test 5: discharged encounter — 409 returned, no observation written [Fact] public async Task DischargedEncounter_Returns409_NoObservationWritten() { // Discharge the encounter var patchResp = await _client.PatchAsJsonAsync( $"/api/v1/encounters/{_encounterId}/status", new { status = "Discharged" }); patchResp.StatusCode.Should().Be(HttpStatusCode.OK); // Attempt to ingest against a discharged encounter var resp = await _client.PostAsJsonAsync( $"/api/v1/encounters/{_encounterId}/observations", new BatchIngestRequest(new List { new("HEART_RATE", 78, "bpm", ObservationSource.Device, DateTimeOffset.UtcNow, null) })); resp.StatusCode.Should().Be(HttpStatusCode.Conflict); var body = await resp.Content.ReadFromJsonAsync(); body!.RootElement.GetProperty("error").GetProperty("code").GetString() .Should().Be("ENCOUNTER_NOT_ACTIVE"); using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var count = await db.Observations.CountAsync(); count.Should().Be(0, "no observation must be written when encounter is not active"); } // Test 6: plausibility violation — 422 returned, no rows written [Fact] public async Task ImplausibleValue_Returns422_NoRowsWritten() { // Heart rate of 350 bpm: above plausibility ceiling of 300 var resp = await _client.PostAsJsonAsync( $"/api/v1/encounters/{_encounterId}/observations", new BatchIngestRequest(new List { new("HEART_RATE", 350, "bpm", ObservationSource.Device, DateTimeOffset.UtcNow, null) })); resp.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); var body = await resp.Content.ReadFromJsonAsync(); body!.RootElement.GetProperty("error").GetProperty("code").GetString() .Should().Be("OBSERVATION_OUT_OF_PLAUSIBLE_RANGE"); using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); (await db.Observations.CountAsync()).Should().Be(0); (await db.OutboxEvents.CountAsync()).Should().Be(0); } }