diff --git a/VigilCareRecordsAPI.Tests/AuthTests.cs b/VigilCareRecordsAPI.Tests/AuthTests.cs
new file mode 100644
index 0000000..65ad217
--- /dev/null
+++ b/VigilCareRecordsAPI.Tests/AuthTests.cs
@@ -0,0 +1,151 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+
+///
+/// Integration tests for authentication flow: login, refresh, logout, and role-based access.
+///
+[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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ var data = body.GetProperty("data");
+ data.GetProperty("username").GetString().Should().Be("approver1");
+ data.GetProperty("role").GetString().Should().Be("CLINICAL_APPROVER");
+ }
+}
diff --git a/VigilCareRecordsAPI.Tests/ConcurrencyTests.cs b/VigilCareRecordsAPI.Tests/ConcurrencyTests.cs
new file mode 100644
index 0000000..dfaaec2
--- /dev/null
+++ b/VigilCareRecordsAPI.Tests/ConcurrencyTests.cs
@@ -0,0 +1,131 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+
+///
+/// Integration tests for concurrent operations: duplicate batch creation and
+/// parallel assignment attempts.
+///
+[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();
+ await DbResetHelper.ResetAsync(db);
+ await DataSeeder.SeedAsync(db);
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ ///
+ /// Two parallel assignment requests for the same batch — exactly one should succeed,
+ /// the other should get 409 BATCH_ALREADY_ASSIGNED.
+ ///
+ [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();
+ var batchId = uploadBody.GetProperty("data").GetProperty("id").GetGuid();
+
+ // Get user IDs
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ 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);
+ }
+
+ ///
+ /// Two uploads with the same file content for the same patient — the Redis dedup
+ /// guard should ensure exactly one succeeds.
+ ///
+ [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();
+ 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 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);
+ }
+}
diff --git a/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs b/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs
index 44f301a..bb62f8e 100644
--- a/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs
+++ b/VigilCareRecordsAPI.Tests/Helpers/DbResetHelper.cs
@@ -11,7 +11,7 @@ public static class DbResetHelper
clinical.clinical_alerts, clinical.alert_thresholds,
clinical.outbox_events, clinical.observations,
clinical.encounters, clinical.patients,
- idempotency_records,
+ idempotency_records, promotion_attempts,
digitization_events, draft_observations,
draft_encounters, draft_patients,
scanned_documents, digitization_batches, users
diff --git a/VigilCareRecordsAPI.Tests/PromotionRetryTests.cs b/VigilCareRecordsAPI.Tests/PromotionRetryTests.cs
new file mode 100644
index 0000000..35d774b
--- /dev/null
+++ b/VigilCareRecordsAPI.Tests/PromotionRetryTests.cs
@@ -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;
+
+///
+/// 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);
+ }
+}
diff --git a/docs/vigilcare-records-gap-analysis.md b/docs/vigilcare-records-gap-analysis.md
index 063e4a4..d4a13d6 100644
--- a/docs/vigilcare-records-gap-analysis.md
+++ b/docs/vigilcare-records-gap-analysis.md
@@ -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 |
| 22 | No frontend tests | P4 | E | 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 |
---