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

448 lines
18 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
[Collection("Database")]
public class PromotionTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private HttpClient _client = null!;
public PromotionTests(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);
_client = _fixture.CreateClient();
}
public Task DisposeAsync() => Task.CompletedTask;
/// <summary>
/// Full pipeline: upload → entry → verify → approve → live observations exist, zero alerts for backfill.
/// </summary>
[Fact]
public async Task Approve_BackfillBatch_CreatesLiveRecords_ZeroOutboxEvents()
{
// Arrange: drive batch through full lifecycle to verified status
var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync(
_fixture, track: "BACKFILL");
// Act: approve as clinical approver (approver1)
_client = await AuthHelper.LoginAsync(_fixture, "approver1");
var idempotencyKey = Guid.NewGuid().ToString();
_client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey);
var approveResponse = await _client.PostAsJsonAsync(
$"/api/v1/digitization-batches/{batchId}/approve",
new ApproveRequest(EnableRetroactiveAlerts: false));
// Assert: HTTP 200
approveResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await approveResponse.Content
.ReadFromJsonAsync<ApiResponse<PromotionResultResponse>>();
result.Should().NotBeNull();
result!.Success.Should().BeTrue();
result.Data.Should().NotBeNull();
var promotion = result.Data!;
promotion.BatchId.Should().Be(batchId);
promotion.Status.Should().Be("PROMOTED");
promotion.PatientId.Should().NotBeEmpty();
promotion.Mrn.Should().StartWith("VCR-");
promotion.EncounterId.Should().NotBeEmpty();
promotion.ObservationIds.Should().HaveCount(3);
promotion.OutboxEventsWritten.Should().Be(0,
"backfill with enableRetroactiveAlerts=false should write zero outbox events");
// Verify live records in database
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Patient exists with correct MRN
var patient = await db.Patients.FirstOrDefaultAsync(p => p.Id == promotion.PatientId);
patient.Should().NotBeNull();
patient!.Mrn.Should().Be(promotion.Mrn);
patient.FullName.Should().Be("TEST PATIENT");
patient.DateOfBirth.Should().Be(new DateOnly(1990, 5, 15));
// Encounter exists
var encounter = await db.Encounters.FirstOrDefaultAsync(e => e.Id == promotion.EncounterId);
encounter.Should().NotBeNull();
encounter!.PatientId.Should().Be(promotion.PatientId);
encounter.Department.Should().Be(Department.GeneralMedicine);
// All 3 observations exist with correct source
var observations = await db.Observations
.Where(o => o.SourceBatchId == batchId)
.ToListAsync();
observations.Should().HaveCount(3);
observations.Should().AllSatisfy(o =>
{
o.Source.Should().Be("digitization_backfill");
o.PatientId.Should().Be(promotion.PatientId);
o.EncounterId.Should().Be(promotion.EncounterId);
});
// Zero outbox events for this batch
var observationIds = observations.Select(o => o.Id).ToList();
var outboxEvents = await db.OutboxEvents
.Where(e => observationIds.Contains(e.AggregateId))
.ToListAsync();
outboxEvents.Should().BeEmpty(
"backfill with enableRetroactiveAlerts=false should not create outbox events");
// Batch status is Promoted
var batch = await db.DigitizationBatches.FirstAsync(b => b.Id == batchId);
batch.Status.Should().Be(BatchStatus.Promoted);
batch.PromotedAt.Should().NotBeNull();
batch.PromotionEncounterId.Should().Be(promotion.EncounterId);
// DigitizationEvent with type "promoted" exists
var promotedEvent = await db.DigitizationEvents
.FirstOrDefaultAsync(e => e.BatchId == batchId && e.EventType == DigitizationEventType.Promoted);
promotedEvent.Should().NotBeNull();
// Clean up header for other tests
_client.DefaultRequestHeaders.Remove("Idempotency-Key");
}
/// <summary>
/// Backfill batch with enableRetroactiveAlerts=true should write outbox events for all observations.
/// </summary>
[Fact]
public async Task Approve_BackfillWithRetroactiveAlerts_CreatesOutboxEvents()
{
// Arrange
var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync(
_fixture, track: "BACKFILL");
// Act
_client = await AuthHelper.LoginAsync(_fixture, "approver1");
var idempotencyKey = Guid.NewGuid().ToString();
_client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey);
var approveResponse = await _client.PostAsJsonAsync(
$"/api/v1/digitization-batches/{batchId}/approve",
new ApproveRequest(EnableRetroactiveAlerts: true));
// Assert
approveResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await approveResponse.Content
.ReadFromJsonAsync<ApiResponse<PromotionResultResponse>>();
result!.Data!.OutboxEventsWritten.Should().Be(3,
"backfill with enableRetroactiveAlerts=true should write one outbox event per observation");
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var observationIds = await db.Observations
.Where(o => o.SourceBatchId == batchId)
.Select(o => o.Id)
.ToListAsync();
var outboxEvents = await db.OutboxEvents
.Where(e => observationIds.Contains(e.AggregateId))
.ToListAsync();
outboxEvents.Should().HaveCount(3);
outboxEvents.Should().AllSatisfy(e =>
{
e.EventType.Should().Be("observation.created");
e.AggregateType.Should().Be("Observation");
e.ProcessedAt.Should().BeNull();
});
_client.DefaultRequestHeaders.Remove("Idempotency-Key");
}
/// <summary>
/// Live-capture track always writes outbox events regardless of enableRetroactiveAlerts flag.
/// </summary>
[Fact]
public async Task Approve_LiveCaptureBatch_AlwaysCreatesOutboxEvents()
{
// Arrange
var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync(
_fixture, track: "LIVE_CAPTURE");
// Act
_client = await AuthHelper.LoginAsync(_fixture, "approver1");
var idempotencyKey = Guid.NewGuid().ToString();
_client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey);
var approveResponse = await _client.PostAsJsonAsync(
$"/api/v1/digitization-batches/{batchId}/approve",
new ApproveRequest(EnableRetroactiveAlerts: false));
// Assert
approveResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await approveResponse.Content
.ReadFromJsonAsync<ApiResponse<PromotionResultResponse>>();
result!.Data!.OutboxEventsWritten.Should().Be(3,
"live_capture track should always write outbox events");
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var observations = await db.Observations
.Where(o => o.SourceBatchId == batchId)
.ToListAsync();
observations.Should().AllSatisfy(o =>
o.Source.Should().Be("live_capture"));
_client.DefaultRequestHeaders.Remove("Idempotency-Key");
}
/// <summary>
/// Idempotent promotion: approve twice with same Idempotency-Key produces one set of live rows.
/// </summary>
[Fact]
public async Task Approve_SameIdempotencyKey_ReturnsIdenticalResult_NoDuplicateRows()
{
// Arrange
var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync(_fixture);
_client = await AuthHelper.LoginAsync(_fixture, "approver1");
var idempotencyKey = Guid.NewGuid().ToString();
// Act: first approve
_client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey);
var firstResponse = await _client.PostAsJsonAsync(
$"/api/v1/digitization-batches/{batchId}/approve",
new ApproveRequest(EnableRetroactiveAlerts: false));
firstResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var firstResult = await firstResponse.Content
.ReadFromJsonAsync<ApiResponse<PromotionResultResponse>>();
// Act: second approve with same key
_client.DefaultRequestHeaders.Remove("Idempotency-Key");
_client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey);
var secondResponse = await _client.PostAsJsonAsync(
$"/api/v1/digitization-batches/{batchId}/approve",
new ApproveRequest(EnableRetroactiveAlerts: false));
secondResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var secondResult = await secondResponse.Content
.ReadFromJsonAsync<ApiResponse<PromotionResultResponse>>();
// Assert: identical results
secondResult!.Data!.PatientId.Should().Be(firstResult!.Data!.PatientId);
secondResult.Data.EncounterId.Should().Be(firstResult.Data.EncounterId);
secondResult.Data.ObservationIds.Should().BeEquivalentTo(firstResult.Data.ObservationIds);
secondResult.Data.Mrn.Should().Be(firstResult.Data.Mrn);
// Assert: only one set of rows in database
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var observations = await db.Observations
.Where(o => o.SourceBatchId == batchId)
.ToListAsync();
observations.Should().HaveCount(3,
"idempotent replay should not create duplicate observations");
_client.DefaultRequestHeaders.Remove("Idempotency-Key");
}
/// <summary>
/// Separation of duties: entry clerk cannot approve their own batch.
/// </summary>
[Fact]
public async Task Approve_ByEntryClerk_Returns409()
{
// Arrange
var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync(_fixture);
// Act: try to approve as entry1 (who entered the data)
_client = await AuthHelper.LoginAsync(_fixture, "entry1");
_client.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString());
var approveResponse = await _client.PostAsJsonAsync(
$"/api/v1/digitization-batches/{batchId}/approve",
new ApproveRequest());
// Assert: either 403 (wrong role) or 409 (separation of duties)
// entry1 has DataEntryClerk role, so the [Authorize(Roles = ...)] will reject with 403
approveResponse.StatusCode.Should().Be(HttpStatusCode.Forbidden);
_client.DefaultRequestHeaders.Remove("Idempotency-Key");
}
/// <summary>
/// Missing Idempotency-Key header returns 400.
/// </summary>
[Fact]
public async Task Approve_MissingIdempotencyKey_Returns400()
{
// Arrange
var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync(_fixture);
_client = await AuthHelper.LoginAsync(_fixture, "approver1");
// Act: approve without Idempotency-Key header
var approveResponse = await _client.PostAsJsonAsync(
$"/api/v1/digitization-batches/{batchId}/approve",
new ApproveRequest());
// Assert
approveResponse.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var result = await approveResponse.Content.ReadFromJsonAsync<ApiResponse<object>>();
result!.Error!.Code.Should().Be("MISSING_IDEMPOTENCY_KEY");
}
/// <summary>
/// Batch in wrong status (e.g., uploaded) returns 409.
/// </summary>
[Fact]
public async Task Approve_WrongStatus_Returns409()
{
// Arrange: create a batch but don't verify it
_client = await AuthHelper.LoginAsync(_fixture, "intake1");
var fileContent = new ByteArrayContent(
System.Text.Encoding.ASCII.GetBytes(
$"%PDF-1.4\n%%EOF\n%test-{Guid.NewGuid()}"));
fileContent.Headers.ContentType =
new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
var formData = new MultipartFormDataContent
{
{ fileContent, "file", "test.pdf" },
{ new StringContent("VITALS_SHEET"), "batchType" }
};
var uploadResponse = await _client.PostAsync(
"/api/v1/digitization-batches", formData);
uploadResponse.EnsureSuccessStatusCode();
var uploadResult = await uploadResponse.Content.ReadFromJsonAsync<JsonElement>();
var batchId = uploadResult.GetProperty("data").GetProperty("id").GetGuid();
// Act: try to approve an uploaded batch
_client = await AuthHelper.LoginAsync(_fixture, "approver1");
_client.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString());
var approveResponse = await _client.PostAsJsonAsync(
$"/api/v1/digitization-batches/{batchId}/approve",
new ApproveRequest());
// Assert
approveResponse.StatusCode.Should().Be(HttpStatusCode.Conflict);
_client.DefaultRequestHeaders.Remove("Idempotency-Key");
}
/// <summary>
/// GET /promotion-result returns correct data after promotion.
/// </summary>
[Fact]
public async Task GetPromotionResult_AfterPromotion_ReturnsLiveIds()
{
// Arrange: create and promote a batch
var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync(_fixture);
_client = await AuthHelper.LoginAsync(_fixture, "approver1");
var idempotencyKey = Guid.NewGuid().ToString();
_client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey);
var approveResponse = await _client.PostAsJsonAsync(
$"/api/v1/digitization-batches/{batchId}/approve",
new ApproveRequest());
approveResponse.EnsureSuccessStatusCode();
var approveResult = await approveResponse.Content
.ReadFromJsonAsync<ApiResponse<PromotionResultResponse>>();
_client.DefaultRequestHeaders.Remove("Idempotency-Key");
// Act: get promotion result
var resultResponse = await _client.GetAsync(
$"/api/v1/digitization-batches/{batchId}/promotion-result");
// Assert
resultResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await resultResponse.Content
.ReadFromJsonAsync<ApiResponse<PromotionResultResponse>>();
result!.Data!.BatchId.Should().Be(batchId);
result.Data.PatientId.Should().Be(approveResult!.Data!.PatientId);
result.Data.EncounterId.Should().Be(approveResult.Data.EncounterId);
result.Data.ObservationIds.Should().HaveCount(3);
}
/// <summary>
/// GET /promotion-result on non-promoted batch returns 409.
/// </summary>
[Fact]
public async Task GetPromotionResult_NotPromoted_Returns409()
{
// Arrange
var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync(_fixture);
_client = await AuthHelper.LoginAsync(_fixture, "approver1");
// Act: query promotion result before approving
var resultResponse = await _client.GetAsync(
$"/api/v1/digitization-batches/{batchId}/promotion-result");
// Assert
resultResponse.StatusCode.Should().Be(HttpStatusCode.Conflict);
}
/// <summary>
/// MRN generation produces unique, sequential values.
/// </summary>
[Fact]
public async Task Approve_MultipleBatches_ProducesSequentialMrns()
{
// Arrange & Act: promote two batches with distinct patients so each gets a new MRN
var batchId1 = await BatchPipelineHelper.CreateAndVerifyBatchAsync(_fixture);
var batchId2 = await BatchPipelineHelper.CreateAndVerifyBatchAsync(
_fixture, patientFullName: "Test Patient Two", patientDateOfBirth: "1991-06-20");
_client = await AuthHelper.LoginAsync(_fixture, "approver1");
_client.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString());
var result1 = await _client.PostAsJsonAsync(
$"/api/v1/digitization-batches/{batchId1}/approve",
new ApproveRequest());
_client.DefaultRequestHeaders.Remove("Idempotency-Key");
_client.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString());
var result2 = await _client.PostAsJsonAsync(
$"/api/v1/digitization-batches/{batchId2}/approve",
new ApproveRequest());
_client.DefaultRequestHeaders.Remove("Idempotency-Key");
var promotion1 = (await result1.Content
.ReadFromJsonAsync<ApiResponse<PromotionResultResponse>>())!.Data!;
var promotion2 = (await result2.Content
.ReadFromJsonAsync<ApiResponse<PromotionResultResponse>>())!.Data!;
// Assert: MRNs are unique
promotion1.Mrn.Should().NotBe(promotion2.Mrn);
promotion1.Mrn.Should().StartWith("VCR-");
promotion2.Mrn.Should().StartWith("VCR-");
// Assert: MRNs follow sequential pattern
var mrn1Num = int.Parse(promotion1.Mrn.Split('-')[1]);
var mrn2Num = int.Parse(promotion2.Mrn.Split('-')[1]);
(mrn2Num - mrn1Num).Should().BeGreaterThanOrEqualTo(1);
}
}