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, 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); body!.RootElement.GetProperty("error").GetProperty("code").GetString() .Should().Be("ENTRY_NOT_ALLOWED"); } }