Files
vigilcare-records/VigilCareRecordsAPI.Tests/DraftEntryTests.cs
T

384 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;
[Collection("Database")]
public class DraftEntryTests : 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");
}
}