feature: Approval and Promotion to VigilCareClinical
This commit is contained in:
@@ -0,0 +1,569 @@
|
||||
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")));
|
||||
|
||||
// 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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test: Entry queue returns Uploaded, InEntry, and Rejected batches.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EntryQueue_ReturnsUploadedInEntryAndRejectedBatches()
|
||||
{
|
||||
// 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)
|
||||
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
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// <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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user