add: Integration test gaps for deferred promotion and retry
This commit is contained in:
@@ -0,0 +1,151 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for authentication flow: login, refresh, logout, and role-based access.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Database")]
|
||||||
|
public class AuthTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly ApiFixture _fixture;
|
||||||
|
|
||||||
|
public AuthTests(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;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_ValidCredentials_ReturnsTokenAndProfile()
|
||||||
|
{
|
||||||
|
var client = _fixture.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||||
|
new { username = "entry1", password = "password" });
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var data = body.GetProperty("data");
|
||||||
|
data.GetProperty("token").GetString().Should().NotBeNullOrEmpty();
|
||||||
|
data.GetProperty("refreshToken").GetString().Should().NotBeNullOrEmpty();
|
||||||
|
data.GetProperty("username").GetString().Should().Be("entry1");
|
||||||
|
data.GetProperty("role").GetString().Should().Be("DATA_ENTRY_CLERK");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_InvalidPassword_Returns422()
|
||||||
|
{
|
||||||
|
var client = _fixture.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||||
|
new { username = "entry1", password = "wrongpassword" });
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_NonexistentUser_Returns422()
|
||||||
|
{
|
||||||
|
var client = _fixture.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||||
|
new { username = "nonexistent", password = "password" });
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProtectedEndpoint_NoToken_Returns401()
|
||||||
|
{
|
||||||
|
var client = _fixture.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/api/v1/digitization-batches");
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RoleRestricted_WrongRole_Returns403()
|
||||||
|
{
|
||||||
|
// entry1 is DATA_ENTRY_CLERK — cannot access admin dashboard
|
||||||
|
var client = await AuthHelper.LoginAsync(_fixture, "entry1");
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/api/v1/work-queue/overview");
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_ValidToken_ReturnsNewTokens()
|
||||||
|
{
|
||||||
|
var client = _fixture.CreateClient();
|
||||||
|
|
||||||
|
// Login to get refresh token
|
||||||
|
var loginResponse = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||||
|
new { username = "verifier1", password = "password" });
|
||||||
|
loginResponse.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var loginBody = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var refreshToken = loginBody.GetProperty("data").GetProperty("refreshToken").GetString();
|
||||||
|
|
||||||
|
// Refresh
|
||||||
|
var refreshResponse = await client.PostAsJsonAsync("/api/v1/auth/refresh",
|
||||||
|
new { refreshToken });
|
||||||
|
|
||||||
|
refreshResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var refreshBody = await refreshResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
refreshBody.GetProperty("data").GetProperty("token").GetString().Should().NotBeNullOrEmpty();
|
||||||
|
refreshBody.GetProperty("data").GetProperty("refreshToken").GetString()
|
||||||
|
.Should().NotBe(refreshToken, "refresh should rotate the token");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Logout_RevokesRefreshToken()
|
||||||
|
{
|
||||||
|
var client = _fixture.CreateClient();
|
||||||
|
|
||||||
|
// Login
|
||||||
|
var loginResponse = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||||
|
new { username = "intake1", password = "password" });
|
||||||
|
loginResponse.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var loginBody = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var refreshToken = loginBody.GetProperty("data").GetProperty("refreshToken").GetString();
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
var logoutResponse = await client.PostAsJsonAsync("/api/v1/auth/logout",
|
||||||
|
new { refreshToken });
|
||||||
|
logoutResponse.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||||
|
|
||||||
|
// Attempt to refresh with revoked token
|
||||||
|
var refreshResponse = await client.PostAsJsonAsync("/api/v1/auth/refresh",
|
||||||
|
new { refreshToken });
|
||||||
|
refreshResponse.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Me_Authenticated_ReturnsProfile()
|
||||||
|
{
|
||||||
|
var client = await AuthHelper.LoginAsync(_fixture, "approver1");
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/api/v1/auth/me");
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var data = body.GetProperty("data");
|
||||||
|
data.GetProperty("username").GetString().Should().Be("approver1");
|
||||||
|
data.GetProperty("role").GetString().Should().Be("CLINICAL_APPROVER");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for concurrent operations: duplicate batch creation and
|
||||||
|
/// parallel assignment attempts.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Database")]
|
||||||
|
public class ConcurrencyTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly ApiFixture _fixture;
|
||||||
|
|
||||||
|
public ConcurrencyTests(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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Two parallel assignment requests for the same batch — exactly one should succeed,
|
||||||
|
/// the other should get 409 BATCH_ALREADY_ASSIGNED.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ParallelAssign_SameBatch_ExactlyOneSucceeds()
|
||||||
|
{
|
||||||
|
// Arrange: upload a batch
|
||||||
|
var intakeClient = 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 intakeClient.PostAsync("/api/v1/digitization-batches", formData);
|
||||||
|
uploadResponse.EnsureSuccessStatusCode();
|
||||||
|
var uploadBody = await uploadResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var batchId = uploadBody.GetProperty("data").GetProperty("id").GetGuid();
|
||||||
|
|
||||||
|
// Get user IDs
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var entry1Id = await BatchSeedHelper.UserIdAsync(db, "entry1");
|
||||||
|
var entry2Id = await BatchSeedHelper.UserIdAsync(db, "entry2");
|
||||||
|
|
||||||
|
// Act: parallel assignment
|
||||||
|
var task1 = intakeClient.PatchAsJsonAsync(
|
||||||
|
$"/api/v1/digitization-batches/{batchId}/assign",
|
||||||
|
new { entryClerkUserId = entry1Id });
|
||||||
|
|
||||||
|
var task2 = intakeClient.PatchAsJsonAsync(
|
||||||
|
$"/api/v1/digitization-batches/{batchId}/assign",
|
||||||
|
new { entryClerkUserId = entry2Id });
|
||||||
|
|
||||||
|
var results = await Task.WhenAll(task1, task2);
|
||||||
|
|
||||||
|
// Assert: exactly one 200 and one 409
|
||||||
|
var statuses = results.Select(r => r.StatusCode).OrderBy(s => s).ToList();
|
||||||
|
statuses.Should().Contain(HttpStatusCode.OK);
|
||||||
|
statuses.Should().Contain(HttpStatusCode.Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Two uploads with the same file content for the same patient — the Redis dedup
|
||||||
|
/// guard should ensure exactly one succeeds.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ParallelUpload_SameSha256_ExactlyOneSucceeds()
|
||||||
|
{
|
||||||
|
// Arrange: create a patient first via a promoted batch
|
||||||
|
var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync(_fixture);
|
||||||
|
var approverClient = await AuthHelper.LoginAsync(_fixture, "approver1");
|
||||||
|
approverClient.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString());
|
||||||
|
var approveResp = await approverClient.PostAsJsonAsync(
|
||||||
|
$"/api/v1/digitization-batches/{batchId}/approve",
|
||||||
|
new ApproveRequest());
|
||||||
|
approveResp.EnsureSuccessStatusCode();
|
||||||
|
approverClient.DefaultRequestHeaders.Remove("Idempotency-Key");
|
||||||
|
|
||||||
|
var approveBody = await approveResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var patientId = approveBody.GetProperty("data").GetProperty("patientId").GetGuid();
|
||||||
|
|
||||||
|
// Same file content for both uploads
|
||||||
|
var fileBytes = System.Text.Encoding.ASCII.GetBytes(
|
||||||
|
$"%PDF-1.4\n%%EOF\n%duplicate-test-{Guid.NewGuid()}");
|
||||||
|
|
||||||
|
var intakeClient = await AuthHelper.LoginAsync(_fixture, "intake1");
|
||||||
|
|
||||||
|
// Act: two parallel uploads with same content + same patient
|
||||||
|
var task1 = UploadWithBytes(intakeClient, fileBytes, patientId);
|
||||||
|
var task2 = UploadWithBytes(intakeClient, fileBytes, patientId);
|
||||||
|
|
||||||
|
var results = await Task.WhenAll(task1, task2);
|
||||||
|
|
||||||
|
// Assert: exactly one success and one conflict
|
||||||
|
var statuses = results.Select(r => r.StatusCode).OrderBy(s => s).ToList();
|
||||||
|
statuses.Should().Contain(HttpStatusCode.Created);
|
||||||
|
statuses.Should().Contain(HttpStatusCode.Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<HttpResponseMessage> UploadWithBytes(
|
||||||
|
HttpClient client, byte[] fileBytes, Guid patientId)
|
||||||
|
{
|
||||||
|
var fileContent = new ByteArrayContent(fileBytes);
|
||||||
|
fileContent.Headers.ContentType =
|
||||||
|
new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||||
|
|
||||||
|
var formData = new MultipartFormDataContent
|
||||||
|
{
|
||||||
|
{ fileContent, "file", "duplicate.pdf" },
|
||||||
|
{ new StringContent("VITALS_SHEET"), "batchType" },
|
||||||
|
{ new StringContent(patientId.ToString()), "patientId" }
|
||||||
|
};
|
||||||
|
|
||||||
|
return await client.PostAsync("/api/v1/digitization-batches", formData);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ public static class DbResetHelper
|
|||||||
clinical.clinical_alerts, clinical.alert_thresholds,
|
clinical.clinical_alerts, clinical.alert_thresholds,
|
||||||
clinical.outbox_events, clinical.observations,
|
clinical.outbox_events, clinical.observations,
|
||||||
clinical.encounters, clinical.patients,
|
clinical.encounters, clinical.patients,
|
||||||
idempotency_records,
|
idempotency_records, promotion_attempts,
|
||||||
digitization_events, draft_observations,
|
digitization_events, draft_observations,
|
||||||
draft_encounters, draft_patients,
|
draft_encounters, draft_patients,
|
||||||
scanned_documents, digitization_batches, users
|
scanned_documents, digitization_batches, users
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -818,7 +818,7 @@ A batch stuck in `APPROVED` with exhausted retries is invisible in Prometheus da
|
|||||||
| 21 | No toast/notification system | P4 | E | Open |
|
| 21 | No toast/notification system | P4 | E | Open |
|
||||||
| 22 | No frontend tests | P4 | E | Open |
|
| 22 | No frontend tests | P4 | E | Open |
|
||||||
| 23 | No field-level draft audit | P5 | F | Open |
|
| 23 | No field-level draft audit | P5 | F | Open |
|
||||||
| 24 | Integration test gaps | P5 | F | Open |
|
| 24 | Integration test gaps | P5 | F | Done |
|
||||||
| 25 | MetricsCollector missing retry gauges | P5 | F | Open |
|
| 25 | MetricsCollector missing retry gauges | P5 | F | Open |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user