392 lines
16 KiB
C#
392 lines
16 KiB
C#
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using StackExchange.Redis;
|
|
|
|
[Collection("Integration")]
|
|
public class ReconciliationTests : IAsyncLifetime
|
|
{
|
|
private readonly ApiFixture _fixture;
|
|
private readonly HttpClient _http;
|
|
|
|
public ReconciliationTests(ApiFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
_http = fixture.CreateClient();
|
|
}
|
|
|
|
public Task InitializeAsync() => Task.CompletedTask;
|
|
|
|
public Task DisposeAsync() => Task.CompletedTask;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Check 1 — Unacknowledged critical alerts
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Check1_StaleCriticalAlert_CreatesReconciliationAlert()
|
|
{
|
|
await PrepareAsync();
|
|
|
|
var patientId = await CreatePatientAsync();
|
|
var encounterId = await CreateActiveEncounterAsync(patientId);
|
|
var alertId = await IngestCriticalPotassiumAsync(encounterId);
|
|
|
|
using (var scope = _fixture.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var alert = await db.ClinicalAlerts.FindAsync(alertId);
|
|
alert!.TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-31);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
await using var checkScope = _fixture.Services.CreateAsyncScope();
|
|
var check = checkScope.ServiceProvider.GetRequiredService<UnacknowledgedAlertsCheck>();
|
|
var count = await check.RunAsync(CancellationToken.None);
|
|
|
|
Assert.Equal(1, count);
|
|
|
|
using var verifyScope = _fixture.Services.CreateScope();
|
|
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var rec = await verifyDb.ReconciliationAlerts
|
|
.FirstOrDefaultAsync(r => r.CheckType == UnacknowledgedAlertsCheck.CheckType
|
|
&& r.EncounterId == encounterId
|
|
&& r.ResolvedAt == null);
|
|
|
|
Assert.NotNull(rec);
|
|
Assert.Contains(alertId.ToString(), rec!.Details);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Check1_AcknowledgedAlert_DoesNotCreateReconciliationAlert()
|
|
{
|
|
await PrepareAsync();
|
|
|
|
var patientId = await CreatePatientAsync();
|
|
var encounterId = await CreateActiveEncounterAsync(patientId);
|
|
var alertId = await IngestCriticalPotassiumAsync(encounterId);
|
|
|
|
var ackResp = await _http.PostAsJsonAsync(
|
|
$"/api/v1/alerts/{alertId}/acknowledge",
|
|
new AcknowledgeAlertRequest("Reviewed."));
|
|
ackResp.EnsureSuccessStatusCode();
|
|
|
|
using (var scope = _fixture.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var alert = await db.ClinicalAlerts.FindAsync(alertId);
|
|
alert!.TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-31);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
await using var checkScope = _fixture.Services.CreateAsyncScope();
|
|
var check = checkScope.ServiceProvider.GetRequiredService<UnacknowledgedAlertsCheck>();
|
|
var count = await check.RunAsync(CancellationToken.None);
|
|
|
|
Assert.Equal(0, count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Check1_SecondRun_DoesNotInsertDuplicate()
|
|
{
|
|
await PrepareAsync();
|
|
|
|
var patientId = await CreatePatientAsync();
|
|
var encounterId = await CreateActiveEncounterAsync(patientId);
|
|
await IngestCriticalPotassiumAsync(encounterId);
|
|
|
|
using (var scope = _fixture.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var alert = await db.ClinicalAlerts.FirstAsync(a => a.EncounterId == encounterId);
|
|
alert.TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-31);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
await using (var s = _fixture.Services.CreateAsyncScope())
|
|
{
|
|
var c = s.ServiceProvider.GetRequiredService<UnacknowledgedAlertsCheck>();
|
|
await c.RunAsync(CancellationToken.None);
|
|
}
|
|
|
|
int secondCount;
|
|
await using (var s = _fixture.Services.CreateAsyncScope())
|
|
{
|
|
var c = s.ServiceProvider.GetRequiredService<UnacknowledgedAlertsCheck>();
|
|
secondCount = await c.RunAsync(CancellationToken.None);
|
|
}
|
|
|
|
Assert.Equal(0, secondCount);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Check 2 — Pending orders without results
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Check2_StalePendingOrder_CreatesReconciliationAlert()
|
|
{
|
|
await PrepareAsync();
|
|
|
|
var patientId = await CreatePatientAsync();
|
|
var encounterId = await CreateActiveEncounterAsync(patientId);
|
|
|
|
using (var scope = _fixture.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
db.Orders.Add(new Order
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
EncounterId = encounterId,
|
|
OrderType = OrderType.Lab,
|
|
Description = "Comprehensive metabolic panel",
|
|
OrderedBy = "Dr. Osei",
|
|
Status = OrderStatus.Pending,
|
|
OrderedAt = DateTimeOffset.UtcNow.AddHours(-5),
|
|
ResultedAt = null,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
await using var checkScope = _fixture.Services.CreateAsyncScope();
|
|
var check = checkScope.ServiceProvider.GetRequiredService<PendingOrdersCheck>();
|
|
var count = await check.RunAsync(CancellationToken.None);
|
|
|
|
Assert.True(count >= 1);
|
|
|
|
using var verifyScope = _fixture.Services.CreateScope();
|
|
var db2 = verifyScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var rec = await db2.ReconciliationAlerts
|
|
.FirstOrDefaultAsync(r => r.CheckType == PendingOrdersCheck.CheckType
|
|
&& r.EncounterId == encounterId
|
|
&& r.ResolvedAt == null);
|
|
Assert.NotNull(rec);
|
|
Assert.Contains("Comprehensive metabolic panel", rec!.Details);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Check2_ResultedOrder_DoesNotCreateReconciliationAlert()
|
|
{
|
|
await PrepareAsync();
|
|
|
|
var patientId = await CreatePatientAsync();
|
|
var encounterId = await CreateActiveEncounterAsync(patientId);
|
|
|
|
using (var scope = _fixture.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
db.Orders.Add(new Order
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
EncounterId = encounterId,
|
|
OrderType = OrderType.Lab,
|
|
Description = "CBC",
|
|
OrderedBy = "Dr. Osei",
|
|
Status = OrderStatus.Resulted,
|
|
OrderedAt = DateTimeOffset.UtcNow.AddHours(-5),
|
|
ResultedAt = DateTimeOffset.UtcNow.AddHours(-4),
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
await using var checkScope = _fixture.Services.CreateAsyncScope();
|
|
var check = checkScope.ServiceProvider.GetRequiredService<PendingOrdersCheck>();
|
|
var count = await check.RunAsync(CancellationToken.None);
|
|
|
|
Assert.Equal(0, count);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Check 3 — Active inpatient without recent observations
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Check3_ActiveInpatientNoObservations_CreatesReconciliationAlert()
|
|
{
|
|
await PrepareAsync();
|
|
|
|
var patientId = await CreatePatientAsync();
|
|
var encounterId = await CreateActiveEncounterAsync(patientId);
|
|
|
|
await using var checkScope = _fixture.Services.CreateAsyncScope();
|
|
var check = checkScope.ServiceProvider.GetRequiredService<DisconnectedMonitorsCheck>();
|
|
var count = await check.RunAsync(CancellationToken.None);
|
|
|
|
Assert.True(count >= 1);
|
|
|
|
using var verifyScope = _fixture.Services.CreateScope();
|
|
var db = verifyScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var rec = await db.ReconciliationAlerts
|
|
.FirstOrDefaultAsync(r => r.CheckType == DisconnectedMonitorsCheck.CheckType
|
|
&& r.EncounterId == encounterId
|
|
&& r.ResolvedAt == null);
|
|
Assert.NotNull(rec);
|
|
Assert.Contains("no observations ever recorded", rec!.Details);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Check3_ActiveInpatientStaleObservation_CreatesReconciliationAlert()
|
|
{
|
|
await PrepareAsync();
|
|
|
|
var patientId = await CreatePatientAsync();
|
|
var encounterId = await CreateActiveEncounterAsync(patientId);
|
|
|
|
var ingestResp = await _http.PostAsJsonAsync(
|
|
$"/api/v1/encounters/{encounterId}/observations",
|
|
new BatchIngestRequest(new List<IngestObservationRequest>
|
|
{
|
|
new("HEART_RATE", 72, "bpm", ObservationSource.Device,
|
|
DateTimeOffset.UtcNow.AddHours(-3), null),
|
|
}));
|
|
ingestResp.EnsureSuccessStatusCode();
|
|
|
|
await using var checkScope = _fixture.Services.CreateAsyncScope();
|
|
var check = checkScope.ServiceProvider.GetRequiredService<DisconnectedMonitorsCheck>();
|
|
var count = await check.RunAsync(CancellationToken.None);
|
|
|
|
Assert.True(count >= 1);
|
|
|
|
using var verifyScope = _fixture.Services.CreateScope();
|
|
var db = verifyScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var rec = await db.ReconciliationAlerts
|
|
.FirstOrDefaultAsync(r => r.CheckType == DisconnectedMonitorsCheck.CheckType
|
|
&& r.EncounterId == encounterId
|
|
&& r.ResolvedAt == null);
|
|
Assert.NotNull(rec);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Check3_RecentObservation_DoesNotCreateReconciliationAlert()
|
|
{
|
|
await PrepareAsync();
|
|
|
|
var patientId = await CreatePatientAsync();
|
|
var encounterId = await CreateActiveEncounterAsync(patientId);
|
|
|
|
var ingestResp = await _http.PostAsJsonAsync(
|
|
$"/api/v1/encounters/{encounterId}/observations",
|
|
new BatchIngestRequest(new List<IngestObservationRequest>
|
|
{
|
|
new("HEART_RATE", 72, "bpm", ObservationSource.Device,
|
|
DateTimeOffset.UtcNow.AddMinutes(-30), null),
|
|
}));
|
|
ingestResp.EnsureSuccessStatusCode();
|
|
|
|
await using var checkScope = _fixture.Services.CreateAsyncScope();
|
|
var check = checkScope.ServiceProvider.GetRequiredService<DisconnectedMonitorsCheck>();
|
|
var count = await check.RunAsync(CancellationToken.None);
|
|
|
|
Assert.Equal(0, count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Check3_OutpatientEncounter_IsIgnored()
|
|
{
|
|
await PrepareAsync();
|
|
|
|
var patientId = await CreatePatientAsync();
|
|
|
|
var resp = await _http.PostAsJsonAsync(
|
|
$"/api/v1/patients/{patientId}/encounters",
|
|
new OpenEncounterRequest(EncounterType.Outpatient, Department.GeneralMedicine, "Dr. Osei"));
|
|
resp.EnsureSuccessStatusCode();
|
|
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
|
var encounterId = body!.RootElement.GetProperty("data").GetProperty("id").GetGuid();
|
|
|
|
await using var checkScope = _fixture.Services.CreateAsyncScope();
|
|
var check = checkScope.ServiceProvider.GetRequiredService<DisconnectedMonitorsCheck>();
|
|
await check.RunAsync(CancellationToken.None);
|
|
|
|
using var verifyScope = _fixture.Services.CreateScope();
|
|
var db = verifyScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var rec = await db.ReconciliationAlerts
|
|
.FirstOrDefaultAsync(r => r.CheckType == DisconnectedMonitorsCheck.CheckType
|
|
&& r.EncounterId == encounterId);
|
|
Assert.Null(rec);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
private async Task PrepareAsync()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
await DbResetHelper.ResetAsync(db);
|
|
|
|
db.AlertThresholds.AddRange(
|
|
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,
|
|
},
|
|
new AlertThreshold
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ObservationCode = "HEART_RATE",
|
|
DisplayName = "Heart Rate",
|
|
Unit = "bpm",
|
|
CriticalLow = 30,
|
|
WarningLow = 50,
|
|
WarningHigh = 100,
|
|
CriticalHigh = 150,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
|
var cache = redis.GetDatabase(1);
|
|
await cache.StringSetAsync(
|
|
"threshold:POTASSIUM_MEQ_L",
|
|
"""{"ObservationCode":"POTASSIUM_MEQ_L","CriticalLow":2.5,"WarningLow":3.5,"WarningHigh":5.0,"CriticalHigh":6.5}""");
|
|
await cache.StringSetAsync(
|
|
"threshold:HEART_RATE",
|
|
"""{"ObservationCode":"HEART_RATE","CriticalLow":30,"WarningLow":50,"WarningHigh":100,"CriticalHigh":150}""");
|
|
}
|
|
|
|
private async Task<Guid> CreatePatientAsync()
|
|
{
|
|
var resp = await _http.PostAsJsonAsync(
|
|
"/api/v1/patients",
|
|
new RegisterPatientRequest("Reconciliation", "Test", new DateOnly(1970, 6, 1), "Other"));
|
|
resp.EnsureSuccessStatusCode();
|
|
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
|
return body!.RootElement.GetProperty("data").GetProperty("id").GetGuid();
|
|
}
|
|
|
|
private async Task<Guid> CreateActiveEncounterAsync(Guid patientId)
|
|
{
|
|
var resp = await _http.PostAsJsonAsync(
|
|
$"/api/v1/patients/{patientId}/encounters",
|
|
new OpenEncounterRequest(EncounterType.Inpatient, Department.Icu, "Dr. Osei"));
|
|
resp.EnsureSuccessStatusCode();
|
|
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
|
return body!.RootElement.GetProperty("data").GetProperty("id").GetGuid();
|
|
}
|
|
|
|
private async Task<Guid> IngestCriticalPotassiumAsync(Guid encounterId)
|
|
{
|
|
var ingestResp = await _http.PostAsJsonAsync(
|
|
$"/api/v1/encounters/{encounterId}/observations",
|
|
new BatchIngestRequest(new List<IngestObservationRequest>
|
|
{
|
|
new("POTASSIUM_MEQ_L", 2.1m, "mEq/L", ObservationSource.Device,
|
|
DateTimeOffset.UtcNow, null),
|
|
}));
|
|
ingestResp.EnsureSuccessStatusCode();
|
|
|
|
var body = await ingestResp.Content.ReadFromJsonAsync<JsonDocument>();
|
|
return body!.RootElement.GetProperty("data").GetProperty("alertId").GetGuid();
|
|
}
|
|
}
|