diff --git a/specs/013-user-tasks/contracts/rest-api.md b/specs/013-user-tasks/contracts/rest-api.md index 5f13d96f9..fc66a1118 100644 --- a/specs/013-user-tasks/contracts/rest-api.md +++ b/specs/013-user-tasks/contracts/rest-api.md @@ -24,7 +24,7 @@ The descriptor is advisory: it decides what a client renders, never what the ser `GET /user-tasks?scope=assigned|available|history|all|needsAttention&cursor=&limit=&sort=&direction=&status=&priorityFrom=&priorityTo=&due=&from=&to=&workflowDefinitionId=&workflowInstanceId=&reference=&taskType=&search=&includeTotalCount=` -Returns `{ items, nextCursor, totalCount? }`. Default/max limit: 50/200. Stable sorts: created, due, priority, title, updated, each with an ID tiebreaker. +Returns `{ items, nextCursor, totalCount? }`. Default/max limit: 50/200. Stable sorts: created, due, priority, title, updated, each with an always-ascending ID tiebreaker. Title cursors are **not** portable across persistence providers or database collations (InMemory/VNext use ordinal title comparison; EF uses column collation) — recreate the list after a provider change. `created`, `updated`, `due`, `priority`, and the Id tiebreaker remain the portable page stream. - `scope` is part of the authorization predicate, not a display filter. `all` and `needsAttention` require manager scope and answer `403` — not an empty page — for anyone else. - `status` is repeatable. Unknown values are dropped rather than rejected, so a stale bookmark still loads. diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskRepository.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskRepository.cs index 82dee445e..f6db52ff9 100644 --- a/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskRepository.cs +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskRepository.cs @@ -349,14 +349,19 @@ public sealed class EFCoreUserTaskRepository(Store ApplyOrdering(IQueryable records, UserTaskQuery query) { + // Id is always ThenBy ascending so a page of ties is the same in both directions and across providers. return query.Sort.ToLowerInvariant() switch { "due" when query.Descending => records.OrderBy(x => x.DueAt == null).ThenByDescending(x => x.DueAt).ThenBy(x => x.Id), "due" => records.OrderBy(x => x.DueAt == null).ThenBy(x => x.DueAt).ThenBy(x => x.Id), "priority" when query.Descending => records.OrderByDescending(x => x.Priority).ThenBy(x => x.Id), "priority" => records.OrderBy(x => x.Priority).ThenBy(x => x.Id), + // Title OrderBy and cursor use column collation (self-consistent). Title cursors are not + // portable to InMemory/VNext ordinal comparison; recreate the list after a provider change. "title" when query.Descending => records.OrderByDescending(x => x.Title).ThenBy(x => x.Id), "title" => records.OrderBy(x => x.Title).ThenBy(x => x.Id), + "updated" when query.Descending => records.OrderByDescending(x => x.UpdatedAt).ThenBy(x => x.Id), + "updated" => records.OrderBy(x => x.UpdatedAt).ThenBy(x => x.Id), _ when query.Descending => records.OrderByDescending(x => x.CreatedAt).ThenBy(x => x.Id), _ => records.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id) }; @@ -373,12 +378,15 @@ public sealed class EFCoreUserTaskRepository(Store x.Priority < priority || (x.Priority == priority && string.Compare(x.Id, cursorId) > 0)) : records.Where(x => x.Priority > priority || (x.Priority == priority && string.Compare(x.Id, cursorId) > 0)), "title" => query.Descending - ? records.Where(x => string.Compare(x.Title, cursorValue) < 0 || (x.Title == cursorValue && string.Compare(x.Id, cursorId) > 0)) - : records.Where(x => string.Compare(x.Title, cursorValue) > 0 || (x.Title == cursorValue && string.Compare(x.Id, cursorId) > 0)), + ? records.Where(x => string.Compare(x.Title, cursorValue) < 0 || (string.Compare(x.Title, cursorValue) == 0 && string.Compare(x.Id, cursorId) > 0)) + : records.Where(x => string.Compare(x.Title, cursorValue) > 0 || (string.Compare(x.Title, cursorValue) == 0 && string.Compare(x.Id, cursorId) > 0)), "due" when cursorValue == "~null" => records.Where(x => x.DueAt == null && string.Compare(x.Id, cursorId) > 0), "due" when DateTimeOffset.TryParse(cursorValue, out var dueAt) => query.Descending ? records.Where(x => x.DueAt == null || x.DueAt < dueAt || (x.DueAt == dueAt && string.Compare(x.Id, cursorId) > 0)) : records.Where(x => x.DueAt == null || x.DueAt > dueAt || (x.DueAt == dueAt && string.Compare(x.Id, cursorId) > 0)), + "updated" when DateTimeOffset.TryParse(cursorValue, out var updatedAt) => query.Descending + ? records.Where(x => x.UpdatedAt < updatedAt || (x.UpdatedAt == updatedAt && string.Compare(x.Id, cursorId) > 0)) + : records.Where(x => x.UpdatedAt > updatedAt || (x.UpdatedAt == updatedAt && string.Compare(x.Id, cursorId) > 0)), _ when DateTimeOffset.TryParse(cursorValue, out var createdAt) => query.Descending ? records.Where(x => x.CreatedAt < createdAt || (x.CreatedAt == createdAt && string.Compare(x.Id, cursorId) > 0)) : records.Where(x => x.CreatedAt > createdAt || (x.CreatedAt == createdAt && string.Compare(x.Id, cursorId) > 0)), @@ -393,6 +401,7 @@ public sealed class EFCoreUserTaskRepository(Store record.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture), "title" => record.Title, "due" => record.DueAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture) ?? "~null", + "updated" => record.UpdatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture), _ => record.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture) }; return Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(new[] { value, record.Id }, JsonOptions)) diff --git a/src/modules/Elsa.UserTasks.Persistence.VNext/Repositories/VNextUserTaskRepository.cs b/src/modules/Elsa.UserTasks.Persistence.VNext/Repositories/VNextUserTaskRepository.cs index 6e1378525..dca94ba7c 100644 --- a/src/modules/Elsa.UserTasks.Persistence.VNext/Repositories/VNextUserTaskRepository.cs +++ b/src/modules/Elsa.UserTasks.Persistence.VNext/Repositories/VNextUserTaskRepository.cs @@ -18,6 +18,12 @@ public sealed class VNextUserTaskRepository(IDocumentStore documentStore) : IUse Converters = { new JsonStringEnumConverter() } }; + /// + /// Title OrderBy, ties, and cursors share this comparer. Title cursors are not portable to EF + /// (column collation) or across databases; recreate the list after a provider change. + /// + private static readonly StringComparer TitleComparer = StringComparer.Ordinal; + public async Task GetAsync(string tenantId, string taskId, CancellationToken cancellationToken = default) { var document = await documentStore.LoadAsync(StorageUnitName, DocumentId(tenantId, taskId), cancellationToken); @@ -270,11 +276,13 @@ public sealed class VNextUserTaskRepository(IDocumentStore documentStore) : IUse || (task.IsOpen && task.Assignee is null) || task.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling; + // Id is always ThenBy ascending so a page of ties is the same in both directions and across providers. private static IEnumerable ApplyOrdering(IEnumerable tasks, UserTaskQuery query) => query.Sort.ToLowerInvariant() switch { "priority" => query.Descending ? tasks.OrderByDescending(x => x.Priority).ThenBy(x => x.Id) : tasks.OrderBy(x => x.Priority).ThenBy(x => x.Id), - "title" => query.Descending ? tasks.OrderByDescending(x => x.Title).ThenBy(x => x.Id) : tasks.OrderBy(x => x.Title).ThenBy(x => x.Id), + "title" => query.Descending ? tasks.OrderByDescending(x => x.Title, TitleComparer).ThenBy(x => x.Id) : tasks.OrderBy(x => x.Title, TitleComparer).ThenBy(x => x.Id), "due" => query.Descending ? tasks.OrderBy(x => x.DueAt == null).ThenByDescending(x => x.DueAt).ThenBy(x => x.Id) : tasks.OrderBy(x => x.DueAt == null).ThenBy(x => x.DueAt).ThenBy(x => x.Id), + "updated" => query.Descending ? tasks.OrderByDescending(x => x.UpdatedAt).ThenBy(x => x.Id) : tasks.OrderBy(x => x.UpdatedAt).ThenBy(x => x.Id), _ => query.Descending ? tasks.OrderByDescending(x => x.CreatedAt).ThenBy(x => x.Id) : tasks.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id) }; @@ -285,14 +293,23 @@ public sealed class VNextUserTaskRepository(IDocumentStore documentStore) : IUse return query.Sort.ToLowerInvariant() switch { "priority" when int.TryParse(value, out var priority) => tasks.Where(x => query.Descending ? x.Priority < priority || x.Priority == priority && string.Compare(x.Id, id) > 0 : x.Priority > priority || x.Priority == priority && string.Compare(x.Id, id) > 0), - "title" => tasks.Where(x => query.Descending ? string.Compare(x.Title, value) < 0 || x.Title == value && string.Compare(x.Id, id) > 0 : string.Compare(x.Title, value) > 0 || x.Title == value && string.Compare(x.Id, id) > 0), + "title" => tasks.Where(x => TitleIsAfterCursor(x.Title, value, x.Id, id, query.Descending)), "due" when value == "~null" => tasks.Where(x => x.DueAt == null && string.Compare(x.Id, id) > 0), "due" when DateTimeOffset.TryParse(value, out var due) => tasks.Where(x => x.DueAt == null || query.Descending && x.DueAt < due || !query.Descending && x.DueAt > due || x.DueAt == due && string.Compare(x.Id, id) > 0), + "updated" when DateTimeOffset.TryParse(value, out var updated) => tasks.Where(x => query.Descending ? x.UpdatedAt < updated || x.UpdatedAt == updated && string.Compare(x.Id, id) > 0 : x.UpdatedAt > updated || x.UpdatedAt == updated && string.Compare(x.Id, id) > 0), _ when DateTimeOffset.TryParse(value, out var created) => tasks.Where(x => query.Descending ? x.CreatedAt < created || x.CreatedAt == created && string.Compare(x.Id, id) > 0 : x.CreatedAt > created || x.CreatedAt == created && string.Compare(x.Id, id) > 0), _ => tasks }; } + private static bool TitleIsAfterCursor(string title, string cursorTitle, string id, string cursorId, bool descending) + { + var comparison = TitleComparer.Compare(title, cursorTitle); + return descending + ? comparison < 0 || comparison == 0 && string.Compare(id, cursorId) > 0 + : comparison > 0 || comparison == 0 && string.Compare(id, cursorId) > 0; + } + private static string CreateCursor(UserTask task, string sort) { var value = sort.ToLowerInvariant() switch @@ -300,6 +317,7 @@ public sealed class VNextUserTaskRepository(IDocumentStore documentStore) : IUse "priority" => task.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture), "title" => task.Title, "due" => task.DueAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture) ?? "~null", + "updated" => task.UpdatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture), _ => task.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture) }; return Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(new[] { value, task.Id }, JsonOptions)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); diff --git a/src/modules/Elsa.UserTasks/Repositories/InMemoryUserTaskRepository.cs b/src/modules/Elsa.UserTasks/Repositories/InMemoryUserTaskRepository.cs index 5e0e8c5a8..78b193d06 100644 --- a/src/modules/Elsa.UserTasks/Repositories/InMemoryUserTaskRepository.cs +++ b/src/modules/Elsa.UserTasks/Repositories/InMemoryUserTaskRepository.cs @@ -1,5 +1,5 @@ -using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using Elsa.UserTasks.Contracts; using Elsa.UserTasks.Models; @@ -11,6 +11,17 @@ namespace Elsa.UserTasks.Repositories; /// public sealed class InMemoryUserTaskRepository : IUserTaskRepository { + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + /// + /// Title OrderBy, ties, and cursors share this comparer. Title cursors are not portable to EF + /// (column collation) or across databases; recreate the list after a provider change. + /// + private static readonly StringComparer TitleComparer = StringComparer.Ordinal; + private readonly object _sync = new(); private readonly Dictionary _tasks = new(StringComparer.Ordinal); @@ -44,14 +55,13 @@ public sealed class InMemoryUserTaskRepository : IUserTaskRepository .Where(x => MatchesSearch(x, query.Search)); var filteredCount = query.IncludeTotalCount ? items.Count() : 0; - var materialized = Sort(items, query.Sort, query.Descending).ToList(); - if (!string.IsNullOrWhiteSpace(query.Cursor) && DecodeCursor(query.Cursor!) is { } cursor) - materialized = materialized.Where(x => IsAfterCursor(x, cursor, query.Descending, query.Sort)).ToList(); + var materialized = ApplyOrdering(items, query).ToList(); + materialized = ApplyCursor(materialized, query).ToList(); int? total = query.IncludeTotalCount ? filteredCount : null; var limit = Math.Clamp(query.Limit, 1, 200); var page = materialized.Take(limit).Select(Clone).ToArray(); - var next = materialized.Count > limit ? EncodeCursor(page[^1], query.Sort) : null; + var next = materialized.Count > limit ? CreateCursor(page[^1], query.Sort) : null; return Task.FromResult(new UserTaskQueryResult(page, next, total)); } } @@ -191,120 +201,76 @@ public sealed class InMemoryUserTaskRepository : IUserTaskRepository || task.Tags.Any(x => x.Contains(value, StringComparison.OrdinalIgnoreCase)); } - private static IEnumerable Sort(IEnumerable items, string sort, bool descending) + // Same contract as EF/VNext: REST sorts only, Id always ThenBy ascending, JSON base64url cursors. + private static IEnumerable ApplyOrdering(IEnumerable tasks, UserTaskQuery query) => query.Sort.ToLowerInvariant() switch { - var normalized = NormalizeSort(sort); - return normalized switch + "priority" => query.Descending ? tasks.OrderByDescending(x => x.Priority).ThenBy(x => x.Id) : tasks.OrderBy(x => x.Priority).ThenBy(x => x.Id), + "title" => query.Descending ? tasks.OrderByDescending(x => x.Title, TitleComparer).ThenBy(x => x.Id) : tasks.OrderBy(x => x.Title, TitleComparer).ThenBy(x => x.Id), + "due" => query.Descending ? tasks.OrderBy(x => x.DueAt == null).ThenByDescending(x => x.DueAt).ThenBy(x => x.Id) : tasks.OrderBy(x => x.DueAt == null).ThenBy(x => x.DueAt).ThenBy(x => x.Id), + "updated" => query.Descending ? tasks.OrderByDescending(x => x.UpdatedAt).ThenBy(x => x.Id) : tasks.OrderBy(x => x.UpdatedAt).ThenBy(x => x.Id), + _ => query.Descending ? tasks.OrderByDescending(x => x.CreatedAt).ThenBy(x => x.Id) : tasks.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id) + }; + + private static IEnumerable ApplyCursor(IEnumerable tasks, UserTaskQuery query) + { + if (string.IsNullOrWhiteSpace(query.Cursor) || !TryReadCursor(query.Cursor, out var value, out var id)) + return tasks; + return query.Sort.ToLowerInvariant() switch { - "due" => descending - ? items.OrderByDescending(x => x.DueAt.HasValue).ThenByDescending(x => x.DueAt).ThenByDescending(x => x.Id, StringComparer.Ordinal) - : items.OrderBy(x => x.DueAt.HasValue ? 0 : 1).ThenBy(x => x.DueAt).ThenBy(x => x.Id, StringComparer.Ordinal), - "priority" => descending - ? items.OrderByDescending(x => x.Priority).ThenByDescending(x => x.Id, StringComparer.Ordinal) - : items.OrderBy(x => x.Priority).ThenBy(x => x.Id, StringComparer.Ordinal), - "updated" => descending - ? items.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.Id, StringComparer.Ordinal) - : items.OrderBy(x => x.UpdatedAt).ThenBy(x => x.Id, StringComparer.Ordinal), - "completed" => descending - ? items.OrderByDescending(x => x.CompletedAt.HasValue).ThenByDescending(x => x.CompletedAt).ThenByDescending(x => x.Id, StringComparer.Ordinal) - : items.OrderBy(x => x.CompletedAt.HasValue ? 0 : 1).ThenBy(x => x.CompletedAt).ThenBy(x => x.Id, StringComparer.Ordinal), - "title" => descending - ? items.OrderByDescending(x => x.Title, StringComparer.OrdinalIgnoreCase).ThenByDescending(x => x.Id, StringComparer.Ordinal) - : items.OrderBy(x => x.Title, StringComparer.OrdinalIgnoreCase).ThenBy(x => x.Id, StringComparer.Ordinal), - _ => descending - ? items.OrderByDescending(x => x.CreatedAt).ThenByDescending(x => x.Id, StringComparer.Ordinal) - : items.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id, StringComparer.Ordinal) + "priority" when int.TryParse(value, out var priority) => tasks.Where(x => query.Descending ? x.Priority < priority || x.Priority == priority && string.Compare(x.Id, id) > 0 : x.Priority > priority || x.Priority == priority && string.Compare(x.Id, id) > 0), + "title" => tasks.Where(x => TitleIsAfterCursor(x.Title, value, x.Id, id, query.Descending)), + "due" when value == "~null" => tasks.Where(x => x.DueAt == null && string.Compare(x.Id, id) > 0), + "due" when DateTimeOffset.TryParse(value, out var due) => tasks.Where(x => x.DueAt == null || query.Descending && x.DueAt < due || !query.Descending && x.DueAt > due || x.DueAt == due && string.Compare(x.Id, id) > 0), + "updated" when DateTimeOffset.TryParse(value, out var updated) => tasks.Where(x => query.Descending ? x.UpdatedAt < updated || x.UpdatedAt == updated && string.Compare(x.Id, id) > 0 : x.UpdatedAt > updated || x.UpdatedAt == updated && string.Compare(x.Id, id) > 0), + _ when DateTimeOffset.TryParse(value, out var created) => tasks.Where(x => query.Descending ? x.CreatedAt < created || x.CreatedAt == created && string.Compare(x.Id, id) > 0 : x.CreatedAt > created || x.CreatedAt == created && string.Compare(x.Id, id) > 0), + _ => tasks }; } - private static bool IsAfterCursor(UserTask task, (string Kind, string Value, string Id) cursor, bool descending, string sort) + private static bool TitleIsAfterCursor(string title, string cursorTitle, string id, string cursorId, bool descending) { - var normalized = NormalizeSort(sort); - // Due/completed ordering keeps null values at the end in both directions. A generic numeric - // comparison would incorrectly drop nulls after a descending non-null page (or reintroduce - // non-null values after a descending null page). - if (descending && (normalized is "due" or "completed")) + var comparison = TitleComparer.Compare(title, cursorTitle); + return descending + ? comparison < 0 || comparison == 0 && string.Compare(id, cursorId) > 0 + : comparison > 0 || comparison == 0 && string.Compare(id, cursorId) > 0; + } + + private static string CreateCursor(UserTask task, string sort) + { + var value = sort.ToLowerInvariant() switch { - var valueIsNull = SortValue(task, normalized) == null; - var cursorIsNull = cursor.Value == "~"; - if (valueIsNull != cursorIsNull) - return valueIsNull; - } - - var comparison = CompareSortValue(SortKind(sort), SortValue(task, sort), cursor.Kind, cursor.Value); - if (comparison == 0) - comparison = StringComparer.Ordinal.Compare(task.Id, cursor.Id); - return descending ? comparison < 0 : comparison > 0; + "priority" => task.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture), + "title" => task.Title, + "due" => task.DueAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture) ?? "~null", + "updated" => task.UpdatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + _ => task.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture) + }; + return Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(new[] { value, task.Id }, JsonOptions)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } - private static string EncodeCursor(UserTask task, string sort) - { - var kind = SortKind(sort); - var value = EncodeSortValue(SortValue(task, sort), kind); - return Convert.ToBase64String(Encoding.UTF8.GetBytes(kind + "|" + value + "|" + task.Id)); - } - - private static (string Kind, string Value, string Id)? DecodeCursor(string cursor) + private static bool TryReadCursor(string? cursor, out string value, out string id) { + value = id = ""; + if (string.IsNullOrWhiteSpace(cursor)) + return false; try { - var value = Encoding.UTF8.GetString(Convert.FromBase64String(cursor)); - var first = value.IndexOf('|'); - var last = value.LastIndexOf('|'); - return first <= 0 || last <= first ? null : (value[..first], value[(first + 1)..last], value[(last + 1)..]); + var padded = cursor.Replace('-', '+').Replace('_', '/') + new string('=', (4 - cursor.Length % 4) % 4); + var values = JsonSerializer.Deserialize(Convert.FromBase64String(padded), JsonOptions); + if (values is not [var parsedValue, var parsedId] || string.IsNullOrWhiteSpace(parsedId)) + return false; + value = parsedValue; + id = parsedId; + return true; } catch (FormatException) { - return null; + return false; + } + catch (JsonException) + { + return false; } - } - - private static string NormalizeSort(string sort) => sort.ToLowerInvariant() switch - { - "dueat" => "due", - "completedat" => "completed", - _ => sort.ToLowerInvariant() is "due" or "priority" or "updated" or "completed" or "title" ? sort.ToLowerInvariant() : "created" - }; - - private static string SortKind(string sort) => NormalizeSort(sort) switch - { - "priority" => "i", - "title" => "s", - "due" or "completed" => "n", - _ => "n" - }; - - private static object? SortValue(UserTask task, string sort) => NormalizeSort(sort) switch - { - "due" => task.DueAt, - "priority" => task.Priority, - "updated" => task.UpdatedAt, - "completed" => task.CompletedAt, - "title" => task.Title, - _ => task.CreatedAt - }; - - private static string EncodeSortValue(object? value, string kind) => value switch - { - null => "~", - DateTimeOffset date => date.UtcTicks.ToString(System.Globalization.CultureInfo.InvariantCulture), - int number => number.ToString(System.Globalization.CultureInfo.InvariantCulture), - _ => Convert.ToBase64String(Encoding.UTF8.GetBytes(value.ToString() ?? "")) - }; - - private static int CompareSortValue(string kind, object? value, string cursorKind, string cursorValue) - { - if (!string.Equals(kind, cursorKind, StringComparison.Ordinal)) - return 0; - if (value == null || cursorValue == "~") - return value == null && cursorValue == "~" ? 0 : value == null ? 1 : -1; - if (kind == "i") - return int.Parse(value.ToString()!, System.Globalization.CultureInfo.InvariantCulture).CompareTo(int.Parse(cursorValue, System.Globalization.CultureInfo.InvariantCulture)); - if (kind == "n") - return long.Parse(value switch { DateTimeOffset d => d.UtcTicks.ToString(System.Globalization.CultureInfo.InvariantCulture), _ => value.ToString()! }, System.Globalization.CultureInfo.InvariantCulture) - .CompareTo(long.Parse(cursorValue, System.Globalization.CultureInfo.InvariantCulture)); - var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(cursorValue)); - return StringComparer.OrdinalIgnoreCase.Compare(value.ToString(), decoded); } private static string Key(string tenantId, string taskId) => tenantId + "\0" + taskId; diff --git a/test/unit/Elsa.UserTasks.Persistence.ConformanceTests/UserTaskConformanceTestBase.cs b/test/unit/Elsa.UserTasks.Persistence.ConformanceTests/UserTaskConformanceTestBase.cs index 594887dc7..82810b6b5 100644 --- a/test/unit/Elsa.UserTasks.Persistence.ConformanceTests/UserTaskConformanceTestBase.cs +++ b/test/unit/Elsa.UserTasks.Persistence.ConformanceTests/UserTaskConformanceTestBase.cs @@ -34,7 +34,8 @@ public abstract class UserTaskConformanceTestBase(UserTaskStoreFixture fixture) string title = "Approve request", int priority = 50, DateTimeOffset? dueAt = null, - DateTimeOffset? createdAt = null) + DateTimeOffset? createdAt = null, + DateTimeOffset? updatedAt = null) { var ordinal = ++_sequence; var created = createdAt ?? Clock.UtcNow.AddMinutes(ordinal); @@ -56,7 +57,7 @@ public abstract class UserTaskConformanceTestBase(UserTaskStoreFixture fixture) CandidateUsers = candidate is null ? [] : [candidate], InvitationDefinitions = [new UserTaskInvitationDefinition("bearer", ["Complete"], BearerOnly: true)], CreatedAt = created, - UpdatedAt = created + UpdatedAt = updatedAt ?? created }; } diff --git a/test/unit/Elsa.UserTasks.Persistence.ConformanceTests/UserTaskRepositoryConformanceTests.cs b/test/unit/Elsa.UserTasks.Persistence.ConformanceTests/UserTaskRepositoryConformanceTests.cs index bb52100ca..a15614e27 100644 --- a/test/unit/Elsa.UserTasks.Persistence.ConformanceTests/UserTaskRepositoryConformanceTests.cs +++ b/test/unit/Elsa.UserTasks.Persistence.ConformanceTests/UserTaskRepositoryConformanceTests.cs @@ -327,6 +327,8 @@ public abstract class UserTaskRepositoryConformanceTests(UserTaskStoreFixture fi [InlineData("priority", true)] [InlineData("title", false)] [InlineData("title", true)] + [InlineData("updated", false)] + [InlineData("updated", true)] public async Task CursorsAreStableAcrossEverySupportedSortAndDirection(string sort, bool descending) { await ActivateAsync(); @@ -342,6 +344,21 @@ public abstract class UserTaskRepositoryConformanceTests(UserTaskStoreFixture fi Assert.Equal(expected, await PageThroughAsync(query, pageSize)); } + [ConformanceFact] + public async Task UpdatedSortUsesUpdatedAtThenAscendingId() + { + await ActivateAsync(); + await SeedSortableTasksAsync(); + + // Seed UpdatedAt order is Bravo, Foxtrot, Charlie+Delta (shared, Id tie), Echo, Alpha — + // not the created/title order — so a provider that still maps updated to created fails. + var ascending = await Repository.QueryAsync(Query(sort: "updated", limit: 200)); + Assert.Equal(["Bravo", "Foxtrot", "Charlie", "Delta", "Echo", "Alpha"], ascending.Items.Select(x => x.Title)); + + var descending = await Repository.QueryAsync(Query(sort: "updated", descending: true, limit: 200)); + Assert.Equal(["Alpha", "Echo", "Charlie", "Delta", "Foxtrot", "Bravo"], descending.Items.Select(x => x.Title)); + } + [ConformanceFact] public async Task TasksWithoutADueDateOrderLastInBothDirections() { @@ -392,21 +409,23 @@ public abstract class UserTaskRepositoryConformanceTests(UserTaskStoreFixture fi /// /// Seeds a set that exercises every sort key at once: distinct titles, distinct priorities, a mix of - /// present and absent due dates, and two rows sharing a due date so the identity tiebreaker is used. + /// present and absent due dates, updated times that are not the created order, and two rows sharing a + /// due date and two sharing an updated time so the identity tiebreaker is used. /// private async Task SeedSortableTasksAsync() { var subject = Subject(); var baseline = Clock.UtcNow; var shared = baseline.AddDays(3); + var sharedUpdated = baseline.AddHours(3); UserTask[] tasks = [ - CreateTask(subject, "Alpha", priority: 10, dueAt: baseline.AddDays(1)), - CreateTask(subject, "Bravo", priority: 90, dueAt: shared), - CreateTask(subject, "Charlie", priority: 50, dueAt: shared), - CreateTask(subject, "Delta", priority: 30, dueAt: baseline.AddDays(5)), - CreateTask(subject, "Echo", priority: 70, dueAt: null), - CreateTask(subject, "Foxtrot", priority: 20, dueAt: null) + CreateTask(subject, "Alpha", priority: 10, dueAt: baseline.AddDays(1), updatedAt: baseline.AddHours(6)), + CreateTask(subject, "Bravo", priority: 90, dueAt: shared, updatedAt: baseline.AddHours(1)), + CreateTask(subject, "Charlie", priority: 50, dueAt: shared, updatedAt: sharedUpdated), + CreateTask(subject, "Delta", priority: 30, dueAt: baseline.AddDays(5), updatedAt: sharedUpdated), + CreateTask(subject, "Echo", priority: 70, dueAt: null, updatedAt: baseline.AddHours(5)), + CreateTask(subject, "Foxtrot", priority: 20, dueAt: null, updatedAt: baseline.AddHours(2)) ]; foreach (var task in tasks) diff --git a/test/unit/Elsa.UserTasks.UnitTests/UserTaskTests.cs b/test/unit/Elsa.UserTasks.UnitTests/UserTaskTests.cs index eb0f2bd37..2eb1c4843 100644 --- a/test/unit/Elsa.UserTasks.UnitTests/UserTaskTests.cs +++ b/test/unit/Elsa.UserTasks.UnitTests/UserTaskTests.cs @@ -59,13 +59,15 @@ public class UserTaskTests [InlineData("priority", true)] [InlineData("title", false)] [InlineData("title", true)] + [InlineData("updated", false)] + [InlineData("updated", true)] public async Task Repository_CursorCoversSupportedSortsAndDirections(string sort, bool descending) { var repository = new InMemoryUserTaskRepository(); var now = DateTimeOffset.UtcNow; - await repository.AddProjectionAsync(new() { Id = "task-a", TenantId = "tenant", Title = "Alpha", Priority = 10, DueAt = now.AddHours(1), CreatedAt = now.AddMinutes(1) }); - await repository.AddProjectionAsync(new() { Id = "task-b", TenantId = "tenant", Title = "Beta", Priority = 50, DueAt = null, CreatedAt = now.AddMinutes(2) }); - await repository.AddProjectionAsync(new() { Id = "task-c", TenantId = "tenant", Title = "Gamma", Priority = 90, DueAt = now.AddHours(2), CreatedAt = now.AddMinutes(3) }); + await repository.AddProjectionAsync(new() { Id = "task-a", TenantId = "tenant", Title = "Alpha", Priority = 10, DueAt = now.AddHours(1), CreatedAt = now.AddMinutes(1), UpdatedAt = now.AddHours(3) }); + await repository.AddProjectionAsync(new() { Id = "task-b", TenantId = "tenant", Title = "Beta", Priority = 50, DueAt = null, CreatedAt = now.AddMinutes(2), UpdatedAt = now.AddHours(1) }); + await repository.AddProjectionAsync(new() { Id = "task-c", TenantId = "tenant", Title = "Gamma", Priority = 90, DueAt = now.AddHours(2), CreatedAt = now.AddMinutes(3), UpdatedAt = now.AddHours(2) }); var first = await repository.QueryAsync(new() { TenantId = "tenant", Limit = 2, Sort = sort, Descending = descending, IncludeTotalCount = true }); var second = await repository.QueryAsync(new() { TenantId = "tenant", Limit = 2, Sort = sort, Descending = descending, Cursor = first.NextCursor, IncludeTotalCount = true }); @@ -76,6 +78,63 @@ public class UserTaskTests Assert.Equal(3, ids.Distinct().Count()); } + [Fact] + public async Task Repository_DescendingSortUsesAscendingIdTiebreaker() + { + var repository = new InMemoryUserTaskRepository(); + var now = DateTimeOffset.UtcNow; + await repository.AddProjectionAsync(new() { Id = "task-b", TenantId = "tenant", Priority = 50, CreatedAt = now }); + await repository.AddProjectionAsync(new() { Id = "task-a", TenantId = "tenant", Priority = 50, CreatedAt = now }); + + var page = await repository.QueryAsync(new() { TenantId = "tenant", Sort = "priority", Descending = true, Limit = 10 }); + + Assert.Equal(["task-a", "task-b"], page.Items.Select(x => x.Id)); + } + + [Fact] + public async Task Repository_TitleCursorUsesTheSameComparisonForTies() + { + var repository = new InMemoryUserTaskRepository(); + await repository.AddProjectionAsync(new() { Id = "task-b", TenantId = "tenant", Title = "Same" }); + await repository.AddProjectionAsync(new() { Id = "task-a", TenantId = "tenant", Title = "Same" }); + + var first = await repository.QueryAsync(new() { TenantId = "tenant", Sort = "title", Limit = 1 }); + var second = await repository.QueryAsync(new() { TenantId = "tenant", Sort = "title", Limit = 1, Cursor = first.NextCursor }); + + Assert.Equal("task-a", Assert.Single(first.Items).Id); + Assert.Equal("task-b", Assert.Single(second.Items).Id); + } + + [Fact] + public async Task Repository_TitleCursorDoesNotDropUnicodeVariantTitles() + { + var repository = new InMemoryUserTaskRepository(); + await repository.AddProjectionAsync(new() { Id = "task-a", TenantId = "tenant", Title = "caf\u00E9" }); + await repository.AddProjectionAsync(new() { Id = "task-b", TenantId = "tenant", Title = "cafe\u0301" }); + + // InMemory title sort is ordinal: NFC/NFD café are distinct keys and must both + // appear. Recreate the list after switching to an EF collation host. + var first = await repository.QueryAsync(new() { TenantId = "tenant", Sort = "title", Limit = 1 }); + var second = await repository.QueryAsync(new() { TenantId = "tenant", Sort = "title", Limit = 1, Cursor = first.NextCursor }); + + Assert.Equal(2, first.Items.Concat(second.Items).Select(x => x.Id).Distinct().Count()); + Assert.Null(second.NextCursor); + } + + [Fact] + public async Task Repository_UnknownSortCompletedUsesCreatedOrder() + { + var repository = new InMemoryUserTaskRepository(); + var now = DateTimeOffset.UtcNow; + await repository.AddProjectionAsync(new() { Id = "task-a", TenantId = "tenant", CreatedAt = now.AddMinutes(1), CompletedAt = now.AddHours(3) }); + await repository.AddProjectionAsync(new() { Id = "task-b", TenantId = "tenant", CreatedAt = now.AddMinutes(2), CompletedAt = now.AddHours(1) }); + + var created = await repository.QueryAsync(new() { TenantId = "tenant", Sort = "created", Limit = 10 }); + var completed = await repository.QueryAsync(new() { TenantId = "tenant", Sort = "completed", Limit = 10 }); + + Assert.Equal(created.Items.Select(x => x.Id), completed.Items.Select(x => x.Id)); + } + [Fact] public async Task Manager_HidesProtectedFieldsUntilClaimAndCompletesAfterBookmarkFinalization() {