using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using StackExchange.Redis; [Collection("Integration")] public class QsofaDetectorTests : IAsyncLifetime { private readonly ApiFixture _fixture; private Guid _encounterId; private Guid _patientId; public QsofaDetectorTests(ApiFixture fixture) => _fixture = fixture; public async Task InitializeAsync() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await DbResetHelper.ResetAsync(db); var patient = new Patient { Id = Guid.NewGuid(), Mrn = "MRN-QSOFA-001", FirstName = "qSOFA", 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. qSOFA", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow }; db.Patients.Add(patient); db.Encounters.Add(encounter); await db.SaveChangesAsync(); _patientId = patient.Id; _encounterId = encounter.Id; var redis = scope.ServiceProvider.GetRequiredService(); var cache = redis.GetDatabase(); foreach (var key in QsofaCalculator.AllCriterionKeys(_encounterId)) await cache.KeyDeleteAsync(key); } public Task DisposeAsync() => Task.CompletedTask; [Fact] public async Task OneCriterionMet_NoAlert() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); var result = await detector.ProcessObservationAsync( _encounterId, _patientId, "RESP_RATE", 24m); result.Outcome.Should().Be(QsofaOutcome.InsufficientCriteria); result.ActiveCriteria.Should().Be(1); var redis = scope.ServiceProvider.GetRequiredService(); var ttl = await redis.GetDatabase() .KeyTimeToLiveAsync(QsofaCalculator.CriterionKey(_encounterId, "RESP_RATE")); ttl.Should().NotBeNull().And.BeGreaterThan(TimeSpan.Zero); } [Fact] public async Task TwoCriteriaMet_CreatesAlert() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); var r1 = await detector.ProcessObservationAsync( _encounterId, _patientId, "RESP_RATE", 24m); r1.Outcome.Should().Be(QsofaOutcome.InsufficientCriteria); var r2 = await detector.ProcessObservationAsync( _encounterId, _patientId, "SYSTOLIC_BP", 95m); r2.Outcome.Should().Be(QsofaOutcome.AlertCreated); var alert = await db.ClinicalAlerts.SingleAsync(); alert.AlertType.Should().Be(AlertType.QsofaWarning); alert.Severity.Should().Be(AlertSeverity.Critical); alert.Status.Should().Be(AlertStatus.Open); alert.EncounterId.Should().Be(_encounterId); alert.PatientId.Should().Be(_patientId); alert.Details.Should().Contain("qSOFA score 2/3"); var outbox = await db.OutboxEvents.SingleAsync(); outbox.Topic.Should().Be("alert.generated"); outbox.PartitionKey.Should().Be(_encounterId.ToString()); } [Fact] public async Task CriterionNormalizes_KeyDeleted() { 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(); await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m); var result = await detector.ProcessObservationAsync( _encounterId, _patientId, "RESP_RATE", 18m); var respKeyExists = await cache.KeyExistsAsync( QsofaCalculator.CriterionKey(_encounterId, "RESP_RATE")); respKeyExists.Should().BeFalse("a normalized respiratory rate must clear the criterion key"); var sbpKeyExists = await cache.KeyExistsAsync( QsofaCalculator.CriterionKey(_encounterId, "SYSTOLIC_BP")); sbpKeyExists.Should().BeTrue("the other active criterion must remain in Redis"); result.Outcome.Should().Be(QsofaOutcome.InsufficientCriteria); result.ActiveCriteria.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"); } [Fact] public async Task DuplicateAlert_Idempotent() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m); var r3 = await detector.ProcessObservationAsync( _encounterId, _patientId, "SYSTOLIC_BP", 95m); r3.Outcome.Should().Be(QsofaOutcome.AlertAlreadyOpen); var alertCount = await db.ClinicalAlerts.CountAsync(); alertCount.Should().Be(1, "WHERE NOT EXISTS prevents duplicate QSOFA_WARNING while one is open"); var outboxCount = await db.OutboxEvents.CountAsync(); outboxCount.Should().Be(1, "outbox event must be written exactly once"); } [Fact] public async Task NonQsofaCode_Ignored() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); var redis = scope.ServiceProvider.GetRequiredService(); var result = await detector.ProcessObservationAsync( _encounterId, _patientId, "HEART_RATE", 95m); result.Outcome.Should().Be(QsofaOutcome.NotQsofaCode); foreach (var key in QsofaCalculator.AllCriterionKeys(_encounterId)) { var exists = await redis.GetDatabase().KeyExistsAsync(key); exists.Should().BeFalse("non-qSOFA codes must not create Redis state"); } } }