feature: Clinical Data Model Expansion & Observation Vocabulary
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
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 ClinicalDemographicsAndObservationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ApiFixture _fixture;
|
||||
private readonly HttpClient _client;
|
||||
private Guid _patientId;
|
||||
private Guid _encounterId;
|
||||
|
||||
public ClinicalDemographicsAndObservationTests(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-P10-001", FirstName = "Phase10", LastName = "Test",
|
||||
DateOfBirth = new DateOnly(1965, 3, 15), Gender = "F",
|
||||
BloodType = BloodType.BPositive, Allergies = "Sulfa",
|
||||
EmergencyContactName = "Test Contact", EmergencyContactPhone = "555-9999",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
var encounter = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
||||
Status = EncounterStatus.Active, Department = Department.Icu,
|
||||
AttendingPhysician = "Dr. Phase10",
|
||||
RoomBed = "ICU-7A", AdmissionReason = "Sepsis workup",
|
||||
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 = "SYSTOLIC_BP",
|
||||
DisplayName = "Systolic BP", Unit = "mmHg",
|
||||
CriticalLow = 70, WarningLow = 90, WarningHigh = 160, CriticalHigh = 180,
|
||||
CreatedAt = DateTimeOffset.UtcNow },
|
||||
new AlertThreshold { Id = Guid.NewGuid(), ObservationCode = "DIASTOLIC_BP",
|
||||
DisplayName = "Diastolic BP", Unit = "mmHg",
|
||||
CriticalLow = 40, WarningLow = 60, WarningHigh = 90, CriticalHigh = 110,
|
||||
CreatedAt = DateTimeOffset.UtcNow },
|
||||
new AlertThreshold { Id = Guid.NewGuid(), ObservationCode = "LACTATE_MMOL_L",
|
||||
DisplayName = "Serum Lactate", Unit = "mmol/L",
|
||||
CriticalLow = null, WarningLow = null, WarningHigh = 2.0m, CriticalHigh = 4.0m,
|
||||
CreatedAt = DateTimeOffset.UtcNow },
|
||||
new AlertThreshold { Id = Guid.NewGuid(), ObservationCode = "AVPU",
|
||||
DisplayName = "AVPU Consciousness", Unit = "score",
|
||||
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = 2m,
|
||||
CreatedAt = DateTimeOffset.UtcNow },
|
||||
new AlertThreshold { Id = Guid.NewGuid(), ObservationCode = "SUPPLEMENTAL_O2",
|
||||
DisplayName = "Supplemental O2", Unit = "flag",
|
||||
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow },
|
||||
new AlertThreshold { Id = Guid.NewGuid(), ObservationCode = "GLUCOSE_MG_DL",
|
||||
DisplayName = "Blood Glucose", Unit = "mg/dL",
|
||||
CriticalLow = 40, WarningLow = 70, WarningHigh = 180, CriticalHigh = 400,
|
||||
CreatedAt = DateTimeOffset.UtcNow }
|
||||
);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
var cache = redis.GetDatabase(1);
|
||||
foreach (var t in await db.AlertThresholds.ToListAsync())
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
t.ObservationCode, t.CriticalLow, t.WarningLow, t.WarningHigh, t.CriticalHigh
|
||||
});
|
||||
await cache.StringSetAsync($"threshold:{t.ObservationCode}", json);
|
||||
}
|
||||
|
||||
_patientId = patient.Id;
|
||||
_encounterId = encounter.Id;
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task CriticalSystolicBp_AlertCreated()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{_encounterId}/observations",
|
||||
new BatchIngestRequest(new List<IngestObservationRequest>
|
||||
{
|
||||
new("SYSTOLIC_BP", 65, "mmHg", 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().BeTrue();
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var alert = await db.ClinicalAlerts.SingleAsync();
|
||||
alert.AlertType.Should().Be(AlertType.CriticalSystolicBp);
|
||||
alert.Severity.Should().Be(AlertSeverity.Critical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AvpuUnresponsive_CriticalAlert()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{_encounterId}/observations",
|
||||
new BatchIngestRequest(new List<IngestObservationRequest>
|
||||
{
|
||||
new("AVPU", 3, "score", ObservationSource.Manual, DateTimeOffset.UtcNow, null)
|
||||
}));
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var alert = await db.ClinicalAlerts.SingleAsync();
|
||||
alert.AlertType.Should().Be(AlertType.CriticalAvpu);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SupplementalO2_NoAlert()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{_encounterId}/observations",
|
||||
new BatchIngestRequest(new List<IngestObservationRequest>
|
||||
{
|
||||
new("SUPPLEMENTAL_O2", 1, "flag", ObservationSource.Manual, DateTimeOffset.UtcNow, null)
|
||||
}));
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
(await db.ClinicalAlerts.CountAsync()).Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CriticalGlucose_AlertCreated()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{_encounterId}/observations",
|
||||
new BatchIngestRequest(new List<IngestObservationRequest>
|
||||
{
|
||||
new("GLUCOSE_MG_DL", 30, "mg/dL", ObservationSource.Lab, DateTimeOffset.UtcNow, null)
|
||||
}));
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var alert = await db.ClinicalAlerts.SingleAsync();
|
||||
alert.AlertType.Should().Be(AlertType.CriticalGlucoseMgDl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatientRegistration_ClinicalFields_RoundTrip()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/api/v1/patients", new
|
||||
{
|
||||
firstName = "Demo",
|
||||
lastName = "Patient",
|
||||
dateOfBirth = "1990-06-15",
|
||||
gender = "M",
|
||||
bloodType = "AB-",
|
||||
allergies = "Latex, Iodine",
|
||||
emergencyContactName = "Demo Contact",
|
||||
emergencyContactPhone = "555-1234"
|
||||
});
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
var data = body!.RootElement.GetProperty("data");
|
||||
data.GetProperty("bloodType").GetString().Should().Be("AB-");
|
||||
data.GetProperty("allergies").GetString().Should().Be("Latex, Iodine");
|
||||
data.GetProperty("emergencyContactName").GetString().Should().Be("Demo Contact");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenEncounter_WithRoomBed_Success()
|
||||
{
|
||||
var patientResp = await _client.PostAsJsonAsync("/api/v1/patients", new
|
||||
{
|
||||
firstName = "Room", lastName = "Test",
|
||||
dateOfBirth = "1985-01-01", gender = "F"
|
||||
});
|
||||
var patientBody = await patientResp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
var newPatientId = patientBody!.RootElement.GetProperty("data").GetProperty("id").GetGuid();
|
||||
|
||||
var resp = await _client.PostAsJsonAsync($"/api/v1/patients/{newPatientId}/encounters", new
|
||||
{
|
||||
encounterType = "INPATIENT",
|
||||
department = "ICU",
|
||||
attendingPhysician = "Dr. Room",
|
||||
roomBed = "ICU-3C",
|
||||
admissionReason = "Acute respiratory distress"
|
||||
});
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
var data = body!.RootElement.GetProperty("data");
|
||||
data.GetProperty("roomBed").GetString().Should().Be("ICU-3C");
|
||||
data.GetProperty("admissionReason").GetString().Should().Be("Acute respiratory distress");
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ using Minio.DataModel.Args;
|
||||
using Parquet;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class DataLakePhase9Tests : IClassFixture<ApiFixture>
|
||||
[Collection("Integration")]
|
||||
public class DataLakePhase9Tests
|
||||
{
|
||||
private readonly ApiFixture _fixture;
|
||||
private readonly HttpClient _http;
|
||||
@@ -208,7 +209,6 @@ public class DataLakePhase9Tests : IClassFixture<ApiFixture>
|
||||
await EnsureThresholdAsync(db, "HEART_RATE", "Heart Rate", "bpm", 30, 50, 100, 150);
|
||||
await EnsureThresholdAsync(db, "POTASSIUM_MEQ_L", "Serum Potassium", "mEq/L", 2.5m, 3.5m, 5.0m, 6.5m);
|
||||
await EnsureThresholdAsync(db, "TEMP_C", "Temperature", "°C", 34m, 36m, 37.8m, 40m);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
var cache = redis.GetDatabase(1);
|
||||
@@ -245,5 +245,15 @@ public class DataLakePhase9Tests : IClassFixture<ApiFixture>
|
||||
CriticalHigh = criticalHigh,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException ex) when (
|
||||
ex.InnerException is Npgsql.PostgresException { SqlState: "23505" })
|
||||
{
|
||||
// Another test inserted the same observation code concurrently.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class ObservabilityPhase8Tests : IClassFixture<ApiFixture>
|
||||
[Collection("Integration")]
|
||||
public class ObservabilityPhase8Tests
|
||||
{
|
||||
private static readonly string[] ExpectedMetrics =
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user