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 | ---