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>
This commit is contained in:
parent
d0c7653ccf
commit
249bde780b
|
|
@ -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=`
|
`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.
|
- `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.
|
- `status` is repeatable. Unknown values are dropped rather than rejected, so a stale bookmark still loads.
|
||||||
|
|
|
||||||
|
|
@ -349,14 +349,19 @@ public sealed class EFCoreUserTaskRepository(Store<UserTasksElsaDbContext, UserT
|
||||||
|
|
||||||
private static IQueryable<UserTaskRecord> ApplyOrdering(IQueryable<UserTaskRecord> records, UserTaskQuery query)
|
private static IQueryable<UserTaskRecord> ApplyOrdering(IQueryable<UserTaskRecord> 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
|
return query.Sort.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
"due" when query.Descending => records.OrderBy(x => x.DueAt == null).ThenByDescending(x => x.DueAt).ThenBy(x => x.Id),
|
"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),
|
"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" when query.Descending => records.OrderByDescending(x => x.Priority).ThenBy(x => x.Id),
|
||||||
"priority" => records.OrderBy(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" when query.Descending => records.OrderByDescending(x => x.Title).ThenBy(x => x.Id),
|
||||||
"title" => records.OrderBy(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),
|
_ when query.Descending => records.OrderByDescending(x => x.CreatedAt).ThenBy(x => x.Id),
|
||||||
_ => records.OrderBy(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<UserTasksElsaDbContext, UserT
|
||||||
? records.Where(x => 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))
|
||||||
: records.Where(x => 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
|
"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 || (string.Compare(x.Title, cursorValue) == 0 && 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)),
|
||||||
"due" when cursorValue == "~null" => records.Where(x => x.DueAt == null && 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
|
"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))
|
||||||
: 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
|
_ 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))
|
||||||
: 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<UserTasksElsaDbContext, UserT
|
||||||
"priority" => record.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
"priority" => record.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
"title" => record.Title,
|
"title" => record.Title,
|
||||||
"due" => record.DueAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture) ?? "~null",
|
"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)
|
_ => record.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture)
|
||||||
};
|
};
|
||||||
return Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(new[] { value, record.Id }, JsonOptions))
|
return Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(new[] { value, record.Id }, JsonOptions))
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@ public sealed class VNextUserTaskRepository(IDocumentStore documentStore) : IUse
|
||||||
Converters = { new JsonStringEnumConverter() }
|
Converters = { new JsonStringEnumConverter() }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly StringComparer TitleComparer = StringComparer.Ordinal;
|
||||||
|
|
||||||
public async Task<UserTask?> GetAsync(string tenantId, string taskId, CancellationToken cancellationToken = default)
|
public async Task<UserTask?> GetAsync(string tenantId, string taskId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var document = await documentStore.LoadAsync(StorageUnitName, DocumentId(tenantId, taskId), cancellationToken);
|
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.IsOpen && task.Assignee is null)
|
||||||
|| task.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling;
|
|| 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<UserTask> ApplyOrdering(IEnumerable<UserTask> tasks, UserTaskQuery query) => query.Sort.ToLowerInvariant() switch
|
private static IEnumerable<UserTask> ApplyOrdering(IEnumerable<UserTask> 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),
|
"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),
|
"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)
|
_ => 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
|
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),
|
"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 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),
|
"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),
|
_ 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
|
_ => 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)
|
private static string CreateCursor(UserTask task, string sort)
|
||||||
{
|
{
|
||||||
var value = sort.ToLowerInvariant() switch
|
var value = sort.ToLowerInvariant() switch
|
||||||
|
|
@ -300,6 +317,7 @@ public sealed class VNextUserTaskRepository(IDocumentStore documentStore) : IUse
|
||||||
"priority" => task.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
"priority" => task.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
"title" => task.Title,
|
"title" => task.Title,
|
||||||
"due" => task.DueAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture) ?? "~null",
|
"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)
|
_ => task.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture)
|
||||||
};
|
};
|
||||||
return Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(new[] { value, task.Id }, JsonOptions)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
return Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(new[] { value, task.Id }, JsonOptions)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Elsa.UserTasks.Contracts;
|
using Elsa.UserTasks.Contracts;
|
||||||
using Elsa.UserTasks.Models;
|
using Elsa.UserTasks.Models;
|
||||||
|
|
||||||
|
|
@ -11,6 +11,17 @@ namespace Elsa.UserTasks.Repositories;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class InMemoryUserTaskRepository : IUserTaskRepository
|
public sealed class InMemoryUserTaskRepository : IUserTaskRepository
|
||||||
{
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
Converters = { new JsonStringEnumConverter() }
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly StringComparer TitleComparer = StringComparer.Ordinal;
|
||||||
|
|
||||||
private readonly object _sync = new();
|
private readonly object _sync = new();
|
||||||
private readonly Dictionary<string, UserTask> _tasks = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, UserTask> _tasks = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
|
@ -44,14 +55,13 @@ public sealed class InMemoryUserTaskRepository : IUserTaskRepository
|
||||||
.Where(x => MatchesSearch(x, query.Search));
|
.Where(x => MatchesSearch(x, query.Search));
|
||||||
|
|
||||||
var filteredCount = query.IncludeTotalCount ? items.Count() : 0;
|
var filteredCount = query.IncludeTotalCount ? items.Count() : 0;
|
||||||
var materialized = Sort(items, query.Sort, query.Descending).ToList();
|
var materialized = ApplyOrdering(items, query).ToList();
|
||||||
if (!string.IsNullOrWhiteSpace(query.Cursor) && DecodeCursor(query.Cursor!) is { } cursor)
|
materialized = ApplyCursor(materialized, query).ToList();
|
||||||
materialized = materialized.Where(x => IsAfterCursor(x, cursor, query.Descending, query.Sort)).ToList();
|
|
||||||
|
|
||||||
int? total = query.IncludeTotalCount ? filteredCount : null;
|
int? total = query.IncludeTotalCount ? filteredCount : null;
|
||||||
var limit = Math.Clamp(query.Limit, 1, 200);
|
var limit = Math.Clamp(query.Limit, 1, 200);
|
||||||
var page = materialized.Take(limit).Select(Clone).ToArray();
|
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));
|
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));
|
|| task.Tags.Any(x => x.Contains(value, StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<UserTask> Sort(IEnumerable<UserTask> items, string sort, bool descending)
|
// Same contract as EF/VNext: REST sorts only, Id always ThenBy ascending, JSON base64url cursors.
|
||||||
|
private static IEnumerable<UserTask> ApplyOrdering(IEnumerable<UserTask> tasks, UserTaskQuery query) => query.Sort.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
var normalized = NormalizeSort(sort);
|
"priority" => query.Descending ? tasks.OrderByDescending(x => x.Priority).ThenBy(x => x.Id) : tasks.OrderBy(x => x.Priority).ThenBy(x => x.Id),
|
||||||
return normalized switch
|
"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<UserTask> ApplyCursor(IEnumerable<UserTask> 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
|
"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),
|
||||||
? items.OrderByDescending(x => x.DueAt.HasValue).ThenByDescending(x => x.DueAt).ThenByDescending(x => x.Id, StringComparer.Ordinal)
|
"title" => tasks.Where(x => TitleIsAfterCursor(x.Title, value, x.Id, id, query.Descending)),
|
||||||
: items.OrderBy(x => x.DueAt.HasValue ? 0 : 1).ThenBy(x => x.DueAt).ThenBy(x => x.Id, StringComparer.Ordinal),
|
"due" when value == "~null" => tasks.Where(x => x.DueAt == null && string.Compare(x.Id, id) > 0),
|
||||||
"priority" => descending
|
"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),
|
||||||
? items.OrderByDescending(x => x.Priority).ThenByDescending(x => x.Id, StringComparer.Ordinal)
|
"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),
|
||||||
: items.OrderBy(x => x.Priority).ThenBy(x => x.Id, StringComparer.Ordinal),
|
_ 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),
|
||||||
"updated" => descending
|
_ => tasks
|
||||||
? 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)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
var comparison = TitleComparer.Compare(title, cursorTitle);
|
||||||
// Due/completed ordering keeps null values at the end in both directions. A generic numeric
|
return descending
|
||||||
// comparison would incorrectly drop nulls after a descending non-null page (or reintroduce
|
? comparison < 0 || comparison == 0 && string.Compare(id, cursorId) > 0
|
||||||
// non-null values after a descending null page).
|
: comparison > 0 || comparison == 0 && string.Compare(id, cursorId) > 0;
|
||||||
if (descending && (normalized is "due" or "completed"))
|
}
|
||||||
|
|
||||||
|
private static string CreateCursor(UserTask task, string sort)
|
||||||
|
{
|
||||||
|
var value = sort.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
var valueIsNull = SortValue(task, normalized) == null;
|
"priority" => task.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
var cursorIsNull = cursor.Value == "~";
|
"title" => task.Title,
|
||||||
if (valueIsNull != cursorIsNull)
|
"due" => task.DueAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture) ?? "~null",
|
||||||
return valueIsNull;
|
"updated" => task.UpdatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
||||||
}
|
_ => task.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture)
|
||||||
|
};
|
||||||
var comparison = CompareSortValue(SortKind(sort), SortValue(task, sort), cursor.Kind, cursor.Value);
|
return Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(new[] { value, task.Id }, JsonOptions)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
if (comparison == 0)
|
|
||||||
comparison = StringComparer.Ordinal.Compare(task.Id, cursor.Id);
|
|
||||||
return descending ? comparison < 0 : comparison > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string EncodeCursor(UserTask task, string sort)
|
private static bool TryReadCursor(string? cursor, out string value, out string id)
|
||||||
{
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
|
value = id = "";
|
||||||
|
if (string.IsNullOrWhiteSpace(cursor))
|
||||||
|
return false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var value = Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
|
var padded = cursor.Replace('-', '+').Replace('_', '/') + new string('=', (4 - cursor.Length % 4) % 4);
|
||||||
var first = value.IndexOf('|');
|
var values = JsonSerializer.Deserialize<string[]>(Convert.FromBase64String(padded), JsonOptions);
|
||||||
var last = value.LastIndexOf('|');
|
if (values is not [var parsedValue, var parsedId] || string.IsNullOrWhiteSpace(parsedId))
|
||||||
return first <= 0 || last <= first ? null : (value[..first], value[(first + 1)..last], value[(last + 1)..]);
|
return false;
|
||||||
|
value = parsedValue;
|
||||||
|
id = parsedId;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
catch (FormatException)
|
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;
|
private static string Key(string tenantId, string taskId) => tenantId + "\0" + taskId;
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,8 @@ public abstract class UserTaskConformanceTestBase(UserTaskStoreFixture fixture)
|
||||||
string title = "Approve request",
|
string title = "Approve request",
|
||||||
int priority = 50,
|
int priority = 50,
|
||||||
DateTimeOffset? dueAt = null,
|
DateTimeOffset? dueAt = null,
|
||||||
DateTimeOffset? createdAt = null)
|
DateTimeOffset? createdAt = null,
|
||||||
|
DateTimeOffset? updatedAt = null)
|
||||||
{
|
{
|
||||||
var ordinal = ++_sequence;
|
var ordinal = ++_sequence;
|
||||||
var created = createdAt ?? Clock.UtcNow.AddMinutes(ordinal);
|
var created = createdAt ?? Clock.UtcNow.AddMinutes(ordinal);
|
||||||
|
|
@ -56,7 +57,7 @@ public abstract class UserTaskConformanceTestBase(UserTaskStoreFixture fixture)
|
||||||
CandidateUsers = candidate is null ? [] : [candidate],
|
CandidateUsers = candidate is null ? [] : [candidate],
|
||||||
InvitationDefinitions = [new UserTaskInvitationDefinition("bearer", ["Complete"], BearerOnly: true)],
|
InvitationDefinitions = [new UserTaskInvitationDefinition("bearer", ["Complete"], BearerOnly: true)],
|
||||||
CreatedAt = created,
|
CreatedAt = created,
|
||||||
UpdatedAt = created
|
UpdatedAt = updatedAt ?? created
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -327,6 +327,8 @@ public abstract class UserTaskRepositoryConformanceTests(UserTaskStoreFixture fi
|
||||||
[InlineData("priority", true)]
|
[InlineData("priority", true)]
|
||||||
[InlineData("title", false)]
|
[InlineData("title", false)]
|
||||||
[InlineData("title", true)]
|
[InlineData("title", true)]
|
||||||
|
[InlineData("updated", false)]
|
||||||
|
[InlineData("updated", true)]
|
||||||
public async Task CursorsAreStableAcrossEverySupportedSortAndDirection(string sort, bool descending)
|
public async Task CursorsAreStableAcrossEverySupportedSortAndDirection(string sort, bool descending)
|
||||||
{
|
{
|
||||||
await ActivateAsync();
|
await ActivateAsync();
|
||||||
|
|
@ -342,6 +344,21 @@ public abstract class UserTaskRepositoryConformanceTests(UserTaskStoreFixture fi
|
||||||
Assert.Equal(expected, await PageThroughAsync(query, pageSize));
|
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]
|
[ConformanceFact]
|
||||||
public async Task TasksWithoutADueDateOrderLastInBothDirections()
|
public async Task TasksWithoutADueDateOrderLastInBothDirections()
|
||||||
{
|
{
|
||||||
|
|
@ -392,21 +409,23 @@ public abstract class UserTaskRepositoryConformanceTests(UserTaskStoreFixture fi
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds a set that exercises every sort key at once: distinct titles, distinct priorities, a mix of
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task SeedSortableTasksAsync()
|
private async Task SeedSortableTasksAsync()
|
||||||
{
|
{
|
||||||
var subject = Subject();
|
var subject = Subject();
|
||||||
var baseline = Clock.UtcNow;
|
var baseline = Clock.UtcNow;
|
||||||
var shared = baseline.AddDays(3);
|
var shared = baseline.AddDays(3);
|
||||||
|
var sharedUpdated = baseline.AddHours(3);
|
||||||
UserTask[] tasks =
|
UserTask[] tasks =
|
||||||
[
|
[
|
||||||
CreateTask(subject, "Alpha", priority: 10, dueAt: baseline.AddDays(1)),
|
CreateTask(subject, "Alpha", priority: 10, dueAt: baseline.AddDays(1), updatedAt: baseline.AddHours(6)),
|
||||||
CreateTask(subject, "Bravo", priority: 90, dueAt: shared),
|
CreateTask(subject, "Bravo", priority: 90, dueAt: shared, updatedAt: baseline.AddHours(1)),
|
||||||
CreateTask(subject, "Charlie", priority: 50, dueAt: shared),
|
CreateTask(subject, "Charlie", priority: 50, dueAt: shared, updatedAt: sharedUpdated),
|
||||||
CreateTask(subject, "Delta", priority: 30, dueAt: baseline.AddDays(5)),
|
CreateTask(subject, "Delta", priority: 30, dueAt: baseline.AddDays(5), updatedAt: sharedUpdated),
|
||||||
CreateTask(subject, "Echo", priority: 70, dueAt: null),
|
CreateTask(subject, "Echo", priority: 70, dueAt: null, updatedAt: baseline.AddHours(5)),
|
||||||
CreateTask(subject, "Foxtrot", priority: 20, dueAt: null)
|
CreateTask(subject, "Foxtrot", priority: 20, dueAt: null, updatedAt: baseline.AddHours(2))
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach (var task in tasks)
|
foreach (var task in tasks)
|
||||||
|
|
|
||||||
|
|
@ -59,13 +59,15 @@ public class UserTaskTests
|
||||||
[InlineData("priority", true)]
|
[InlineData("priority", true)]
|
||||||
[InlineData("title", false)]
|
[InlineData("title", false)]
|
||||||
[InlineData("title", true)]
|
[InlineData("title", true)]
|
||||||
|
[InlineData("updated", false)]
|
||||||
|
[InlineData("updated", true)]
|
||||||
public async Task Repository_CursorCoversSupportedSortsAndDirections(string sort, bool descending)
|
public async Task Repository_CursorCoversSupportedSortsAndDirections(string sort, bool descending)
|
||||||
{
|
{
|
||||||
var repository = new InMemoryUserTaskRepository();
|
var repository = new InMemoryUserTaskRepository();
|
||||||
var now = DateTimeOffset.UtcNow;
|
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-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) });
|
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) });
|
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 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 });
|
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());
|
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]
|
[Fact]
|
||||||
public async Task Manager_HidesProtectedFieldsUntilClaimAndCompletesAfterBookmarkFinalization()
|
public async Task Manager_HidesProtectedFieldsUntilClaimAndCompletesAfterBookmarkFinalization()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue