elsa-core/test/unit/Elsa.UserTasks.Persistence.ConformanceTests/UserTaskConformanceTestBase.cs
Sipke Schoorstra 249bde780b
fix(user-tasks): align query sort and cursor contract across providers (#8144)
* fix(user-tasks): align query sort and cursor contract across providers

Implement the REST updated sort on EF and VNext, drop the InMemory-only
completed sort, and use one always-ascending Id tiebreaker plus the
production JSON base64url cursor codec in every repository.

Closes #8110

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(user-tasks): keep title cursor ties comparison-consistent

Use the same string.Compare relation for title ordering and cursor
tie detection so culture-equal Unicode forms are not skipped. Assert
updated sort order directly so a created fallback cannot pass.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(user-tasks): use one ordinal comparer for title sort and cursors

OrderBy, title-tie detection, and cursor filtering now share
StringComparer.Ordinal on the LINQ-to-objects providers so Unicode
variants cannot skip a page.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(user-tasks): share default title comparison across providers

InMemory and VNext title sort/cursors now use the same OrderBy plus
string.Compare relation as EF (SQL collation / current culture) so a
title cursor stays portable. Ties still use Compare == 0, not ordinal ==.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* docs(user-tasks): title cursors are provider-scoped, not portable

Restore InMemory/VNext ordinal title OrderBy/tie/cursor (EF stays
column-collation). Document that title pages must be recreated after a
provider or collation change; other sorts plus Id stay portable.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-14 06:43:03 +02:00

110 lines
4.7 KiB
C#

using Elsa.UserTasks.Contracts;
using Elsa.UserTasks.Models;
using Elsa.UserTasks.Persistence.ConformanceTests.Providers;
namespace Elsa.UserTasks.Persistence.ConformanceTests;
/// <summary>
/// Shared arrangement for every conformance class.
///
/// The stores are shared for the whole provider collection so a container-backed provider is migrated once,
/// and each test isolates itself with its own tenant instead. Every contract except the deliberately
/// tenant-agnostic invitation-hash lookup is tenant-scoped, so this is isolation, not a shortcut.
/// </summary>
public abstract class UserTaskConformanceTestBase(UserTaskStoreFixture fixture)
{
private int _sequence;
protected UserTaskStoreFixture Fixture { get; } = fixture;
protected IUserTaskRepository Repository => Fixture.Repository;
protected UserTaskStoreFixture.TestClock Clock => Fixture.Clock;
/// <summary>This test's private tenant. Never reused, so a shared store still gives per-test isolation.</summary>
protected string TenantId { get; } = $"tenant-{Guid.NewGuid():N}";
protected Task ActivateAsync() => Fixture.ActivateAsync();
protected ParticipantReference Subject(string id = "user-1") => new(TenantId, "oidc", UserTaskParticipantType.User, id);
protected ParticipantReference Group(string id) => new(TenantId, "oidc", UserTaskParticipantType.Group, id);
/// <summary>Builds a task in this test's tenant with store-unique keys, ready for <c>AddProjectionAsync</c>.</summary>
protected UserTask CreateTask(
ParticipantReference? candidate = null,
string title = "Approve request",
int priority = 50,
DateTimeOffset? dueAt = null,
DateTimeOffset? createdAt = null,
DateTimeOffset? updatedAt = null)
{
var ordinal = ++_sequence;
var created = createdAt ?? Clock.UtcNow.AddMinutes(ordinal);
return new()
{
// Ordinal-prefixed so the identity tiebreaker is predictable and a failure is readable.
Id = $"task-{ordinal:D4}-{Guid.NewGuid():N}",
TenantId = TenantId,
WorkflowDefinitionId = "definition-1",
WorkflowInstanceId = "instance-1",
ActivityInstanceId = "activity-1",
BookmarkId = $"bookmark-{Guid.NewGuid():N}",
MaterializationKey = $"materialization-{Guid.NewGuid():N}",
Title = title,
Summary = "Review the request",
Tags = ["finance"],
Priority = priority,
DueAt = dueAt,
CandidateUsers = candidate is null ? [] : [candidate],
InvitationDefinitions = [new UserTaskInvitationDefinition("bearer", ["Complete"], BearerOnly: true)],
CreatedAt = created,
UpdatedAt = updatedAt ?? created
};
}
/// <summary>Projects a task and returns the stored copy, so a test starts from committed state in one line.</summary>
protected async Task<UserTask> ProjectAsync(UserTask task)
{
await Repository.AddProjectionAsync(task);
return await Repository.GetAsync(task.TenantId, task.Id)
?? throw new InvalidOperationException($"The projection of '{task.Id}' was not readable afterwards.");
}
protected async Task<UserTask> GetAsync(string taskId) =>
await Repository.GetAsync(TenantId, taskId) ?? throw new InvalidOperationException($"Task '{taskId}' was not found.");
protected UserTaskQuery Query(
UserTaskQueryScopeKind kind = UserTaskQueryScopeKind.Available,
ParticipantReference? subject = null,
int limit = 50,
string sort = "created",
bool descending = false,
bool includeTotalCount = false,
string? cursor = null) => new()
{
TenantId = TenantId,
Limit = limit,
Sort = sort,
Descending = descending,
IncludeTotalCount = includeTotalCount,
Cursor = cursor,
Scope = new(TenantId, subject ?? Subject(), [], Kind: kind)
};
/// <summary>Pages a query to exhaustion through its cursors and returns the ids in the order seen.</summary>
protected async Task<IReadOnlyList<string>> PageThroughAsync(UserTaskQuery query, int pageSize)
{
var seen = new List<string>();
string? cursor = null;
for (var page = 0; page < 100; page++)
{
var result = await Repository.QueryAsync(query with { Limit = pageSize, Cursor = cursor });
seen.AddRange(result.Items.Select(x => x.Id));
if (result.NextCursor is null)
return seen;
cursor = result.NextCursor;
}
throw new InvalidOperationException("The cursor never terminated; paging looped past 100 pages.");
}
}