feature: Reconciliation Jobs

This commit is contained in:
voltsrage
2026-06-17 14:36:10 +08:00
parent 56d052aa4f
commit 101040f9d9
40 changed files with 2845 additions and 32 deletions
@@ -31,7 +31,7 @@ public class AlertLifecycleTests : IAsyncLifetime
var encounter = new Encounter
{
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
Status = EncounterStatus.Active, Department = "General Medicine",
Status = EncounterStatus.Active, Department = Department.GeneralMedicine,
AttendingPhysician = "Dr. Test", AdmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow
};
@@ -130,7 +130,7 @@ public class NotificationPipelineTests : IAsyncLifetime
var resp = await _http.PostAsJsonAsync(
$"/api/v1/patients/{patientId}/encounters",
new OpenEncounterRequest(EncounterType.Inpatient, "ICU", "Dr. Osei"));
new OpenEncounterRequest(EncounterType.Inpatient, Department.Icu, "Dr. Osei"));
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
@@ -35,7 +35,7 @@ public class ObservationIngestTests : IAsyncLifetime
var encounter = new Encounter
{
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
Status = EncounterStatus.Active, Department = "ICU",
Status = EncounterStatus.Active, Department = Department.Icu,
AttendingPhysician = "Dr. Test", AdmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow
};
@@ -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();
}
}
@@ -28,7 +28,7 @@ public class SirsDetectorTests : IAsyncLifetime
var encounter = new Encounter
{
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
Status = EncounterStatus.Active, Department = "ICU",
Status = EncounterStatus.Active, Department = Department.Icu,
AttendingPhysician = "Dr. SIRS", AdmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow
};