feature: Observation Ingest, Synchronous Alert Detection, and Alert Lifecycle
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
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<AppDbContext>();
|
||||
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 = "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<IConnectionMultiplexer>();
|
||||
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<IngestObservationRequest>
|
||||
{
|
||||
new("HEART_RATE", 78, "bpm", ObservationSource.Device, DateTimeOffset.UtcNow, null)
|
||||
}));
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
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<AppDbContext>();
|
||||
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<IngestObservationRequest>
|
||||
{
|
||||
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<JsonDocument>();
|
||||
body!.RootElement.GetProperty("data").GetProperty("alertGenerated").GetBoolean()
|
||||
.Should().BeTrue();
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
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<IngestObservationRequest>
|
||||
{
|
||||
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<AppDbContext>();
|
||||
|
||||
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<IngestObservationRequest>
|
||||
{
|
||||
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<JsonDocument>();
|
||||
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<AppDbContext>();
|
||||
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<IngestObservationRequest>
|
||||
{
|
||||
new("HEART_RATE", 78, "bpm", ObservationSource.Device, DateTimeOffset.UtcNow, null)
|
||||
}));
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
body!.RootElement.GetProperty("error").GetProperty("code").GetString()
|
||||
.Should().Be("ENCOUNTER_NOT_ACTIVE");
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
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<IngestObservationRequest>
|
||||
{
|
||||
new("HEART_RATE", 350, "bpm", ObservationSource.Device, DateTimeOffset.UtcNow, null)
|
||||
}));
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
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<AppDbContext>();
|
||||
(await db.Observations.CountAsync()).Should().Be(0);
|
||||
(await db.OutboxEvents.CountAsync()).Should().Be(0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user