From de68d63185dad2b82eb475a1ce84520ed70e8569 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 20:19:41 -0400 Subject: [PATCH 1/3] WIP dead check jobs --- .../Components/Pages/ChangeSetView.razor | 43 +++++++-- .../Admin/ChangeSetViewTests.cs | 95 +++++++++++++++++-- 2 files changed, 120 insertions(+), 18 deletions(-) diff --git a/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor b/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor index 90bfed11b..ca292a862 100644 --- a/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor +++ b/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor @@ -4,6 +4,8 @@ @using FluentCMS.Core.Audit @using FluentCMS.Core.ChangeSets @using FluentCMS.Core.Desk +@using FluentCMS.Core.Identity +@using FluentCMS.Core.Jobs @inject IChangeSetDirectory Directory @inject IChangeSetLists Lists @inject IDeskQueues Queues @@ -92,12 +94,19 @@ } - @if (set.State == ChangeSetState.Open) + @if (OffersSubmit) {
- + @if (set.State == ChangeSetState.Open) + { + + } + else + { + + } } @if (set.State == ChangeSetState.Approved) @@ -142,7 +151,7 @@ } @* A form posted from a page shown before the set moved on must still find its handler, which then says the set moved on. *@ - @if (set.State != ChangeSetState.Open) + @if (!OffersSubmit) { } @@ -214,14 +223,22 @@ } } - else if (_checks?.Job is { } job) + else if (CheckJobEnded) + { + @L["The check run ended without a result. Whoever opened the set can run the checks again."] + } + else if (_checks?.Job is { State: JobState.Queued or JobState.Running } job) { @L["The checks are running: {0} of {1} items done.", job.Progress.Done, job.Progress.Total?.ToString(CultureInfo.CurrentCulture) ?? "?"] } - else + else if (set.State == ChangeSetState.Open) { @L["The checks run when the set is submitted."] } + else + { + @L["No check results are stored for this set."] + } @if (_checks?.Run is { } results && (results.Problems.Count > 0 || results.Items.Any(item => item.Results.Count > 0))) @@ -251,15 +268,16 @@ @foreach (var finding in result.Findings) { + @* Each part is isolated, so a right-to-left override in a judgement's text cannot reorder the others. *@ - @finding.Message + @finding.Message @if (finding.Pointer.Length > 0) { - @finding.Pointer + @finding.Pointer } @if (finding.Excerpt is { } excerpt) { - @excerpt + @excerpt } } @@ -462,7 +480,7 @@ private async Task SubmitAsync() { - await ActAsync(Submit, [ChangeSetState.Open], async () => + await ActAsync(Submit, OffersSubmit && _set is { } set ? [set.State] : [], async () => { var submitted = await Checks.SubmitAsync(Id); return submitted.IsSuccess @@ -522,6 +540,13 @@ } } + // A person submits a set they opened, and runs its checks again when its check job ended without moving it; an agent submits its + // own sets by tool, and a person pressing S on one would split its work in two. + private bool OffersSubmit => _set is { Initiator: UserInitiator } set + && (set.State == ChangeSetState.Open || (set.State == ChangeSetState.ChecksRunning && CheckJobEnded)); + + private bool CheckJobEnded => _set?.State == ChangeSetState.ChecksRunning && _checks is { Run: null, Job.State: JobState.Succeeded or JobState.Failed or JobState.Dead }; + private static readonly ChangeSetState[] RejectableStates = [ChangeSetState.AwaitingReview, ChangeSetState.ChecksFailed, ChangeSetState.Approved]; // A revert set that may still publish; a rejected one leaves room for another. diff --git a/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs b/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs index e69ccac2e..d0c00dc81 100644 --- a/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs +++ b/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs @@ -107,15 +107,78 @@ public void The_checks_card_shows_the_latest_run_with_failing_results_first_and_ var card = cut.Find("[data-testid=change-set-checks]"); Assert.Equal("fail", card.QuerySelector("[data-testid=check-run-status]")!.GetAttribute("data-status")); - Assert.Equal(["no-superlatives", "short-headline"], card.QuerySelectorAll("[data-check]").Select(check => check.GetAttribute("data-check"))); + Assert.Equal(["no-superlatives", "plain-words", "short-headline"], card.QuerySelectorAll("[data-check]").Select(check => check.GetAttribute("data-check"))); + Assert.Contains("blocks", card.QuerySelector("[data-check=no-superlatives]")!.TextContent); + Assert.Contains("warns", card.QuerySelector("[data-check=plain-words]")!.TextContent); var finding = card.QuerySelector("[data-check=no-superlatives] [data-finding]")!; - Assert.Contains("Remove 'best in class'.", finding.TextContent); + Assert.Equal(["Remove 'best in class'.", "/headline", "Best in class"], finding.QuerySelectorAll("bdi").Select(part => part.TextContent)); Assert.Equal("/headline", finding.QuerySelector("code")!.TextContent); Assert.Equal("Best in class", finding.QuerySelector("q")!.TextContent); Assert.Contains("The engine could not read one item.", card.QuerySelector("[data-testid=check-problems]")!.TextContent); Assert.Contains("Routing has not decided on this set.", cut.Find("[data-testid=change-set-routing]").TextContent); } + [Fact] + public void A_person_submits_a_set_they_opened_while_an_agents_open_set_offers_no_submit() + { + var personal = new FakeSets { State = ChangeSetState.Open, Opener = new UserInitiator(Guid.NewGuid(), "Dana") }; + var agents = new FakeSets { State = ChangeSetState.Open }; + using var personalContext = Context(personal); + using var agentsContext = Context(agents); + + var offered = personalContext.Render(page => page.Add(p => p.Id, personal.Set.Id)); + var notOffered = agentsContext.Render(page => page.Add(p => p.Id, agents.Set.Id)); + + Assert.Equal("Submit for checks S", offered.Find("[data-action=submit]").TextContent.Trim()); + Assert.Empty(notOffered.FindAll("[data-action=submit]")); + Assert.Contains("The checks run when the set is submitted.", notOffered.Find("[data-testid=change-set-checks]").TextContent); + } + + [Theory] + [InlineData(FluentCMS.Core.Jobs.JobState.Dead)] + [InlineData(FluentCMS.Core.Jobs.JobState.Failed)] + [InlineData(FluentCMS.Core.Jobs.JobState.Succeeded)] + public void A_check_job_that_ended_without_moving_the_set_reads_as_ended_and_its_opener_can_run_the_checks_again(FluentCMS.Core.Jobs.JobState ended) + { + var personal = new FakeSets { State = ChangeSetState.ChecksRunning, JobState = ended, Opener = new UserInitiator(Guid.NewGuid(), "Dana") }; + var agents = new FakeSets { State = ChangeSetState.ChecksRunning, JobState = ended }; + using var personalContext = Context(personal); + using var agentsContext = Context(agents); + + var mine = personalContext.Render(page => page.Add(p => p.Id, personal.Set.Id)); + var theirs = agentsContext.Render(page => page.Add(p => p.Id, agents.Set.Id)); + + Assert.NotNull(mine.Find("[data-testid=check-job-ended]")); + Assert.DoesNotContain("items done", mine.Find("[data-testid=change-set-checks]").TextContent); + Assert.Equal("Run the checks again", mine.Find("[data-action=submit]").TextContent.Trim()); + Assert.NotNull(theirs.Find("[data-testid=check-job-ended]")); + Assert.Empty(theirs.FindAll("[data-action=submit]")); + } + + [Fact] + public void A_passed_run_without_results_shows_that_it_passed() + { + var sets = new FakeSets { State = ChangeSetState.AwaitingReview, Passed = true }; + using var context = Context(sets); + + var cut = context.Render(page => page.Add(p => p.Id, sets.Set.Id)); + + var card = cut.Find("[data-testid=change-set-checks]"); + Assert.Equal("pass", card.QuerySelector("[data-testid=check-run-status]")!.GetAttribute("data-status")); + Assert.Empty(card.QuerySelectorAll("[data-check]")); + } + + [Fact] + public void A_set_past_open_with_no_stored_run_says_so() + { + var sets = new FakeSets { State = ChangeSetState.Approved }; + using var context = Context(sets); + + var cut = context.Render(page => page.Add(p => p.Id, sets.Set.Id)); + + Assert.Contains("No check results are stored for this set.", cut.Find("[data-testid=change-set-checks]").TextContent); + } + [Fact] public void A_set_still_being_checked_shows_the_job_progress() { @@ -132,7 +195,7 @@ public void A_set_still_being_checked_shows_the_job_progress() [InlineData(ChangeSetState.ChecksFailed, new[] { "reject" })] [InlineData(ChangeSetState.Approved, new[] { "publish", "reject" })] [InlineData(ChangeSetState.Published, new[] { "revert" })] - [InlineData(ChangeSetState.Open, new[] { "submit" })] + [InlineData(ChangeSetState.Open, new string[0])] [InlineData(ChangeSetState.ChecksRunning, new string[0])] [InlineData(ChangeSetState.Rejected, new string[0])] [InlineData(ChangeSetState.Reverted, new string[0])] @@ -271,6 +334,13 @@ private sealed class FakeSets : IChangeSetDirectory public ChangeSetState State { get; init; } = ChangeSetState.AwaitingReview; + public Initiator Opener { get; init; } = new AgentInitiator(Guid.NewGuid(), "catalog-sync"); + + public FluentCMS.Core.Jobs.JobState JobState { get; init; } = FluentCMS.Core.Jobs.JobState.Running; + + // A passed run with no results, rather than the failing run checks failed shows. + public bool Passed { get; init; } + public Guid? RevertsChangeSetId { get; init; } public string? RejectionReason { get; init; } @@ -282,7 +352,7 @@ private sealed class FakeSets : IChangeSetDirectory Guid.NewGuid(), "Sync prices from the catalog export.", ["https://example.com/catalog", "exports/catalog-2026-10-18.csv", "javascript:alert(1)", "Dana in sales"], - new AgentInitiator(Guid.NewGuid(), "catalog-sync"), + Opener, null, Operations, State, @@ -361,24 +431,31 @@ public Task> GetAsync(Guid changeSetId, Cancellatio Id = Guid.NewGuid(), Type = "checks.run", Payload = default, - State = FluentCMS.Core.Jobs.JobState.Running, + State = sets.JobState, Attempts = 1, MaxAttempts = 6, CreatedAt = Opened, ScheduledAt = Opened, Progress = new FluentCMS.Core.Jobs.JobProgress(3, 10, null), }; + if (sets.Passed) + { + var passed = new ChangeSetCheckRun(Guid.NewGuid(), sets.Set.Id, job.Id, 1, CheckRunMode.Inline, Opened, Opened, CheckStatus.Pass, 0m, [], []); + return Task.FromResult(Result.Success(new ChangeSetChecksState(sets.Set, passed, job))); + } + if (sets.State is not ChangeSetState.ChecksFailed) { return Task.FromResult(Result.Success(new ChangeSetChecksState(sets.Set, null, sets.State == ChangeSetState.ChecksRunning ? job : null))); } - CheckResult Result(string check, CheckStatus status, params CheckFinding[] findings) => - new(Guid.NewGuid(), check, 1, "terms", CheckKind.Deterministic, CheckSeverity.Block, "A check.", status, findings, null); + CheckResult Result(string check, CheckStatus status, CheckSeverity severity, params CheckFinding[] findings) => + new(Guid.NewGuid(), check, 1, "terms", CheckKind.Deterministic, severity, "A check.", status, findings, null); var item = new ItemChecks(ItemKind.Entry, Guid.NewGuid(), 1, "product-page", [ - Result("short-headline", CheckStatus.Pass), - Result("no-superlatives", CheckStatus.Fail, new CheckFinding("Remove 'best in class'.", "/headline", "Best in class")), + Result("short-headline", CheckStatus.Pass, CheckSeverity.Block), + Result("no-superlatives", CheckStatus.Fail, CheckSeverity.Block, new CheckFinding("Remove 'best in class'.", "/headline", "Best in class")), + Result("plain-words", CheckStatus.Warn, CheckSeverity.Warn, new CheckFinding("Prefer 'use' to 'utilize'.", "/body", "utilize")), ]); var run = new ChangeSetCheckRun(Guid.NewGuid(), sets.Set.Id, job.Id, 1, CheckRunMode.Inline, Opened, Opened, CheckStatus.Fail, 0m, [item], ["The engine could not read one item."]); return Task.FromResult(Result.Success(new ChangeSetChecksState(sets.Set, run, job))); From 5a806988d4f6eeab2f6200500403999e8a68cf19 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 20:27:25 -0400 Subject: [PATCH 2/3] Show a dead check job as ended and let its set recover from the set view Closes #2628. --- .../Browser/AccessibilityTests.cs | 3 +- .../Desk/CheckCardTests.cs | 58 +++++++++++++ .../Desk/CheckedSets.cs | 81 +++++++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 tests/FluentCMS.IntegrationTests/Desk/CheckCardTests.cs create mode 100644 tests/FluentCMS.IntegrationTests/Desk/CheckedSets.cs diff --git a/tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs b/tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs index 610b56ad4..8e1b294c6 100644 --- a/tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs +++ b/tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs @@ -35,9 +35,10 @@ public async Task The_showcase_desk_and_set_view_have_no_wcag_21_aa_violations() { await session.Host.AsUserAsync(session.OwnerId, async users => await users.CreateAsync(new FluentCMS.Core.Identity.NewUser($"editor{user}@example.com", $"Editor {user}", "An0ther!Passw0rd"))); } + var failing = await Desk.CheckedSets.FailingAsync(session.Host, session.OwnerId); await session.SignInWithKeyboardAsync(); - string[] paths = ["/ui", "/", "/?queue=stale-facts", $"/sets/{set.Id}?queue=awaiting-me", "/security", "/media", $"/media/{asset}", "/content", "/content/product", "/content/product/new", "/content/supplier/new", "/content/kitchen-sink/new"]; + string[] paths = ["/ui", "/", "/?queue=stale-facts", $"/sets/{set.Id}?queue=awaiting-me", $"/sets/{failing}", "/security", "/media", $"/media/{asset}", "/content", "/content/product", "/content/product/new", "/content/supplier/new", "/content/kitchen-sink/new"]; var violations = new List(); foreach (var theme in new[] { "light", "dark" }) { diff --git a/tests/FluentCMS.IntegrationTests/Desk/CheckCardTests.cs b/tests/FluentCMS.IntegrationTests/Desk/CheckCardTests.cs new file mode 100644 index 000000000..e49a390c0 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Desk/CheckCardTests.cs @@ -0,0 +1,58 @@ +using System.Text.RegularExpressions; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Persistence; + +namespace FluentCMS.IntegrationTests.Desk; + +public sealed class CheckCardTests +{ + // A check job that ended without moving its set leaves the set in checks running; the person who opened it recovers it from the + // set view, which fails the set so it can be reopened. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_set_whose_check_job_ended_says_so_and_its_opener_recovers_it(string provider) + { + using var install = new TemporaryInstall(); + await using var database = await TemporaryDatabase.CreateAsync(provider); + await using var host = await AdminHost.StartAsync(install, settings: database.Configuration); + var ownerId = await host.CreateFirstAdministratorAsync(); + var type = await host.AsUserAsync(ownerId, async types => (await types.CreateAsync( + new NewContentType("Brand", "brand", new ContentTypeDefinition([new FieldDefinition("name", "text")])), TestChangeSetTargets.ContentModel)).Value); + var setId = await CheckedSets.DeadJobAsync(host, ownerId, type); + await host.SignInAsync(AdminHost.Owner); + + var page = await host.Client.GetStringAsync($"/sets/{setId}"); + Assert.Contains("data-testid=\"check-job-ended\"", page); + Assert.DoesNotContain("items done", page); + Assert.Contains("Run the checks again", page); + + var seen = Regex.Match(page, "name=\"Submit.Seen\" value=\"([0-9a-f]+)\"").Groups[1].Value; + var recovered = await host.SubmitAsync($"/sets/{setId}", "submit", new Dictionary { ["Submit.Seen"] = seen }); + + Assert.Contains("data-state=\"checks-failed\"", await recovered.Content.ReadAsStringAsync()); + var set = await host.AsUserAsync(ownerId, async sets => (await sets.GetAsync(setId)).Value); + Assert.Equal(ChangeSetState.ChecksFailed, set.State); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_failing_run_shows_its_findings_and_each_severity(string provider) + { + using var install = new TemporaryInstall(); + await using var database = await TemporaryDatabase.CreateAsync(provider); + await using var host = await AdminHost.StartAsync(install, settings: database.Configuration); + var ownerId = await host.CreateFirstAdministratorAsync(); + var setId = await CheckedSets.FailingAsync(host, ownerId); + await host.SignInAsync(AdminHost.Owner); + + var page = await host.Client.GetStringAsync($"/sets/{setId}"); + + Assert.Contains("data-state=\"checks-failed\"", page); + Assert.Contains("data-check=\"no-superlatives\" data-status=\"fail\"", page); + Assert.Contains("blocks", page[page.IndexOf("data-check=\"no-superlatives\"", StringComparison.Ordinal)..]); + Assert.Contains("", page[page.IndexOf("data-finding", StringComparison.Ordinal)..]); + } +} diff --git a/tests/FluentCMS.IntegrationTests/Desk/CheckedSets.cs b/tests/FluentCMS.IntegrationTests/Desk/CheckedSets.cs new file mode 100644 index 000000000..2a8a727a5 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Desk/CheckedSets.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Checks; +using FluentCMS.Core.Content; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Jobs; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Desk; + +// Sets in the states the checks card shows, made through the code that makes them. +internal static class CheckedSets +{ + // A product page whose headline says "best in class", submitted while a terms check against it is active: its run fails. + public static async Task FailingAsync(AdminHost host, Guid ownerId) + { + await host.AsUserAsync(ownerId, async types => (await types.CreateAsync( + new NewContentType("Product page", "product-page", new ContentTypeDefinition([new FieldDefinition("headline", "text")])), + TestChangeSetTargets.ContentModel)).Value); + await host.AsUserAsync(ownerId, async checks => + { + var created = (await checks.CreateAsync(new NewCheck("no-superlatives", new CheckDefinition( + "terms", + "Headlines make no claims they cannot prove.", + Json("""{"terms": ["best in class"]}"""), + [ + new CheckExample("passes", CheckVerdict.Pass, Json("""{"headline": "A sturdy kettle"}"""), contentType: "product-page"), + new CheckExample("fails", CheckVerdict.Fail, Json("""{"headline": "The best in class kettle"}"""), contentType: "product-page"), + ], + scope: new CheckScope(["product-page"], null))))).Value; + return (await checks.ActivateAsync(created.Name, created.Version)).Value; + }); + var entry = await host.AsUserAsync(ownerId, async entries => + (await entries.CreateAsync(new NewEntry("product-page", Json("""{"headline": "The best in class kettle"}""")))).Value); + + var set = await host.AsUserAsync(ownerId, async directory => + { + var opened = (await directory.OpenAsync(new NewChangeSet("Add the kettle page.", []))).Value; + return (await directory.AddOperationAsync(opened.Id, ChangeOperation.Create(OperationKind.Create, ItemKind.Entry, entry.Id, null, 1).Value)).Value; + }); + return await host.AsUserAsync(ownerId, async checks => (await checks.SubmitAsync(set.Id)).Value.ChangeSet.Id); + } + + // A set a person submitted whose check job ended without moving it, as when every attempt failed. + public static async Task DeadJobAsync(AdminHost host, Guid ownerId, ContentType type) + { + var set = await host.AsUserAsync(ownerId, async directory => + { + var opened = (await directory.OpenAsync(new NewChangeSet("Add the brand type.", []))).Value; + await directory.AddOperationAsync(opened.Id, ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, type.Id, null, 1).Value); + return (await directory.TransitionAsync(opened.Id, ChangeSetState.ChecksRunning)).Value; + }); + + await using var scope = host.App.Services.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var now = DateTimeOffset.UtcNow; + context.Add(new JobRow + { + Id = Guid.CreateVersion7(), + Type = "checks.run", + Payload = JsonSerializer.Serialize(new { changeSetId = set.Id }), + SubjectType = "change_set", + SubjectId = set.Id, + State = JobState.Dead, + Attempts = 6, + MaxAttempts = 6, + CreatedAt = now, + ScheduledAt = now, + FinishedAt = now, + }); + await context.SaveChangesAsync(); + return set.Id; + } + + private static JsonElement Json(string json) + { + return JsonSerializer.Deserialize(json); + } +} From 770fc3e433427dbaf7173587d07312f2bd2a840d Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 22:40:58 -0400 Subject: [PATCH 3/3] Say a dead job's set can't be reviewed yet, report a recovery as one, and test an agent's set refusing Submit --- .../Components/Pages/ChangeSetView.razor | 8 ++- .../Desk/CheckCardTests.cs | 54 ++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor b/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor index ca9befcbd..030407717 100644 --- a/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor +++ b/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor @@ -74,7 +74,7 @@

@L["Review"]

- @ReviewHint(set.State) + @(CheckJobEnded ? L["The check run ended without a result, so the set cannot be reviewed yet. Whoever opened it can run the checks again."] : ReviewHint(set.State))
@if (_message is not null) @@ -484,13 +484,17 @@ private async Task SubmitAsync() { + // Running the checks again on a set whose job ended recovers it, which fails the set rather than submitting it. + var done = CheckJobEnded + ? L["The check run had ended without a result, so the set now shows its checks as failed."] + : L["Submitted. The checks run on the set before it is reviewed."]; await ActAsync(Submit, OffersSubmit && _set is { } set ? [set.State] : [], async () => { var submitted = await Checks.SubmitAsync(Id); return submitted.IsSuccess ? FluentCMS.Core.Results.Result.Success(submitted.Value.ChangeSet) : FluentCMS.Core.Results.Result.Failure(submitted.Errors); - }, L["Submitted. The checks run on the set before it is reviewed."]); + }, done); } private async Task ApproveAsync() diff --git a/tests/FluentCMS.IntegrationTests/Desk/CheckCardTests.cs b/tests/FluentCMS.IntegrationTests/Desk/CheckCardTests.cs index e49a390c0..9947ea43d 100644 --- a/tests/FluentCMS.IntegrationTests/Desk/CheckCardTests.cs +++ b/tests/FluentCMS.IntegrationTests/Desk/CheckCardTests.cs @@ -2,6 +2,8 @@ using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Schema; using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; namespace FluentCMS.IntegrationTests.Desk; @@ -25,17 +27,54 @@ public async Task A_set_whose_check_job_ended_says_so_and_its_opener_recovers_it var page = await host.Client.GetStringAsync($"/sets/{setId}"); Assert.Contains("data-testid=\"check-job-ended\"", page); + Assert.DoesNotContain("The checks are running, so it cannot be reviewed yet.", page); Assert.DoesNotContain("items done", page); Assert.Contains("Run the checks again", page); var seen = Regex.Match(page, "name=\"Submit.Seen\" value=\"([0-9a-f]+)\"").Groups[1].Value; var recovered = await host.SubmitAsync($"/sets/{setId}", "submit", new Dictionary { ["Submit.Seen"] = seen }); - Assert.Contains("data-state=\"checks-failed\"", await recovered.Content.ReadAsStringAsync()); + var after = await recovered.Content.ReadAsStringAsync(); + Assert.Contains("data-state=\"checks-failed\"", after); + Assert.Contains("now shows its checks as failed", after); + Assert.DoesNotContain("Submitted. The checks run", after); var set = await host.AsUserAsync(ownerId, async sets => (await sets.GetAsync(setId)).Value); Assert.Equal(ChangeSetState.ChecksFailed, set.State); } + // An agent submits its own sets by tool: a person's post of Submit for one is refused, whatever the form claims. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_post_submitting_an_agents_open_set_is_refused(string provider) + { + using var install = new TemporaryInstall(); + await using var database = await TemporaryDatabase.CreateAsync(provider); + await using var host = await AdminHost.StartAsync(install, settings: database.Configuration); + var ownerId = await host.CreateFirstAdministratorAsync(); + var issued = await Mcp.McpAgents.IssueAsync(host, ownerId, Mcp.McpAgents.Credential(DateTimeOffset.UtcNow, "catalog-agent") with + { + Scope = FluentCMS.Core.Agents.AgentScope.Create( + [FluentCMS.Core.Sites.Site.Default.Id], null, null, [FluentCMS.Core.Agents.AgentOperation.Read, FluentCMS.Core.Agents.AgentOperation.Create]).Value, + }); + Guid setId; + await using (var client = await Mcp.McpAgents.ConnectOverHttpAsync(host, issued.Key)) + { + var opened = await client.CallToolAsync("open_change_set", new Dictionary { ["rationale"] = "Add the brand type." }); + setId = Guid.Parse((string)System.Text.Json.Nodes.JsonNode.Parse(((ModelContextProtocol.Protocol.TextContentBlock)opened.Content.Single()).Text)!["changeSet"]!["id"]!); + } + + await host.SignInAsync(AdminHost.Owner); + var page = await host.Client.GetStringAsync($"/sets/{setId}"); + Assert.DoesNotContain("data-action=\"submit\"", page); + var seen = await LastMoveAsync(host, setId); + var refused = await host.SubmitAsync($"/sets/{setId}", "submit", new Dictionary { ["Submit.Seen"] = seen }); + + Assert.Contains("data-testid=\"review-refusal\"", await refused.Content.ReadAsStringAsync()); + var set = await host.AsUserAsync(ownerId, async sets => (await sets.GetAsync(setId)).Value); + Assert.Equal(ChangeSetState.Open, set.State); + } + [Theory] [InlineData(DatabaseProviders.Sqlite)] [PostgresData] @@ -53,6 +92,17 @@ public async Task A_failing_run_shows_its_findings_and_each_severity(string prov Assert.Contains("data-state=\"checks-failed\"", page); Assert.Contains("data-check=\"no-superlatives\" data-status=\"fail\"", page); Assert.Contains("blocks", page[page.IndexOf("data-check=\"no-superlatives\"", StringComparison.Ordinal)..]); - Assert.Contains("", page[page.IndexOf("data-finding", StringComparison.Ordinal)..]); + var finding = page[page.IndexOf("data-finding", StringComparison.Ordinal)..]; + Assert.StartsWith("data-finding>", finding.Replace(" ", string.Empty).Replace("\n", string.Empty).Replace("\r", string.Empty)); + } + + private static async Task LastMoveAsync(AdminHost host, Guid id) + { + await using var scope = host.App.Services.CreateAsyncScope(); + var rows = await scope.ServiceProvider.GetRequiredService().AuditEvents + .AsNoTracking() + .Where(row => row.SubjectType == "change_set" && row.SubjectId == id) + .ToListAsync(); + return rows.OrderBy(row => row.Timestamp).Last(row => ChangeSetMoves.Operations.Contains(row.Operation)).Id.ToString("N"); } }