feature: Live Capture (Track B)

This commit is contained in:
voltsrage
2026-06-27 04:23:51 +08:00
parent 01000a2489
commit 88e70b3dbe
30 changed files with 4547 additions and 23 deletions
@@ -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));
}
}
}