diff --git a/src/FluentCMS.Admin/Components/Layout/MainLayout.razor b/src/FluentCMS.Admin/Components/Layout/MainLayout.razor index d7729e11b..ae30f29f7 100644 --- a/src/FluentCMS.Admin/Components/Layout/MainLayout.razor +++ b/src/FluentCMS.Admin/Components/Layout/MainLayout.razor @@ -8,6 +8,7 @@ FluentCMS @if (Initiator.Current is UserInitiator user) { + @L["Desk"] @L["Audit log"] @L["Users"] @L["Agents"] diff --git a/src/FluentCMS.Admin/Components/Pages/Desk.razor b/src/FluentCMS.Admin/Components/Pages/Desk.razor new file mode 100644 index 000000000..8b9a63a42 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Pages/Desk.razor @@ -0,0 +1,317 @@ +@page "/" +@using System.Globalization +@using FluentCMS.Core.ChangeSets +@using FluentCMS.Core.Desk +@using FluentCMS.Core.Entities +@inject IDeskQueues Queues +@inject IStringLocalizer L + +@L["Desk"] + +

@L["Desk"]

+

@L["Change sets and facts that are waiting for a person."]

+ + +@if (_countsError is not null) +{ + +} + +
+

@Title(_queue)

+

@Description(_queue)

+ @if (_queue == DeskQueue.AwaitingMe) + { +

+ @L["Until routing records approvals, this queue lists every set awaiting review, as awaiting anyone does."] +

+ } + + @if (_error is not null) + { + + } + else if (_total == 0) + { +

@Empty(_queue)

+ } + else if (_shown == 0) + { +

+ @L["This page is past the end of the queue."] + @L["Go to the first page"] +

+ } + else + { +
+ @if (_facts is not null) + { + + + + @L["Entity"] + @L["Field"] + @L["Reason"] + @L["Stale since"] + @L["Held by"] + + + + @foreach (var fact in _facts) + { + + + + @fact.Type @fact.EntityId.ToString("N")[..8] + + + @fact.Field + + @if (fact.Reason == StaleReason.Expired) + { + @L["Expired"] + } + else + { + @L["Unverified"] + } + + @Time(fact.StaleSince) + + + @if (fact.IsPublished) + { + @L["Published"] + } + @if (fact.IsLatest) + { + @L["Latest draft"] + } + + + + } + +
+ } + else + { + + + + @L["Change set"] + @L["Started by"] + @L["Items"] + @Since(_queue) + + + + @foreach (var set in _sets ?? []) + { + + + @set.Rationale + @if (set.RevertsChangeSetId is not null) + { + @L["Revert"] + } + + + + @if (set.Initiator is AgentInitiator) + { + @L["Agent"] + } + else + { + @L["User"] + } + @set.Initiator.DisplayName + + + @set.ItemCount.ToString(CultureInfo.CurrentCulture) + @Time(set.StateSince) + + } + +
+ } +
+ + + } +
+ +@code { + public const int PageSize = 50; + + private static readonly DeskQueue[] Order = + [ + DeskQueue.AwaitingMe, + DeskQueue.AwaitingAnyone, + DeskQueue.ChecksFailed, + DeskQueue.StaleFacts, + DeskQueue.RecentlyPublished, + DeskQueue.Reverted, + ]; + + // Each queue has its own URL, so a queue and a page of it can be bookmarked and shared. + public static readonly IReadOnlyDictionary Slugs = new Dictionary + { + [DeskQueue.AwaitingMe] = "awaiting-me", + [DeskQueue.AwaitingAnyone] = "awaiting-anyone", + [DeskQueue.ChecksFailed] = "checks-failed", + [DeskQueue.StaleFacts] = "stale-facts", + [DeskQueue.RecentlyPublished] = "recently-published", + [DeskQueue.Reverted] = "reverted", + }; + + private DeskQueue _queue; + private int _page; + private int _offset; + private int _total; + private int _shown; + private IReadOnlyDictionary _counts = new Dictionary(); + private IReadOnlyList? _sets; + private IReadOnlyList? _facts; + private string? _error; + private string? _countsError; + + [SupplyParameterFromQuery(Name = "queue")] + private string? QueueSlug { get; set; } + + [SupplyParameterFromQuery(Name = "page")] + private int? PageNumber { get; set; } + + protected override async Task OnInitializedAsync() + { + // An unknown queue shows the default one rather than an error, as a mistyped link would otherwise land nowhere. + _queue = Slugs.FirstOrDefault(pair => pair.Value == QueueSlug).Key; + _page = Math.Max(1, PageNumber ?? 1); + _offset = (int)Math.Min((_page - 1L) * PageSize, int.MaxValue - PageSize); + + var counts = await Queues.CountAsync(); + if (counts.IsSuccess) + { + _counts = counts.Value; + } + else + { + _countsError = Describe(counts.Errors); + } + + if (_queue == DeskQueue.StaleFacts) + { + var facts = await Queues.ListStaleFactsAsync(_offset, PageSize); + if (facts.IsSuccess) + { + (_facts, _total, _shown) = (facts.Value.Items, facts.Value.Total, facts.Value.Items.Count); + } + else + { + _error = Describe(facts.Errors); + } + } + else + { + var sets = await Queues.ListChangeSetsAsync(_queue, _offset, PageSize); + if (sets.IsSuccess) + { + (_sets, _total, _shown) = (sets.Value.Items, sets.Value.Total, sets.Value.Items.Count); + } + else + { + _error = Describe(sets.Errors); + } + } + } + + private static string Href(DeskQueue queue, int page) + { + return page > 1 ? $"?queue={Slugs[queue]}&page={page}" : $"?queue={Slugs[queue]}"; + } + + private string Count(DeskQueue queue) + { + return _counts.TryGetValue(queue, out var count) ? count.ToString(CultureInfo.CurrentCulture) : "–"; + } + + private string Title(DeskQueue queue) => queue switch + { + DeskQueue.AwaitingMe => L["Awaiting me"], + DeskQueue.AwaitingAnyone => L["Awaiting anyone"], + DeskQueue.ChecksFailed => L["Checks failed"], + DeskQueue.StaleFacts => L["Stale facts"], + DeskQueue.RecentlyPublished => L["Recently published"], + _ => L["Reverted"], + }; + + private string Description(DeskQueue queue) => queue switch + { + DeskQueue.AwaitingMe => L["Change sets waiting for your review, the longest waiting first."], + DeskQueue.AwaitingAnyone => L["Change sets waiting for any reviewer, the longest waiting first."], + DeskQueue.ChecksFailed => L["Change sets whose checks failed, the longest waiting first. The agent or person who opened one fixes and resubmits it, or a reviewer rejects it."], + DeskQueue.StaleFacts => L["Facts past their valid-until date or not verified recently, the longest stale first."], + DeskQueue.RecentlyPublished => L["Change sets published in the last seven days, the newest first."], + _ => L["Change sets reverted in the last seven days, the newest first."], + }; + + private string Empty(DeskQueue queue) => queue switch + { + DeskQueue.AwaitingMe or DeskQueue.AwaitingAnyone => L["Nothing is awaiting review."], + DeskQueue.ChecksFailed => L["No change set has failed its checks."], + DeskQueue.StaleFacts => L["No fact is stale."], + DeskQueue.RecentlyPublished => L["Nothing was published in the last seven days."], + _ => L["Nothing was reverted in the last seven days."], + }; + + private string Since(DeskQueue queue) => queue switch + { + DeskQueue.ChecksFailed => L["Failed"], + DeskQueue.RecentlyPublished => L["Published"], + DeskQueue.Reverted => L["Reverted"], + _ => L["Waiting since"], + }; + + private static RenderFragment Time(DateTimeOffset time) => + @; + + // The error code is the resource key; the service's own message is the text until a resource exists. + private string Describe(IReadOnlyList errors) + { + return string.Join(" ", errors.Select(error => + { + var localized = L[error.Code]; + return localized.ResourceNotFound ? error.Message : localized.Value; + })); + } +} diff --git a/src/FluentCMS.Admin/Components/Pages/Home.razor b/src/FluentCMS.Admin/Components/Pages/Home.razor deleted file mode 100644 index 67ba542b8..000000000 --- a/src/FluentCMS.Admin/Components/Pages/Home.razor +++ /dev/null @@ -1,8 +0,0 @@ -@page "/" -@inject IStringLocalizer L - -FluentCMS - -@* Placeholder until F-DSK makes the desk the landing page. *@ -

@L["FluentCMS admin"]

-

@L["The desk arrives with the first feature slice."]

diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs b/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs index 3b8292c71..ef6803f72 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs @@ -26,4 +26,7 @@ public static class ChangeSetErrorCodes public const string RevertFixed = "revert_set_fixed"; public const string InitiatorNotAllowed = "initiator_not_allowed"; public const string ItemKindMismatch = "item_kind_mismatch"; + public const string ListStatesMissing = "list_states_missing"; + public const string OffsetInvalid = "offset_invalid"; + public const string LimitInvalid = "limit_invalid"; } diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSetListQuery.cs b/src/FluentCMS.Core/ChangeSets/ChangeSetListQuery.cs new file mode 100644 index 000000000..6ec30e9fc --- /dev/null +++ b/src/FluentCMS.Core/ChangeSets/ChangeSetListQuery.cs @@ -0,0 +1,48 @@ +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.ChangeSets; + +// The change sets in any of the given states, optionally only those that entered their state at or after a time, one page at a time. +public sealed record ChangeSetListQuery +{ + public const int MaxLimit = 200; + + public required IReadOnlyCollection States { get; init; } + + public DateTimeOffset? EnteredSince { get; init; } + + // Newest first puts the set that entered its state last first; otherwise the set that has waited longest comes first. + public bool NewestFirst { get; init; } + + public int Offset { get; init; } + + public int Limit { get; init; } = 50; + + public Result Validate() + { + var errors = new List(); + + if (States is null || States.Count == 0 || States.Any(state => !Enum.IsDefined(state))) + { + errors.Add(new Error(ChangeSetErrorCodes.ListStatesMissing, "A list names one or more change set states to list.")); + } + + errors.AddRange(WindowErrors(Offset, Limit)); + + return errors.Count == 0 ? Result.Success() : Result.Failure(errors); + } + + // The same window applies to the other lists the desk pages through, such as its stale facts. + public static IEnumerable WindowErrors(int offset, int limit) + { + if (offset < 0) + { + yield return new Error(ChangeSetErrorCodes.OffsetInvalid, $"The offset is {offset}; it is 0 or more."); + } + + if (limit is < 1 or > MaxLimit) + { + yield return new Error(ChangeSetErrorCodes.LimitInvalid, $"The limit is {limit}; it is from 1 to {MaxLimit}."); + } + } +} diff --git a/src/FluentCMS.Core/ChangeSets/IChangeSetLists.cs b/src/FluentCMS.Core/ChangeSets/IChangeSetLists.cs new file mode 100644 index 000000000..10b74cc6d --- /dev/null +++ b/src/FluentCMS.Core/ChangeSets/IChangeSetLists.cs @@ -0,0 +1,28 @@ +using FluentCMS.Core.Identity; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.ChangeSets; + +// The read side for lists of change sets, for the desk's queues and the agents' desk tools (DSK-02, MCP-01). IChangeSetDirectory +// reads and writes one set at a time; this only reads. The times come from each set's own audit events. A list appends no event. +public interface IChangeSetLists +{ + Task> ListAsync(ChangeSetListQuery query, CancellationToken cancellationToken = default); + + // How many sets the query selects, whatever its offset and limit. + Task> CountAsync(ChangeSetListQuery query, CancellationToken cancellationToken = default); +} + +// One page of a list and how many sets the whole list holds. +public sealed record ChangeSetList(IReadOnlyList Items, int Total); + +// A change set as a list shows it: how many items it has rather than its operations. StateSince is when it entered its state. +public sealed record ChangeSetSummary( + Guid Id, + string Rationale, + Initiator Initiator, + ChangeSetState State, + int ItemCount, + Guid? RevertsChangeSetId, + DateTimeOffset OpenedAt, + DateTimeOffset StateSince); diff --git a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs index 3d81778ba..51de78c58 100644 --- a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs +++ b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using FluentCMS.Core.Agents; using FluentCMS.Core.Audit; using FluentCMS.Core.Checks; +using FluentCMS.Core.Desk; using FluentCMS.Core.Extensions; using FluentCMS.Core.Media; using FluentCMS.Core.Models; @@ -37,6 +38,9 @@ public static IServiceCollection AddFluentCmsCore(this IServiceCollection servic // Reads the active checks through ICheckDirectory, which Infrastructure provides. services.AddScoped(); + // Reads through IChangeSetLists and IEntityFacts, which Infrastructure provides. + services.AddScoped(); + return services; } } diff --git a/src/FluentCMS.Core/Desk/DeskErrorCodes.cs b/src/FluentCMS.Core/Desk/DeskErrorCodes.cs new file mode 100644 index 000000000..760deac83 --- /dev/null +++ b/src/FluentCMS.Core/Desk/DeskErrorCodes.cs @@ -0,0 +1,6 @@ +namespace FluentCMS.Core.Desk; + +public static class DeskErrorCodes +{ + public const string QueueNotChangeSets = "queue_not_change_sets"; +} diff --git a/src/FluentCMS.Core/Desk/DeskQueues.cs b/src/FluentCMS.Core/Desk/DeskQueues.cs new file mode 100644 index 000000000..e720405c7 --- /dev/null +++ b/src/FluentCMS.Core/Desk/DeskQueues.cs @@ -0,0 +1,99 @@ +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Entities; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Desk; + +internal sealed class DeskQueues(IChangeSetLists lists, IEntityFacts facts, TimeProvider clock) : IDeskQueues +{ + // How far back the recently published and reverted queues reach. + public static readonly TimeSpan RecentWithin = TimeSpan.FromDays(7); + + private static readonly DeskQueue[] ChangeSetQueues = + [ + DeskQueue.AwaitingMe, + DeskQueue.AwaitingAnyone, + DeskQueue.ChecksFailed, + DeskQueue.RecentlyPublished, + DeskQueue.Reverted, + ]; + + public async Task>> CountAsync(CancellationToken cancellationToken = default) + { + var now = clock.GetUtcNow(); + var counts = new Dictionary(); + + foreach (var queue in ChangeSetQueues.Where(queue => queue != DeskQueue.AwaitingMe)) + { + var counted = await lists.CountAsync(Query(queue, now), cancellationToken); + if (!counted.IsSuccess) + { + return Result>.Failure(counted.Errors); + } + + counts[queue] = counted.Value; + } + + // The same sets as awaiting anyone until approvals exist; see Query. + counts[DeskQueue.AwaitingMe] = counts[DeskQueue.AwaitingAnyone]; + + var stale = await facts.StaleAsync(cancellationToken: cancellationToken); + if (!stale.IsSuccess) + { + return Result>.Failure(stale.Errors); + } + + counts[DeskQueue.StaleFacts] = stale.Value.Count; + return Result>.Success(counts); + } + + public Task> ListChangeSetsAsync(DeskQueue queue, int offset, int limit, CancellationToken cancellationToken = default) + { + if (!ChangeSetQueues.Contains(queue)) + { + return Task.FromResult(Result.Failure( + DeskErrorCodes.QueueNotChangeSets, + $"The {queue} queue does not list change sets; it is one of {string.Join(", ", ChangeSetQueues)}.")); + } + + return lists.ListAsync(Query(queue, clock.GetUtcNow()) with { Offset = offset, Limit = limit }, cancellationToken); + } + + public async Task> ListStaleFactsAsync(int offset, int limit, CancellationToken cancellationToken = default) + { + var window = ChangeSetListQuery.WindowErrors(offset, limit).ToList(); + if (window.Count > 0) + { + return Result.Failure(window); + } + + var stale = await facts.StaleAsync(cancellationToken: cancellationToken); + return stale.IsSuccess + ? Result.Success(new StaleFactList([.. stale.Value.Skip(offset).Take(limit)], stale.Value.Count)) + : Result.Failure(stale.Errors); + } + + // Awaiting me lists every set awaiting review until routing records approvals (F-RTE); it will then leave out the sets the + // reviewer has approved. Work queues put the set that has waited longest first; the recent ones the newest. + private static ChangeSetListQuery Query(DeskQueue queue, DateTimeOffset now) + { + return queue switch + { + DeskQueue.AwaitingMe or DeskQueue.AwaitingAnyone => new ChangeSetListQuery { States = [ChangeSetState.AwaitingReview] }, + DeskQueue.ChecksFailed => new ChangeSetListQuery { States = [ChangeSetState.ChecksFailed] }, + DeskQueue.RecentlyPublished => new ChangeSetListQuery + { + States = [ChangeSetState.Published], + EnteredSince = now - RecentWithin, + NewestFirst = true, + }, + DeskQueue.Reverted => new ChangeSetListQuery + { + States = [ChangeSetState.Reverted], + EnteredSince = now - RecentWithin, + NewestFirst = true, + }, + _ => throw new ArgumentOutOfRangeException(nameof(queue), queue, "Not a queue of change sets."), + }; + } +} diff --git a/src/FluentCMS.Core/Desk/IDeskQueues.cs b/src/FluentCMS.Core/Desk/IDeskQueues.cs new file mode 100644 index 000000000..73edc6344 --- /dev/null +++ b/src/FluentCMS.Core/Desk/IDeskQueues.cs @@ -0,0 +1,30 @@ +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Entities; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Desk; + +// The six queues of the review desk (DSK-02). Stale facts lists facts; every other queue lists change sets. +public enum DeskQueue +{ + AwaitingMe, + AwaitingAnyone, + ChecksFailed, + StaleFacts, + RecentlyPublished, + Reverted, +} + +// Each queue is defined here once, for the desk and for the agents' desk tools (MCP-01). Reads only, so no event is appended. +public interface IDeskQueues +{ + // How many sets or facts each of the six queues holds now. + Task>> CountAsync(CancellationToken cancellationToken = default); + + // A page of a queue of change sets; stale facts is listed by ListStaleFactsAsync. + Task> ListChangeSetsAsync(DeskQueue queue, int offset, int limit, CancellationToken cancellationToken = default); + + Task> ListStaleFactsAsync(int offset, int limit, CancellationToken cancellationToken = default); +} + +public sealed record StaleFactList(IReadOnlyList Items, int Total); diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetLists.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetLists.cs new file mode 100644 index 000000000..a875e0615 --- /dev/null +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetLists.cs @@ -0,0 +1,160 @@ +using System.Runtime.CompilerServices; +using FluentCMS.Core.Audit; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Results; +using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Infrastructure.ChangeSets; + +// The directory appends an event with the set as its subject when it opens the set and on every move, so those events date the set +// without a column of its own. Other events about a set, such as a refused move, are left out. SQL rather than LINQ because EF Core +// will not compare or order a DateTimeOffset on SQLite, which stores it as text; stored timestamps are all UTC, so the text sorts in +// time order, as in AuditReader. +internal sealed class ChangeSetLists(FluentCmsDbContext context) : IChangeSetLists +{ + private const string SubjectType = "change_set"; + + private static readonly string[] Moves = + [ + ChangeSetDirectory.OpenedOperation, + "change_set.reopened", + "change_set.submitted", + "change_set.checks_failed", + "change_set.awaiting_review", + "change_set.approved", + "change_set.published", + "change_set.rejected", + "change_set.reverted", + ]; + + public async Task> ListAsync(ChangeSetListQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + var valid = query.Validate(); + if (!valid.IsSuccess) + { + return Result.Failure(valid.Errors); + } + + var (sql, parameters) = Select(query); + var order = query.NewestFirst ? "DESC" : "ASC"; + var rows = await context.Database + .SqlQuery(Format( + $"{sql} ORDER BY \"StateSince\" {order}, \"Id\" {order} LIMIT {Parameter(parameters, query.Limit)} OFFSET {Parameter(parameters, query.Offset)}", + parameters)) + .ToListAsync(cancellationToken); + + var total = await CountRowsAsync(query, cancellationToken); + return Result.Success(new ChangeSetList([.. rows.Select(ToSummary)], total)); + } + + public async Task> CountAsync(ChangeSetListQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + var valid = query.Validate(); + return valid.IsSuccess + ? Result.Success(await CountRowsAsync(query, cancellationToken)) + : Result.Failure(valid.Errors); + } + + private Task CountRowsAsync(ChangeSetListQuery query, CancellationToken cancellationToken) + { + var (sql, parameters) = Select(query); + return context.Database + .SqlQuery(Format($"SELECT CAST(COUNT(*) AS INTEGER) AS \"Value\" FROM ({sql}) AS \"Counted\"", parameters)) + .SingleAsync(cancellationToken); + } + + // The sets of the site in the query's states with their item count and times, kept to those that entered their state in time. + // Table and column names are fixed here; every value travels as a parameter. + private static (string Sql, List Parameters) Select(ChangeSetListQuery query) + { + var parameters = new List(); + var openedSubject = Parameter(parameters, SubjectType); + var opened = Parameter(parameters, ChangeSetDirectory.OpenedOperation); + var movedSubject = Parameter(parameters, SubjectType); + var moves = string.Join(", ", Moves.Select(move => Parameter(parameters, move))); + var site = Parameter(parameters, Site.Default.Id); + var states = string.Join(", ", query.States.Distinct().Select(state => Parameter(parameters, state.ToString()))); + + var sql = $""" + SELECT * FROM ( + SELECT s."Id", s."Rationale", s."InitiatorKind", s."InitiatorId", s."InitiatorName", s."State", s."RevertsChangeSetId", + (SELECT CAST(COUNT(*) AS INTEGER) FROM "ChangeSetOperations" o WHERE o."ChangeSetId" = s."Id") AS "ItemCount", + (SELECT MIN(e."Timestamp") FROM "AuditEvents" e + WHERE e."SubjectType" = {openedSubject} AND e."SubjectId" = s."Id" AND e."Operation" = {opened}) AS "OpenedAt", + (SELECT MAX(e."Timestamp") FROM "AuditEvents" e + WHERE e."SubjectType" = {movedSubject} AND e."SubjectId" = s."Id" AND e."Operation" IN ({moves})) AS "StateSince" + FROM "ChangeSets" s + WHERE s."SiteId" = {site} AND s."State" IN ({states}) + ) AS "Listed" + """; + + if (query.EnteredSince is { } since) + { + sql += $" WHERE \"StateSince\" >= {Parameter(parameters, since.ToUniversalTime())}"; + } + + return (sql, parameters); + } + + private static string Parameter(List parameters, object value) + { + parameters.Add(value); + return $"{{{parameters.Count - 1}}}"; + } + + private static FormattableString Format(string sql, List parameters) + { + return FormattableStringFactory.Create(sql, [.. parameters]); + } + + private static ChangeSetSummary ToSummary(SummaryRow row) + { + Initiator initiator = row.InitiatorKind switch + { + InitiatorKinds.User => new UserInitiator(row.InitiatorId, row.InitiatorName), + InitiatorKinds.Agent => new AgentInitiator(row.InitiatorId, row.InitiatorName), + _ => throw new InvalidOperationException($"Change set {row.Id} names an unknown initiator kind '{row.InitiatorKind}'."), + }; + + // Opening a set appends its first event in the same unit of work, so a set without one means a broken store. + return new ChangeSetSummary( + row.Id, + row.Rationale, + initiator, + Enum.Parse(row.State), + row.ItemCount, + row.RevertsChangeSetId, + row.OpenedAt ?? throw new InvalidOperationException($"Change set {row.Id} has no audit event."), + row.StateSince ?? throw new InvalidOperationException($"Change set {row.Id} has no audit event.")); + } + + private sealed class SummaryRow + { + public Guid Id { get; set; } + + public string Rationale { get; set; } = string.Empty; + + public string InitiatorKind { get; set; } = string.Empty; + + public Guid InitiatorId { get; set; } + + public string InitiatorName { get; set; } = string.Empty; + + public string State { get; set; } = string.Empty; + + public Guid? RevertsChangeSetId { get; set; } + + public int ItemCount { get; set; } + + public DateTimeOffset? OpenedAt { get; set; } + + public DateTimeOffset? StateSince { get; set; } + } +} diff --git a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs index d9dd9264e..1d2f7eb93 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -89,6 +89,7 @@ private static void AddSchema(IServiceCollection services) private static void AddChangeSets(IServiceCollection services) { services.AddScoped(); + services.AddScoped(); } private static void AddAgents(IServiceCollection services) diff --git a/tests/FluentCMS.IntegrationTests/Desk/DeskQueueTests.cs b/tests/FluentCMS.IntegrationTests/Desk/DeskQueueTests.cs new file mode 100644 index 000000000..e65c2ed6e --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Desk/DeskQueueTests.cs @@ -0,0 +1,251 @@ +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Desk; +using FluentCMS.Core.Entities; +using FluentCMS.Core.Identity; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.IntegrationTests.ChangeSets; +using FluentCMS.IntegrationTests.Content; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static FluentCMS.IntegrationTests.ChangeSets.ChangeSetScenario; + +namespace FluentCMS.IntegrationTests.Desk; + +public sealed class DeskQueueTests +{ + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Each_queue_lists_its_sets_in_its_order_and_counts_them(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + var old = await scenario.PublishAsync(Create(await scenario.ContentTypeAsync("old"))); + scenario.AdvanceClock(TimeSpan.FromDays(8)); + + var published = await scenario.PublishAsync(Create(await scenario.ContentTypeAsync("product"))); + var undone = await scenario.PublishAsync(Create(await scenario.ContentTypeAsync("category"))); + var revert = await RevertAsync(scenario, undone.Id); + await MoveAsPersonAsync(scenario, revert.Id, ChangeSetState.ChecksRunning, ChangeSetState.Approved, ChangeSetState.Published); + var brand = await scenario.PublishAsync(Create(await scenario.ContentTypeAsync("brand"))); + var waiting = await AwaitingReviewAsync(scenario, "article"); + var failed = await scenario.OpenAsync(Create(await scenario.ContentTypeAsync("page"))); + await SubmitAsync(scenario, failed.Id); + await MoveAsPersonAsync(scenario, failed.Id, ChangeSetState.ChecksFailed); + var revertWaiting = await RevertAsync(scenario, brand.Id); + await MoveAsPersonAsync(scenario, revertWaiting.Id, ChangeSetState.ChecksRunning, ChangeSetState.AwaitingReview); + await scenario.OpenAsync(Create(await scenario.ContentTypeAsync("draft"))); + await SubmitAsync(scenario, (await scenario.OpenAsync(Create(await scenario.ContentTypeAsync("running")))).Id); + + var counts = (await QueuesAsync(scenario, queues => queues.CountAsync())).Value; + + Assert.Equal( + [ + (DeskQueue.AwaitingMe, 2), + (DeskQueue.AwaitingAnyone, 2), + (DeskQueue.ChecksFailed, 1), + (DeskQueue.StaleFacts, 0), + (DeskQueue.RecentlyPublished, 3), + (DeskQueue.Reverted, 1), + ], + counts.OrderBy(pair => pair.Key).Select(pair => (pair.Key, pair.Value))); + + Assert.Equal([waiting.Id, revertWaiting.Id], await IdsAsync(scenario, DeskQueue.AwaitingMe)); + Assert.Equal([waiting.Id, revertWaiting.Id], await IdsAsync(scenario, DeskQueue.AwaitingAnyone)); + Assert.Equal([failed.Id], await IdsAsync(scenario, DeskQueue.ChecksFailed)); + Assert.Equal([brand.Id, revert.Id, published.Id], await IdsAsync(scenario, DeskQueue.RecentlyPublished)); + Assert.Equal([undone.Id], await IdsAsync(scenario, DeskQueue.Reverted)); + Assert.DoesNotContain(old.Id, await IdsAsync(scenario, DeskQueue.RecentlyPublished)); + + var awaiting = (await QueuesAsync(scenario, queues => queues.ListChangeSetsAsync(DeskQueue.AwaitingAnyone, 0, 50))).Value.Items; + Assert.Equal(((Guid?)null, (Initiator)Agent, ChangeSetState.AwaitingReview, 1), Describe(awaiting[0])); + Assert.Equal(((Guid?)brand.Id, (Initiator)Amir, ChangeSetState.AwaitingReview, 1), Describe(awaiting[1])); + Assert.Equal(CatalogSync.Rationale, awaiting[0].Rationale); + Assert.All(awaiting, set => Assert.True(set.OpenedAt < set.StateSince)); + Assert.True(awaiting[0].StateSince < awaiting[1].StateSince); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_page_holds_its_window_of_the_list_and_the_total(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + var first = await AwaitingReviewAsync(scenario, "one"); + var second = await AwaitingReviewAsync(scenario, "two"); + var third = await AwaitingReviewAsync(scenario, "three"); + + var page = await ListAsync(scenario, new ChangeSetListQuery { States = [ChangeSetState.AwaitingReview], Limit = 2 }); + var last = await ListAsync(scenario, new ChangeSetListQuery { States = [ChangeSetState.AwaitingReview], Offset = 2, Limit = 2 }); + var newest = await ListAsync(scenario, new ChangeSetListQuery { States = [ChangeSetState.AwaitingReview], NewestFirst = true, Limit = 1 }); + var beyond = await ListAsync(scenario, new ChangeSetListQuery { States = [ChangeSetState.AwaitingReview], Offset = 5 }); + + Assert.Equal([first.Id, second.Id], page.Items.Select(set => set.Id)); + Assert.Equal([third.Id], last.Items.Select(set => set.Id)); + Assert.Equal([third.Id], newest.Items.Select(set => set.Id)); + Assert.Empty(beyond.Items); + Assert.All([page, last, newest, beyond], list => Assert.Equal(3, list.Total)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Only_sets_that_entered_their_state_in_time_are_listed(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + var before = await AwaitingReviewAsync(scenario, "before"); + scenario.AdvanceClock(TimeSpan.FromHours(1)); + var cutoff = new DateTimeOffset(2026, 10, 18, 12, 30, 0, TimeSpan.Zero); + var after = await AwaitingReviewAsync(scenario, "after"); + + // A refused move appends an event about the set, and the set has still not moved. + var refused = await scenario.RunAsync(directory => directory.TransitionAsync(before.Id, ChangeSetState.Approved)); + Assert.False(refused.IsSuccess); + + var listed = await ListAsync(scenario, new ChangeSetListQuery { States = [ChangeSetState.AwaitingReview], EnteredSince = cutoff }); + await using var scope = scenario.Services.CreateAsyncScope(); + var counted = await scope.ServiceProvider.GetRequiredService() + .CountAsync(new ChangeSetListQuery { States = [ChangeSetState.AwaitingReview], EnteredSince = cutoff.ToOffset(TimeSpan.FromHours(2)) }); + + Assert.Equal([after.Id], listed.Items.Select(set => set.Id)); + Assert.Equal(1, listed.Total); + Assert.Equal(1, counted.Value); + Assert.Equal(2, (await ListAsync(scenario, new ChangeSetListQuery { States = [ChangeSetState.AwaitingReview] })).Total); + Assert.NotEqual(before.Id, after.Id); + } + + [Theory] + [InlineData(-1, 50, ChangeSetErrorCodes.OffsetInvalid)] + [InlineData(0, 0, ChangeSetErrorCodes.LimitInvalid)] + [InlineData(0, 201, ChangeSetErrorCodes.LimitInvalid)] + public async Task A_window_out_of_range_is_refused(int offset, int limit, string code) + { + await using var scenario = await ChangeSetScenario.CreateAsync(DatabaseProviders.Sqlite); + + var sets = await QueuesAsync(scenario, queues => queues.ListChangeSetsAsync(DeskQueue.AwaitingAnyone, offset, limit)); + var facts = await QueuesAsync(scenario, queues => queues.ListStaleFactsAsync(offset, limit)); + + Assert.Equal(code, Assert.Single(sets.Errors).Code); + Assert.Equal(code, Assert.Single(facts.Errors).Code); + } + + [Fact] + public async Task A_list_without_states_is_refused() + { + await using var scenario = await ChangeSetScenario.CreateAsync(DatabaseProviders.Sqlite); + await using var scope = scenario.Services.CreateAsyncScope(); + var lists = scope.ServiceProvider.GetRequiredService(); + + var listed = await lists.ListAsync(new ChangeSetListQuery { States = [] }); + var counted = await lists.CountAsync(new ChangeSetListQuery { States = [] }); + + Assert.Equal(ChangeSetErrorCodes.ListStatesMissing, Assert.Single(listed.Errors).Code); + Assert.Equal(ChangeSetErrorCodes.ListStatesMissing, Assert.Single(counted.Errors).Code); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Reading_the_queues_appends_no_event(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + await AwaitingReviewAsync(scenario, "product"); + var events = await scenario.QueryAsync(context => context.AuditEvents.CountAsync()); + + Assert.True((await QueuesAsync(scenario, queues => queues.CountAsync())).IsSuccess); + foreach (var queue in Enum.GetValues().Where(queue => queue != DeskQueue.StaleFacts)) + { + Assert.True((await QueuesAsync(scenario, queues => queues.ListChangeSetsAsync(queue, 0, 50))).IsSuccess); + } + + Assert.True((await QueuesAsync(scenario, queues => queues.ListStaleFactsAsync(0, 50))).IsSuccess); + Assert.Equal(events, await scenario.QueryAsync(context => context.AuditEvents.CountAsync())); + Assert.Equal( + DeskErrorCodes.QueueNotChangeSets, + Assert.Single((await QueuesAsync(scenario, queues => queues.ListChangeSetsAsync(DeskQueue.StaleFacts, 0, 50))).Errors).Code); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_stale_facts_queue_lists_expired_and_unverified_facts_the_longest_stale_first(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + await scenario.Schema.CreateReferenceModelAsync(); + var unverified = await PricedAsync(scenario, validUntil: "null", lastVerified: "\"2020-01-01T00:00:00Z\""); + var expired = await PricedAsync(scenario, validUntil: "\"2019-06-01T00:00:00Z\"", lastVerified: "null"); + await PricedAsync(scenario, validUntil: "null", lastVerified: "null"); + + await using var scope = scenario.Services.CreateAsyncScope(); + var queues = scope.ServiceProvider.GetRequiredService(); + var count = (await queues.CountAsync()).Value[DeskQueue.StaleFacts]; + var first = (await queues.ListStaleFactsAsync(0, 1)).Value; + var second = (await queues.ListStaleFactsAsync(1, 1)).Value; + + Assert.Equal(2, count); + Assert.Equal((expired, StaleReason.Expired, 2), (Assert.Single(first.Items).EntityId, first.Items[0].Reason, first.Total)); + Assert.Equal((unverified, StaleReason.Unverified, 2), (Assert.Single(second.Items).EntityId, second.Items[0].Reason, second.Total)); + } + + private static async Task AwaitingReviewAsync(ChangeSetScenario scenario, string slug) + { + var set = await scenario.OpenAsync(Create(await scenario.ContentTypeAsync(slug))); + await SubmitAsync(scenario, set.Id); + await MoveAsPersonAsync(scenario, set.Id, ChangeSetState.AwaitingReview); + return set; + } + + private static async Task SubmitAsync(ChangeSetScenario scenario, Guid id) + { + Assert.True((await scenario.RunAsync(directory => directory.TransitionAsync(id, ChangeSetState.ChecksRunning))).IsSuccess); + } + + private static async Task MoveAsPersonAsync(ChangeSetScenario scenario, Guid id, params ChangeSetState[] states) + { + foreach (var state in states) + { + var moved = await scenario.AsPersonAsync(directory => directory.TransitionAsync(id, state)); + Assert.True(moved.IsSuccess, string.Join("; ", moved.Errors.Select(error => error.Message))); + } + } + + private static async Task RevertAsync(ChangeSetScenario scenario, Guid id) + { + return (await scenario.AsPersonAsync(directory => directory.RevertAsync(id, "The prices were wrong."))).Value; + } + + private static async Task> IdsAsync(ChangeSetScenario scenario, DeskQueue queue) + { + var listed = await QueuesAsync(scenario, queues => queues.ListChangeSetsAsync(queue, 0, 50)); + return [.. listed.Value.Items.Select(set => set.Id)]; + } + + private static async Task ListAsync(ChangeSetScenario scenario, ChangeSetListQuery query) + { + await using var scope = scenario.Services.CreateAsyncScope(); + return (await scope.ServiceProvider.GetRequiredService().ListAsync(query)).Value; + } + + private static async Task QueuesAsync(ChangeSetScenario scenario, Func> read) + { + await using var scope = scenario.Services.CreateAsyncScope(); + return await read(scope.ServiceProvider.GetRequiredService()); + } + + private static (Guid? Reverts, Initiator Initiator, ChangeSetState State, int Items) Describe(ChangeSetSummary set) + { + return (set.RevertsChangeSetId, set.Initiator, set.State, set.ItemCount); + } + + private static async Task PricedAsync(EntryScenario scenario, string validUntil, string lastVerified) + { + const string sourced = """{"kind": "url", "reference": "https://example.com/catalog"}"""; + var created = await scenario.CreateAsync("product", $$""" + { + "name": {"value": "Drill", "source": {{sourced}}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}, + "price": {"value": 99, "source": {{sourced}}, "owner": null, "validFrom": null, "validUntil": {{validUntil}}, "lastVerified": {{lastVerified}}} + } + """); + Assert.True(created.IsSuccess, string.Join("; ", created.Errors.Select(error => error.Message))); + return created.Value.Id; + } +} diff --git a/tests/FluentCMS.IntegrationTests/Desk/DeskScreenTests.cs b/tests/FluentCMS.IntegrationTests/Desk/DeskScreenTests.cs new file mode 100644 index 000000000..6e2bc4f3f --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Desk/DeskScreenTests.cs @@ -0,0 +1,87 @@ +using System.Net; +using System.Text.RegularExpressions; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Schema; + +namespace FluentCMS.IntegrationTests.Desk; + +public sealed partial class DeskScreenTests +{ + [Fact] + public async Task An_anonymous_request_for_the_desk_is_sent_to_sign_in() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + + var response = await host.Client.GetAsync("/?queue=checks-failed"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/sign-in?ReturnUrl=%2F%3Fqueue%3Dchecks-failed", host.LocationOf(response)); + } + + [Fact] + public async Task Signing_in_lands_on_the_desk_with_the_queues_and_their_counts() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + var ownerId = await host.CreateFirstAdministratorAsync(); + var waiting = await AwaitingReviewAsync(host, ownerId, "product"); + await AwaitingReviewAsync(host, ownerId, "category"); + + var signedIn = await host.SignInAsync(AdminHost.Owner); + Assert.Equal("/", host.LocationOf(signedIn)); + var desk = await host.Client.GetStringAsync("/"); + + Assert.Contains("data-testid=\"desk-queues\"", desk); + Assert.Equal( + [("awaiting-me", "2"), ("awaiting-anyone", "2"), ("checks-failed", "0"), ("stale-facts", "0"), ("recently-published", "0"), ("reverted", "0")], + QueueCounts(desk)); + Assert.Contains("data-current-queue=\"awaiting-me\"", desk); + Assert.Contains($"data-change-set=\"{waiting:N}\"", desk); + Assert.Contains("Add the product type.", desk); + Assert.Matches("]*>Desk", desk); + + var empty = await host.Client.GetStringAsync("/?queue=reverted"); + Assert.Contains("data-current-queue=\"reverted\"", empty); + Assert.Contains("data-testid=\"desk-empty\"", empty); + Assert.DoesNotContain("data-change-set=", empty); + } + + [Fact] + public async Task The_desk_renders_right_to_left() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + await host.SignInAsync(AdminHost.Owner); + + var desk = await host.Client.GetStringAsync("/?dir=rtl"); + + Assert.Contains("", desk); + Assert.Contains("data-testid=\"desk-queues\"", desk); + } + + private static async Task AwaitingReviewAsync(AdminHost host, Guid userId, string slug) + { + var type = await host.AsUserAsync(userId, async directory => + (await directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition([new FieldDefinition("title", "text")])))).Value); + + return await host.AsUserAsync(userId, async directory => + { + var set = (await directory.OpenAsync(new NewChangeSet($"Add the {slug} type.", []))).Value; + Assert.True((await directory.AddOperationAsync(set.Id, ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, type.Id, null, 1).Value)).IsSuccess); + Assert.True((await directory.TransitionAsync(set.Id, ChangeSetState.ChecksRunning)).IsSuccess); + Assert.True((await directory.TransitionAsync(set.Id, ChangeSetState.AwaitingReview)).IsSuccess); + return set.Id; + }); + } + + private static List<(string Queue, string Count)> QueueCounts(string html) + { + return [.. QueueLink().Matches(html).Select(match => (match.Groups[1].Value, match.Groups[2].Value))]; + } + + [GeneratedRegex("data-queue=\"([a-z-]+)\"[^>]*>[^<]*]*data-count=\"(\\d+)\"")] + private static partial Regex QueueLink(); +} diff --git a/tests/FluentCMS.Tests/Admin/DeskTests.cs b/tests/FluentCMS.Tests/Admin/DeskTests.cs new file mode 100644 index 000000000..3f7109f05 --- /dev/null +++ b/tests/FluentCMS.Tests/Admin/DeskTests.cs @@ -0,0 +1,257 @@ +using Bunit; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Desk; +using FluentCMS.Core.Entities; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Results; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using DeskPage = FluentCMS.Admin.Components.Pages.Desk; + +namespace FluentCMS.Tests.Admin; + +public sealed class DeskTests +{ + private static readonly DateTimeOffset Now = new(2026, 10, 18, 9, 30, 0, TimeSpan.Zero); + + [Fact] + public void Lists_the_six_queues_with_their_counts_and_opens_on_awaiting_me() + { + using var context = Context(new FakeQueues()); + + var cut = context.Render(); + + var links = cut.FindAll("[data-testid=desk-queues] a"); + Assert.Equal( + ["awaiting-me", "awaiting-anyone", "checks-failed", "stale-facts", "recently-published", "reverted"], + links.Select(link => link.GetAttribute("data-queue"))); + Assert.Equal(["2", "2", "1", "3", "4", "0"], links.Select(link => link.QuerySelector("[data-count]")!.TextContent)); + Assert.Equal("?queue=checks-failed", links[2].GetAttribute("href")); + Assert.Equal("page", links[0].GetAttribute("aria-current")); + Assert.All(links.Skip(1), link => Assert.False(link.HasAttribute("aria-current"))); + Assert.Equal("awaiting-me", cut.Find("[data-testid=desk-queue]").GetAttribute("data-current-queue")); + Assert.NotNull(cut.Find("[data-testid=awaiting-me-note]")); + } + + [Fact] + public void A_set_shows_its_rationale_initiator_items_and_time_and_links_to_its_history() + { + var queues = new FakeQueues(); + using var context = Context(queues); + + var cut = context.Render(); + + var rows = cut.FindAll("[data-change-set]"); + Assert.Equal(2, rows.Count); + Assert.Equal(queues.Agent.Id.ToString("N"), rows[0].GetAttribute("data-change-set")); + var link = rows[0].QuerySelector("a")!; + Assert.Equal("Sync prices from the catalog export.", link.TextContent); + Assert.Equal($"audit?type=change_set&id={queues.Agent.Id}", link.GetAttribute("href")); + Assert.Equal("agent", rows[0].QuerySelector("[data-initiator]")!.GetAttribute("data-initiator")); + Assert.Contains("catalog-sync", rows[0].TextContent); + Assert.Contains("12", rows[0].QuerySelectorAll("td")[2].TextContent); + Assert.Equal("2026-10-18T09:30:00.0000000+00:00", rows[0].QuerySelector("time")!.GetAttribute("datetime")); + Assert.Contains("2026-10-18 09:30 UTC", rows[0].QuerySelector("time")!.TextContent); + Assert.Null(rows[0].QuerySelector("[data-reverts]")); + + Assert.Equal("user", rows[1].QuerySelector("[data-initiator]")!.GetAttribute("data-initiator")); + Assert.Equal(queues.Agent.Id.ToString("N"), rows[1].QuerySelector("[data-reverts]")!.GetAttribute("data-reverts")); + Assert.Equal([(DeskQueue.AwaitingMe, 0, DeskPage.PageSize)], queues.SetPages); + } + + [Fact] + public void The_queue_and_page_come_from_the_address() + { + var queues = new FakeQueues { Total = 120 }; + using var context = Context(queues); + context.Services.GetRequiredService().NavigateTo("/?queue=recently-published&page=2"); + + var cut = context.Render(); + + Assert.Equal([(DeskQueue.RecentlyPublished, 50, 50)], queues.SetPages); + Assert.Equal("recently-published", cut.Find("[data-testid=desk-queue]").GetAttribute("data-current-queue")); + Assert.Equal("page", cut.Find("[data-queue=recently-published]").GetAttribute("aria-current")); + Assert.Empty(cut.FindAll("[data-testid=awaiting-me-note]")); + Assert.Contains("51 to 52 of 120", cut.Find("[data-testid=desk-pages]").TextContent); + Assert.Equal("?queue=recently-published", cut.Find("a[rel=prev]").GetAttribute("href")); + Assert.Equal("?queue=recently-published&page=3", cut.Find("a[rel=next]").GetAttribute("href")); + Assert.Contains("Published", cut.FindAll("th")[3].TextContent); + } + + [Theory] + [InlineData("/?queue=nonsense", DeskQueue.AwaitingMe, 0)] + [InlineData("/?queue=reverted&page=0", DeskQueue.Reverted, 0)] + [InlineData("/?queue=reverted&page=-3", DeskQueue.Reverted, 0)] + public void An_unknown_queue_or_page_falls_back_to_the_first(string address, DeskQueue queue, int offset) + { + var queues = new FakeQueues(); + using var context = Context(queues); + context.Services.GetRequiredService().NavigateTo(address); + + context.Render(); + + Assert.Equal([(queue, offset, DeskPage.PageSize)], queues.SetPages); + } + + [Fact] + public void The_last_page_has_no_next_link_and_the_first_no_previous_link() + { + using var context = Context(new FakeQueues()); + + var cut = context.Render(); + + Assert.Contains("1 to 2 of 2", cut.Find("[data-testid=desk-pages]").TextContent); + Assert.Empty(cut.FindAll("a[rel=prev]")); + Assert.Empty(cut.FindAll("a[rel=next]")); + } + + [Fact] + public void A_page_past_the_end_links_back_to_the_first() + { + var queues = new FakeQueues { Total = 2 }; + using var context = Context(queues); + context.Services.GetRequiredService().NavigateTo("/?queue=awaiting-anyone&page=9"); + queues.Empty = true; + + var cut = context.Render(); + + Assert.Equal("?queue=awaiting-anyone", cut.Find("[data-testid=desk-past-end] a").GetAttribute("href")); + Assert.Empty(cut.FindAll("table")); + } + + [Fact] + public void An_empty_queue_says_so() + { + var queues = new FakeQueues { Total = 0, Empty = true }; + using var context = Context(queues); + context.Services.GetRequiredService().NavigateTo("/?queue=checks-failed"); + + var cut = context.Render(); + + Assert.Equal("No change set has failed its checks.", cut.Find("[data-testid=desk-empty]").TextContent); + Assert.Empty(cut.FindAll("table")); + Assert.Empty(cut.FindAll("[data-testid=desk-pages]")); + } + + [Fact] + public void The_stale_facts_queue_lists_each_fact_with_its_reason_and_links_to_its_entity() + { + var queues = new FakeQueues(); + using var context = Context(queues); + context.Services.GetRequiredService().NavigateTo("/?queue=stale-facts"); + + var cut = context.Render(); + + var rows = cut.FindAll("[data-fact]"); + Assert.Equal(2, rows.Count); + Assert.Equal("expired", rows[0].QuerySelector("[data-reason]")!.GetAttribute("data-reason")); + Assert.Equal("unverified", rows[1].QuerySelector("[data-reason]")!.GetAttribute("data-reason")); + Assert.Equal($"audit?type=entity&id={queues.Entity}", rows[0].QuerySelector("a")!.GetAttribute("href")); + Assert.Contains("price", rows[0].TextContent); + Assert.Contains("Published", rows[0].TextContent); + Assert.DoesNotContain("Latest draft", rows[0].TextContent); + Assert.Contains("Latest draft", rows[1].TextContent); + Assert.Equal([(0, DeskPage.PageSize)], queues.FactPages); + Assert.Empty(queues.SetPages); + } + + [Fact] + public void A_refused_read_is_reported_in_place_of_the_list() + { + var queues = new FakeQueues { Refuse = true }; + using var context = Context(queues); + + var cut = context.Render(); + + // The code is the resource key, which the test localizer returns as the text. + Assert.Equal(ChangeSetErrorCodes.LimitInvalid, cut.Find("[data-testid=desk-error]").TextContent); + Assert.Empty(cut.FindAll("table")); + } + + [Fact] + public void Uses_logical_utilities_only() + { + using var context = Context(new FakeQueues()); + + var cut = context.Render(); + + var classes = cut.FindAll("[class]").SelectMany(element => element.ClassList); + Assert.DoesNotContain(classes, Utilities.IsPhysical); + } + + private static AdminTestContext Context(FakeQueues queues) + { + var context = new AdminTestContext(); + context.Services.AddSingleton(queues); + return context; + } + + private sealed class FakeQueues : IDeskQueues + { + public ChangeSetSummary Agent { get; } = new( + Guid.CreateVersion7(), + "Sync prices from the catalog export.", + new AgentInitiator(Guid.NewGuid(), "catalog-sync"), + ChangeSetState.AwaitingReview, + 12, + null, + Now.AddHours(-1), + Now); + + public Guid Entity { get; } = Guid.NewGuid(); + + public int Total { get; init; } = 2; + + public bool Empty { get; set; } + + public bool Refuse { get; init; } + + public List<(DeskQueue Queue, int Offset, int Limit)> SetPages { get; } = []; + + public List<(int Offset, int Limit)> FactPages { get; } = []; + + public Task>> CountAsync(CancellationToken cancellationToken = default) + { + IReadOnlyDictionary counts = new Dictionary + { + [DeskQueue.AwaitingMe] = 2, + [DeskQueue.AwaitingAnyone] = 2, + [DeskQueue.ChecksFailed] = 1, + [DeskQueue.StaleFacts] = 3, + [DeskQueue.RecentlyPublished] = 4, + [DeskQueue.Reverted] = 0, + }; + return Task.FromResult(Result>.Success(counts)); + } + + public Task> ListChangeSetsAsync(DeskQueue queue, int offset, int limit, CancellationToken cancellationToken = default) + { + SetPages.Add((queue, offset, limit)); + if (Refuse) + { + return Task.FromResult(Result.Failure(ChangeSetErrorCodes.LimitInvalid, "The limit is out of range.")); + } + + var revert = Agent with + { + Id = Guid.CreateVersion7(), + Rationale = "Undo the price sync.", + Initiator = new UserInitiator(Guid.NewGuid(), "Amir"), + ItemCount = 1, + RevertsChangeSetId = Agent.Id, + }; + return Task.FromResult(Result.Success(new ChangeSetList(Empty ? [] : [Agent, revert], Total))); + } + + public Task> ListStaleFactsAsync(int offset, int limit, CancellationToken cancellationToken = default) + { + FactPages.Add((offset, limit)); + StaleFact[] facts = + [ + new(Entity, "product", "price", 3, false, true, StaleReason.Expired, Now.AddDays(-20)), + new(Entity, "product", "price", 4, true, false, StaleReason.Unverified, Now.AddDays(-2)), + ]; + return Task.FromResult(Result.Success(new StaleFactList(facts, facts.Length))); + } + } +} diff --git a/tests/FluentCMS.Tests/Desk/DeskQueuesTests.cs b/tests/FluentCMS.Tests/Desk/DeskQueuesTests.cs new file mode 100644 index 000000000..eefe69ee2 --- /dev/null +++ b/tests/FluentCMS.Tests/Desk/DeskQueuesTests.cs @@ -0,0 +1,161 @@ +using FluentCMS.Core; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Desk; +using FluentCMS.Core.Entities; +using FluentCMS.Core.Results; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Tests.Desk; + +public sealed class DeskQueuesTests : IDisposable +{ + private static readonly DateTimeOffset Now = new(2026, 10, 18, 12, 0, 0, TimeSpan.Zero); + + private readonly RecordingLists _lists = new(); + private readonly StaleFacts _facts = new(); + private readonly ServiceProvider _provider; + + public DeskQueuesTests() + { + _provider = new ServiceCollection() + .AddSingleton(new FixedClock(Now)) + .AddFluentCmsCore() + .AddSingleton(_lists) + .AddSingleton(_facts) + .BuildServiceProvider(); + } + + private IDeskQueues Queues => _provider.GetRequiredService(); + + [Theory] + [InlineData(DeskQueue.AwaitingMe, ChangeSetState.AwaitingReview, false, false)] + [InlineData(DeskQueue.AwaitingAnyone, ChangeSetState.AwaitingReview, false, false)] + [InlineData(DeskQueue.ChecksFailed, ChangeSetState.ChecksFailed, false, false)] + [InlineData(DeskQueue.RecentlyPublished, ChangeSetState.Published, true, true)] + [InlineData(DeskQueue.Reverted, ChangeSetState.Reverted, true, true)] + public async Task Each_queue_of_change_sets_selects_its_state_window_and_order(DeskQueue queue, ChangeSetState state, bool recent, bool newestFirst) + { + await Queues.ListChangeSetsAsync(queue, 50, 25); + + var query = Assert.Single(_lists.Listed); + Assert.Equal([state], query.States); + Assert.Equal(recent ? Now.AddDays(-7) : null, query.EnteredSince); + Assert.Equal(newestFirst, query.NewestFirst); + Assert.Equal((50, 25), (query.Offset, query.Limit)); + } + + [Fact] + public async Task Counts_every_queue_and_the_stale_facts() + { + _lists.Counts[ChangeSetState.AwaitingReview] = 3; + _lists.Counts[ChangeSetState.ChecksFailed] = 1; + _lists.Counts[ChangeSetState.Published] = 7; + _facts.Stale = [Fact(), Fact()]; + + var counts = (await Queues.CountAsync()).Value; + + Assert.Equal( + [(DeskQueue.AwaitingMe, 3), (DeskQueue.AwaitingAnyone, 3), (DeskQueue.ChecksFailed, 1), (DeskQueue.StaleFacts, 2), (DeskQueue.RecentlyPublished, 7), (DeskQueue.Reverted, 0)], + counts.OrderBy(pair => pair.Key).Select(pair => (pair.Key, pair.Value))); + Assert.All(_lists.Counted.Where(query => query.States.Contains(ChangeSetState.Published)), query => Assert.Equal(Now.AddDays(-7), query.EnteredSince)); + } + + [Fact] + public async Task Stale_facts_are_paged_in_their_order() + { + _facts.Stale = [Fact("a"), Fact("b"), Fact("c")]; + + var page = (await Queues.ListStaleFactsAsync(1, 1)).Value; + + Assert.Equal(("b", 3), (Assert.Single(page.Items).Field, page.Total)); + } + + [Fact] + public async Task A_failed_read_fails_the_counts() + { + _facts.Refuse = true; + + var counts = await Queues.CountAsync(); + var facts = await Queues.ListStaleFactsAsync(0, 10); + + Assert.Equal("type_not_entity", Assert.Single(counts.Errors).Code); + Assert.Equal("type_not_entity", Assert.Single(facts.Errors).Code); + } + + [Fact] + public async Task Stale_facts_are_not_a_queue_of_change_sets() + { + var listed = await Queues.ListChangeSetsAsync(DeskQueue.StaleFacts, 0, 10); + + Assert.Equal(DeskErrorCodes.QueueNotChangeSets, Assert.Single(listed.Errors).Code); + Assert.Empty(_lists.Listed); + } + + public void Dispose() + { + _provider.Dispose(); + } + + private static StaleFact Fact(string field = "price") + { + return new StaleFact(Guid.NewGuid(), "product", field, 1, true, false, StaleReason.Expired, Now.AddDays(-1)); + } + + private sealed class RecordingLists : IChangeSetLists + { + public List Listed { get; } = []; + + public List Counted { get; } = []; + + public Dictionary Counts { get; } = []; + + public Task> ListAsync(ChangeSetListQuery query, CancellationToken cancellationToken = default) + { + Listed.Add(query); + return Task.FromResult(Result.Success(new ChangeSetList([], 0))); + } + + public Task> CountAsync(ChangeSetListQuery query, CancellationToken cancellationToken = default) + { + Counted.Add(query); + return Task.FromResult(Result.Success(query.States.Sum(state => Counts.GetValueOrDefault(state)))); + } + } + + private sealed class StaleFacts : IEntityFacts + { + public IReadOnlyList Stale { get; set; } = []; + + public bool Refuse { get; set; } + + public Task>> StaleAsync(string? contentType = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(Refuse + ? Result>.Failure(EntityErrorCodes.TypeNotEntity, "Not an entity type.") + : Result>.Success(Stale)); + } + + public Task> GetAsync(Guid entityId, int? version = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task>> UnsourcedAsync(string? contentType = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task>> UnsourcedInChangeSetAsync(Guid changeSetId, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } + + private sealed class FixedClock(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() + { + return now; + } + } +}