using System.Net; using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; /// /// Integration tests for Phase 3: verification, rejection, separation of duties, /// and work queues. Uses ApiFixture with real PostgreSQL and Redis from Phase 2. /// [Collection("Database")] public class VerificationTests : IAsyncLifetime { private readonly ApiFixture _fixture; private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; public VerificationTests(ApiFixture fixture) => _fixture = fixture; public async Task InitializeAsync() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await DbResetHelper.ResetAsync(db); await DataSeeder.SeedAsync(db); } public Task DisposeAsync() => Task.CompletedTask; // --------------------------------------------------------------- // Separation of duties // --------------------------------------------------------------- /// /// Test: entry1 entered the batch. verifier1 attempts to verify a batch they /// also entered (EnteredByUserId = verifier1). Returns 409 SEPARATION_OF_DUTIES_VIOLATION. /// [Fact] public async Task Verify_SameUserAsEntryClerk_Returns409SeparationOfDuties() { // Arrange: batch entered by verifier1 — same user will attempt verification using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var verifierId = await BatchSeedHelper.UserIdAsync(db, "verifier1"); var batch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( db, verifierId); // Act: verifier1 tries to verify their own batch var client = await AuthHelper.LoginAsync(_fixture, "verifier1"); var request = new VerifyBatchRequest( new List { new("patient.fullName", "ok", null), new("observation.heartRate", "ok", null) }, Passed: true ); var response = await client.PostAsJsonAsync( $"/api/v1/digitization-batches/{batch.Id}/verify", request); // Assert response.StatusCode.Should().Be(HttpStatusCode.Conflict); var body = await response.Content.ReadAsStringAsync(); body.Should().Contain("SEPARATION_OF_DUTIES_VIOLATION"); } /// /// Test: Entry clerk A submits a batch. Verifier B (a different person) verifies it. /// The system accepts the verification because the verifier is not the same user /// who entered the data. /// /// Uses a VitalsSheet batch type which requires clinical approval per site config, /// so the expected target status is AwaitingClinicalApproval. /// [Fact] public async Task Verify_DifferentUserFromEntryClerk_Succeeds() { // Arrange using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var batch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( db, (await BatchSeedHelper.UserIdAsync(db, "entry1")), BatchType.VitalsSheet); // Act: VerifierB verifies the batch (different user from EntryClerkA) var client = await AuthHelper.LoginAsync(_fixture, "verifier2"); var request = new VerifyBatchRequest( new List { new("patient.fullName", "ok", null), new("observation.heartRate", "ok", "Within normal range"), new("observation.bloodPressureSystolic", "warning", "Slightly elevated but plausible") }, Passed: true ); var response = await client.PostAsJsonAsync( $"/api/v1/digitization-batches/{batch.Id}/verify", request); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); // Reload batch from database to verify status transition var updatedBatch = await db.DigitizationBatches .AsNoTracking() .FirstAsync(b => b.Id == batch.Id); // VitalsSheet requires clinical approval per site config updatedBatch.Status.Should().Be(BatchStatus.AwaitingClinicalApproval); updatedBatch.VerifiedByUserId.Should().Be((await BatchSeedHelper.UserIdAsync(db, "verifier2"))); // Verify event was written var events = await db.DigitizationEvents .Where(e => e.BatchId == batch.Id && e.EventType == DigitizationEventType.VerifiedPendingClinical) .ToListAsync(); events.Should().HaveCount(1); events[0].ActorUserId.Should().Be((await BatchSeedHelper.UserIdAsync(db, "verifier2"))); events[0].MetadataJson.Should().Contain("fieldChecks"); } /// /// Test: Verify a PatientRegistration batch (clinical approval NOT required). /// The batch should transition directly to Verified, not AwaitingClinicalApproval. /// [Fact] public async Task Verify_NoClinicalApprovalRequired_TransitionsToVerified() { // Arrange: PatientRegistration does NOT require clinical approval using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var batch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( db, (await BatchSeedHelper.UserIdAsync(db, "entry1")), BatchType.PatientRegistration); // Act var client = await AuthHelper.LoginAsync(_fixture, "verifier2"); var request = new VerifyBatchRequest( new List { new("patient.fullName", "ok", null), new("patient.dateOfBirth", "ok", null) }, Passed: true ); var response = await client.PostAsJsonAsync( $"/api/v1/digitization-batches/{batch.Id}/verify", request); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var updatedBatch = await db.DigitizationBatches .AsNoTracking() .FirstAsync(b => b.Id == batch.Id); updatedBatch.Status.Should().Be(BatchStatus.Verified); var events = await db.DigitizationEvents .Where(e => e.BatchId == batch.Id && e.EventType == DigitizationEventType.Verified) .ToListAsync(); events.Should().HaveCount(1); } // --------------------------------------------------------------- // Rejection // --------------------------------------------------------------- /// /// Test: Verifier B rejects a batch with a reason. The batch transitions to /// Rejected, the rejection reason is stored, and the batch appears in the /// entry work queue for re-entry. /// [Fact] public async Task Reject_WithValidReason_TransitionsToRejectedAndAppearsInEntryQueue() { // Arrange using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var batch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( db, (await BatchSeedHelper.UserIdAsync(db, "entry1"))); // Act: VerifierB rejects var client = await AuthHelper.LoginAsync(_fixture, "verifier2"); var request = new RejectBatchRequest( "Patient name does not match the scanned registration form. " + "Please re-enter the patient demographics from the chart."); var response = await client.PostAsJsonAsync( $"/api/v1/digitization-batches/{batch.Id}/reject", request); // Assert — rejection accepted response.StatusCode.Should().Be(HttpStatusCode.OK); var updatedBatch = await db.DigitizationBatches .AsNoTracking() .FirstAsync(b => b.Id == batch.Id); updatedBatch.Status.Should().Be(BatchStatus.Rejected); updatedBatch.RejectionReason.Should().Contain("Patient name does not match"); // Assert — event written var events = await db.DigitizationEvents .Where(e => e.BatchId == batch.Id && e.EventType == DigitizationEventType.Rejected) .ToListAsync(); events.Should().HaveCount(1); events[0].MetadataJson.Should().Contain("PENDING_VERIFICATION"); // Assert — batch appears in entry work queue var entryClient = await AuthHelper.LoginAsync(_fixture, "entry1"); var queueResponse = await entryClient.GetAsync("/api/v1/work-queue/entry"); queueResponse.StatusCode.Should().Be(HttpStatusCode.OK); var queueBody = await queueResponse.Content.ReadAsStringAsync(); queueBody.Should().Contain(batch.Id.ToString()); } /// /// Test: Rejection without a reason returns 422. /// [Fact] public async Task Reject_WithoutReason_Returns422() { // Arrange using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var batch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( db, (await BatchSeedHelper.UserIdAsync(db, "entry1"))); // Act var client = await AuthHelper.LoginAsync(_fixture, "verifier2"); var request = new RejectBatchRequest(""); var response = await client.PostAsJsonAsync( $"/api/v1/digitization-batches/{batch.Id}/reject", request); // Assert response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); var body = await response.Content.ReadAsStringAsync(); body.Should().Contain("REJECTION_REASON_REQUIRED"); } /// /// Test: Rejection with a reason shorter than 10 characters returns 422. /// [Fact] public async Task Reject_WithShortReason_Returns422() { // Arrange using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var batch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( db, (await BatchSeedHelper.UserIdAsync(db, "entry1"))); // Act var client = await AuthHelper.LoginAsync(_fixture, "verifier2"); var request = new RejectBatchRequest("Bad data"); var response = await client.PostAsJsonAsync( $"/api/v1/digitization-batches/{batch.Id}/reject", request); // Assert response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); var body = await response.Content.ReadAsStringAsync(); body.Should().Contain("REJECTION_REASON_TOO_SHORT"); } // --------------------------------------------------------------- // Rejection loop: reject → re-enter → re-submit → verify // --------------------------------------------------------------- /// /// Test: Full rejection loop. /// 1. Batch is in PendingVerification (entered by EntryClerkA). /// 2. VerifierB rejects it with a reason. /// 3. Batch status → Rejected, appears in entry queue. /// 4. EntryClerkA or EntryClerkB picks it up (status → InEntry, simulated). /// 5. Re-submits (status → PendingVerification, simulated). /// 6. VerifierB verifies it (should succeed this time). /// /// Steps 4-5 are simulated by directly updating the database since the /// re-entry and re-submission endpoints are Phase 2 code. /// [Fact] public async Task RejectionLoop_RejectThenReenterThenVerify_Succeeds() { // Arrange: batch in PendingVerification entered by EntryClerkA using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var batch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( db, (await BatchSeedHelper.UserIdAsync(db, "entry1")), BatchType.PatientRegistration); var verifierClient = await AuthHelper.LoginAsync(_fixture, "verifier2"); // Step 1: VerifierB rejects the batch var rejectRequest = new RejectBatchRequest( "Date of birth is clearly wrong — year 1899 is not plausible for a current patient."); var rejectResponse = await verifierClient.PostAsJsonAsync( $"/api/v1/digitization-batches/{batch.Id}/reject", rejectRequest); rejectResponse.StatusCode.Should().Be(HttpStatusCode.OK); // Verify it's now Rejected var rejectedBatch = await db.DigitizationBatches .AsNoTracking() .FirstAsync(b => b.Id == batch.Id); rejectedBatch.Status.Should().Be(BatchStatus.Rejected); // Step 2: Verify it appears in the entry queue var entryClient = await AuthHelper.LoginAsync(_fixture, "entry2"); var entryQueueResponse = await entryClient.GetAsync("/api/v1/work-queue/entry"); var entryQueueBody = await entryQueueResponse.Content.ReadAsStringAsync(); entryQueueBody.Should().Contain(batch.Id.ToString()); // Step 3: Simulate re-entry by EntryClerkB (direct DB update) // In production, this would go through the Phase 2 draft entry endpoints var trackedBatch = await db.DigitizationBatches.FindAsync(batch.Id); trackedBatch!.Status = BatchStatus.InEntry; trackedBatch.EnteredByUserId = (await BatchSeedHelper.UserIdAsync(db, "entry2")); // Different clerk re-enters trackedBatch.RejectionReason = null; trackedBatch.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); // Step 4: Simulate re-submission (status → PendingVerification) trackedBatch.Status = BatchStatus.PendingVerification; trackedBatch.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); // Step 5: VerifierB verifies the corrected batch — should succeed // Note: EntryClerkB did the re-entry, so VerifierB (different user) can verify var verifyRequest = new VerifyBatchRequest( new List { new("patient.fullName", "ok", null), new("patient.dateOfBirth", "ok", "Corrected to 1989") }, Passed: true ); var verifyResponse = await verifierClient.PostAsJsonAsync( $"/api/v1/digitization-batches/{batch.Id}/verify", verifyRequest); verifyResponse.StatusCode.Should().Be(HttpStatusCode.OK); // Final assertion: batch is now Verified (PatientRegistration doesn't need clinical) var finalBatch = await db.DigitizationBatches .AsNoTracking() .FirstAsync(b => b.Id == batch.Id); finalBatch.Status.Should().Be(BatchStatus.Verified); finalBatch.VerifiedByUserId.Should().Be((await BatchSeedHelper.UserIdAsync(db, "verifier2"))); // Verify the full event trail var allEvents = await db.DigitizationEvents .Where(e => e.BatchId == batch.Id) .OrderBy(e => e.OccurredAt) .ToListAsync(); // submitted_for_verification (seed), rejected, verified allEvents.Should().HaveCountGreaterThanOrEqualTo(3); allEvents.Last().EventType.Should().Be(DigitizationEventType.Verified); } // --------------------------------------------------------------- // Work queue tests // --------------------------------------------------------------- /// /// Test: Verification queue returns only PendingVerification batches, /// sorted by UpdatedAt ASC (oldest first). /// [Fact] public async Task VerificationQueue_ReturnsPendingVerificationBatches_SortedByAge() { // Arrange: seed two batches at different times using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var olderBatch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( db, (await BatchSeedHelper.UserIdAsync(db, "entry1"))); // Make the second batch newer by updating its timestamp await Task.Delay(100); // Ensure different timestamps var newerBatch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( db, (await BatchSeedHelper.UserIdAsync(db, "entry2"))); // Also seed a rejected batch — should NOT appear in verification queue await BatchSeedHelper.SeedBatchInRejectedAsync(db, (await BatchSeedHelper.UserIdAsync(db, "entry1"))); // Act var client = await AuthHelper.LoginAsync(_fixture, "verifier1"); var response = await client.GetAsync("/api/v1/work-queue/verification"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var body = await response.Content.ReadAsStringAsync(); var json = JsonDocument.Parse(body); var items = json.RootElement .GetProperty("data") .GetProperty("items"); items.GetArrayLength().Should().Be(2); // Only PendingVerification batches // First item should be the older batch (FIFO) var firstBatchId = items[0].GetProperty("batchId").GetString(); firstBatchId.Should().Be(olderBatch.Id.ToString()); } /// /// Test: Entry queue returns Uploaded, InEntry, and Rejected batches. /// [Fact] public async Task EntryQueue_ReturnsUploadedInEntryAndRejectedBatches() { // Arrange using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); // Seed a rejected batch (should appear in entry queue) var rejectedBatch = await BatchSeedHelper.SeedBatchInRejectedAsync( db, (await BatchSeedHelper.UserIdAsync(db, "entry1"))); // Seed a PendingVerification batch (should NOT appear in entry queue) await BatchSeedHelper.SeedBatchInPendingVerificationAsync( db, (await BatchSeedHelper.UserIdAsync(db, "entry2"))); // Seed an Uploaded batch directly var uploadedBatchId = Guid.NewGuid(); db.DigitizationBatches.Add(new DigitizationBatch { Id = uploadedBatchId, Status = BatchStatus.Uploaded, BatchType = BatchType.EncounterSummary, Track = BatchTrack.Backfill, DocumentRef = $"scans/2026/01/{uploadedBatchId}/test.pdf", DocumentSha256 = Guid.NewGuid().ToString("N"), CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }); db.ScannedDocuments.Add(new ScannedDocument { Id = Guid.NewGuid(), BatchId = uploadedBatchId, ObjectKey = $"scans/2026/01/{uploadedBatchId}/test.pdf", Sha256 = Guid.NewGuid().ToString("N"), ContentType = "application/pdf", FileSizeBytes = 512, UploadedAt = DateTimeOffset.UtcNow }); await db.SaveChangesAsync(); // Act var client = await AuthHelper.LoginAsync(_fixture, "entry1"); var response = await client.GetAsync("/api/v1/work-queue/entry"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var body = await response.Content.ReadAsStringAsync(); var json = JsonDocument.Parse(body); var items = json.RootElement .GetProperty("data") .GetProperty("items"); // Should contain the rejected batch and uploaded batch, but NOT the PendingVerification batch items.GetArrayLength().Should().Be(2); var batchIds = Enumerable.Range(0, items.GetArrayLength()) .Select(i => items[i].GetProperty("batchId").GetString()) .ToList(); batchIds.Should().Contain(rejectedBatch.Id.ToString()); batchIds.Should().Contain(uploadedBatchId.ToString()); } // --------------------------------------------------------------- // Status guard tests // --------------------------------------------------------------- /// /// Test: Attempting to verify a batch that is not in PendingVerification /// status returns 409 ILLEGAL_STATUS_TRANSITION. /// [Fact] public async Task Verify_BatchNotInPendingVerification_Returns409() { // Arrange: seed a batch in Rejected status using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var batch = await BatchSeedHelper.SeedBatchInRejectedAsync( db, (await BatchSeedHelper.UserIdAsync(db, "entry1"))); // Act var client = await AuthHelper.LoginAsync(_fixture, "verifier2"); var request = new VerifyBatchRequest( new List { new("patient.fullName", "ok", null) }, Passed: true ); var response = await client.PostAsJsonAsync( $"/api/v1/digitization-batches/{batch.Id}/verify", request); // Assert response.StatusCode.Should().Be(HttpStatusCode.Conflict); var body = await response.Content.ReadAsStringAsync(); body.Should().Contain("ILLEGAL_STATUS_TRANSITION"); } /// /// Test: Verification with passed=false transitions to Rejected with /// field-level error details as the rejection reason. /// [Fact] public async Task Verify_PassedFalse_TransitionsToRejectedWithFieldErrors() { // Arrange using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var batch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync( db, (await BatchSeedHelper.UserIdAsync(db, "entry1"))); // Act var client = await AuthHelper.LoginAsync(_fixture, "verifier2"); var request = new VerifyBatchRequest( new List { new("patient.fullName", "ok", null), new("observation.heartRate", "error", "Value 350 is not physiologically possible"), new("observation.temperature", "error", "Missing unit — cannot verify") }, Passed: false ); var response = await client.PostAsJsonAsync( $"/api/v1/digitization-batches/{batch.Id}/verify", request); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var updatedBatch = await db.DigitizationBatches .AsNoTracking() .FirstAsync(b => b.Id == batch.Id); updatedBatch.Status.Should().Be(BatchStatus.Rejected); updatedBatch.RejectionReason.Should().Contain("350 is not physiologically possible"); updatedBatch.RejectionReason.Should().Contain("Missing unit"); // Verify event metadata contains field checks var evt = await db.DigitizationEvents .Where(e => e.BatchId == batch.Id && e.EventType == DigitizationEventType.VerificationFailed) .FirstAsync(); evt.MetadataJson.Should().Contain("fieldChecks"); evt.MetadataJson.Should().Contain("errorCount"); } }