feature: Reconciliation Jobs
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
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("Dr. Mensah", "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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user