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(); await DbResetHelper.ResetAsync(db); await DataSeeder.SeedAsync(db); _client = _fixture.CreateClient(); } public Task DisposeAsync() => Task.CompletedTask; /// /// Full pipeline: upload → entry → verify → approve → live observations exist, zero alerts for backfill. /// [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>(); 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(); // 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"); } /// /// Backfill batch with enableRetroactiveAlerts=true should write outbox events for all observations. /// [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>(); 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(); 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"); } /// /// Live-capture track always writes outbox events regardless of enableRetroactiveAlerts flag. /// [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>(); 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(); 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"); } /// /// Idempotent promotion: approve twice with same Idempotency-Key produces one set of live rows. /// [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>(); // 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>(); // 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(); 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"); } /// /// Separation of duties: entry clerk cannot approve their own batch. /// [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"); } /// /// Missing Idempotency-Key header returns 400. /// [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>(); result!.Error!.Code.Should().Be("MISSING_IDEMPOTENCY_KEY"); } /// /// Batch in wrong status (e.g., uploaded) returns 409. /// [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(); 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"); } /// /// GET /promotion-result returns correct data after promotion. /// [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>(); _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>(); 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); } /// /// GET /promotion-result on non-promoted batch returns 409. /// [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); } /// /// MRN generation produces unique, sequential values. /// [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>())!.Data!; var promotion2 = (await result2.Content .ReadFromJsonAsync>())!.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); } }