feature: Draft Data Entry
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
public class DraftEntryTests : IClassFixture<ApiFixture>, IAsyncLifetime
|
||||
{
|
||||
private readonly ApiFixture _fixture;
|
||||
private HttpClient _client = null!;
|
||||
private Guid _batchId;
|
||||
|
||||
public DraftEntryTests(ApiFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await DbResetHelper.ResetAsync(db);
|
||||
|
||||
// Re-seed users — truncation removed them
|
||||
await DataSeeder.SeedAsync(db);
|
||||
|
||||
// Create a batch directly in the database in UPLOADED status.
|
||||
// We skip the MinIO upload path because integration tests focus on
|
||||
// draft entry logic, not document storage (covered in Phase 1 tests).
|
||||
var batch = new DigitizationBatch
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Status = BatchStatus.Uploaded,
|
||||
BatchType = BatchType.VitalsSheet,
|
||||
Track = BatchTrack.Backfill,
|
||||
DocumentRef = "test/scan.pdf",
|
||||
DocumentSha256 = "abc123def456",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.DigitizationBatches.Add(batch);
|
||||
|
||||
// Seed a ScannedDocument to satisfy the foreign key
|
||||
db.ScannedDocuments.Add(new ScannedDocument
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batch.Id,
|
||||
ObjectKey = "test/scan.pdf",
|
||||
Sha256 = "abc123def456",
|
||||
ContentType = "application/pdf",
|
||||
FileSizeBytes = 1024,
|
||||
UploadedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_batchId = batch.Id;
|
||||
|
||||
// Authenticate as entry clerk
|
||||
_client = await AuthHelper.LoginAsync(_fixture, "entry1");
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
// ─── Test 1: Incomplete vitals batch cannot submit ────────────
|
||||
|
||||
[Fact]
|
||||
public async Task IncompleteVitalsBatch_CannotSubmit_Returns422()
|
||||
{
|
||||
// Attempt to submit an empty batch — no patient, no encounter, no observations
|
||||
// First, we need to get it into IN_ENTRY status by saving something
|
||||
await _client.PutAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{_batchId}/draft/patient",
|
||||
new { fullName = "Test Patient" });
|
||||
|
||||
// Now attempt to submit — should fail because:
|
||||
// - Patient missing DOB and sex (but we only need patient for vitals)
|
||||
// - No encounter context
|
||||
// - No observations
|
||||
var submitResp = await _client.PostAsync(
|
||||
$"/api/v1/digitization-batches/{_batchId}/submit-for-verification", null);
|
||||
|
||||
submitResp.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
|
||||
|
||||
var body = await submitResp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
var errorCode = body!.RootElement
|
||||
.GetProperty("error").GetProperty("code").GetString();
|
||||
errorCode.Should().Be("BATCH_INCOMPLETE");
|
||||
|
||||
var errorMsg = body.RootElement
|
||||
.GetProperty("error").GetProperty("message").GetString();
|
||||
errorMsg.Should().Contain("Encounter context is required");
|
||||
errorMsg.Should().Contain("At least one observation");
|
||||
|
||||
// Verify batch is still IN_ENTRY, not PendingVerification
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var batch = await db.DigitizationBatches.FindAsync(_batchId);
|
||||
batch!.Status.Should().Be(BatchStatus.InEntry);
|
||||
}
|
||||
|
||||
// ─── Test 2: Plausible observations save successfully ────────
|
||||
|
||||
[Fact]
|
||||
public async Task PlausibleObservation_SavesSuccessfully_Returns201()
|
||||
{
|
||||
// Heart rate 78 bpm is well within [1-300] plausible range
|
||||
var resp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{_batchId}/draft/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 78,
|
||||
unit = "bpm",
|
||||
recordedAt = DateTimeOffset.UtcNow,
|
||||
note = "Resting heart rate from chart"
|
||||
});
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
body!.RootElement.GetProperty("data").GetProperty("observationCode").GetString()
|
||||
.Should().Be("HEART_RATE");
|
||||
body.RootElement.GetProperty("data").GetProperty("value").GetDecimal()
|
||||
.Should().Be(78);
|
||||
|
||||
// Verify the observation was persisted
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var obsCount = await db.DraftObservations.CountAsync(o => o.BatchId == _batchId);
|
||||
obsCount.Should().Be(1);
|
||||
|
||||
// Verify batch transitioned to IN_ENTRY
|
||||
var batch = await db.DigitizationBatches.FindAsync(_batchId);
|
||||
batch!.Status.Should().Be(BatchStatus.InEntry);
|
||||
|
||||
// Verify entry_started event was logged
|
||||
var events = await db.DigitizationEvents
|
||||
.Where(e => e.BatchId == _batchId && e.EventType == DigitizationEventType.EntryStarted)
|
||||
.ToListAsync();
|
||||
events.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
// ─── Test 3: Implausible observations are rejected ───────────
|
||||
|
||||
[Fact]
|
||||
public async Task ImplausibleObservation_Returns422_NoRowWritten()
|
||||
{
|
||||
// Heart rate 350 bpm is above the plausibility ceiling of 300
|
||||
var resp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{_batchId}/draft/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 350,
|
||||
unit = "bpm",
|
||||
recordedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
|
||||
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
body!.RootElement.GetProperty("error").GetProperty("code").GetString()
|
||||
.Should().Be("OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");
|
||||
|
||||
// Verify no observation was persisted
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var obsCount = await db.DraftObservations.CountAsync(o => o.BatchId == _batchId);
|
||||
obsCount.Should().Be(0, "implausible observations must not be persisted");
|
||||
}
|
||||
|
||||
// ─── Test 4: Full draft lifecycle — entry through submit ─────
|
||||
|
||||
[Fact]
|
||||
public async Task FullDraftLifecycle_EntryThroughSubmit()
|
||||
{
|
||||
// 1. Upsert patient
|
||||
var patientResp = await _client.PutAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{_batchId}/draft/patient",
|
||||
new
|
||||
{
|
||||
fullName = "Chen Wei-Lin",
|
||||
dateOfBirth = "1985-03-15",
|
||||
sex = "M",
|
||||
bloodType = "O+",
|
||||
noKnownAllergies = true
|
||||
});
|
||||
patientResp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
// 2. Upsert encounter
|
||||
var encounterResp = await _client.PutAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{_batchId}/draft/encounter",
|
||||
new
|
||||
{
|
||||
admissionDate = DateTimeOffset.UtcNow.AddDays(-2),
|
||||
department = "ICU",
|
||||
roomBed = "ICU-3B",
|
||||
admissionReason = "Chest pain"
|
||||
});
|
||||
encounterResp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
// 3. Add plausible observations
|
||||
var obs1Resp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{_batchId}/draft/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 92,
|
||||
unit = "bpm",
|
||||
recordedAt = DateTimeOffset.UtcNow.AddHours(-1)
|
||||
});
|
||||
obs1Resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
var obs2Resp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{_batchId}/draft/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "BP_SYSTOLIC",
|
||||
value = 138,
|
||||
unit = "mmHg",
|
||||
recordedAt = DateTimeOffset.UtcNow.AddHours(-1)
|
||||
});
|
||||
obs2Resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
// 4. Verify GET /draft returns all data
|
||||
var draftResp = await _client.GetAsync(
|
||||
$"/api/v1/digitization-batches/{_batchId}/draft");
|
||||
draftResp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var draftBody = await draftResp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
draftBody!.RootElement.GetProperty("data").GetProperty("patient")
|
||||
.GetProperty("fullName").GetString().Should().Be("Chen Wei-Lin");
|
||||
draftBody.RootElement.GetProperty("data").GetProperty("encounter")
|
||||
.GetProperty("department").GetString().Should().Be("ICU");
|
||||
draftBody.RootElement.GetProperty("data").GetProperty("observations")
|
||||
.GetArrayLength().Should().Be(2);
|
||||
|
||||
// 5. Submit for verification — should succeed
|
||||
var submitResp = await _client.PostAsync(
|
||||
$"/api/v1/digitization-batches/{_batchId}/submit-for-verification", null);
|
||||
submitResp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
// 6. Verify batch is now PENDING_VERIFICATION
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var batch = await db.DigitizationBatches.FindAsync(_batchId);
|
||||
batch!.Status.Should().Be(BatchStatus.PendingVerification);
|
||||
|
||||
// 7. Verify submitted_for_verification event was logged
|
||||
var submitEvent = await db.DigitizationEvents
|
||||
.FirstOrDefaultAsync(e =>
|
||||
e.BatchId == _batchId &&
|
||||
e.EventType == DigitizationEventType.SubmittedForVerification);
|
||||
submitEvent.Should().NotBeNull();
|
||||
}
|
||||
|
||||
// ─── Test 5: Observation CRUD — add, update, delete ──────────
|
||||
|
||||
[Fact]
|
||||
public async Task ObservationCrud_AddUpdateDelete()
|
||||
{
|
||||
// Add
|
||||
var addResp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{_batchId}/draft/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "TEMP_C",
|
||||
value = 37.2,
|
||||
unit = "C",
|
||||
recordedAt = DateTimeOffset.UtcNow,
|
||||
note = "Oral temperature"
|
||||
});
|
||||
addResp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
var addBody = await addResp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
var obsId = addBody!.RootElement.GetProperty("data").GetProperty("id").GetGuid();
|
||||
|
||||
// Update — correct the value (clerk misread the chart)
|
||||
var updateResp = await _client.PutAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{_batchId}/draft/observations/{obsId}",
|
||||
new
|
||||
{
|
||||
observationCode = "TEMP_C",
|
||||
value = 38.1,
|
||||
unit = "C",
|
||||
recordedAt = DateTimeOffset.UtcNow,
|
||||
note = "Corrected — misread decimal"
|
||||
});
|
||||
updateResp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var updateBody = await updateResp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
updateBody!.RootElement.GetProperty("data").GetProperty("value").GetDecimal()
|
||||
.Should().Be(38.1m);
|
||||
updateBody.RootElement.GetProperty("data").GetProperty("note").GetString()
|
||||
.Should().Contain("Corrected");
|
||||
|
||||
// Delete
|
||||
var deleteResp = await _client.DeleteAsync(
|
||||
$"/api/v1/digitization-batches/{_batchId}/draft/observations/{obsId}");
|
||||
deleteResp.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
|
||||
// Verify observation is gone
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var obsCount = await db.DraftObservations.CountAsync(o => o.BatchId == _batchId);
|
||||
obsCount.Should().Be(0);
|
||||
}
|
||||
|
||||
// ─── Test 6: Rejected batch re-entry ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RejectedBatch_CanReEnter_TransitionsToInEntry()
|
||||
{
|
||||
// Manually set batch to REJECTED status (simulating a verifier rejection)
|
||||
using (var scope = _fixture.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var batch = await db.DigitizationBatches.FindAsync(_batchId);
|
||||
batch!.Status = BatchStatus.Rejected;
|
||||
batch.RejectionReason = "Missing encounter details";
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Data entry clerk corrects the entry — should transition from REJECTED to IN_ENTRY
|
||||
var resp = await _client.PutAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{_batchId}/draft/encounter",
|
||||
new
|
||||
{
|
||||
admissionDate = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
department = "Emergency Department",
|
||||
roomBed = "ER-7"
|
||||
});
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
// Verify batch is now IN_ENTRY (transitioned from REJECTED)
|
||||
using var verifyScope = _fixture.Services.CreateScope();
|
||||
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var verifyBatch = await verifyDb.DigitizationBatches.FindAsync(_batchId);
|
||||
verifyBatch!.Status.Should().Be(BatchStatus.InEntry);
|
||||
|
||||
// Verify the entry_started event recorded the previous status
|
||||
var evt = await verifyDb.DigitizationEvents
|
||||
.Where(e => e.BatchId == _batchId && e.EventType == DigitizationEventType.EntryStarted)
|
||||
.OrderByDescending(e => e.OccurredAt)
|
||||
.FirstOrDefaultAsync();
|
||||
evt.Should().NotBeNull();
|
||||
evt!.MetadataJson.Should().Contain("REJECTED");
|
||||
}
|
||||
|
||||
// ─── Test 7: Data entry blocked on verified batch ────────────
|
||||
|
||||
[Fact]
|
||||
public async Task VerifiedBatch_DataEntryBlocked_Returns409()
|
||||
{
|
||||
// Manually set batch to VERIFIED status
|
||||
using (var scope = _fixture.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var batch = await db.DigitizationBatches.FindAsync(_batchId);
|
||||
batch!.Status = BatchStatus.Verified;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Attempt to add an observation — should be blocked
|
||||
var resp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/digitization-batches/{_batchId}/draft/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 80,
|
||||
unit = "bpm",
|
||||
recordedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
body!.RootElement.GetProperty("error").GetProperty("code").GetString()
|
||||
.Should().Be("ENTRY_NOT_ALLOWED");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user