add: Integration test gaps for deferred promotion and retry
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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 deferred promotion and retry paths.
|
||||
/// Validates that the PromoteAsync retry path creates complete clinical entities
|
||||
/// (Patient, Encounter, Observation, OutboxEvent) — the P0 fix.
|
||||
/// </summary>
|
||||
[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<AppDbContext>();
|
||||
await DbResetHelper.ResetAsync(db);
|
||||
await DataSeeder.SeedAsync(db);
|
||||
_client = await AuthHelper.LoginAsync(_fixture, "approver1");
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<AppDbContext>();
|
||||
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<JsonElement>();
|
||||
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<AppDbContext>();
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that PromoteAsync without retroactive alerts creates no outbox events.
|
||||
/// </summary>
|
||||
[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<AppDbContext>();
|
||||
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<AppDbContext>();
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that PromoteAsync on a non-APPROVED batch returns 409.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<JsonElement>();
|
||||
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<AppDbContext>();
|
||||
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<AppDbContext>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user