Do Pagination missing sortBy and sortDirection parameters

This commit is contained in:
voltsrage
2026-06-27 16:14:52 +08:00
parent 299e6e1453
commit 1c7e7fee7d
19 changed files with 2082 additions and 102 deletions
@@ -0,0 +1,275 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Tests for batch cancellation and sort parameters.
/// </summary>
[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<AppDbContext>();
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<JsonElement>();
body.GetProperty("data").GetProperty("status").GetString()
.Should().Be("CANCELLED");
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<AppDbContext>();
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<AppDbContext>();
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<AppDbContext>();
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<JsonElement>();
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<JsonElement>();
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<AppDbContext>();
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<JsonElement>();
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<Guid> 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<JsonElement>();
return body.GetProperty("data").GetProperty("id").GetGuid();
}
}
@@ -434,10 +434,11 @@ public class VerificationTests : IAsyncLifetime
}
/// <summary>
/// Test: Entry queue returns Uploaded, InEntry, and Rejected batches.
/// Test: Entry queue returns InEntry and Rejected batches, but NOT Uploaded
/// (Uploaded batches must be assigned first, which transitions them to InEntry).
/// </summary>
[Fact]
public async Task EntryQueue_ReturnsUploadedInEntryAndRejectedBatches()
public async Task EntryQueue_ReturnsInEntryAndRejectedBatches_ExcludesUploaded()
{
// Arrange
using var scope = _fixture.Services.CreateScope();
@@ -451,7 +452,7 @@ public class VerificationTests : IAsyncLifetime
var pendingBatch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync(
db, (await BatchSeedHelper.UserIdAsync(db, "entry2")));
// Seed an Uploaded batch directly
// Seed an Uploaded batch directly (should NOT appear — must be assigned first)
var uploadedBatchId = Guid.NewGuid();
db.DigitizationBatches.Add(new DigitizationBatch
{
@@ -495,12 +496,12 @@ public class VerificationTests : IAsyncLifetime
.ToList();
batchIds.Should().Contain(rejectedBatch.Id.ToString());
batchIds.Should().Contain(uploadedBatchId.ToString());
batchIds.Should().NotContain(uploadedBatchId.ToString(),
"Uploaded batches must be assigned before appearing in the entry queue");
batchIds.Should().NotContain(pendingBatch.Id.ToString());
var entryStatuses = new[]
{
BatchStatus.Uploaded.ToDbString(),
BatchStatus.InEntry.ToDbString(),
BatchStatus.Rejected.ToDbString()
};