595 lines
24 KiB
C#
595 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 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)
|
|
var pendingBatch = 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");
|
|
|
|
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());
|
|
batchIds.Should().NotContain(pendingBatch.Id.ToString());
|
|
|
|
var entryStatuses = new[]
|
|
{
|
|
BatchStatus.Uploaded.ToDbString(),
|
|
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");
|
|
}
|
|
} |