feature: Observation Ingest, Synchronous Alert Detection, and Alert Lifecycle

This commit is contained in:
voltsrage
2026-06-16 21:05:06 +08:00
parent 882d4af3e6
commit de603df151
26 changed files with 1471 additions and 5 deletions
@@ -0,0 +1,94 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
[Collection("Integration")]
public class AlertLifecycleTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private readonly HttpClient _client;
private Guid _alertId;
public AlertLifecycleTests(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);
var patient = new Patient
{
Id = Guid.NewGuid(), Mrn = "MRN-T002", FirstName = "Alert", LastName = "Test",
DateOfBirth = new DateOnly(1980, 6, 15), Gender = "M", CreatedAt = DateTimeOffset.UtcNow
};
var encounter = new Encounter
{
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
Status = EncounterStatus.Active, Department = "General Medicine",
AttendingPhysician = "Dr. Test", AdmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow
};
var alert = new ClinicalAlert
{
Id = Guid.NewGuid(), EncounterId = encounter.Id, PatientId = patient.Id,
AlertType = AlertType.CriticalPotassiumMeqL, Severity = AlertSeverity.Critical,
Details = "Potassium 2.1 mEq/L is below critical low.", Status = AlertStatus.Open,
TriggeredAt = DateTimeOffset.UtcNow
};
db.Patients.Add(patient);
db.Encounters.Add(encounter);
db.ClinicalAlerts.Add(alert);
await db.SaveChangesAsync();
_alertId = alert.Id;
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task AcknowledgeAlert_TransitionsToAcknowledged()
{
var resp = await _client.PostAsJsonAsync(
$"/api/v1/alerts/{_alertId}/acknowledge",
new { clinicianId = "DR-OSEI", note = "Reviewing now, ordering repeat labs." });
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
body!.RootElement.GetProperty("data").GetProperty("status").GetString()
.Should().Be("Acknowledged");
body.RootElement.GetProperty("data").GetProperty("acknowledgedBy").GetString()
.Should().Be("DR-OSEI");
}
[Fact]
public async Task ResolveWithoutAcknowledge_Returns409()
{
var resp = await _client.PostAsync($"/api/v1/alerts/{_alertId}/resolve", null);
resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
body!.RootElement.GetProperty("error").GetProperty("code").GetString()
.Should().Be("ALERT_NOT_ACKNOWLEDGED");
}
[Fact]
public async Task AcknowledgeThenResolve_FullLifecycle()
{
await _client.PostAsJsonAsync(
$"/api/v1/alerts/{_alertId}/acknowledge",
new { clinicianId = "DR-PATEL", note = "Treated." });
var resolveResp = await _client.PostAsync(
$"/api/v1/alerts/{_alertId}/resolve", null);
resolveResp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resolveResp.Content.ReadFromJsonAsync<JsonDocument>();
body!.RootElement.GetProperty("data").GetProperty("status").GetString()
.Should().Be("Resolved");
}
}
@@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;
public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
// Override configuration to point at a test database — never run tests against
// the development database; a botched rollback could corrupt seed data.
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] =
"Host=localhost;Port=5436;Database=vigilcare_test;Username=postgres;Password=password",
["Redis:ConnectionString"] = "localhost:6382,defaultDatabase=1,allowAdmin=true"
});
});
}
public async Task InitializeAsync()
{
// Apply migrations against the test database on first run
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
// Flush the test Redis database (db=1) to avoid cross-test cache pollution
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
var server = redis.GetServer(redis.GetEndPoints().First());
await server.FlushDatabaseAsync(1);
}
public new async Task DisposeAsync()
{
await base.DisposeAsync();
}
}
@@ -0,0 +1,2 @@
[CollectionDefinition("Integration")]
public class IntegrationTestCollection : ICollectionFixture<ApiFixture>;
@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
public static class DbResetHelper
{
// Truncate all data between tests — faster than dropping and re-creating
// the database, and preserves the schema (migrations do not re-run).
public static async Task ResetAsync(AppDbContext db)
{
await db.Database.ExecuteSqlRawAsync(@"
TRUNCATE TABLE reconciliation_alerts, outbox_events, orders,
clinical_alerts, observations, encounters,
alert_thresholds, patients
RESTART IDENTITY CASCADE;
");
}
}
@@ -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);
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="FluentAssertions" Version="8.10.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="StackExchange.Redis" Version="3.0.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VigilCareClinicalAPI\VigilCareClinicalAPI.csproj" />
</ItemGroup>
</Project>