diff --git a/VigilCareRecordsAPI.Tests/Helpers/BatchPipelineHelper.cs b/VigilCareRecordsAPI.Tests/Helpers/BatchPipelineHelper.cs
new file mode 100644
index 0000000..b33545e
--- /dev/null
+++ b/VigilCareRecordsAPI.Tests/Helpers/BatchPipelineHelper.cs
@@ -0,0 +1,144 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+
+public static class BatchPipelineHelper
+{
+ ///
+ /// Drives a batch from upload through to verified status, returning the batch ID.
+ /// Uses intake1 for upload, entry1 for data entry, verifier1 for verification.
+ ///
+ public static async Task CreateAndVerifyBatchAsync(
+ ApiFixture fixture,
+ string batchType = "VITALS_SHEET",
+ string track = "BACKFILL",
+ string patientFullName = "Test Patient",
+ string patientDateOfBirth = "1990-05-15")
+ {
+ // === Upload as intake1 ===
+ var client = await AuthHelper.LoginAsync(fixture, "intake1");
+
+ var fileContent = new ByteArrayContent(GenerateTestPdf());
+ fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
+
+ var formData = new MultipartFormDataContent
+ {
+ { fileContent, "file", $"test-scan-{Guid.NewGuid()}.pdf" },
+ { new StringContent(batchType), "batchType" },
+ { new StringContent(track), "track" }
+ };
+
+ 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();
+
+ // === Assign to entry1 ===
+ var assignResponse = await client.PatchAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/assign",
+ new { entryClerkUserId = await GetUserIdAsync(fixture, "entry1") });
+ assignResponse.EnsureSuccessStatusCode();
+
+ // === Data entry as entry1 ===
+ client = await AuthHelper.LoginAsync(fixture, "entry1");
+
+ // Add draft patient
+ (await client.PutAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/draft/patient",
+ new
+ {
+ fullName = patientFullName,
+ dateOfBirth = patientDateOfBirth,
+ sex = "Male",
+ bloodType = "A+",
+ noKnownAllergies = true
+ })).EnsureSuccessStatusCode();
+
+ // Add draft encounter
+ (await client.PutAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/draft/encounter",
+ new
+ {
+ admissionDate = "2024-01-15T08:00:00Z",
+ department = Department.GeneralMedicine.ToDbString(),
+ roomBed = "301-A",
+ admissionReason = "Routine checkup",
+ status = "active"
+ })).EnsureSuccessStatusCode();
+
+ // Add draft observations
+ (await client.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/draft/observations",
+ new
+ {
+ observationCode = "HR",
+ value = 88.0,
+ unit = "bpm",
+ recordedAt = "2024-01-15T09:30:00Z",
+ note = "Resting heart rate"
+ })).EnsureSuccessStatusCode();
+
+ (await client.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/draft/observations",
+ new
+ {
+ observationCode = "TEMP",
+ value = 37.2,
+ unit = "C",
+ recordedAt = "2024-01-15T09:30:00Z",
+ note = "Oral temperature"
+ })).EnsureSuccessStatusCode();
+
+ (await client.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/draft/observations",
+ new
+ {
+ observationCode = "BP_SYS",
+ value = 120.0,
+ unit = "mmHg",
+ recordedAt = "2024-01-15T09:31:00Z"
+ })).EnsureSuccessStatusCode();
+
+ // Submit for verification
+ (await client.PostAsync(
+ $"/api/v1/digitization-batches/{batchId}/submit-for-verification",
+ null)).EnsureSuccessStatusCode();
+
+ // === Verify as verifier1 ===
+ client = await AuthHelper.LoginAsync(fixture, "verifier1");
+
+ var verifyRequest = new VerifyBatchRequest(
+ new List
+ {
+ new("patient.fullName", "ok", null),
+ new("observation.heartRate", "ok", null),
+ new("observation.temperature", "ok", null),
+ new("observation.bloodPressureSystolic", "ok", null)
+ },
+ Passed: true
+ );
+
+ var verifyResponse = await client.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/verify", verifyRequest);
+ verifyResponse.EnsureSuccessStatusCode();
+
+ return batchId;
+ }
+
+ private static async Task GetUserIdAsync(ApiFixture fixture, string username)
+ {
+ var client = await AuthHelper.LoginAsync(fixture, username);
+ var meResponse = await client.GetFromJsonAsync("/api/v1/auth/me");
+ return meResponse.GetProperty("data").GetProperty("id").GetGuid();
+ }
+
+ private static byte[] GenerateTestPdf()
+ {
+ // Minimal valid PDF for testing
+ var pdf = "%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" +
+ "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" +
+ "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n" +
+ $"%%EOF\n%% test-{Guid.NewGuid()}";
+ return System.Text.Encoding.ASCII.GetBytes(pdf);
+ }
+}
\ No newline at end of file
diff --git a/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs b/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs
index 4852745..7dffd83 100644
--- a/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs
+++ b/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs
@@ -7,9 +7,12 @@ public static class DbResetHelper
public static async Task ResetAsync(AppDbContext db)
{
await db.Database.ExecuteSqlRawAsync(@"
- TRUNCATE TABLE digitization_events, draft_observations,
- draft_encounters, draft_patients,
- scanned_documents, digitization_batches, users
+ TRUNCATE TABLE clinical.outbox_events, clinical.observations,
+ clinical.encounters, clinical.patients,
+ idempotency_records,
+ digitization_events, draft_observations,
+ draft_encounters, draft_patients,
+ scanned_documents, digitization_batches, users
RESTART IDENTITY CASCADE;
");
}
diff --git a/VigilCareRecordsAPI.Tests/PromotionTests.cs b/VigilCareRecordsAPI.Tests/PromotionTests.cs
new file mode 100644
index 0000000..dafc955
--- /dev/null
+++ b/VigilCareRecordsAPI.Tests/PromotionTests.cs
@@ -0,0 +1,448 @@
+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);
+ }
+}
\ No newline at end of file
diff --git a/VigilCareRecordsAPI/Controllers/ApprovalController.cs b/VigilCareRecordsAPI/Controllers/ApprovalController.cs
new file mode 100644
index 0000000..6b30213
--- /dev/null
+++ b/VigilCareRecordsAPI/Controllers/ApprovalController.cs
@@ -0,0 +1,86 @@
+
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+///
+/// Approval and promotion of verified digitization batches to VigilCareClinical live tables.
+///
+[ApiController]
+[Route("api/v1/digitization-batches")]
+[Produces("application/json")]
+[Authorize]
+public class ApprovalController : ControllerBase
+{
+ private readonly IPromotionService _promotion;
+ private readonly ILogger _logger;
+
+ public ApprovalController(IPromotionService promotion, ILogger logger)
+ {
+ _promotion = promotion;
+ _logger = logger;
+ }
+
+ ///
+ /// Approves a verified or awaiting-clinical-approval batch and promotes its draft data
+ /// into live VigilCareClinical tables (Patient, Encounter, Observations).
+ ///
+ /// Requires the Idempotency-Key header for safe retries. If the same key is resubmitted,
+ /// the original response is returned without re-executing the promotion.
+ ///
+ /// Separation of duties: the approver cannot be the entry clerk or verifier of the same batch.
+ ///
+ /// The batch ID to approve.
+ /// Optional request body with alert configuration.
+ /// Promotion result with live IDs for patient, encounter, and observations.
+ [HttpPost("{id:guid}/approve")]
+ [Authorize(Roles = "CLINICAL_APPROVER,ADMINISTRATOR")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse