* 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>
110 lines
4.7 KiB
C#
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.");
|
|
}
|
|
}
|