using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using StackExchange.Redis; [Collection("Integration")] public class SirsDetectorTests : IAsyncLifetime { private readonly ApiFixture _fixture; private Guid _encounterId; private Guid _patientId; public SirsDetectorTests(ApiFixture fixture) => _fixture = fixture; public async Task InitializeAsync() { // Reset PostgreSQL test data using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await DbResetHelper.ResetAsync(db); var patient = new Patient { Id = Guid.NewGuid(), Mrn = "MRN-SIRS-001", FirstName = "SIRS", 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. SIRS", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow }; db.Patients.Add(patient); db.Encounters.Add(encounter); await db.SaveChangesAsync(); _patientId = patient.Id; _encounterId = encounter.Id; // Flush all SIRS keys for this encounter from Redis var redis = scope.ServiceProvider.GetRequiredService(); var cache = redis.GetDatabase(); foreach (var key in SirsEvaluator.AllCriterionKeys(_encounterId)) await cache.KeyDeleteAsync(key); } public Task DisposeAsync() => Task.CompletedTask; private SirsDetector CreateDetector() { using var scope = _fixture.Services.CreateScope(); return scope.ServiceProvider.GetRequiredService(); } // Test 1: one criterion met — insufficient for alert [Fact] public async Task OneCriterion_NoAlert() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); var result = await detector.ProcessObservationAsync( _encounterId, _patientId, "HEART_RATE", 95m); result.Outcome.Should().Be(SirsOutcome.InsufficientCriteria); result.ActiveCount.Should().Be(1); // Verify Redis key was set var redis = scope.ServiceProvider.GetRequiredService(); var ttl = await redis.GetDatabase() .KeyTimeToLiveAsync(SirsEvaluator.CriterionKey(_encounterId, "HEART_RATE")); ttl.Should().NotBeNull().And.BeGreaterThan(TimeSpan.Zero); } // Test 2: two criteria met — SEPSIS_WARNING alert created [Fact] public async Task TwoCriteriaMet_AlertCreated() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); // Tachycardia var r1 = await detector.ProcessObservationAsync( _encounterId, _patientId, "HEART_RATE", 95m); r1.Outcome.Should().Be(SirsOutcome.InsufficientCriteria); // Fever — now count = 2 → alert var r2 = await detector.ProcessObservationAsync( _encounterId, _patientId, "TEMP_C", 38.5m); r2.Outcome.Should().Be(SirsOutcome.AlertCreated); // Verify clinical_alert row in PostgreSQL var alert = await db.ClinicalAlerts.SingleAsync(); alert.AlertType.Should().Be(AlertType.SepsisWarning); alert.Severity.Should().Be(AlertSeverity.Critical); alert.Status.Should().Be(AlertStatus.Open); alert.EncounterId.Should().Be(_encounterId); alert.PatientId.Should().Be(_patientId); // Verify outbox event was written in the same transaction var outbox = await db.OutboxEvents.SingleAsync(); outbox.Topic.Should().Be("alert.generated"); outbox.PartitionKey.Should().Be(_encounterId.ToString()); } // Test 3: third criterion met while alert already open — no second alert [Fact] public async Task ThreeCriteriaMet_NoSecondAlert() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 95m); await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 38.5m); // Third criterion (tachypnea) — alert already open var r3 = await detector.ProcessObservationAsync( _encounterId, _patientId, "RESP_RATE", 22m); r3.Outcome.Should().Be(SirsOutcome.AlertAlreadyOpen); // Still exactly one alert — the WHERE NOT EXISTS prevented a duplicate var alertCount = await db.ClinicalAlerts.CountAsync(); alertCount.Should().Be(1, "a second SEPSIS_WARNING must not be created while one is already open"); } // Test 4: criterion clears — Redis key deleted, alert remains open // // This is the most important test in Phase 5. It verifies: // (a) The DEL path works when a criterion is no longer met. // (b) Clearing a criterion does not resolve the existing alert — the clinical // workflow requires explicit acknowledgment. A patient whose temperature // normalises may still be septic; the alert is for the clinician to evaluate. [Fact] public async Task CriterionClears_KeyDeleted_AlertRemainsOpen() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); var redis = scope.ServiceProvider.GetRequiredService(); var cache = redis.GetDatabase(); // Establish two criteria and create the alert await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 95m); await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 38.5m); // Temperature normalises var result = await detector.ProcessObservationAsync( _encounterId, _patientId, "TEMP_C", 37.0m); // TEMP_C key must be gone from Redis var tempKeyExists = await cache.KeyExistsAsync( SirsEvaluator.CriterionKey(_encounterId, "TEMP_C")); tempKeyExists.Should().BeFalse("a cleared criterion must be deleted from Redis immediately"); // HEART_RATE key must still exist (criterion still met) var hrKeyExists = await cache.KeyExistsAsync( SirsEvaluator.CriterionKey(_encounterId, "HEART_RATE")); hrKeyExists.Should().BeTrue("an active criterion must remain until its own TTL or a clearing observation"); // Active count is now 1, but alert must stay open result.Outcome.Should().Be(SirsOutcome.InsufficientCriteria); result.ActiveCount.Should().Be(1); var alert = await db.ClinicalAlerts.SingleAsync(); alert.Status.Should().Be(AlertStatus.Open, "the existing alert is not auto-resolved when criteria drop below 2 — " + "clinical acknowledgment is required"); } // Test 5: at-least-once redelivery — idempotency under simulated crash [Fact] public async Task DuplicateObservationEvent_AlertCreatedOnce() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 95m); // Simulate: the consumer crashes after this call completes but before committing // the Kafka offset. On restart, the same observation is redelivered. await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 38.5m); await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 38.5m); // duplicate var alertCount = await db.ClinicalAlerts.CountAsync(); alertCount.Should().Be(1, "WHERE NOT EXISTS prevents duplicate alert on redelivery"); var outboxCount = await db.OutboxEvents.CountAsync(); outboxCount.Should().Be(1, "outbox event must be written exactly once"); } // Test 6: non-SIRS code — engine ignores it entirely [Fact] public async Task NonSirsCode_NoRedisInteraction() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); var redis = scope.ServiceProvider.GetRequiredService(); var result = await detector.ProcessObservationAsync( _encounterId, _patientId, "POTASSIUM_MEQ_L", 3.2m); result.Outcome.Should().Be(SirsOutcome.NotSirsCode); // No SIRS keys were created for this encounter var anyKey = await redis.GetDatabase() .KeyExistsAsync(SirsEvaluator.CriterionKey(_encounterId, "POTASSIUM_MEQ_L")); anyKey.Should().BeFalse(); } }