using System.Net; using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; /// /// Integration tests for deferred promotion and retry paths. /// Validates that the PromoteAsync retry path creates complete clinical entities /// (Patient, Encounter, Observation, OutboxEvent) — the P0 fix. /// [Collection("Database")] public class PromotionRetryTests : IAsyncLifetime { private readonly ApiFixture _fixture; private HttpClient _client = null!; public PromotionRetryTests(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 = await AuthHelper.LoginAsync(_fixture, "approver1"); } public Task DisposeAsync() => Task.CompletedTask; /// /// Simulates a deferred promotion by manually setting a batch to APPROVED status /// (as the controller would after an infrastructure failure), then calls PromoteAsync /// via the POST .../promote endpoint and verifies all clinical entities are created. /// [Fact] public async Task PromoteAsync_AfterDeferral_CreatesAllClinicalEntities() { // Arrange: drive batch through to verified var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync(_fixture); // Simulate what DeferPromotionAsync does: set batch to APPROVED with retry metadata using (var scope = _fixture.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var batch = await db.DigitizationBatches.FindAsync(batchId); batch!.Status = BatchStatus.Approved; batch.ApprovedByUserId = (await db.Users.FirstAsync(u => u.Username == "approver1")).Id; batch.EnableRetroactiveAlerts = true; batch.UpdatedAt = DateTimeOffset.UtcNow; db.PromotionAttempts.Add(new PromotionAttempt { Id = Guid.NewGuid(), BatchId = batchId, AttemptNumber = 1, Succeeded = false, ErrorMessage = "Simulated infrastructure failure", AttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-5), NextRetryAt = DateTimeOffset.UtcNow.AddMinutes(-1) }); await db.SaveChangesAsync(); } // Act: call the promote endpoint (same path PromotionRetryService uses) var response = await _client.PostAsync( $"/api/v1/digitization-batches/{batchId}/promote", null); // Assert: HTTP 200 response.StatusCode.Should().Be(HttpStatusCode.OK); var body = await response.Content.ReadFromJsonAsync(); var data = body.GetProperty("data"); data.GetProperty("batchId").GetGuid().Should().Be(batchId); data.GetProperty("patientId").GetGuid().Should().NotBeEmpty(); data.GetProperty("mrn").GetString().Should().StartWith("VCR-"); data.GetProperty("encounterId").GetGuid().Should().NotBeEmpty(); // Verify clinical entities in database using var verifyScope = _fixture.Services.CreateScope(); var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); // Batch is PROMOTED var promotedBatch = await verifyDb.DigitizationBatches.FindAsync(batchId); promotedBatch!.Status.Should().Be(BatchStatus.Promoted); promotedBatch.PatientId.Should().NotBeNull(); promotedBatch.PromotionEncounterId.Should().NotBeNull(); // Patient exists var patient = await verifyDb.Patients.FindAsync(promotedBatch.PatientId!.Value); patient.Should().NotBeNull(); patient!.Mrn.Should().StartWith("VCR-"); // Clinical Encounter exists var encounter = await verifyDb.Encounters.FindAsync(promotedBatch.PromotionEncounterId!.Value); encounter.Should().NotBeNull(); encounter!.PatientId.Should().Be(patient.Id); // Clinical Observations exist var observations = await verifyDb.Observations .Where(o => o.SourceBatchId == batchId) .ToListAsync(); observations.Should().HaveCount(3); observations.Should().AllSatisfy(o => { o.PatientId.Should().Be(patient.Id); o.EncounterId.Should().Be(encounter.Id); }); // OutboxEvents exist (enableRetroactiveAlerts was true) var outboxEvents = await verifyDb.OutboxEvents .Where(e => observations.Select(o => o.Id).Contains(e.AggregateId)) .ToListAsync(); outboxEvents.Should().HaveCount(3, "enableRetroactiveAlerts=true should create outbox events on retry path"); // LiveObservations exist var liveObs = await verifyDb.LiveObservations .Where(o => o.SourceBatchId == batchId) .ToListAsync(); liveObs.Should().HaveCount(3); // LiveEncounter exists var liveEnc = await verifyDb.LiveEncounters.FindAsync(encounter.Id); liveEnc.Should().NotBeNull(); } /// /// Verifies that PromoteAsync without retroactive alerts creates no outbox events. /// [Fact] public async Task PromoteAsync_NoRetroactiveAlerts_CreatesZeroOutboxEvents() { var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync( _fixture, patientFullName: "No Alerts Patient", patientDateOfBirth: "1985-03-20"); using (var scope = _fixture.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var batch = await db.DigitizationBatches.FindAsync(batchId); batch!.Status = BatchStatus.Approved; batch.ApprovedByUserId = (await db.Users.FirstAsync(u => u.Username == "approver1")).Id; batch.EnableRetroactiveAlerts = false; batch.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); } var response = await _client.PostAsync( $"/api/v1/digitization-batches/{batchId}/promote", null); response.StatusCode.Should().Be(HttpStatusCode.OK); using var verifyScope = _fixture.Services.CreateScope(); var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); var observations = await verifyDb.Observations .Where(o => o.SourceBatchId == batchId) .ToListAsync(); observations.Should().HaveCount(3); var outboxEvents = await verifyDb.OutboxEvents .Where(e => observations.Select(o => o.Id).Contains(e.AggregateId)) .ToListAsync(); outboxEvents.Should().BeEmpty( "enableRetroactiveAlerts=false should write zero outbox events on retry"); } /// /// Verifies that PromoteAsync on a non-APPROVED batch returns 409. /// [Fact] public async Task PromoteAsync_WrongStatus_Returns409() { var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync( _fixture, patientFullName: "Wrong Status Patient", patientDateOfBirth: "1992-07-10"); // Batch is in VERIFIED status (not APPROVED) var response = await _client.PostAsync( $"/api/v1/digitization-batches/{batchId}/promote", null); response.StatusCode.Should().Be(HttpStatusCode.Conflict); } /// /// Verifies that patient deduplication works on the retry path — /// promoting two batches for the same patient (same name + DOB) should /// reuse the same Patient/MRN rather than creating a duplicate. /// [Fact] public async Task PromoteAsync_SamePatient_DeduplicatesOnRetry() { // Create and promote batch 1 via normal approve path var batchId1 = await BatchPipelineHelper.CreateAndVerifyBatchAsync( _fixture, patientFullName: "Dedup Patient", patientDateOfBirth: "1988-11-01"); _client.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString()); var approve1 = await _client.PostAsJsonAsync( $"/api/v1/digitization-batches/{batchId1}/approve", new ApproveRequest()); approve1.EnsureSuccessStatusCode(); _client.DefaultRequestHeaders.Remove("Idempotency-Key"); var result1 = await approve1.Content.ReadFromJsonAsync(); var patientId1 = result1.GetProperty("data").GetProperty("patientId").GetGuid(); var mrn1 = result1.GetProperty("data").GetProperty("mrn").GetString(); // Create batch 2 for same patient, defer to APPROVED, then promote var batchId2 = await BatchPipelineHelper.CreateAndVerifyBatchAsync( _fixture, patientFullName: "Dedup Patient", patientDateOfBirth: "1988-11-01"); using (var scope = _fixture.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var batch = await db.DigitizationBatches.FindAsync(batchId2); batch!.Status = BatchStatus.Approved; batch.ApprovedByUserId = (await db.Users.FirstAsync(u => u.Username == "approver1")).Id; batch.EnableRetroactiveAlerts = false; batch.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); } var promote2 = await _client.PostAsync( $"/api/v1/digitization-batches/{batchId2}/promote", null); promote2.EnsureSuccessStatusCode(); // Verify same patient was reused using var verifyScope = _fixture.Services.CreateScope(); var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); var batch2 = await verifyDb.DigitizationBatches.FindAsync(batchId2); batch2!.PatientId.Should().Be(patientId1, "retry path should deduplicate and reuse the same patient"); var patient = await verifyDb.Patients.FindAsync(patientId1); patient!.Mrn.Should().Be(mrn1); } }