feature: Corrections and Supersession
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for correction supersession via the full HTTP pipeline.
|
||||
/// </summary>
|
||||
[Collection("Database")]
|
||||
public class CorrectionSupersessionTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ApiFixture _fixture;
|
||||
|
||||
public CorrectionSupersessionTests(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);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task WrongPotassium_CorrectionBatch_SupersedesOriginal()
|
||||
{
|
||||
// Act 1: promote original batch with wrong potassium (3.5)
|
||||
var (originalBatchId, patientId) =
|
||||
await CorrectionPipelineHelper.PromoteLabBatchAsync(_fixture, potassiumValue: 3.5m);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var liveObsAfterOriginal = await db.LiveObservations
|
||||
.Where(o => o.SourceBatchId == originalBatchId)
|
||||
.ToListAsync();
|
||||
|
||||
liveObsAfterOriginal.Should().HaveCount(2);
|
||||
liveObsAfterOriginal.First(o => o.ObservationCode == "K").Value.Should().Be(3.5m);
|
||||
|
||||
// Act 2: promote correction batch with correct potassium (5.3)
|
||||
var correctionBatchId = await CorrectionPipelineHelper.PromoteCorrectionBatchAsync(
|
||||
_fixture, originalBatchId, patientId, potassiumValue: 5.3m);
|
||||
|
||||
db.ChangeTracker.Clear();
|
||||
|
||||
// Assert: original observations superseded, correction observations live
|
||||
var originalObs = await db.LiveObservations
|
||||
.Where(o => o.SourceBatchId == originalBatchId)
|
||||
.ToListAsync();
|
||||
originalObs.Should().AllSatisfy(o =>
|
||||
{
|
||||
o.IsSuperseded.Should().BeTrue();
|
||||
o.SupersededByBatchId.Should().Be(correctionBatchId);
|
||||
});
|
||||
originalObs.First(o => o.ObservationCode == "K").Value.Should().Be(3.5m,
|
||||
"original erroneous value is preserved for audit");
|
||||
|
||||
var correctionObs = await db.LiveObservations
|
||||
.Where(o => o.SourceBatchId == correctionBatchId && !o.IsSuperseded)
|
||||
.ToListAsync();
|
||||
correctionObs.Should().HaveCount(2);
|
||||
correctionObs.First(o => o.ObservationCode == "K").Value.Should().Be(5.3m);
|
||||
|
||||
var allObs = await db.LiveObservations.Where(o => o.PatientId == patientId).ToListAsync();
|
||||
allObs.Should().HaveCount(4, "2 original (superseded) + 2 correction (active)");
|
||||
|
||||
var originalEvents = await db.DigitizationEvents
|
||||
.Where(e => e.BatchId == originalBatchId)
|
||||
.ToListAsync();
|
||||
originalEvents.Should().Contain(e => e.EventType == DigitizationEventType.Superseded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CannotSupersede_NonPromotedBatch()
|
||||
{
|
||||
// Arrange: batch still in PendingVerification
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var entry1Id = await BatchSeedHelper.UserIdAsync(db, "entry1");
|
||||
var pendingBatch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync(db, entry1Id);
|
||||
|
||||
// Act: attempt correction upload against non-promoted batch
|
||||
var client = await AuthHelper.LoginAsync(_fixture, "intake1");
|
||||
var fileContent = new ByteArrayContent([0x25, 0x50, 0x44, 0x46]);
|
||||
fileContent.Headers.ContentType =
|
||||
new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
var formData = new MultipartFormDataContent
|
||||
{
|
||||
{ fileContent, "file", "test.pdf" },
|
||||
{ new StringContent("LAB_RESULTS"), "batchType" },
|
||||
{ new StringContent("BACKFILL"), "track" },
|
||||
{ new StringContent(pendingBatch.Id.ToString()), "supersedesBatchId" }
|
||||
};
|
||||
|
||||
var response = await client.PostAsync("/api/v1/digitization-batches", formData);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
body.Should().Contain("Only promoted batches can be superseded");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CannotSupersede_AlreadySupersededBatch()
|
||||
{
|
||||
// Arrange: promote original + one correction (supersedes original)
|
||||
var (originalBatchId, patientId) =
|
||||
await CorrectionPipelineHelper.PromoteLabBatchAsync(_fixture, potassiumValue: 100m);
|
||||
await CorrectionPipelineHelper.PromoteCorrectionBatchAsync(
|
||||
_fixture, originalBatchId, patientId, potassiumValue: 102m);
|
||||
|
||||
// Act: attempt second correction against the already-superseded original
|
||||
var client = await AuthHelper.LoginAsync(_fixture, "intake1");
|
||||
var fileContent = new ByteArrayContent([0x25, 0x50, 0x44, 0x46]);
|
||||
fileContent.Headers.ContentType =
|
||||
new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
var formData = new MultipartFormDataContent
|
||||
{
|
||||
{ fileContent, "file", "test2.pdf" },
|
||||
{ new StringContent("LAB_RESULTS"), "batchType" },
|
||||
{ new StringContent("BACKFILL"), "track" },
|
||||
{ new StringContent(patientId.ToString()), "patientId" },
|
||||
{ new StringContent(originalBatchId.ToString()), "supersedesBatchId" }
|
||||
};
|
||||
|
||||
var response = await client.PostAsync("/api/v1/digitization-batches", formData);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
body.Should().Contain("already been superseded");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatientDigitizationHistory_ShowsFullCorrectionChain()
|
||||
{
|
||||
// Arrange: promote original + correction
|
||||
var (originalBatchId, patientId) =
|
||||
await CorrectionPipelineHelper.PromoteLabBatchAsync(_fixture, potassiumValue: 3.5m);
|
||||
var correctionBatchId = await CorrectionPipelineHelper.PromoteCorrectionBatchAsync(
|
||||
_fixture, originalBatchId, patientId, potassiumValue: 5.3m);
|
||||
|
||||
// Act
|
||||
var client = await AuthHelper.LoginAsync(_fixture, "admin1");
|
||||
var response = await client.GetAsync(
|
||||
$"/api/v1/patients/{patientId}/digitization-history");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var result = await response.Content
|
||||
.ReadFromJsonAsync<ApiResponse<PatientDigitizationHistoryResponse>>();
|
||||
|
||||
result!.Data!.TotalBatches.Should().Be(2);
|
||||
result.Data.SupersededBatches.Should().Be(1);
|
||||
|
||||
var originalEntry = result.Data.Entries.First(e => e.BatchId == originalBatchId);
|
||||
originalEntry.HasBeenSuperseded.Should().BeTrue();
|
||||
originalEntry.SupersededByBatchId.Should().Be(correctionBatchId);
|
||||
|
||||
var correctionEntry = result.Data.Entries.First(e => e.BatchId == correctionBatchId);
|
||||
correctionEntry.IsCorrection.Should().BeTrue();
|
||||
correctionEntry.SupersedesBatchId.Should().Be(originalBatchId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatientHistory_ReturnsNotFound_ForUnknownPatient()
|
||||
{
|
||||
var client = await AuthHelper.LoginAsync(_fixture, "admin1");
|
||||
var response = await client.GetAsync(
|
||||
$"/api/v1/patients/{Guid.NewGuid()}/digitization-history");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Drives lab-result batches with potassium observations through the HTTP pipeline.
|
||||
/// Uses DataSeeder usernames (intake1, entry1, verifier1, approver1).
|
||||
/// </summary>
|
||||
public static class CorrectionPipelineHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a LAB_RESULTS batch with K and Na draft observations, drives it through
|
||||
/// verify → approve, and returns the promoted batch ID and live patient ID.
|
||||
/// </summary>
|
||||
public static async Task<(Guid BatchId, Guid PatientId)> PromoteLabBatchAsync(
|
||||
ApiFixture fixture,
|
||||
decimal potassiumValue,
|
||||
decimal sodiumValue = 140m)
|
||||
{
|
||||
var batchId = await CreateLabBatchThroughVerificationAsync(fixture, potassiumValue, sodiumValue);
|
||||
|
||||
var client = await AuthHelper.LoginAsync(fixture, "approver1");
|
||||
client.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString());
|
||||
|
||||
var approveResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{batchId}/approve",
|
||||
new { enableRetroactiveAlerts = false });
|
||||
approveResponse.EnsureSuccessStatusCode();
|
||||
|
||||
using var scope = fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var batch = await db.DigitizationBatches.AsNoTracking().FirstAsync(b => b.Id == batchId);
|
||||
|
||||
return (batchId, batch.PatientId!.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a correction batch linked to a promoted batch, drives entry → verify → approve.
|
||||
/// </summary>
|
||||
public static async Task<Guid> PromoteCorrectionBatchAsync(
|
||||
ApiFixture fixture,
|
||||
Guid supersedesBatchId,
|
||||
Guid patientId,
|
||||
decimal potassiumValue,
|
||||
decimal sodiumValue = 140m)
|
||||
{
|
||||
var client = await AuthHelper.LoginAsync(fixture, "intake1");
|
||||
|
||||
var fileContent = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes("%PDF-correction"));
|
||||
fileContent.Headers.ContentType =
|
||||
new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
|
||||
var formData = new MultipartFormDataContent
|
||||
{
|
||||
{ fileContent, "file", $"correction-{Guid.NewGuid()}.pdf" },
|
||||
{ new StringContent("LAB_RESULTS"), "batchType" },
|
||||
{ new StringContent("BACKFILL"), "track" },
|
||||
{ new StringContent(patientId.ToString()), "patientId" },
|
||||
{ new StringContent(supersedesBatchId.ToString()), "supersedesBatchId" }
|
||||
};
|
||||
|
||||
var uploadResponse = await client.PostAsync("/api/v1/digitization-batches", formData);
|
||||
uploadResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var uploadBody = await uploadResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var correctionBatchId = uploadBody.GetProperty("data").GetProperty("id").GetGuid();
|
||||
|
||||
// Data entry — correction re-enters all observations
|
||||
client = await AuthHelper.LoginAsync(fixture, "entry2");
|
||||
await client.PostAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{correctionBatchId}/draft/observations",
|
||||
new { observationCode = "K", value = potassiumValue, unit = "mmol/L",
|
||||
recordedAt = DateTimeOffset.UtcNow });
|
||||
await client.PostAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{correctionBatchId}/draft/observations",
|
||||
new { observationCode = "Na", value = sodiumValue, unit = "mmol/L",
|
||||
recordedAt = DateTimeOffset.UtcNow });
|
||||
var submitResponse = await client.PostAsync(
|
||||
$"/api/v1/digitization-batches/{correctionBatchId}/submit-for-verification", null);
|
||||
submitResponse.EnsureSuccessStatusCode();
|
||||
|
||||
// Verify and approve
|
||||
client = await AuthHelper.LoginAsync(fixture, "verifier2");
|
||||
var verifyResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{correctionBatchId}/verify",
|
||||
new { fieldChecks = new[] { new { fieldName = "observation.K", status = "ok", note = (string?)null } }, passed = true });
|
||||
verifyResponse.EnsureSuccessStatusCode();
|
||||
|
||||
client = await AuthHelper.LoginAsync(fixture, "approver2");
|
||||
client.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString());
|
||||
var approveResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{correctionBatchId}/approve",
|
||||
new { enableRetroactiveAlerts = false });
|
||||
approveResponse.EnsureSuccessStatusCode();
|
||||
|
||||
return correctionBatchId;
|
||||
}
|
||||
|
||||
private static async Task<Guid> CreateLabBatchThroughVerificationAsync(
|
||||
ApiFixture fixture, decimal potassiumValue, decimal sodiumValue)
|
||||
{
|
||||
var client = await AuthHelper.LoginAsync(fixture, "intake1");
|
||||
|
||||
var fileContent = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes("%PDF-lab"));
|
||||
fileContent.Headers.ContentType =
|
||||
new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
|
||||
var formData = new MultipartFormDataContent
|
||||
{
|
||||
{ fileContent, "file", $"lab-{Guid.NewGuid()}.pdf" },
|
||||
{ new StringContent("LAB_RESULTS"), "batchType" },
|
||||
{ new StringContent("BACKFILL"), "track" }
|
||||
};
|
||||
|
||||
var uploadResponse = await client.PostAsync("/api/v1/digitization-batches", formData);
|
||||
uploadResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var uploadBody = await uploadResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var batchId = uploadBody.GetProperty("data").GetProperty("id").GetGuid();
|
||||
|
||||
client = await AuthHelper.LoginAsync(fixture, "entry1");
|
||||
await client.PutAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{batchId}/draft/patient",
|
||||
new { fullName = "Correction Test Patient", dateOfBirth = "1980-01-15", sex = "Female" });
|
||||
await client.PutAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{batchId}/draft/encounter",
|
||||
new { admissionDate = "2024-06-01T08:00:00Z", department = Department.InternalMedicine.ToDbString(),
|
||||
roomBed = "4A-12", admissionReason = "Electrolyte panel", status = "active" });
|
||||
await client.PostAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{batchId}/draft/observations",
|
||||
new { observationCode = "K", value = potassiumValue, unit = "mmol/L",
|
||||
recordedAt = DateTimeOffset.UtcNow });
|
||||
await client.PostAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{batchId}/draft/observations",
|
||||
new { observationCode = "Na", value = sodiumValue, unit = "mmol/L",
|
||||
recordedAt = DateTimeOffset.UtcNow });
|
||||
await client.PostAsync(
|
||||
$"/api/v1/digitization-batches/{batchId}/submit-for-verification", null);
|
||||
|
||||
client = await AuthHelper.LoginAsync(fixture, "verifier1");
|
||||
await client.PostAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{batchId}/verify",
|
||||
new { fieldChecks = new[] { new { fieldName = "observation.K", status = "ok", note = (string?)null } }, passed = true });
|
||||
|
||||
return batchId;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ public static class DbResetHelper
|
||||
public static async Task ResetAsync(AppDbContext db)
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(@"
|
||||
TRUNCATE TABLE clinical.outbox_events, clinical.observations,
|
||||
TRUNCATE TABLE live_observations, live_encounters,
|
||||
clinical.outbox_events, clinical.observations,
|
||||
clinical.encounters, clinical.patients,
|
||||
idempotency_records,
|
||||
digitization_events, draft_observations,
|
||||
|
||||
Reference in New Issue
Block a user