Files
vigilcare-clinical/VigilCareClinicalAPI.Tests/ObservationIngestTests.cs
T

233 lines
11 KiB
C#

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);
}
}