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

596 lines
24 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Integration tests for Phase 3: verification, rejection, separation of duties,
/// and work queues. Uses ApiFixture with real PostgreSQL and Redis from Phase 2.
/// </summary>
[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<AppDbContext>();
await DbResetHelper.ResetAsync(db);
await DataSeeder.SeedAsync(db);
}
public Task DisposeAsync() => Task.CompletedTask;
// ---------------------------------------------------------------
// Separation of duties
// ---------------------------------------------------------------
/// <summary>
/// Test: entry1 entered the batch. verifier1 attempts to verify a batch they
/// also entered (EnteredByUserId = verifier1). Returns 409 SEPARATION_OF_DUTIES_VIOLATION.
/// </summary>
[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<AppDbContext>();
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<FieldCheck>
{
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");
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task Verify_DifferentUserFromEntryClerk_Succeeds()
{
// Arrange
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<FieldCheck>
{
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");
}
/// <summary>
/// Test: Verify a PatientRegistration batch (clinical approval NOT required).
/// The batch should transition directly to Verified, not AwaitingClinicalApproval.
/// </summary>
[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<AppDbContext>();
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<FieldCheck>
{
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
// ---------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task Reject_WithValidReason_TransitionsToRejectedAndAppearsInEntryQueue()
{
// Arrange
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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());
}
/// <summary>
/// Test: Rejection without a reason returns 422.
/// </summary>
[Fact]
public async Task Reject_WithoutReason_Returns422()
{
// Arrange
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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");
}
/// <summary>
/// Test: Rejection with a reason shorter than 10 characters returns 422.
/// </summary>
[Fact]
public async Task Reject_WithShortReason_Returns422()
{
// Arrange
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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
// ---------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
[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<AppDbContext>();
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<FieldCheck>
{
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
// ---------------------------------------------------------------
/// <summary>
/// Test: Verification queue returns only PendingVerification batches,
/// sorted by UpdatedAt ASC (oldest first).
/// </summary>
[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<AppDbContext>();
var olderBatch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync(
db, (await BatchSeedHelper.UserIdAsync(db, "entry1")));
var trackedOlder = await db.DigitizationBatches.FindAsync(olderBatch.Id);
trackedOlder!.UpdatedAt = DateTimeOffset.UtcNow.AddHours(-2);
await db.SaveChangesAsync();
var newerBatch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync(
db, (await BatchSeedHelper.UserIdAsync(db, "entry2")));
var trackedNewer = await db.DigitizationBatches.FindAsync(newerBatch.Id);
trackedNewer!.UpdatedAt = DateTimeOffset.UtcNow.AddHours(-1);
await db.SaveChangesAsync();
// Also seed a rejected batch — should NOT appear in verification queue
var rejectedBatch = 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");
var batchIds = Enumerable.Range(0, items.GetArrayLength())
.Select(i => items[i].GetProperty("batchId").GetString())
.ToList();
batchIds.Should().Contain(olderBatch.Id.ToString());
batchIds.Should().Contain(newerBatch.Id.ToString());
batchIds.Should().NotContain(rejectedBatch.Id.ToString());
foreach (var i in Enumerable.Range(0, items.GetArrayLength()))
{
items[i].GetProperty("status").GetString()
.Should().Be(BatchStatus.PendingVerification.ToDbString());
}
// Older batch should appear before newer batch (FIFO by UpdatedAt)
batchIds.IndexOf(olderBatch.Id.ToString())
.Should().BeLessThan(batchIds.IndexOf(newerBatch.Id.ToString()));
}
/// <summary>
/// Test: Entry queue returns InEntry and Rejected batches, but NOT Uploaded
/// (Uploaded batches must be assigned first, which transitions them to InEntry).
/// </summary>
[Fact]
public async Task EntryQueue_ReturnsInEntryAndRejectedBatches_ExcludesUploaded()
{
// Arrange
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// 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)
var pendingBatch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync(
db, (await BatchSeedHelper.UserIdAsync(db, "entry2")));
// Seed an Uploaded batch directly (should NOT appear — must be assigned first)
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");
var batchIds = Enumerable.Range(0, items.GetArrayLength())
.Select(i => items[i].GetProperty("batchId").GetString())
.ToList();
batchIds.Should().Contain(rejectedBatch.Id.ToString());
batchIds.Should().NotContain(uploadedBatchId.ToString(),
"Uploaded batches must be assigned before appearing in the entry queue");
batchIds.Should().NotContain(pendingBatch.Id.ToString());
var entryStatuses = new[]
{
BatchStatus.InEntry.ToDbString(),
BatchStatus.Rejected.ToDbString()
};
foreach (var i in Enumerable.Range(0, items.GetArrayLength()))
{
entryStatuses.Should().Contain(items[i].GetProperty("status").GetString());
}
}
// ---------------------------------------------------------------
// Status guard tests
// ---------------------------------------------------------------
/// <summary>
/// Test: Attempting to verify a batch that is not in PendingVerification
/// status returns 409 ILLEGAL_STATUS_TRANSITION.
/// </summary>
[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<AppDbContext>();
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<FieldCheck> { 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");
}
/// <summary>
/// Test: Verification with passed=false transitions to Rejected with
/// field-level error details as the rejection reason.
/// </summary>
[Fact]
public async Task Verify_PassedFalse_TransitionsToRejectedWithFieldErrors()
{
// Arrange
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<FieldCheck>
{
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");
}
}