using System.Net; using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; /// /// Tests for batch cancellation and sort parameters. /// [Collection("Database")] public class BatchOperationsTests : IAsyncLifetime { private readonly ApiFixture _fixture; private HttpClient _adminClient = null!; public BatchOperationsTests(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); _adminClient = await AuthHelper.LoginAsync(_fixture, "admin1"); } public Task DisposeAsync() => Task.CompletedTask; // --------------------------------------------------------------- // Cancellation tests // --------------------------------------------------------------- [Fact] public async Task Cancel_UploadedBatch_Returns200AndTransitionsToCancelled() { // Arrange: upload a batch var batchId = await UploadBatchAsync(); // Act var response = await _adminClient.PostAsJsonAsync( $"/api/v1/digitization-batches/{batchId}/cancel", new { Reason = "Wrong document scanned" }); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var body = await response.Content.ReadFromJsonAsync(); body.GetProperty("data").GetProperty("status").GetString() .Should().Be("CANCELLED"); using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var batch = await db.DigitizationBatches.FindAsync(batchId); batch!.Status.Should().Be(BatchStatus.Cancelled); var cancelEvent = await db.DigitizationEvents .FirstOrDefaultAsync(e => e.BatchId == batchId && e.EventType == DigitizationEventType.Cancelled); cancelEvent.Should().NotBeNull(); } [Fact] public async Task Cancel_InEntryBatch_Returns200() { // Arrange: upload + assign (transitions to IN_ENTRY) var batchId = await UploadBatchAsync(); using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var entryUserId = await BatchSeedHelper.UserIdAsync(db, "entry1"); await _adminClient.PatchAsJsonAsync( $"/api/v1/digitization-batches/{batchId}/assign", new { EntryClerkUserId = entryUserId }); // Act var response = await _adminClient.PostAsJsonAsync( $"/api/v1/digitization-batches/{batchId}/cancel", new { Reason = "Test upload during training" }); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); // Reload to verify db.ChangeTracker.Clear(); var batch = await db.DigitizationBatches.FindAsync(batchId); batch!.Status.Should().Be(BatchStatus.Cancelled); } [Fact] public async Task Cancel_RejectedBatch_Returns200() { // Arrange: seed a rejected batch using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var entryUserId = await BatchSeedHelper.UserIdAsync(db, "entry1"); var rejectedBatch = await BatchSeedHelper.SeedBatchInRejectedAsync(db, entryUserId); // Act var response = await _adminClient.PostAsJsonAsync( $"/api/v1/digitization-batches/{rejectedBatch.Id}/cancel", new { Reason = "Patient linked incorrectly" }); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); } [Fact] public async Task Cancel_PromotedBatch_Returns409() { // Arrange: drive batch to promoted var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync(_fixture); var approverClient = await AuthHelper.LoginAsync(_fixture, "approver1"); approverClient.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString()); await approverClient.PostAsJsonAsync( $"/api/v1/digitization-batches/{batchId}/approve", new ApproveRequest()); approverClient.DefaultRequestHeaders.Remove("Idempotency-Key"); // Act var response = await _adminClient.PostAsJsonAsync( $"/api/v1/digitization-batches/{batchId}/cancel", new { Reason = "Should not work" }); // Assert response.StatusCode.Should().Be(HttpStatusCode.Conflict); } [Fact] public async Task Cancel_NonAdmin_Returns403() { var batchId = await UploadBatchAsync(); var entryClient = await AuthHelper.LoginAsync(_fixture, "entry1"); var response = await entryClient.PostAsJsonAsync( $"/api/v1/digitization-batches/{batchId}/cancel", new { Reason = "Should not be allowed" }); response.StatusCode.Should().Be(HttpStatusCode.Forbidden); } [Fact] public async Task Cancel_CancelledBatchExcludedFromEntryQueue() { // Arrange: seed an IN_ENTRY batch and cancel it using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var entryUserId = await BatchSeedHelper.UserIdAsync(db, "entry1"); var batchId = await UploadBatchAsync(); await _adminClient.PatchAsJsonAsync( $"/api/v1/digitization-batches/{batchId}/assign", new { EntryClerkUserId = entryUserId }); await _adminClient.PostAsJsonAsync( $"/api/v1/digitization-batches/{batchId}/cancel", new { Reason = "Cleanup" }); // Act var entryClient = await AuthHelper.LoginAsync(_fixture, "entry1"); var response = await entryClient.GetAsync("/api/v1/work-queue/entry"); response.StatusCode.Should().Be(HttpStatusCode.OK); var body = await response.Content.ReadFromJsonAsync(); var items = body.GetProperty("data").GetProperty("items"); var batchIds = Enumerable.Range(0, items.GetArrayLength()) .Select(i => items[i].GetProperty("batchId").GetString()) .ToList(); batchIds.Should().NotContain(batchId.ToString()); } // --------------------------------------------------------------- // Sort parameter tests // --------------------------------------------------------------- [Fact] public async Task List_SortByCreatedAtAsc_ReturnsOldestFirst() { // Arrange: create two batches var batchId1 = await UploadBatchAsync(); await Task.Delay(50); var batchId2 = await UploadBatchAsync(); // Act var response = await _adminClient.GetAsync( "/api/v1/digitization-batches?sortBy=createdAt&sortDirection=asc"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var body = await response.Content.ReadFromJsonAsync(); var items = body.GetProperty("data").GetProperty("items"); var ids = Enumerable.Range(0, items.GetArrayLength()) .Select(i => items[i].GetProperty("id").GetGuid()) .ToList(); var idx1 = ids.IndexOf(batchId1); var idx2 = ids.IndexOf(batchId2); idx1.Should().BeLessThan(idx2, "oldest batch should come first with asc sort"); } [Fact] public async Task List_InvalidSortField_Returns422() { var response = await _adminClient.GetAsync( "/api/v1/digitization-batches?sortBy=invalidField"); response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); } [Fact] public async Task WorkQueue_SortByCreatedAtDesc_ReturnsNewestFirst() { // Arrange: seed two pending verification batches with different ages using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var entryUserId = await BatchSeedHelper.UserIdAsync(db, "entry1"); var olderBatch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync(db, entryUserId); var newerBatch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync(db, entryUserId); // Make the "older" batch actually older olderBatch.CreatedAt = DateTimeOffset.UtcNow.AddHours(-2); olderBatch.UpdatedAt = DateTimeOffset.UtcNow.AddHours(-2); newerBatch.CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5); newerBatch.UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-5); await db.SaveChangesAsync(); // Act var verifierClient = await AuthHelper.LoginAsync(_fixture, "verifier1"); var response = await verifierClient.GetAsync( "/api/v1/work-queue/verification?sortBy=createdAt&sortDirection=desc"); response.StatusCode.Should().Be(HttpStatusCode.OK); var body = await response.Content.ReadFromJsonAsync(); var items = body.GetProperty("data").GetProperty("items"); var batchIds = Enumerable.Range(0, items.GetArrayLength()) .Select(i => items[i].GetProperty("batchId").GetString()) .ToList(); var idxNewer = batchIds.IndexOf(newerBatch.Id.ToString()); var idxOlder = batchIds.IndexOf(olderBatch.Id.ToString()); idxNewer.Should().BeLessThan(idxOlder, "newest batch should come first with desc sort"); } // --------------------------------------------------------------- // Helpers // --------------------------------------------------------------- private async Task UploadBatchAsync() { 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 response = await intakeClient.PostAsync("/api/v1/digitization-batches", formData); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadFromJsonAsync(); return body.GetProperty("data").GetProperty("id").GetGuid(); } }