383 lines
15 KiB
C#
383 lines
15 KiB
C#
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");
|
|
}
|
|
} |