feature: Live Capture (Track B)
This commit is contained in:
@@ -8,12 +8,13 @@ public static class DbResetHelper
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(@"
|
||||
TRUNCATE TABLE live_observations, live_encounters,
|
||||
clinical.outbox_events, clinical.observations,
|
||||
clinical.encounters, clinical.patients,
|
||||
idempotency_records,
|
||||
digitization_events, draft_observations,
|
||||
draft_encounters, draft_patients,
|
||||
scanned_documents, digitization_batches, users
|
||||
clinical.clinical_alerts, clinical.alert_thresholds,
|
||||
clinical.outbox_events, clinical.observations,
|
||||
clinical.encounters, clinical.patients,
|
||||
idempotency_records,
|
||||
digitization_events, draft_observations,
|
||||
draft_encounters, draft_patients,
|
||||
scanned_documents, digitization_batches, users
|
||||
RESTART IDENTITY CASCADE;
|
||||
");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StackExchange.Redis;
|
||||
|
||||
[Collection("Database")]
|
||||
public class LiveCaptureIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ApiFixture _fixture;
|
||||
private HttpClient _client = null!;
|
||||
|
||||
public LiveCaptureIntegrationTests(ApiFixture fixture) => _fixture = fixture;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await DbResetHelper.ResetAsync(db);
|
||||
await DataSeeder.SeedAsync(db);
|
||||
_client = _fixture.CreateClient();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Happy path: normal observations, no alert
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RecordObservations_NormalValues_PromotesSynchronouslyWithNoAlert()
|
||||
{
|
||||
// Arrange — create patient and encounter via setup
|
||||
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||
|
||||
var (patientId, encounterId) = await CreateTestPatientAndEncounterAsync();
|
||||
|
||||
var request = new RecordObservationsRequest(
|
||||
Observations: new List<LiveCaptureObservationRequest>
|
||||
{
|
||||
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null),
|
||||
new("TEMP_C", 36.8m, "C", DateTimeOffset.UtcNow, null),
|
||||
new("SPO2", 98m, "%", DateTimeOffset.UtcNow, null)
|
||||
},
|
||||
ClinicianAttestation: true,
|
||||
PasswordConfirm: "password"
|
||||
);
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
|
||||
var body = await response.Content
|
||||
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
|
||||
Assert.True(body!.Success);
|
||||
Assert.Equal(encounterId, body.Data!.EncounterId);
|
||||
Assert.Equal(3, body.Data.Observations.Count);
|
||||
Assert.Equal(0, body.Data.CriticalAlertCount);
|
||||
|
||||
// Verify all observations have live IDs
|
||||
foreach (var obs in body.Data.Observations)
|
||||
{
|
||||
Assert.NotEqual(Guid.Empty, obs.LiveObservationId);
|
||||
Assert.NotEqual(Guid.Empty, obs.DraftObservationId);
|
||||
Assert.Null(obs.CriticalAlert);
|
||||
}
|
||||
|
||||
// Verify batch exists in database with Promoted status
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var batch = await db.DigitizationBatches.FindAsync(body.Data.BatchId);
|
||||
Assert.NotNull(batch);
|
||||
Assert.Equal(BatchStatus.Promoted, batch!.Status);
|
||||
Assert.Equal(BatchTrack.LiveCapture, batch.Track);
|
||||
Assert.True(batch.ClinicianAttestation);
|
||||
Assert.NotNull(batch.PromotedAt);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Critical path: alert fires BEFORE response returns
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RecordObservations_CriticalPotassium_AlertFiresBeforeResponseReturns()
|
||||
{
|
||||
// Arrange
|
||||
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||
|
||||
var (patientId, encounterId) = await CreateTestPatientAndEncounterAsync();
|
||||
|
||||
// Seed a critical threshold for potassium:
|
||||
// critical_low = 2.5, warning_low = 3.5, warning_high = 5.0, critical_high = 6.5
|
||||
await SeedPotassiumThresholdAsync();
|
||||
|
||||
// Value of 2.1 is below critical_low of 2.5 — life-threatening
|
||||
var request = new RecordObservationsRequest(
|
||||
Observations: new List<LiveCaptureObservationRequest>
|
||||
{
|
||||
new("POTASSIUM_MEQ_L", 2.1m, "mEq/L", DateTimeOffset.UtcNow,
|
||||
"Bedside iSTAT result")
|
||||
},
|
||||
ClinicianAttestation: true,
|
||||
PasswordConfirm: "password"
|
||||
);
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||
|
||||
// Assert — response contains the alert inline
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
|
||||
var body = await response.Content
|
||||
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
|
||||
Assert.True(body!.Success);
|
||||
Assert.Equal(1, body.Data!.CriticalAlertCount);
|
||||
Assert.Single(body.Data.Observations);
|
||||
|
||||
var obsResponse = body.Data.Observations[0];
|
||||
Assert.NotNull(obsResponse.CriticalAlert);
|
||||
Assert.Equal("CRITICAL", obsResponse.CriticalAlert!.Severity);
|
||||
Assert.Equal("CRITICAL_LOW", obsResponse.CriticalAlert.ThresholdBound);
|
||||
Assert.Equal(2.5m, obsResponse.CriticalAlert.ThresholdValue);
|
||||
Assert.Contains("below critical low", obsResponse.CriticalAlert.Message);
|
||||
|
||||
// Verify the alert exists in the database BEFORE we read anything else —
|
||||
// the alert was committed in the same transaction as the observation
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var alert = await db.ClinicalAlerts.FindAsync(obsResponse.CriticalAlert.AlertId);
|
||||
Assert.NotNull(alert);
|
||||
Assert.Equal(AlertStatus.Open, alert!.Status);
|
||||
Assert.Equal(AlertSeverity.Critical, alert.Severity);
|
||||
Assert.Equal(AlertType.CriticalPotassiumMeqL, alert.AlertType);
|
||||
Assert.Contains("below critical low", alert.Details);
|
||||
|
||||
// Verify outbox event for alert was written in the same transaction
|
||||
var alertOutbox = await db.OutboxEvents
|
||||
.Where(e => e.EventType == "alert.generated" &&
|
||||
e.AggregateId == obsResponse.CriticalAlert.AlertId)
|
||||
.FirstOrDefaultAsync();
|
||||
Assert.NotNull(alertOutbox);
|
||||
Assert.Contains(obsResponse.CriticalAlert.AlertId.ToString(), alertOutbox!.PayloadJson);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Critical high: value above critical_high threshold
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RecordObservations_CriticalHighPotassium_AlertWithCorrectBound()
|
||||
{
|
||||
// Arrange
|
||||
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||
|
||||
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
|
||||
await SeedPotassiumThresholdAsync();
|
||||
|
||||
// Value of 7.2 is above critical_high of 6.5
|
||||
var request = new RecordObservationsRequest(
|
||||
Observations: new List<LiveCaptureObservationRequest>
|
||||
{
|
||||
new("POTASSIUM_MEQ_L", 7.2m, "mEq/L", DateTimeOffset.UtcNow, null)
|
||||
},
|
||||
ClinicianAttestation: true,
|
||||
PasswordConfirm: "password"
|
||||
);
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var body = await response.Content
|
||||
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
|
||||
|
||||
var alert = body!.Data!.Observations[0].CriticalAlert;
|
||||
Assert.NotNull(alert);
|
||||
Assert.Equal("CRITICAL_HIGH", alert!.ThresholdBound);
|
||||
Assert.Equal(6.5m, alert.ThresholdValue);
|
||||
Assert.Contains("above critical high", alert.Message);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Mixed batch: one critical, two normal
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RecordObservations_MixedBatch_OnlyCriticalObservationGetsAlert()
|
||||
{
|
||||
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||
|
||||
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
|
||||
await SeedPotassiumThresholdAsync();
|
||||
|
||||
var request = new RecordObservationsRequest(
|
||||
Observations: new List<LiveCaptureObservationRequest>
|
||||
{
|
||||
new("HEART_RATE", 80m, "bpm", DateTimeOffset.UtcNow, null),
|
||||
new("POTASSIUM_MEQ_L", 2.1m, "mEq/L", DateTimeOffset.UtcNow, null),
|
||||
new("TEMP_C", 37.0m, "C", DateTimeOffset.UtcNow, null)
|
||||
},
|
||||
ClinicianAttestation: true,
|
||||
PasswordConfirm: "password"
|
||||
);
|
||||
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var body = await response.Content
|
||||
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
|
||||
|
||||
Assert.Equal(1, body!.Data!.CriticalAlertCount);
|
||||
Assert.Null(body.Data.Observations[0].CriticalAlert); // heart rate — normal
|
||||
Assert.NotNull(body.Data.Observations[1].CriticalAlert); // potassium — critical
|
||||
Assert.Null(body.Data.Observations[2].CriticalAlert); // temp — normal
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Open encounter + vitals in one request
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task OpenEncounterWithVitals_CreatesEncounterAndPromotesObservations()
|
||||
{
|
||||
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||
|
||||
var patientId = await CreateTestPatientAsync();
|
||||
|
||||
var request = new OpenEncounterWithVitalsRequest(
|
||||
PatientId: patientId,
|
||||
Department: "Outpatient Clinic",
|
||||
RoomBed: "OPD-3",
|
||||
AdmissionReason: "Follow-up consultation",
|
||||
Observations: new List<LiveCaptureObservationRequest>
|
||||
{
|
||||
new("HEART_RATE", 68m, "bpm", DateTimeOffset.UtcNow, null),
|
||||
new("BP_SYSTOLIC", 120m, "mmHg", DateTimeOffset.UtcNow, null),
|
||||
new("BP_DIASTOLIC", 80m, "mmHg", DateTimeOffset.UtcNow, null)
|
||||
},
|
||||
ClinicianAttestation: true,
|
||||
PasswordConfirm: "password"
|
||||
);
|
||||
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
"/api/v1/live-capture/encounters", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var body = await response.Content
|
||||
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
|
||||
|
||||
Assert.True(body!.Success);
|
||||
Assert.NotEqual(Guid.Empty, body.Data!.EncounterId);
|
||||
Assert.Equal(3, body.Data.Observations.Count);
|
||||
Assert.Equal(0, body.Data.CriticalAlertCount);
|
||||
|
||||
// Verify encounter was created in VigilCareClinical
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var encounter = await db.Encounters.FindAsync(body.Data.EncounterId);
|
||||
Assert.NotNull(encounter);
|
||||
Assert.Equal("active", encounter!.Status);
|
||||
Assert.Equal(Department.OutpatientClinic, encounter.Department);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Authorization: non-clinician role rejected
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RecordObservations_NonClinicianRole_Returns403()
|
||||
{
|
||||
_client = await AuthHelper.LoginAsync(_fixture, "entry1");
|
||||
|
||||
var request = new RecordObservationsRequest(
|
||||
Observations: new List<LiveCaptureObservationRequest>
|
||||
{
|
||||
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null)
|
||||
},
|
||||
ClinicianAttestation: true,
|
||||
PasswordConfirm: "password"
|
||||
);
|
||||
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/live-capture/encounters/{Guid.NewGuid()}/observations", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Attestation: false attestation rejected
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RecordObservations_AttestationFalse_Returns422()
|
||||
{
|
||||
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||
|
||||
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
|
||||
|
||||
var request = new RecordObservationsRequest(
|
||||
Observations: new List<LiveCaptureObservationRequest>
|
||||
{
|
||||
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null)
|
||||
},
|
||||
ClinicianAttestation: false, // Must be true
|
||||
PasswordConfirm: "password"
|
||||
);
|
||||
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
var body = await response.Content
|
||||
.ReadFromJsonAsync<ApiResponse<object>>();
|
||||
Assert.Equal("ATTESTATION_REQUIRED", body!.Error!.Code);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Attestation: wrong password rejected
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RecordObservations_WrongPassword_Returns422()
|
||||
{
|
||||
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||
|
||||
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
|
||||
|
||||
var request = new RecordObservationsRequest(
|
||||
Observations: new List<LiveCaptureObservationRequest>
|
||||
{
|
||||
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null)
|
||||
},
|
||||
ClinicianAttestation: true,
|
||||
PasswordConfirm: "wrong-password" // Incorrect
|
||||
);
|
||||
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
var body = await response.Content
|
||||
.ReadFromJsonAsync<ApiResponse<object>>();
|
||||
Assert.Equal("PASSWORD_CONFIRM_INVALID", body!.Error!.Code);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Edge case: discharged encounter rejected
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RecordObservations_DischargedEncounter_Returns409()
|
||||
{
|
||||
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||
|
||||
var (_, encounterId) = await CreateTestPatientAndDischargedEncounterAsync();
|
||||
|
||||
var request = new RecordObservationsRequest(
|
||||
Observations: new List<LiveCaptureObservationRequest>
|
||||
{
|
||||
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null)
|
||||
},
|
||||
ClinicianAttestation: true,
|
||||
PasswordConfirm: "password"
|
||||
);
|
||||
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
var body = await response.Content
|
||||
.ReadFromJsonAsync<ApiResponse<object>>();
|
||||
Assert.Equal("ENCOUNTER_NOT_ACTIVE", body!.Error!.Code);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Edge case: duplicate active encounter
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task OpenEncounterWithVitals_PatientHasActiveEncounter_Returns409()
|
||||
{
|
||||
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||
|
||||
var (patientId, _) = await CreateTestPatientAndEncounterAsync();
|
||||
|
||||
var request = new OpenEncounterWithVitalsRequest(
|
||||
PatientId: patientId,
|
||||
Department: Department.EmergencyDepartment.ToDbString(),
|
||||
RoomBed: "ER-1",
|
||||
AdmissionReason: "Chest pain",
|
||||
Observations: new List<LiveCaptureObservationRequest>
|
||||
{
|
||||
new("HEART_RATE", 90m, "bpm", DateTimeOffset.UtcNow, null)
|
||||
},
|
||||
ClinicianAttestation: true,
|
||||
PasswordConfirm: "password"
|
||||
);
|
||||
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
"/api/v1/live-capture/encounters", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
var body = await response.Content
|
||||
.ReadFromJsonAsync<ApiResponse<object>>();
|
||||
Assert.Equal("ACTIVE_ENCOUNTER_EXISTS", body!.Error!.Code);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Edge case: empty observations rejected
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RecordObservations_EmptyList_Returns422()
|
||||
{
|
||||
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||
|
||||
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
|
||||
|
||||
var request = new RecordObservationsRequest(
|
||||
Observations: new List<LiveCaptureObservationRequest>(),
|
||||
ClinicianAttestation: true,
|
||||
PasswordConfirm: "password"
|
||||
);
|
||||
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
var body = await response.Content
|
||||
.ReadFromJsonAsync<ApiResponse<object>>();
|
||||
Assert.Equal("EMPTY_OBSERVATIONS", body!.Error!.Code);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Audit trail: verify digitization events written
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RecordObservations_WritesAttestationAndPromotionEvents()
|
||||
{
|
||||
_client = await AuthHelper.LoginAsync(_fixture, "clinician1");
|
||||
|
||||
var (_, encounterId) = await CreateTestPatientAndEncounterAsync();
|
||||
|
||||
var request = new RecordObservationsRequest(
|
||||
Observations: new List<LiveCaptureObservationRequest>
|
||||
{
|
||||
new("HEART_RATE", 72m, "bpm", DateTimeOffset.UtcNow, null)
|
||||
},
|
||||
ClinicianAttestation: true,
|
||||
PasswordConfirm: "password"
|
||||
);
|
||||
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/live-capture/encounters/{encounterId}/observations", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var body = await response.Content
|
||||
.ReadFromJsonAsync<ApiResponse<LiveCaptureResponse>>();
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var events = await db.DigitizationEvents
|
||||
.Where(e => e.BatchId == body!.Data!.BatchId)
|
||||
.OrderBy(e => e.OccurredAt)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Equal(2, events.Count);
|
||||
Assert.Equal(DigitizationEventType.LiveCaptureAttested, events[0].EventType);
|
||||
Assert.Equal(DigitizationEventType.Promoted, events[1].EventType);
|
||||
|
||||
// Verify metadata contains clinician info
|
||||
Assert.Contains("clinicianName", events[0].MetadataJson!);
|
||||
Assert.Contains("LIVE_CAPTURE", events[0].MetadataJson!);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
private async Task<Guid> CreateTestPatientAsync()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var patient = new Patient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Mrn = $"MRN-{Guid.NewGuid():N}"[..12],
|
||||
FullName = "Test Patient",
|
||||
DateOfBirth = new DateOnly(1985, 3, 15),
|
||||
Sex = "M",
|
||||
BloodType = BloodType.OPos,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
db.Patients.Add(patient);
|
||||
await db.SaveChangesAsync();
|
||||
return patient.Id;
|
||||
}
|
||||
|
||||
private async Task<(Guid patientId, Guid encounterId)> CreateTestPatientAndEncounterAsync()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var patient = new Patient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Mrn = $"MRN-{Guid.NewGuid():N}"[..12],
|
||||
FullName = "Test Patient",
|
||||
DateOfBirth = new DateOnly(1985, 3, 15),
|
||||
Sex = "M",
|
||||
BloodType = BloodType.OPos,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var encounter = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PatientId = patient.Id,
|
||||
Department = Department.InternalMedicine,
|
||||
RoomBed = "IM-201A",
|
||||
AdmissionReason = "Observation",
|
||||
Status = "active",
|
||||
AdmissionDate = DateTimeOffset.UtcNow,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
db.Patients.Add(patient);
|
||||
db.Encounters.Add(encounter);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return (patient.Id, encounter.Id);
|
||||
}
|
||||
|
||||
private async Task<(Guid patientId, Guid encounterId)> CreateTestPatientAndDischargedEncounterAsync()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var patient = new Patient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Mrn = $"MRN-{Guid.NewGuid():N}"[..12],
|
||||
FullName = "Discharged Patient",
|
||||
DateOfBirth = new DateOnly(1970, 7, 20),
|
||||
Sex = "F",
|
||||
BloodType = BloodType.ANeg,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var encounter = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PatientId = patient.Id,
|
||||
Department = Department.Surgery,
|
||||
RoomBed = "SURG-105",
|
||||
AdmissionReason = "Post-op recovery",
|
||||
Status = "discharged",
|
||||
AdmissionDate = DateTimeOffset.UtcNow.AddDays(-3),
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-3),
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
db.Patients.Add(patient);
|
||||
db.Encounters.Add(encounter);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return (patient.Id, encounter.Id);
|
||||
}
|
||||
|
||||
private async Task SeedPotassiumThresholdAsync()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
|
||||
// Only seed if not already present
|
||||
var existing = await db.AlertThresholds
|
||||
.FirstOrDefaultAsync(t => t.ObservationCode == "POTASSIUM_MEQ_L");
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
var threshold = 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
|
||||
};
|
||||
|
||||
db.AlertThresholds.Add(threshold);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Pre-warm Redis cache with ThresholdCacheEntry (same shape as LiveCaptureService)
|
||||
var cache = redis.GetDatabase();
|
||||
await cache.StringSetAsync(
|
||||
"threshold:POTASSIUM_MEQ_L",
|
||||
JsonSerializer.Serialize(new ThresholdCacheEntry(
|
||||
threshold.ObservationCode,
|
||||
threshold.CriticalLow,
|
||||
threshold.WarningLow,
|
||||
threshold.WarningHigh,
|
||||
threshold.CriticalHigh)),
|
||||
TimeSpan.FromMinutes(30));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user