From 20b6d5a5141940fee6e7a9179cda6b883b75ae4d Mon Sep 17 00:00:00 2001 From: Lance Keay Date: Tue, 15 Sep 2026 18:38:07 +0100 Subject: [PATCH 1/3] Seed browser-test fixtures under a root of their own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser suite navigated to sample CMS pages that live in the ordinary content tree. Nothing marked them as test scaffolding, so to an editor they looked like any other page — and on the development environment their content had been removed. Seven back-to-top tests then failed on their first assertion, before exercising any behaviour, because /wiki/wiki-sandbox and /guidance/short-page returned 404. The sample seeder cannot repair that. It imports with skip-on-collision, and the collision test is the page identity rather than whether the page has anything to render, so a page whose versions were deleted is walked past on every run and the route stays 404 for good. Skip is the right behaviour there — the seed button must never overwrite an editor's work — it just means it cannot be the recovery mechanism. So the fixtures move out. A second content-staging bundle, a second seeder, and a pinned /development-testing root that nothing else creates: * long-page — wiki-typed, the half of the back-to-top contract that scrolls * short-page — fits the viewport, where the link must never reveal * search-fixture — carries the keyword "testfixture" so a site search can be relied on to return a hit Imported with replace rather than skip, which is what brings back an emptied fixture. Nothing under that root is anyone's work, so there is nothing to lose by overwriting it, and a fixture is always in the state the tests expect rather than whatever the last person to open it left behind. The root is a folder and is hidden from the menu, so an ordinary visitor has no route to it, while an editor browsing the page tree sees one plainly-labelled container instead of test pages interleaved with their own. The two back-to-top fixtures are kept out of search as well; the search fixture is deliberately in it, since being findable is the thing under test. Seeded behind the existing "seed sample CMS pages" button — the pages are useful to develop against, not only to test against — and at start-up wherever SeedDevelopmentData is set, so the review app the suite runs against always has them. Never in production, where nothing runs the suite and nobody wants a tree of test pages in the CMS. The old /wiki/wiki-sandbox and /guidance/short-page rows are left where they are on environments that already have them; they are no longer seeded, and an editor can delete them. AB#303114 Refs #412 Refs #443 --- .../DependencyManager.cs | 1 + ...fE.CheckPerformanceData.Application.csproj | 5 + .../PageTree/DefaultPageNodeRoots.cs | 17 ++ .../PageTree/SeedContent/sample-content.json | 36 ---- .../SeedContent/test-fixture-content.json | 62 ++++++ .../PageTree/TestFixturePageNodeSeeder.cs | 89 ++++++++ .../PageTree/TestFixtureSeedBundle.cs | 63 ++++++ .../Controllers/PageTreeAdminController.cs | 32 ++- .../Startup/StartupTasksExtensions.cs | 11 + .../SamplePageNodeSeederEntityTypeTests.cs | 14 +- .../TestFixturePageNodeSeederTests.cs | 193 ++++++++++++++++++ .../PageTree/SampleContentSeedBundleTests.cs | 19 +- .../TestFixturePageNodeSeederTests.cs | 176 ++++++++++++++++ .../PageTree/TestFixtureSeedBundleTests.cs | 179 ++++++++++++++++ .../PageTreeAdminControllerTests.cs | 97 ++++++++- 15 files changed, 939 insertions(+), 55 deletions(-) create mode 100644 src/DfE.CheckPerformanceData.Application/PageTree/SeedContent/test-fixture-content.json create mode 100644 src/DfE.CheckPerformanceData.Application/PageTree/TestFixturePageNodeSeeder.cs create mode 100644 src/DfE.CheckPerformanceData.Application/PageTree/TestFixtureSeedBundle.cs create mode 100644 tests/DfE.CheckPerformanceData.IntegrationTests/PageTree/TestFixturePageNodeSeederTests.cs create mode 100644 tests/DfE.CheckPerformanceData.UnitTests/PageTree/TestFixturePageNodeSeederTests.cs create mode 100644 tests/DfE.CheckPerformanceData.UnitTests/PageTree/TestFixtureSeedBundleTests.cs diff --git a/src/DfE.CheckPerformanceData.Application/DependencyManager.cs b/src/DfE.CheckPerformanceData.Application/DependencyManager.cs index c707c80a9..87360eac0 100644 --- a/src/DfE.CheckPerformanceData.Application/DependencyManager.cs +++ b/src/DfE.CheckPerformanceData.Application/DependencyManager.cs @@ -34,6 +34,7 @@ public static IServiceCollection AddApplicationDependencies(this IServiceCollect services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/DfE.CheckPerformanceData.Application/DfE.CheckPerformanceData.Application.csproj b/src/DfE.CheckPerformanceData.Application/DfE.CheckPerformanceData.Application.csproj index 783b48fd3..77936471b 100644 --- a/src/DfE.CheckPerformanceData.Application/DfE.CheckPerformanceData.Application.csproj +++ b/src/DfE.CheckPerformanceData.Application/DfE.CheckPerformanceData.Application.csproj @@ -23,5 +23,10 @@ importer by SamplePageNodeSeeder. Embedded so it travels with the assembly and needs no deployment-relative file path. --> + + + diff --git a/src/DfE.CheckPerformanceData.Application/PageTree/DefaultPageNodeRoots.cs b/src/DfE.CheckPerformanceData.Application/PageTree/DefaultPageNodeRoots.cs index 42bb8c956..93a63df97 100644 --- a/src/DfE.CheckPerformanceData.Application/PageTree/DefaultPageNodeRoots.cs +++ b/src/DfE.CheckPerformanceData.Application/PageTree/DefaultPageNodeRoots.cs @@ -24,6 +24,23 @@ public static class DefaultPageNodeRoots /// Stable Guid for the auto-seeded /help/not-found content page. public static readonly Guid HelpNotFoundId = new("00000000-cd94-4a01-8f01-00000000000f"); + /// + /// The root the automated browser tests' fixture content hangs off. Deliberately NOT in + /// : the four roots above are part of the product and exist in every + /// environment, whereas this one has no business existing anywhere nobody runs the browser + /// suite. It is created by the fixture seeder instead, so it appears only where somebody has + /// asked for it — and, being a folder that is hidden from the menu, an ordinary visitor has + /// no route to it even there. + /// + /// Pinned for the same reason as the roots, and one more besides: the fixture seed re-imports + /// over the top of whatever is already present, and it can only find the rows to repair while + /// their identity is stable across environments. + /// + public static readonly Guid DevelopmentTestingRootId = new("00000000-cd94-4a01-8f01-00000000000e"); + + /// URL segment of the fixture root — /development-testing. + public const string DevelopmentTestingSegment = "development-testing"; + public static readonly IReadOnlySet Segments = new HashSet(All.Select(r => r.Segment), StringComparer.OrdinalIgnoreCase); } diff --git a/src/DfE.CheckPerformanceData.Application/PageTree/SeedContent/sample-content.json b/src/DfE.CheckPerformanceData.Application/PageTree/SeedContent/sample-content.json index 393ef599c..c546a5245 100644 --- a/src/DfE.CheckPerformanceData.Application/PageTree/SeedContent/sample-content.json +++ b/src/DfE.CheckPerformanceData.Application/PageTree/SeedContent/sample-content.json @@ -110,24 +110,6 @@ } ] }, - { - "id": "4cfa30c7-ee17-4c28-a540-67f4c6402b21", - "parentId": "00000000-cd94-4a01-8f01-000000000002", - "segment": "wiki-sandbox", - "title": "Wiki sandbox page", - "pageType": "wiki", - "sortOrder": 3, - "appearInSearch": true, - "versions": [ - { - "versionId": 1, - "minorVersion": 0, - "publishFrom": "2026-08-10T18:56:35.812918Z", - "content": "

This page is seeded specifically so the wiki render path has real content to exercise. It stores raw HTML in the version body, unlike the widget-tree JSON that content-type pages use.

Why a dedicated wiki sample?

Most CMS pages are content-typed and rendered through Content.cshtml. Wiki pages take a separate code path (Wiki.cshtml, IHtmlRenderingService), and without a live wiki page nothing exercises it — regressions there wouldn't surface until someone manually created one.

Layout

Wiki pages use a 1/3–2/3 grid with a sibling-nav in the left column and the article body in the right.

  • Left column: sibling navigation
  • Right column: heading, subtitle, body HTML

Enough words for a good scroll

The body deliberately runs long enough that a typical laptop viewport can scroll comfortably. If you want to add more test content just extend this HTML string.

Paragraph two — filler so the page has scroll depth for exercising the back-to-top link and any future long-form nav behaviour.

Paragraph three — additional filler so the section headings are far enough apart that the sibling nav in the sidebar has room to render the whole tree without collapsing.

Paragraph four — the final paragraph before the closing heading. When you click the back-to-top link from anywhere on the page, the browser should jump straight back to the H1 at the top of this article.

End

You have reached the end. Try the back-to-top link.

", - "bodyPlainText": "Wiki sandbox page This page is seeded specifically so the wiki render path has real content to exercise. It stores raw HTML in the version body, unlike the widget-tree JSON that content-type pages use.Why a dedicated wiki sample?Most CMS pages are content-typed and rendered through Content.cshtml. Wiki pages take a separate code path (Wiki.cshtml, IHtmlRenderingService), and without a live wiki page nothing exercises it — regressions there wouldn't surface until someone manually created one.LayoutWiki pages use a 1/3–2/3 grid with a sibling-nav in the left column and the article body in the right.Left column: sibling navigationRight column: heading, subtitle, body HTMLEnough words for a good scrollThe body deliberately runs long enough that a typical laptop viewport can scroll comfortably. If you want to add more test content just extend this HTML string.Paragraph two — filler so the page has scroll depth for exercising the back-to-top link and any future long-form nav behaviour.Paragraph three — additional filler so the section headings are far enough apart that the sibling nav in the sidebar has room to render the whole tree without collapsing.Paragraph four — the final paragraph before the closing heading. When you click the back-to-top link from anywhere on the page, the browser should jump straight back to the H1 at the top of this article.EndYou have reached the end. Try the back-to-top link." - } - ] - }, { "id": "12f200c0-576e-4196-9116-f201203314bb", "parentId": "00000000-cd94-4a01-8f01-000000000003", @@ -235,24 +217,6 @@ "bodyPlainText": "Post-16 performance data Performance results for sixth-form colleges, FE colleges and school sixth forms. Data is provisional until the checking window closes." } ] - }, - { - "id": "ca9e1bc3-ed66-40d0-8313-cb6a28971d3a", - "parentId": "00000000-cd94-4a01-8f01-000000000004", - "segment": "short-page", - "title": "Short page", - "pageType": "content", - "sortOrder": 3, - "appearInSearch": true, - "versions": [ - { - "versionId": 1, - "minorVersion": 0, - "publishFrom": "2026-08-10T19:53:42.999299Z", - "content": "[\n {\n \"kind\": \"region\",\n \"layout\": \"single\",\n \"columns\": [\n [\n {\n \"kind\": \"widget\",\n \"type\": \"heading\",\n \"props\": {\n \"level\": 2,\n \"text\": \"Short page\"\n }\n },\n {\n \"kind\": \"widget\",\n \"type\": \"richtext\",\n \"props\": {\n \"html\": \"\\u003Cp\\u003EA deliberately short page. There is not enough content here to scroll, so no back-to-top link should appear.\\u003C/p\\u003E\"\n }\n }\n ]\n ]\n }\n]", - "bodyPlainText": "Short page A deliberately short page. There is not enough content here to scroll, so no back-to-top link should appear." - } - ] } ], "contentBlocks": [] diff --git a/src/DfE.CheckPerformanceData.Application/PageTree/SeedContent/test-fixture-content.json b/src/DfE.CheckPerformanceData.Application/PageTree/SeedContent/test-fixture-content.json new file mode 100644 index 000000000..3e7c33baf --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/PageTree/SeedContent/test-fixture-content.json @@ -0,0 +1,62 @@ +{ + "$schema": "cpd-content-v2", + "schemaVersion": 2, + "pageNodes": [ + { + "id": "00000000-cd94-4a01-8f01-0000000e0001", + "parentId": "00000000-cd94-4a01-8f01-00000000000e", + "segment": "long-page", + "title": "Long fixture page", + "pageType": "wiki", + "sortOrder": 0, + "appearInSearch": false, + "versions": [ + { + "versionId": 1, + "minorVersion": 0, + "publishFrom": "2026-09-15T09:00:00.000000Z", + "content": "

This page belongs to the automated browser tests. It is seeded under the development testing root so it never sits alongside content an editor is working on, and it is re-imported every time the seed runs, so editing it here is pointless — the next seed puts it back.

Why a wiki-typed fixture?

Most CMS pages are content-typed and rendered through Content.cshtml. Wiki pages take a separate code path (Wiki.cshtml, IHtmlRenderingService), and without a live wiki page nothing exercises it — regressions there wouldn't surface until someone manually created one.

Layout

Wiki pages use a 1/3–2/3 grid with a sibling-nav in the left column and the article body in the right.

  • Left column: sibling navigation
  • Right column: heading, subtitle, body HTML

Enough words for a good scroll

The body deliberately runs long enough that a typical laptop viewport can scroll comfortably. The back-to-top tests assert on how far the reader has travelled before the link appears, so shortening this page would quietly make those assertions vacuous.

Paragraph two — filler so the page has scroll depth for exercising the back-to-top link and any future long-form nav behaviour.

Paragraph three — additional filler so the section headings are far enough apart that the sibling nav in the sidebar has room to render the whole tree without collapsing.

Paragraph four — the final paragraph before the closing heading. When you click the back-to-top link from anywhere on the page, the browser should jump straight back to the H1 at the top of this article.

End

You have reached the end. Try the back-to-top link.

", + "bodyPlainText": "Long fixture page This page belongs to the automated browser tests. It is seeded under the development testing root so it never sits alongside content an editor is working on, and it is re-imported every time the seed runs, so editing it here is pointless — the next seed puts it back.Why a wiki-typed fixture?Most CMS pages are content-typed and rendered through Content.cshtml. Wiki pages take a separate code path (Wiki.cshtml, IHtmlRenderingService), and without a live wiki page nothing exercises it — regressions there wouldn't surface until someone manually created one.LayoutWiki pages use a 1/3–2/3 grid with a sibling-nav in the left column and the article body in the right.Left column: sibling navigationRight column: heading, subtitle, body HTMLEnough words for a good scrollThe body deliberately runs long enough that a typical laptop viewport can scroll comfortably. The back-to-top tests assert on how far the reader has travelled before the link appears, so shortening this page would quietly make those assertions vacuous.Paragraph two — filler so the page has scroll depth for exercising the back-to-top link and any future long-form nav behaviour.Paragraph three — additional filler so the section headings are far enough apart that the sibling nav in the sidebar has room to render the whole tree without collapsing.Paragraph four — the final paragraph before the closing heading. When you click the back-to-top link from anywhere on the page, the browser should jump straight back to the H1 at the top of this article.EndYou have reached the end. Try the back-to-top link." + } + ] + }, + { + "id": "00000000-cd94-4a01-8f01-0000000e0002", + "parentId": "00000000-cd94-4a01-8f01-00000000000e", + "segment": "short-page", + "title": "Short fixture page", + "pageType": "content", + "sortOrder": 1, + "appearInSearch": false, + "versions": [ + { + "versionId": 1, + "minorVersion": 0, + "publishFrom": "2026-09-15T09:00:00.000000Z", + "content": "[\n {\n \"kind\": \"region\",\n \"layout\": \"single\",\n \"columns\": [\n [\n {\n \"kind\": \"widget\",\n \"type\": \"heading\",\n \"props\": {\n \"level\": 2,\n \"text\": \"Short fixture page\"\n }\n },\n {\n \"kind\": \"widget\",\n \"type\": \"richtext\",\n \"props\": {\n \"html\": \"

A deliberately short fixture. There is not enough content here to scroll, so no back-to-top link should appear.

\"\n }\n }\n ]\n ]\n }\n]", + "bodyPlainText": "Short fixture page A deliberately short fixture. There is not enough content here to scroll, so no back-to-top link should appear." + } + ] + }, + { + "id": "00000000-cd94-4a01-8f01-0000000e0003", + "parentId": "00000000-cd94-4a01-8f01-00000000000e", + "segment": "search-fixture", + "title": "Search fixture page", + "pageType": "content", + "sortOrder": 2, + "appearInSearch": true, + "keywords": "testfixture", + "versions": [ + { + "versionId": 1, + "minorVersion": 0, + "publishFrom": "2026-09-15T09:00:00.000000Z", + "content": "[\n {\n \"kind\": \"region\",\n \"layout\": \"single\",\n \"columns\": [\n [\n {\n \"kind\": \"widget\",\n \"type\": \"heading\",\n \"props\": {\n \"level\": 2,\n \"text\": \"Search fixture page\"\n }\n },\n {\n \"kind\": \"widget\",\n \"type\": \"richtext\",\n \"props\": {\n \"html\": \"

This fixture exists so a site search can be relied on to return at least one hit. It carries the keyword testfixture, which nothing else in the corpus uses, so a search for that term matches this page and nothing an editor wrote.

The admin search-analytics screens record a visitor's search and then show the hits it produced. Those tests used to search for a word that merely happened to be somewhere in the content, which made them fail the moment an editor changed it.

\"\n }\n }\n ]\n ]\n }\n]", + "bodyPlainText": "Search fixture page This fixture exists so a site search can be relied on to return at least one hit. It carries the keyword testfixture, which nothing else in the corpus uses, so a search for that term matches this page and nothing an editor wrote. The admin search-analytics screens record a visitor's search and then show the hits it produced. Those tests used to search for a word that merely happened to be somewhere in the content, which made them fail the moment an editor changed it." + } + ] + } + ], + "contentBlocks": [] +} diff --git a/src/DfE.CheckPerformanceData.Application/PageTree/TestFixturePageNodeSeeder.cs b/src/DfE.CheckPerformanceData.Application/PageTree/TestFixturePageNodeSeeder.cs new file mode 100644 index 000000000..b4e7395f7 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/PageTree/TestFixturePageNodeSeeder.cs @@ -0,0 +1,89 @@ +using DfE.CheckPerformanceData.Application.ContentStaging; + +namespace DfE.CheckPerformanceData.Application.PageTree; + +// Seeds the content the automated browser tests navigate to, under a root of its own. +// +// Two things separate this from SamplePageNodeSeeder, and both are the point of it existing: +// +// * It owns its root. Nothing creates /development-testing at start-up, because a container for +// test scaffolding has no business appearing in an environment nobody runs the browser suite +// against. Created here, the root shows up only where somebody has actually seeded fixtures. +// +// * It imports in Replace, not Skip. The suite was failing because sample pages it navigated to +// had been emptied — an editor deleted the version, the page row survived, and the route +// started returning 404. Skip-on-collision cannot repair that: the collision test is the page +// identity, not whether the page has anything to render, so the seed walks past the broken row +// every time. Replacing the versions is what brings the page back. +// +// Replace is safe here in a way it would not be for the sample content: nothing under this root is +// anyone's work. It is re-imported wholesale on every seed by design, so a fixture is always in the +// state the tests expect rather than whatever the last person to open it left behind. +public sealed class TestFixturePageNodeSeeder( + IPageNodeService pageNodes, + IPageNodeRepository pageNodeRepository, + IContentStagingService staging) +{ + private const string RootTitle = "Development testing"; + + private const string RootSubtitle = + "Content the automated tests navigate to. Re-created on every seed — edits here are lost."; + + /// + /// Creates the fixture root if it is missing, then imports the fixture bundle over the top of + /// whatever is there. Returns the number of fixture pages created or repaired, which is what + /// the admin screen reports back. + /// + public async Task SeedAsync() + { + await EnsureRootAsync(); + + var result = await staging.ImportAsync( + TestFixtureSeedBundle.Load(), + mode: ContentImportMode.Replace, + decisions: null, + newItemMode: ContentImportMode.Replace); + + // Both counts, unlike the sample seed. There, "created" is the whole story because existing + // pages are deliberately left alone. Here a run that repaired three emptied fixtures created + // nothing at all and did all of the work, so reporting creations alone would tell the + // operator that nothing happened. + return result.PageNodesCreated + result.PageNodesUpdated; + } + + // Folder-typed and out of the menu. Folder is what keeps the root out of search results — the + // search query filters folders structurally, so it needs no flag of its own — and clearing + // ShowInMenu keeps it out of the site navigation. Between them there is no route by which an + // ordinary visitor arrives at the fixture tree, while an editor browsing the page tree sees one + // plainly-labelled container rather than test pages interleaved with their own. + // + // The re-hide on an existing root is not belt-and-braces: ShowInMenu does not round-trip + // through a content-staging bundle, so a root that travelled between environments inside an + // export arrives with the column at its default of true. + private async Task EnsureRootAsync() + { + var existing = await pageNodes.GetNodeByPathAsync(DefaultPageNodeRoots.DevelopmentTestingSegment); + + if (existing is null) + { + // Explicit Id through the staging-create path so the row carries the pinned Guid the + // bundle's pages name as their parent. Created with a fresh one the root would still + // sit at the right path, but every fixture would be an orphan and the import would + // quietly seed nothing. + var created = await pageNodeRepository.CreateNodeForStagingAsync( + DefaultPageNodeRoots.DevelopmentTestingRootId, + parentId: null, + DefaultPageNodeRoots.DevelopmentTestingSegment, + path: DefaultPageNodeRoots.DevelopmentTestingSegment, + RootTitle, RootSubtitle, pageName: null, + pageType: "folder", sortOrder: 100, + appearInSearch: false, keywords: null, userId: "system"); + + await pageNodeRepository.SetShowInMenuAsync(created.Id, false, "system"); + return; + } + + if (existing.ShowInMenu) + await pageNodeRepository.SetShowInMenuAsync(existing.Id, false, "system"); + } +} diff --git a/src/DfE.CheckPerformanceData.Application/PageTree/TestFixtureSeedBundle.cs b/src/DfE.CheckPerformanceData.Application/PageTree/TestFixtureSeedBundle.cs new file mode 100644 index 000000000..97313e130 --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/PageTree/TestFixtureSeedBundle.cs @@ -0,0 +1,63 @@ +using System.Reflection; +using DfE.CheckPerformanceData.Application.ContentStaging; + +namespace DfE.CheckPerformanceData.Application.PageTree; + +// Loads the content the automated browser tests navigate to. Same format and same rationale as +// SampleContentSeedBundle — a content-staging bundle the CMS exported, rather than pages assembled +// in C# that can drift from what an editor's output actually looks like. +// +// It is a separate bundle from the sample content because the two have different owners. Sample +// content is demonstration material: an editor may reasonably edit it, and the sample seed leaves +// anything already present alone so that pressing the button never destroys their work. Fixtures +// belong to the test suite. They sit under their own root so they are never mistaken for content, +// and they are re-imported over the top on every seed — which is the only thing that can bring back +// a page whose versions were deleted. A page emptied that way still exists, so an import that skips +// on collision walks straight past it and the route 404s for good. +// +// The root itself is deliberately absent, exactly as the four default roots are absent from the +// sample bundle: it is created by TestFixturePageNodeSeeder, and parentage here is by its pinned +// Guid, which is what lets a static file resolve against any environment's database. +// +// To change a fixture: seed an environment, edit the page through the CMS, export a bundle from +// /admin/content-staging, strip the root, and replace this file. +public static class TestFixtureSeedBundle +{ + // Suffix rather than the full manifest name, for the same reason as the sample bundle: the + // resource name carries the assembly's root namespace and folder path, and pinning the whole + // string would turn a folder rename into a runtime error instead of a compile error. + private const string ResourceSuffix = "SeedContent.test-fixture-content.json"; + + /// The long, wiki-typed fixture — the half of the back-to-top contract that scrolls. + public const string LongPageSegment = "long-page"; + + /// The fixture that fits the viewport, where the back-to-top link must never reveal. + public const string ShortPageSegment = "short-page"; + + /// The fixture that guarantees a site search returns a hit. + public const string SearchFixtureSegment = "search-fixture"; + + /// + /// The term the search fixture carries as a keyword. A single lowercase token so the Postgres + /// text-search stemmer has nothing to do with it, and a word nothing an editor writes would + /// contain — the point is that a search for it matches the fixture and only the fixture. + /// + public const string SearchTerm = "testfixture"; + + public static ContentBundle Load() + { + var assembly = typeof(TestFixtureSeedBundle).Assembly; + var name = assembly.GetManifestResourceNames().SingleOrDefault(n => n.EndsWith(ResourceSuffix, StringComparison.Ordinal)) + ?? throw new InvalidOperationException( + $"The test-fixture content bundle is missing from {assembly.GetName().Name}. It must be declared as an " + + $"EmbeddedResource whose path ends with '{ResourceSuffix}'."); + + using var stream = assembly.GetManifestResourceStream(name)!; + using var reader = new StreamReader(stream); + var json = reader.ReadToEnd(); + + return ContentStagingJson.Deserialize(json) + ?? throw new InvalidOperationException( + "The test-fixture content bundle is present but could not be parsed as a content-staging bundle."); + } +} diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/PageTreeAdminController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/PageTreeAdminController.cs index 5723c0227..fcdebe7a9 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/PageTreeAdminController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/PageTreeAdminController.cs @@ -8,6 +8,7 @@ using DfE.CheckPerformanceData.Web.Models.PageTree; using DfE.CheckPerformanceData.Web.PageTree; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; using System.Globalization; namespace DfE.CheckPerformanceData.Web.Controllers; @@ -22,7 +23,9 @@ public sealed class PageTreeAdminController( IHtmlRenderingService htmlRenderingService, IPageNodeContentEditor nodeContentEditor, ISettingService settingService, - SamplePageNodeSeeder samplePageSeeder) : Controller + SamplePageNodeSeeder samplePageSeeder, + TestFixturePageNodeSeeder testFixtureSeeder, + IHostEnvironment hostEnvironment) : Controller { private const int DefaultPageLength = 20; @@ -302,21 +305,46 @@ public async Task Save(Guid id, string content) // Adds a small set of published sample pages under each of the four root nodes // (/wiki, /help, /support, /guidance). Idempotent: only pages whose (root, segment) // path does not already exist are created. + // + // Outside Production it also refreshes the fixture content the automated browser tests + // navigate to, under its own /development-testing root. Two seeds behind one button because + // they answer the same question — "give me something to look at" — and a developer who wants + // real pages to work against wants both. They stay separate underneath because their rules + // differ: sample content is left alone where it already exists, fixture content is replaced. [HttpPost("/admin/pages/sample-seed")] [ValidateAntiForgeryToken] [RequireAdminSection(AdminNavKeys.SeedSamplePages)] public async Task SampleSeed() { var created = await samplePageSeeder.SeedAsync(); - TempData["SampleSeedResult"] = created switch + var message = created switch { 0 => "Sample pages are already present. Nothing was added.", 1 => "Added 1 sample page.", _ => $"Added {created} sample pages." }; + + if (FixtureSeedingAllowed) + { + var fixtures = await testFixtureSeeder.SeedAsync(); + if (fixtures > 0) + message += $" Refreshed {fixtures} test fixture {(fixtures == 1 ? "page" : "pages")} under /{DefaultPageNodeRoots.DevelopmentTestingSegment}."; + } + + TempData["SampleSeedResult"] = message; return Redirect("/admin/pages"); } + // Fixture content is scaffolding for the automated tests, and Production is the one environment + // where nothing runs them and nobody wants a tree of test pages appearing in the CMS. Everywhere + // else it is wanted — including the deployed DEV and QA apps, where a developer reproducing a + // layout problem needs the same pages the browser suite uses. + // + // Deliberately a bare not-Production test rather than the Dev:ToolsEnabled gate the /dev/* + // surfaces use: that flag is not set on deployed DEV, and switching it on there to compensate + // would enable dev impersonation as a side effect. + private bool FixtureSeedingAllowed => !hostEnvironment.IsProduction(); + // If someone lands on the seed URL as a GET — a stale bookmark, an out-of-date link // that hasn't picked up the POST-form rendering, or a copy-pasted URL — bounce them // back to /admin/pages with a helpful banner rather than a bare 404 (which the raw diff --git a/src/DfE.CheckPerformanceData.Web/Startup/StartupTasksExtensions.cs b/src/DfE.CheckPerformanceData.Web/Startup/StartupTasksExtensions.cs index 649f30f7f..94b3757ef 100644 --- a/src/DfE.CheckPerformanceData.Web/Startup/StartupTasksExtensions.cs +++ b/src/DfE.CheckPerformanceData.Web/Startup/StartupTasksExtensions.cs @@ -38,6 +38,17 @@ await scope.ServiceProvider { using var scope = app.Services.CreateScope(); await scope.ServiceProvider.GetRequiredService().RunAsync(); + + // Content the automated browser tests navigate to, under its own /development-testing + // root. Seeded here as well as behind the admin button so a developer running the stack + // — and the ephemeral review app the browser suite runs against — always has it, + // without anyone remembering to press anything. Re-imported over the top every time, + // which is what brings back a fixture whose versions were deleted; nothing under that + // root is anyone's work, so there is nothing to lose. + // + // The gate is the same SeedDevelopmentData one as the rest of this block, which is set + // only on local, the deployed DEV app and the review apps. + await scope.ServiceProvider.GetRequiredService().SeedAsync(); } } } diff --git a/tests/DfE.CheckPerformanceData.IntegrationTests/PageTree/SamplePageNodeSeederEntityTypeTests.cs b/tests/DfE.CheckPerformanceData.IntegrationTests/PageTree/SamplePageNodeSeederEntityTypeTests.cs index f61fd7c8b..7fec5da01 100644 --- a/tests/DfE.CheckPerformanceData.IntegrationTests/PageTree/SamplePageNodeSeederEntityTypeTests.cs +++ b/tests/DfE.CheckPerformanceData.IntegrationTests/PageTree/SamplePageNodeSeederEntityTypeTests.cs @@ -52,9 +52,9 @@ public async Task SeedAsync_WritesPageNodeAndPageNodeVersion_NotWikiPages() await using (var ctx = _fixture.CreateContext()) { var seeder = new SamplePageNodeSeeder(BuildStaging(ctx)); + var expected = SampleContentSeedBundle.Load().PageNodes.Count; created = await seeder.SeedAsync(); - Assert.True(created >= 13, - $"Seeder should create every sample page under the four roots; got {created}."); + Assert.Equal(expected, created); } await using var conn = new NpgsqlConnection(_fixture.ConnectionString); @@ -65,16 +65,16 @@ public async Task SeedAsync_WritesPageNodeAndPageNodeVersion_NotWikiPages() "SELECT COUNT(*) FROM \"PageNodes\" WHERE \"ParentId\" IS NOT NULL;"); var pageNodeVersionCount = await ScalarLongAsync(conn, "SELECT COUNT(*) FROM \"PageNodeVersions\";"); - Assert.True(pageNodeCount >= 13, - $"Expected at least 13 sample-page PageNode rows; got {pageNodeCount}."); - Assert.True(pageNodeVersionCount >= 13, - $"Expected at least 13 PageNodeVersion rows; got {pageNodeVersionCount}."); + Assert.True(pageNodeCount >= created, + $"Expected at least {created} sample-page PageNode rows; got {pageNodeCount}."); + Assert.True(pageNodeVersionCount >= created, + $"Expected at least {created} PageNodeVersion rows; got {pageNodeVersionCount}."); // Every sample must end up published, or it 404s on the front end and the samples are // useless for browsing and for the browser tests that navigate to them. var publishedCount = await ScalarLongAsync(conn, "SELECT COUNT(*) FROM \"PageNodeVersions\" WHERE \"PublishFrom\" IS NOT NULL;"); - Assert.True(publishedCount >= 13, + Assert.True(publishedCount >= created, $"Expected every sample to have a published version; got {publishedCount}."); // Retired wiki tables must not exist in the current schema — the DropWikiPagePlumbing diff --git a/tests/DfE.CheckPerformanceData.IntegrationTests/PageTree/TestFixturePageNodeSeederTests.cs b/tests/DfE.CheckPerformanceData.IntegrationTests/PageTree/TestFixturePageNodeSeederTests.cs new file mode 100644 index 000000000..d48f401c9 --- /dev/null +++ b/tests/DfE.CheckPerformanceData.IntegrationTests/PageTree/TestFixturePageNodeSeederTests.cs @@ -0,0 +1,193 @@ +using DfE.CheckPerformanceData.Application.Common; +using DfE.CheckPerformanceData.Application.ContentStaging; +using DfE.CheckPerformanceData.Application.PageTree; +using DfE.CheckPerformanceData.IntegrationTests.Fixtures; +using DfE.CheckPerformanceData.Persistence.Contexts; +using DfE.CheckPerformanceData.Persistence.Repositories; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace DfE.CheckPerformanceData.IntegrationTests.PageTree; + +// The fixture seeder exists because a page can be emptied without being deleted: an editor removes +// the version, the row survives, and the route 404s from then on. The sample seeder cannot repair +// that — it skips on collision, and the collision test is the page identity, not whether the page +// has anything to render. Every claim about the repair is about what the importer does to real +// rows, so it is only worth making against a real database. +[Collection(nameof(PostgresCollection))] +public sealed class TestFixturePageNodeSeederTests(PostgresFixture fixture) +{ + private readonly PostgresFixture _fixture = fixture; + + private async Task ResetAsync() + { + await using var ctx = _fixture.CreateContext(); + await ctx.Database.ExecuteSqlRawAsync( + @"TRUNCATE ""PageNodes"", ""PageNodeVersions"" RESTART IDENTITY CASCADE;"); + } + + private async Task SeedAsync() + { + await using var ctx = _fixture.CreateContext(); + var repo = new PageNodeRepository(ctx); + return await new TestFixturePageNodeSeeder( + new PageNodeService(repo), repo, BuildStaging(ctx)).SeedAsync(); + } + + [Fact] + public async Task SeedAsync_CreatesTheRootAndEveryFixtureBeneathIt() + { + await ResetAsync(); + + var touched = await SeedAsync(); + + var expected = TestFixtureSeedBundle.Load().PageNodes.Count; + Assert.Equal(expected, touched); + + await using var conn = await OpenAsync(); + + // The root is the seeder's own — nothing else creates it — and it has to be a folder that + // stays out of the menu, or the fixture tree shows up as a site section. + var root = await SingleRowAsync(conn, @" + SELECT ""PageType"", ""ShowInMenu""::text, ""AppearInSearch""::text + FROM ""PageNodes"" WHERE ""Id"" = @id;", DefaultPageNodeRoots.DevelopmentTestingRootId); + Assert.Equal(["folder", "false", "false"], root); + + // Every fixture lands published under that root, at the path the browser suite navigates to. + foreach (var page in TestFixtureSeedBundle.Load().PageNodes) + { + var path = $"{DefaultPageNodeRoots.DevelopmentTestingSegment}/{page.Segment}"; + var published = await ScalarLongAsync(conn, @" + SELECT COUNT(*) FROM ""PageNodes"" n + JOIN ""PageNodeVersions"" v ON v.""PageNodeId"" = n.""Id"" + WHERE n.""Path"" = @p AND n.""ParentId"" = @root AND v.""PublishFrom"" IS NOT NULL;", + ("p", path), ("root", DefaultPageNodeRoots.DevelopmentTestingRootId)); + Assert.True(published > 0, $"fixture '{path}' has no published version after seeding"); + } + } + + // The defect this seeder was written for. Deleting a page's versions leaves the row in place, + // so the sample seeder's Skip-on-collision walks past it and the 404 is permanent. Replace + // re-imports the versions over the top, which is the only thing that brings the page back. + [Fact] + public async Task SeedAsync_RepairsAFixtureWhoseVersionsWereDeleted() + { + await ResetAsync(); + await SeedAsync(); + + var longPageId = TestFixtureSeedBundle.Load().PageNodes + .Single(p => p.Segment == TestFixtureSeedBundle.LongPageSegment).Id; + + // Exactly the state an editor leaves behind: the page still exists, it just has nothing + // to render. + await using (var ctx = _fixture.CreateContext()) + { + await ctx.Database.ExecuteSqlRawAsync( + @"DELETE FROM ""PageNodeVersions"" WHERE ""PageNodeId"" = {0};", longPageId); + } + + await using (var conn = await OpenAsync()) + { + var before = await ScalarLongAsync(conn, + @"SELECT COUNT(*) FROM ""PageNodeVersions"" WHERE ""PageNodeId"" = @id;", ("id", longPageId)); + Assert.Equal(0L, before); + + var rowSurvives = await ScalarLongAsync(conn, + @"SELECT COUNT(*) FROM ""PageNodes"" WHERE ""Id"" = @id;", ("id", longPageId)); + Assert.Equal(1L, rowSurvives); + } + + await SeedAsync(); + + await using var after = await OpenAsync(); + var restored = await ScalarLongAsync(after, @" + SELECT COUNT(*) FROM ""PageNodeVersions"" + WHERE ""PageNodeId"" = @id AND ""PublishFrom"" IS NOT NULL;", ("id", longPageId)); + Assert.True(restored > 0, "re-seeding did not restore the emptied fixture — the route stays 404"); + } + + // Pressing the button twice must not build a second copy of the tree. Replace mode updates in + // place, and the root guard stops a second root appearing beside the first. + [Fact] + public async Task SeedAsync_IsIdempotent_AndDoesNotDuplicateTheTree() + { + await ResetAsync(); + await SeedAsync(); + + long afterFirst; + await using (var conn = await OpenAsync()) + afterFirst = await ScalarLongAsync(conn, @"SELECT COUNT(*) FROM ""PageNodes"";"); + + await SeedAsync(); + + await using var conn2 = await OpenAsync(); + var afterSecond = await ScalarLongAsync(conn2, @"SELECT COUNT(*) FROM ""PageNodes"";"); + Assert.Equal(afterFirst, afterSecond); + } + + // Fixtures and sample content are separate bundles seeded through the same button, one after + // the other. The fixture import runs in Replace mode, so if the two shared an identity the + // second pass would silently overwrite a page belonging to the first. Seeding both and + // counting the rows is what proves they are actually independent trees. + [Fact] + public async Task SeedAsync_LeavesTheSampleContentUntouched() + { + await ResetAsync(); + + await using (var ctx = _fixture.CreateContext()) + { + var repo = new PageNodeRepository(ctx); + await new DefaultPageNodeSeeder(new PageNodeService(repo), repo).SeedAsync(); + } + await using (var ctx = _fixture.CreateContext()) + await new SamplePageNodeSeeder(BuildStaging(ctx)).SeedAsync(); + + long samplesBefore; + await using (var conn = await OpenAsync()) + samplesBefore = await ScalarLongAsync(conn, @" + SELECT COUNT(*) FROM ""PageNodes"" + WHERE ""ParentId"" IS NOT NULL AND ""ParentId"" <> @root;", + ("root", DefaultPageNodeRoots.DevelopmentTestingRootId)); + + await SeedAsync(); + + await using var conn2 = await OpenAsync(); + var samplesAfter = await ScalarLongAsync(conn2, @" + SELECT COUNT(*) FROM ""PageNodes"" + WHERE ""ParentId"" IS NOT NULL AND ""ParentId"" <> @root;", + ("root", DefaultPageNodeRoots.DevelopmentTestingRootId)); + + Assert.Equal(samplesBefore, samplesAfter); + } + + private async Task OpenAsync() + { + var conn = new NpgsqlConnection(_fixture.ConnectionString); + await conn.OpenAsync(); + return conn; + } + + private static ContentStagingService BuildStaging(IPortalDbContext ctx) => + new(new PageNodeRepository(ctx), new ContentBlockRepository(ctx), new HtmlRenderingService()); + + private static async Task ScalarLongAsync( + NpgsqlConnection conn, string sql, params (string Name, object Value)[] parameters) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + foreach (var (name, value) in parameters) cmd.Parameters.AddWithValue(name, value); + return Convert.ToInt64(await cmd.ExecuteScalarAsync()); + } + + private static async Task SingleRowAsync(NpgsqlConnection conn, string sql, Guid id) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("id", id); + await using var reader = await cmd.ExecuteReaderAsync(); + Assert.True(await reader.ReadAsync(), "expected exactly one row"); + var values = new string[reader.FieldCount]; + for (var i = 0; i < reader.FieldCount; i++) values[i] = reader.GetString(i); + return values; + } +} diff --git a/tests/DfE.CheckPerformanceData.UnitTests/PageTree/SampleContentSeedBundleTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/PageTree/SampleContentSeedBundleTests.cs index 3482c58a0..15f66a35f 100644 --- a/tests/DfE.CheckPerformanceData.UnitTests/PageTree/SampleContentSeedBundleTests.cs +++ b/tests/DfE.CheckPerformanceData.UnitTests/PageTree/SampleContentSeedBundleTests.cs @@ -61,15 +61,22 @@ public void EverySamplePage_HasAPublishedVersion() } } - // The wiki render path takes a different code path from content pages (raw HTML through - // IHtmlRenderingService rather than a widget tree), and it only stays exercised while at - // least one wiki-typed sample exists. - [Fact] - public void Bundle_KeepsAWikiTypedSample_SoTheWikiRenderPathStaysCovered() + // Sample content is demonstration material an editor may edit, rename or delete, and the seed + // leaves anything already present alone so pressing the button never destroys their work. That + // makes it the wrong home for anything a browser test navigates to: an emptied sample page + // still exists, so the seed skips it and the route 404s for good. Those pages moved out to the + // fixture bundle, which is re-imported over the top on every seed, and this pins the split so + // a future sample does not quietly become a test dependency again. + // + // Wiki-render-path coverage moved with them — see TestFixtureSeedBundleTests. + [Theory] + [InlineData("wiki-sandbox")] + [InlineData("short-page")] + public void Bundle_NoLongerCarriesTheBrowserTestFixtures(string segment) { var bundle = SampleContentSeedBundle.Load(); - Assert.Contains(bundle.PageNodes, p => p.PageType == "wiki"); + Assert.DoesNotContain(bundle.PageNodes, p => p.Segment == segment); } // Two pages under the same parent with the same segment would collide on path. Nothing in diff --git a/tests/DfE.CheckPerformanceData.UnitTests/PageTree/TestFixturePageNodeSeederTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/PageTree/TestFixturePageNodeSeederTests.cs new file mode 100644 index 000000000..aef1db04a --- /dev/null +++ b/tests/DfE.CheckPerformanceData.UnitTests/PageTree/TestFixturePageNodeSeederTests.cs @@ -0,0 +1,176 @@ +using DfE.CheckPerformanceData.Application.ContentStaging; +using DfE.CheckPerformanceData.Application.PageTree; +using NSubstitute; + +namespace DfE.CheckPerformanceData.Application.UnitTests.PageTree; + +// The fixture seeder does two things the sample seeder does not: it owns the root its content +// hangs off (nothing else creates it, because the root has no business existing in an environment +// nobody runs browser tests against), and it imports in Replace rather than Skip. Both are load +// bearing, so both are pinned here. What the bundle contains is covered by TestFixtureSeedBundleTests. +public class TestFixturePageNodeSeederTests +{ + private static (TestFixturePageNodeSeeder Seeder, IContentStagingService Staging, + IPageNodeService Pages, IPageNodeRepository Repository) Build( + PageNodeDto? existingRoot = null, + ContentImportResult? result = null) + { + var staging = Substitute.For(); + staging.ImportAsync( + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any()) + .Returns(result ?? new ContentImportResult { PageNodesCreated = 3 }); + + var pages = Substitute.For(); + pages.GetNodeByPathAsync(DefaultPageNodeRoots.DevelopmentTestingSegment).Returns(existingRoot); + + var repository = Substitute.For(); + repository.CreateNodeForStagingAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new PageNodeDto + { + Id = DefaultPageNodeRoots.DevelopmentTestingRootId, + Segment = DefaultPageNodeRoots.DevelopmentTestingSegment, + Path = DefaultPageNodeRoots.DevelopmentTestingSegment, + Title = "Development testing", + PageType = "folder", + }); + + return (new TestFixturePageNodeSeeder(pages, repository, staging), staging, pages, repository); + } + + // Replace on collision is the fix for the failure this seeder exists to solve. A page whose + // versions an editor deleted still exists, so a Skip-on-collision import walks straight past it + // and the route keeps returning 404 however many times the seed is run. Replacing the versions + // is what actually restores the page. + [Fact] + public async Task ImportsInReplaceMode_SoAnEmptiedFixtureIsRestored() + { + var (seeder, staging, _, _) = Build(); + + await seeder.SeedAsync(); + + await staging.Received(1).ImportAsync( + Arg.Any(), + ContentImportMode.Replace, + Arg.Any?>(), + ContentImportMode.Replace); + } + + [Fact] + public async Task PassesTheFixtureBundle_ToTheImporter() + { + var (seeder, staging, _, _) = Build(); + + await seeder.SeedAsync(); + + await staging.Received(1).ImportAsync( + Arg.Is(b => + b.SchemaVersion == ContentBundle.CurrentSchemaVersion && b.PageNodes.Count > 0), + Arg.Any(), + Arg.Any?>(), + Arg.Any()); + } + + // The root is created with the pinned Guid the bundle's pages name as their parent. Created + // with a fresh Guid it would still appear at the right path, but every fixture would be an + // orphan and the import would seed nothing. + [Fact] + public async Task CreatesTheRoot_WithThePinnedGuidTheBundleParentsTo() + { + var (seeder, _, _, repository) = Build(existingRoot: null); + + await seeder.SeedAsync(); + + await repository.Received(1).CreateNodeForStagingAsync( + DefaultPageNodeRoots.DevelopmentTestingRootId, + null, + DefaultPageNodeRoots.DevelopmentTestingSegment, + DefaultPageNodeRoots.DevelopmentTestingSegment, + Arg.Any(), Arg.Any(), Arg.Any(), + "folder", + Arg.Any(), + false, + Arg.Any(), Arg.Any()); + } + + // Folder-typed and out of the menu: the root is a container, not a page. Typing it as a folder + // keeps it out of search results by the same route every other folder is (the search query + // filters folders structurally), and clearing ShowInMenu keeps it out of the site navigation — + // between them, an ordinary visitor has no way to arrive at the fixture tree. + [Fact] + public async Task HidesTheRoot_FromTheSiteMenu() + { + var (seeder, _, _, repository) = Build(existingRoot: null); + + await seeder.SeedAsync(); + + await repository.Received(1).SetShowInMenuAsync( + DefaultPageNodeRoots.DevelopmentTestingRootId, false, Arg.Any()); + } + + // Seeding twice must not create a second root. The path lookup is the guard, and it has to + // happen before the create rather than relying on the importer, because the root is not in + // the bundle. + [Fact] + public async Task DoesNotRecreateTheRoot_WhenItAlreadyExists() + { + var existing = new PageNodeDto + { + Id = DefaultPageNodeRoots.DevelopmentTestingRootId, + Segment = DefaultPageNodeRoots.DevelopmentTestingSegment, + Path = DefaultPageNodeRoots.DevelopmentTestingSegment, + Title = "Development testing", + PageType = "folder", + ShowInMenu = false, + }; + var (seeder, _, _, repository) = Build(existingRoot: existing); + + await seeder.SeedAsync(); + + await repository.DidNotReceive().CreateNodeForStagingAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); + } + + // An environment seeded before the root was hidden — or one where somebody ticked the box in + // the CMS — gets repaired rather than left showing test scaffolding in the site menu. + [Fact] + public async Task ReHidesTheRoot_WhenAnExistingOneIsVisibleInTheMenu() + { + var existing = new PageNodeDto + { + Id = DefaultPageNodeRoots.DevelopmentTestingRootId, + Segment = DefaultPageNodeRoots.DevelopmentTestingSegment, + Path = DefaultPageNodeRoots.DevelopmentTestingSegment, + Title = "Development testing", + PageType = "folder", + ShowInMenu = true, + }; + var (seeder, _, _, repository) = Build(existingRoot: existing); + + await seeder.SeedAsync(); + + await repository.Received(1).SetShowInMenuAsync(existing.Id, false, Arg.Any()); + } + + // The admin screen reports what the press of the button did. Unlike the sample seed — where + // "created" is the whole story because existing pages are deliberately left alone — a fixture + // seed that repaired three emptied pages created nothing and did all the work, so both counts + // have to be reported or the operator is told nothing happened. + [Fact] + public async Task ReportsCreatedAndUpdatedPages() + { + var (seeder, _, _, _) = Build( + result: new ContentImportResult { PageNodesCreated = 1, PageNodesUpdated = 2 }); + + var touched = await seeder.SeedAsync(); + + Assert.Equal(3, touched); + } +} diff --git a/tests/DfE.CheckPerformanceData.UnitTests/PageTree/TestFixtureSeedBundleTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/PageTree/TestFixtureSeedBundleTests.cs new file mode 100644 index 000000000..b979b4a17 --- /dev/null +++ b/tests/DfE.CheckPerformanceData.UnitTests/PageTree/TestFixtureSeedBundleTests.cs @@ -0,0 +1,179 @@ +using DfE.CheckPerformanceData.Application.ContentStaging; +using DfE.CheckPerformanceData.Application.PageTree; + +namespace DfE.CheckPerformanceData.Application.UnitTests.PageTree; + +// The fixture bundle is the content the browser-test suite navigates to. It is a content-staging +// bundle in exactly the same format as the sample content, and for the same reason: the pages are +// what the CMS produced rather than a second, hand-written notion of what a page looks like. +// +// What separates the two bundles is ownership. Sample content is demonstration material an editor +// may reasonably edit, rename or delete; fixture content belongs to the test suite, lives under +// its own root so it never sits alongside an editor's work, and is re-imported over the top on +// every seed so an emptied page comes back. These tests pin the properties the suite relies on — +// a bundle is data, and nothing in the compiler checks it. +public class TestFixtureSeedBundleTests +{ + [Fact] + public void Bundle_IsEmbeddedAndParses() + { + var bundle = TestFixtureSeedBundle.Load(); + + Assert.NotNull(bundle); + Assert.NotEmpty(bundle.PageNodes); + } + + [Fact] + public void Bundle_DeclaresTheSchemaVersionTheImporterAccepts() + { + var bundle = TestFixtureSeedBundle.Load(); + + Assert.Equal(ContentBundle.CurrentSchemaVersion, bundle.SchemaVersion); + } + + // Everything in this bundle hangs off the fixture root, which is the whole point of it: an + // editor browsing the page tree sees one clearly-named container rather than test scaffolding + // interleaved with their own pages. A fixture parented anywhere else would defeat that and — + // because the importer resolves parents by Guid — would be dropped as an orphan on a fresh + // database anyway. + [Fact] + public void EveryFixturePage_IsParentedToTheDevelopmentTestingRoot() + { + var bundle = TestFixtureSeedBundle.Load(); + + foreach (var page in bundle.PageNodes) + { + Assert.True(page.ParentId.HasValue, + $"fixture '{page.Segment}' has no parent — the root comes from the seeder, not this bundle"); + Assert.Equal(DefaultPageNodeRoots.DevelopmentTestingRootId, page.ParentId!.Value); + } + } + + // A draft-only fixture 404s, which is precisely the failure the suite was hitting before the + // fixtures existed. + [Fact] + public void EveryFixturePage_HasAPublishedVersion() + { + var bundle = TestFixtureSeedBundle.Load(); + + foreach (var page in bundle.PageNodes) + { + Assert.True(page.Versions.Any(v => v.PublishFrom is not null), + $"fixture '{page.Segment}' has no published version, so it would 404 after seeding"); + } + } + + // The wiki render path takes a different code path from content pages (raw HTML through + // IHtmlRenderingService rather than a widget tree). The browser tests that cover it navigate + // to a fixture, so the bundle has to keep one wiki-typed page for that coverage to exist. + [Fact] + public void Bundle_KeepsAWikiTypedFixture_SoTheWikiRenderPathStaysCovered() + { + var bundle = TestFixtureSeedBundle.Load(); + + Assert.Contains(bundle.PageNodes, p => p.PageType == "wiki"); + } + + // The back-to-top contract has two halves — a page long enough to scroll, and a page that + // fits the viewport and must therefore never reveal the link. Both need a fixture. + [Theory] + [InlineData(TestFixtureSeedBundle.LongPageSegment)] + [InlineData(TestFixtureSeedBundle.ShortPageSegment)] + [InlineData(TestFixtureSeedBundle.SearchFixtureSegment)] + public void Bundle_CarriesTheFixturesTheSuiteNavigatesTo(string segment) + { + var bundle = TestFixtureSeedBundle.Load(); + + Assert.Contains(bundle.PageNodes, p => p.Segment == segment); + } + + // The long fixture has to out-scroll the viewport or every assertion the back-to-top suite + // makes about reveal timing is vacuous. Body length is a coarse proxy, but it is the one + // thing that can be checked without a browser, and it catches the "someone trimmed the + // filler paragraphs" edit that would otherwise only surface as a red browser test. + [Fact] + public void TheLongFixture_IsSubstantiallyLongerThanTheShortOne() + { + var bundle = TestFixtureSeedBundle.Load(); + + var longBody = BodyLength(bundle, TestFixtureSeedBundle.LongPageSegment); + var shortBody = BodyLength(bundle, TestFixtureSeedBundle.ShortPageSegment); + + Assert.True(longBody > 1000, + $"the long fixture body is {longBody} characters — too short to out-scroll a laptop viewport"); + Assert.True(shortBody < 300, + $"the short fixture body is {shortBody} characters — long enough to scroll, which makes the " + + "'never reveals on a short page' assertion meaningless"); + } + + // Two of the admin analytics tests drive a real site search and then assert the prior-search + // panel lists hits. They used to search for a word that merely happened to be somewhere in the + // corpus; the fixture now owns a term of its own so the query is guaranteed to match. That only + // works while the fixture carries the term AND is visible to search. + [Fact] + public void TheSearchFixture_CarriesTheSearchTerm_AndIsVisibleToSearch() + { + var bundle = TestFixtureSeedBundle.Load(); + + var page = bundle.PageNodes.Single(p => p.Segment == TestFixtureSeedBundle.SearchFixtureSegment); + + Assert.True(page.AppearInSearch, "the search fixture is excluded from search, so it can never be a hit"); + Assert.Contains(TestFixtureSeedBundle.SearchTerm, page.Keywords ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + + // The other two fixtures exist to be navigated to directly, not to be found. Leaving them in + // the search corpus would put test scaffolding in an editor's search results for no gain. + [Theory] + [InlineData(TestFixtureSeedBundle.LongPageSegment)] + [InlineData(TestFixtureSeedBundle.ShortPageSegment)] + public void TheBackToTopFixtures_AreKeptOutOfSearch(string segment) + { + var bundle = TestFixtureSeedBundle.Load(); + + var page = bundle.PageNodes.Single(p => p.Segment == segment); + + Assert.False(page.AppearInSearch); + } + + [Fact] + public void Bundle_HasNoDuplicateSegmentsUnderTheSameParent() + { + var bundle = TestFixtureSeedBundle.Load(); + + var duplicates = bundle.PageNodes + .GroupBy(p => (p.ParentId, p.Segment.ToLowerInvariant())) + .Where(g => g.Count() > 1) + .Select(g => g.Key.Item2) + .ToList(); + + Assert.Empty(duplicates); + } + + // Pinned ids, for the same reason the roots are pinned: the seed re-imports over the top of + // whatever is already there, and it can only find the row to repair if the identity is stable + // across environments. + [Fact] + public void EveryFixturePage_HasAStableNonEmptyId() + { + var bundle = TestFixtureSeedBundle.Load(); + + Assert.All(bundle.PageNodes, p => Assert.NotEqual(Guid.Empty, p.Id)); + Assert.Equal(bundle.PageNodes.Count, bundle.PageNodes.Select(p => p.Id).Distinct().Count()); + } + + // The fixture identities must not collide with the sample content's. They are separate + // bundles imported one after the other, and a shared id would make the second import + // silently overwrite a page belonging to the first. + [Fact] + public void FixtureIds_DoNotCollideWithTheSampleContent() + { + var fixtures = TestFixtureSeedBundle.Load().PageNodes.Select(p => p.Id).ToHashSet(); + var samples = SampleContentSeedBundle.Load().PageNodes.Select(p => p.Id).ToHashSet(); + + Assert.Empty(fixtures.Intersect(samples)); + } + + private static int BodyLength(ContentBundle bundle, string segment) => + bundle.PageNodes.Single(p => p.Segment == segment) + .Versions.Sum(v => (v.BodyPlainText ?? string.Empty).Length); +} diff --git a/tests/DfE.CheckPerformanceData.UnitTests/Web/Controllers/PageTreeAdminControllerTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/Web/Controllers/PageTreeAdminControllerTests.cs index e97f971c1..0b1951573 100644 --- a/tests/DfE.CheckPerformanceData.UnitTests/Web/Controllers/PageTreeAdminControllerTests.cs +++ b/tests/DfE.CheckPerformanceData.UnitTests/Web/Controllers/PageTreeAdminControllerTests.cs @@ -1,6 +1,7 @@ using System.Reflection; using DfE.CheckPerformanceData.Application.Common; using DfE.CheckPerformanceData.Application.ContentPages; +using DfE.CheckPerformanceData.Application.ContentStaging; using DfE.CheckPerformanceData.Application.PageTree; using DfE.CheckPerformanceData.Application.Settings; using DfE.CheckPerformanceData.Web.Admin; @@ -10,6 +11,7 @@ using DfE.CheckPerformanceData.Web.PageTree; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; using NSubstitute; namespace DfE.CheckPerformanceData.Application.UnitTests.Web.Controllers; @@ -24,13 +26,53 @@ public sealed class PageTreeAdminControllerTests private readonly IHtmlRenderingService _renderer = Substitute.For(); private readonly IPageNodeContentEditor _contentEditor = Substitute.For(); private readonly ISettingService _settings = Substitute.For(); - private readonly SamplePageNodeSeeder _sampleSeeder = - new(Substitute.For()); + // Both seeders are concrete classes with no virtual members, so they are built for real over + // substituted collaborators. Asserting on the staging substitute is then the same thing as + // asserting on whether that seeder ran. + private readonly IContentStagingService _sampleStaging = Substitute.For(); + private readonly IContentStagingService _fixtureStaging = Substitute.For(); + private readonly IPageNodeService _fixturePages = Substitute.For(); + private readonly IPageNodeRepository _fixtureRepository = Substitute.For(); + + public PageTreeAdminControllerTests() + { + StubImport(_sampleStaging, new ContentImportResult()); + StubImport(_fixtureStaging, new ContentImportResult()); + + // The fixture seeder creates its root before importing; without a row coming back it would + // fail on the returned node's Id rather than on anything these tests are about. + _fixtureRepository.CreateNodeForStagingAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new PageNodeDto + { + Id = DefaultPageNodeRoots.DevelopmentTestingRootId, + Segment = DefaultPageNodeRoots.DevelopmentTestingSegment, + Path = DefaultPageNodeRoots.DevelopmentTestingSegment, + Title = "Development testing", + PageType = "folder", + }); + } + + private static void StubImport(IContentStagingService staging, ContentImportResult result) => + staging.ImportAsync( + Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any()) + .Returns(result); - private PageTreeAdminController Sut(PageNodePathValidator? validator = null) + private PageTreeAdminController Sut( + PageNodePathValidator? validator = null, + string? environmentName = null) { + var environment = Substitute.For(); + environment.EnvironmentName.Returns(environmentName ?? Environments.Development); + var controller = new PageTreeAdminController( - _service, validator ?? OpenValidator(), _renderer, _contentEditor, _settings, _sampleSeeder); + _service, validator ?? OpenValidator(), _renderer, _contentEditor, _settings, + new SamplePageNodeSeeder(_sampleStaging), + new TestFixturePageNodeSeeder(_fixturePages, _fixtureRepository, _fixtureStaging), + environment); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() @@ -1702,4 +1744,51 @@ public void Copy_HasValidateAntiForgeryTokenAndHttpPost() Assert.NotNull(method.GetCustomAttribute()); Assert.NotNull(method.GetCustomAttribute()); } + + // ── Seed sample pages ──────────────────────────────────────────────────── + + // One button, two seeds. Sample content is demonstration material for editors; fixture content + // is what the automated browser tests navigate to. A developer pressing this wants both, and + // separating them into two buttons would only mean remembering to press two. + [Fact] + public async Task SampleSeed_OutsideProduction_SeedsTheTestFixturesAlongsideTheSamples() + { + StubImport(_fixtureStaging, new ContentImportResult { PageNodesUpdated = 3 }); + + var result = await Sut(environmentName: Environments.Development).SampleSeed(); + + Assert.IsType(result); + await _fixtureStaging.Received(1).ImportAsync( + Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any()); + } + + // Production is the one environment where nothing runs the browser suite and nobody wants a + // tree of test pages appearing in the CMS. The sample seed still runs — that is editor-facing + // demonstration content and is not what this gate is about. + [Fact] + public async Task SampleSeed_InProduction_SeedsNoTestFixtures() + { + var result = await Sut(environmentName: Environments.Production).SampleSeed(); + + Assert.IsType(result); + await _fixtureStaging.DidNotReceive().ImportAsync( + Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any()); + } + + // A fixture seed that repaired three emptied pages created nothing, so reporting creations + // alone would tell the operator nothing happened when in fact the whole tree was restored. + [Fact] + public async Task SampleSeed_ReportsWhatTheFixtureSeedRepaired() + { + StubImport(_fixtureStaging, new ContentImportResult { PageNodesUpdated = 3 }); + + var controller = Sut(environmentName: Environments.Development); + await controller.SampleSeed(); + + var message = Assert.IsType(controller.TempData["SampleSeedResult"]); + Assert.Contains("3 test fixture pages", message); + Assert.Contains(DefaultPageNodeRoots.DevelopmentTestingSegment, message); + } } From 1d1ba8b256e1ad26ae472383a04a8503a9b229b3 Mon Sep 17 00:00:00 2001 From: Lance Keay Date: Tue, 15 Sep 2026 18:43:48 +0100 Subject: [PATCH 2/3] Point the browser suite at the fixture content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine tests failed against any database whose sample content had been edited, and all nine came down to the same thing: they asserted on content editors are free to change. Seven back-to-top tests navigated to /wiki/wiki-sandbox and /guidance/short-page and never got past the first assertion — both page rows existed with no versions, so the app returned 404. They now navigate to the fixtures, which are re-imported on every seed and so come back from exactly that state. Two admin analytics tests searched for "widget" and then asserted the prior-search panel lists hit links. Whether that search matched anything was a matter of luck, and the luck ran out; the panel was behaving correctly with nothing to list. They now search for the keyword the search fixture carries, which is guaranteed to match it and nothing an editor wrote. The runtime-created fixtures move under the same root. Teardown there is best-effort and the delete route is a soft delete, so a leaked page stays in the tree and travels to the next environment inside a content export — eighteen of them are sitting in a preproduction bundle already. They now leak somewhere nobody has to ask what they are. Their seeding helper was duplicated verbatim between two test classes; it moves to CmsSeedHelpers alongside the rest. AB#303114 Refs #412 Refs #443 --- .../Admin/MessagesGroupAndDetailTests.cs | 11 ++-- .../Admin/SearchAnalyticsDashboardTests.cs | 5 +- .../ContentPages/ResultsWidgetE2ETests.cs | 50 ++++++------------- .../Fixtures/PlaywrightFixture.cs | 11 ++-- .../Helpers/CmsSeedHelpers.cs | 29 +++++++++-- .../Helpers/FixtureContent.cs | 36 +++++++++++++ .../Web/BackToTopTests.cs | 24 ++++++--- .../Wiki/SearchPaginationTests.cs | 27 +--------- 8 files changed, 111 insertions(+), 82 deletions(-) create mode 100644 tests/DfE.CheckPerformanceData.E2ETests/Helpers/FixtureContent.cs diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Admin/MessagesGroupAndDetailTests.cs b/tests/DfE.CheckPerformanceData.E2ETests/Admin/MessagesGroupAndDetailTests.cs index b9ae99bf4..dc5196f71 100644 --- a/tests/DfE.CheckPerformanceData.E2ETests/Admin/MessagesGroupAndDetailTests.cs +++ b/tests/DfE.CheckPerformanceData.E2ETests/Admin/MessagesGroupAndDetailTests.cs @@ -36,7 +36,8 @@ public async Task AdminChrome_MessagesBadgeReplacesPair_AndGroupLandingListsBoth AttachCookieToContext(adminCookie); // Post one feedback so the badge count is at least 1 for the numeric-sum assertion. - await PostFeedbackAsync("widget", "widget was not returning results i expected", "u@example.com"); + await PostFeedbackAsync( + FixtureContent.SearchTerm, "search was not returning results i expected", "u@example.com"); var adminLanding = await Page.GotoAsync($"{Fixture.BaseUrl}/admin/"); Assert.NotNull(adminLanding); @@ -100,11 +101,15 @@ public async Task MessageDetail_ShowsUserQueryAndPriorSearchHits() try { - var whatLookingFor = $"e2e-{Guid.NewGuid():N} — cannot find the widget guide"; + var whatLookingFor = $"e2e-{Guid.NewGuid():N} — cannot find the guide i wanted"; // Post a feedback message. The message-service pins the row to the seed // HttpClient's session; the admin then reviews from a fresh admin session. - await PostFeedbackAsync("widget", whatLookingFor, email: null); + // The fixture keyword, not a word that merely happens to be in the content. The + // assertion further down is about how the prior-search hits are rendered, so it needs + // the search to have produced some; picking a term out of editor-owned prose made that + // a matter of luck, and it ran out. + await PostFeedbackAsync(FixtureContent.SearchTerm, whatLookingFor, email: null); var adminCookie = await AuthHelpers.ImpersonateAsAdminAsync(Fixture); AttachCookieToContext(adminCookie); diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Admin/SearchAnalyticsDashboardTests.cs b/tests/DfE.CheckPerformanceData.E2ETests/Admin/SearchAnalyticsDashboardTests.cs index d91b990b7..b3358811e 100644 --- a/tests/DfE.CheckPerformanceData.E2ETests/Admin/SearchAnalyticsDashboardTests.cs +++ b/tests/DfE.CheckPerformanceData.E2ETests/Admin/SearchAnalyticsDashboardTests.cs @@ -586,7 +586,10 @@ await userContext.AddCookiesAsync([new Cookie } } - var search = await userPage.GotoAsync($"{Fixture.BaseUrl}/search?q=widget"); + // Search for the fixture keyword rather than a word that merely happens to be somewhere in + // the content. The assertion below is about how hits are rendered, so it needs hits to + // exist; searching for editor-owned prose made that a matter of luck, and it ran out. + var search = await userPage.GotoAsync($"{Fixture.BaseUrl}/search?q={FixtureContent.SearchTerm}"); Assert.NotNull(search); Assert.Equal(200, search!.Status); diff --git a/tests/DfE.CheckPerformanceData.E2ETests/ContentPages/ResultsWidgetE2ETests.cs b/tests/DfE.CheckPerformanceData.E2ETests/ContentPages/ResultsWidgetE2ETests.cs index 9f5af1213..a3322e03f 100644 --- a/tests/DfE.CheckPerformanceData.E2ETests/ContentPages/ResultsWidgetE2ETests.cs +++ b/tests/DfE.CheckPerformanceData.E2ETests/ContentPages/ResultsWidgetE2ETests.cs @@ -5,8 +5,13 @@ namespace DfE.CheckPerformanceData.E2ETests.ContentPages; // End-to-end coverage for the search-results widget on content pages. Each test -// seeds its own PageNode under /help with a Guid-suffixed segment so tests can -// interleave without collisions; teardown best-effort deletes the leaf page. +// seeds its own PageNode under the fixture root with a Guid-suffixed segment so tests +// can interleave without collisions; teardown best-effort deletes the leaf page. +// +// Under the fixture root rather than /help because teardown is best-effort and the +// delete route is a soft delete, so anything that leaks stays in the tree — and a +// content export carries it to the next environment. Eighteen of these have already +// travelled into a preproduction bundle that way. They now leak somewhere harmless. // // Pairs with: // ResultsWidgetRenderContractTests (source-file tokens) @@ -36,14 +41,14 @@ public override async Task DisposeAsync() await base.DisposeAsync(); } - // Creates /help/{segment} with a results widget; returns (id, url path). + // Creates a fixture page with a results widget; returns (id, url path). private async Task<(Guid Id, string UrlPath, string Segment)> SeedResultsPageAsync( Dictionary? widgetProps = null) { var segment = $"e2e-results-{Guid.NewGuid():N}"; var id = await CmsSeedHelpers.CreatePageNodeAsync( Fixture.SeedClient, - parentId: CmsSeedHelpers.HelpRootId, + parentId: FixtureContent.RootId, pageType: "content", segment: segment, title: "E2E results widget"); @@ -57,41 +62,16 @@ await CmsSeedHelpers.UpdateWidgetAsync( } await CmsSeedHelpers.PublishDraftAsync(Fixture.SeedClient, id); - return (id, $"/help/{segment}", segment); - } - - // Seeds `count` PageNodes under /help whose titles all contain a fresh unique token, - // then returns the token. A search for that token is guaranteed to hit exactly those - // pages — makes the pagination tests independent of whatever content the deployed env - // happens to carry (review-app envs may have far fewer live pages than the local dev - // stack). Each seeded page's Title contributes to PageNode.SearchVector at weight B. - private async Task SeedSearchableFixturesAsync(int count) - { - // Lowercase hex chunk: tsvector-safe (no stopword collision) and short enough to - // keep the test title readable. - var token = "cypde2e" + Guid.NewGuid().ToString("N")[..12].ToLowerInvariant(); - for (int i = 0; i < count; i++) - { - var segment = $"e2e-fixture-{i}-{Guid.NewGuid():N}"; - var id = await CmsSeedHelpers.CreatePageNodeAsync( - Fixture.SeedClient, - parentId: CmsSeedHelpers.HelpRootId, - pageType: "content", - segment: segment, - title: $"E2E fixture {token} number {i}"); - _createdPages.Add(id); - await CmsSeedHelpers.PublishDraftAsync(Fixture.SeedClient, id); - } - return token; + return (id, $"{FixtureContent.RootPath}/{segment}", segment); } - // Creates /help/{segment} with a search-input widget targeting the given action URL. + // Creates a fixture page with a search-input widget targeting the given action URL. private async Task<(Guid Id, string UrlPath)> SeedSearchInputPageAsync(string actionUrl) { var segment = $"e2e-search-{Guid.NewGuid():N}"; var id = await CmsSeedHelpers.CreatePageNodeAsync( Fixture.SeedClient, - parentId: CmsSeedHelpers.HelpRootId, + parentId: FixtureContent.RootId, pageType: "content", segment: segment, title: "E2E search input"); @@ -110,7 +90,7 @@ await CmsSeedHelpers.UpdateWidgetAsync( }); await CmsSeedHelpers.PublishDraftAsync(Fixture.SeedClient, id); - return (id, $"/help/{segment}"); + return (id, $"{FixtureContent.RootPath}/{segment}"); } // ============================================================ @@ -152,7 +132,7 @@ await Expect(Page.Locator(".cypmd-search-results >> text=/result[s]? for/")) public async Task PaginationAppearsAndNavigates_ForMultiPageTerm() { var (_, url, _) = await SeedResultsPageAsync(); - var searchToken = await SeedSearchableFixturesAsync(count: 25); + var searchToken = await CmsSeedHelpers.SeedSearchableFixturesAsync(Fixture.SeedClient, count: 25, _createdPages); await Page.GotoAsync($"{Fixture.BaseUrl}{url}?q={searchToken}"); @@ -219,7 +199,7 @@ public async Task SettingChange_AdjustsResultsPerPage() // are pinnable: // pageSize=20 → 2 pages, 20 items on page 1. // pageSize=5 → 5 pages, 5 items on page 1 (max page link >= 5). - var searchToken = await SeedSearchableFixturesAsync(count: 25); + var searchToken = await CmsSeedHelpers.SeedSearchableFixturesAsync(Fixture.SeedClient, count: 25, _createdPages); try { diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Fixtures/PlaywrightFixture.cs b/tests/DfE.CheckPerformanceData.E2ETests/Fixtures/PlaywrightFixture.cs index 83c14d472..a3d3a44f2 100644 --- a/tests/DfE.CheckPerformanceData.E2ETests/Fixtures/PlaywrightFixture.cs +++ b/tests/DfE.CheckPerformanceData.E2ETests/Fixtures/PlaywrightFixture.cs @@ -36,11 +36,12 @@ public async Task InitializeAsync() // never run against prod). await AuthHelpers.ImpersonateAsEditorAsync(this); - // Run the sample-page seeder so tests that depend on stable seeded content - // (e.g. /wiki/wiki-sandbox for the back-to-top and deleted-content round trip - // suites) can rely on the row being present on a fresh DB. Idempotent — the - // seeder skips any (root, segment) whose path already exists, so re-running - // is safe. + // Run the seed so tests that depend on stable seeded content can rely on the rows + // being present, whatever state the target environment's content is in. One button, + // two seeds: sample pages are created where missing and otherwise left alone, while + // the fixture pages this suite navigates to are re-imported over the top. The second + // half is what matters here — a fixture whose versions an editor deleted still exists, + // so a skip-on-collision seed walks past it and the route stays 404. await EnsureSamplePagesSeededAsync(); } diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Helpers/CmsSeedHelpers.cs b/tests/DfE.CheckPerformanceData.E2ETests/Helpers/CmsSeedHelpers.cs index ec9473ebe..dc1f55983 100644 --- a/tests/DfE.CheckPerformanceData.E2ETests/Helpers/CmsSeedHelpers.cs +++ b/tests/DfE.CheckPerformanceData.E2ETests/Helpers/CmsSeedHelpers.cs @@ -10,10 +10,31 @@ namespace DfE.CheckPerformanceData.E2ETests.Helpers; // another. public static class CmsSeedHelpers { - // Stable root Guid seeded by DefaultPageNodeRoots. Use as parentId when placing a - // test page under /help. Segment must be unique per test to avoid the CMS - // rejecting the second create with "A page already exists at that path". - public static readonly Guid HelpRootId = new("00000000-cd94-4a01-8f01-000000000003"); + // Seeds `count` PageNodes under the fixture root whose titles all contain a fresh + // unique token, then returns the token. A search for that token is guaranteed to hit + // exactly those pages, which makes a pagination test independent of whatever content + // the deployed environment happens to carry — a review app may hold far fewer live + // pages than a local stack. Each page's Title contributes to PageNode.SearchVector at + // weight B. Ids are appended to `createdPages` so the caller's teardown removes them. + public static async Task SeedSearchableFixturesAsync( + HttpClient client, int count, ICollection createdPages) + { + // Lowercase hex chunk: tsvector-safe (no stopword collision) and short enough to + // keep the test title readable. + var token = "cypde2e" + Guid.NewGuid().ToString("N")[..12].ToLowerInvariant(); + for (var i = 0; i < count; i++) + { + var id = await CreatePageNodeAsync( + client, + parentId: FixtureContent.RootId, + pageType: "content", + segment: $"e2e-fixture-{i}-{Guid.NewGuid():N}", + title: $"E2E fixture {token} number {i}"); + createdPages.Add(id); + await PublishDraftAsync(client, id); + } + return token; + } // Creates a PageNode under the given parent. Returns the new node's Guid parsed // from the redirect Location. Throws with the response body if the CMS returns diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Helpers/FixtureContent.cs b/tests/DfE.CheckPerformanceData.E2ETests/Helpers/FixtureContent.cs new file mode 100644 index 000000000..535091b66 --- /dev/null +++ b/tests/DfE.CheckPerformanceData.E2ETests/Helpers/FixtureContent.cs @@ -0,0 +1,36 @@ +namespace DfE.CheckPerformanceData.E2ETests.Helpers; + +// The CMS content this suite navigates to, and the root it hangs off. +// +// These pages are seeded by the application itself — at start-up wherever SeedDevelopmentData is +// set, and behind the "seed sample CMS pages" admin button — under a root nothing else creates. +// That is the whole point of them: the suite used to navigate to sample pages living in the +// ordinary content tree, which an editor was free to empty, and did. A page whose versions are +// deleted still exists, so the sample seed skipped it and the route returned 404 for good. +// +// Values are mirrored from DefaultPageNodeRoots / TestFixtureSeedBundle rather than referenced, +// because this project deliberately takes no project reference on the application — it drives the +// deployed app over HTTP, as a user does. Same convention the pinned /help root was held under. If a value +// here drifts from the source, the browser tests fail on a 404 that names the path, so the +// mismatch is not silent. +public static class FixtureContent +{ + /// Pinned Guid of the /development-testing root. Parent for runtime-created fixtures. + public static readonly Guid RootId = new("00000000-cd94-4a01-8f01-00000000000e"); + + public const string RootPath = "/development-testing"; + + /// Long, wiki-typed page — the half of the back-to-top contract that scrolls. + public const string LongPagePath = $"{RootPath}/long-page"; + + /// Page that fits the viewport, where the back-to-top link must never reveal. + public const string ShortPagePath = $"{RootPath}/short-page"; + + /// + /// Keyword carried by the search fixture. Searching for it is guaranteed to return a hit, + /// which is what the admin analytics tests need before they can assert anything about how the + /// hits are rendered. They used to search for a word that merely happened to be somewhere in + /// the corpus, and failed the moment an editor changed it. + /// + public const string SearchTerm = "testfixture"; +} diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Web/BackToTopTests.cs b/tests/DfE.CheckPerformanceData.E2ETests/Web/BackToTopTests.cs index cd28470d0..1c33c80a7 100644 --- a/tests/DfE.CheckPerformanceData.E2ETests/Web/BackToTopTests.cs +++ b/tests/DfE.CheckPerformanceData.E2ETests/Web/BackToTopTests.cs @@ -1,4 +1,5 @@ using DfE.CheckPerformanceData.E2ETests.Fixtures; +using DfE.CheckPerformanceData.E2ETests.Helpers; using Microsoft.Playwright; using Microsoft.Playwright.Xunit; @@ -24,19 +25,26 @@ namespace DfE.CheckPerformanceData.E2ETests.Web; // before the link appears, and pages that fit (or nearly fit) the viewport // never show it at all. // -// Uses /wiki/wiki-sandbox because it's the sample seeded specifically for this kind -// of layout check (long body, Wiki.cshtml render path). The behaviour is layout- -// agnostic though — every Content/Wiki page picks the same partial + CSS up. -// /guidance/short-page is its counterpart: a one-paragraph sample that fits the -// viewport, used to pin the "never show on a short page" half of the contract. +// Uses the long fixture under /development-testing because it is seeded specifically +// for this kind of layout check (long body, Wiki.cshtml render path). The behaviour is +// layout-agnostic though — every Content/Wiki page picks the same partial + CSS up. +// The short fixture is its counterpart: a one-paragraph page that fits the viewport, +// used to pin the "never show on a short page" half of the contract. +// +// Both are fixture content rather than sample content, and that distinction is the +// point. These used to be /wiki/wiki-sandbox and /guidance/short-page, which sat in the +// ordinary content tree looking like any other page; an editor emptied them and every +// test below failed on its first assertion with a 404. Fixtures live under their own +// root, and the seed re-imports them over the top rather than skipping what already +// exists, so an emptied one comes back. [Collection("E2E")] public sealed class BackToTopTests(PlaywrightFixture fixture) : PageTest { private readonly PlaywrightFixture _fixture = fixture; - private const string TargetPath = "/wiki/wiki-sandbox"; + private const string TargetPath = FixtureContent.LongPagePath; - private const string ShortPagePath = "/guidance/short-page"; + private const string ShortPagePath = FixtureContent.ShortPagePath; [Theory] [InlineData(1280, 900, "desktop")] @@ -234,7 +242,7 @@ public async Task OnAShortPage_LinkNeverReveals_NotEvenAtTheBottomOfTheDocument( var scrollable = await Page.EvaluateAsync( "() => Math.round(document.documentElement.scrollHeight - window.innerHeight)"); Assert.True(scrollable < 900, - $"/guidance/short-page is meant to fit the viewport but has {scrollable}px of scroll beyond it — " + + $"{ShortPagePath} is meant to fit the viewport but has {scrollable}px of scroll beyond it — " + "shorten the seeded body or this test proves nothing"); // Bottom of the document is the most favourable position for a reveal — if the diff --git a/tests/DfE.CheckPerformanceData.E2ETests/Wiki/SearchPaginationTests.cs b/tests/DfE.CheckPerformanceData.E2ETests/Wiki/SearchPaginationTests.cs index 0c1236bd7..b8bc6d158 100644 --- a/tests/DfE.CheckPerformanceData.E2ETests/Wiki/SearchPaginationTests.cs +++ b/tests/DfE.CheckPerformanceData.E2ETests/Wiki/SearchPaginationTests.cs @@ -32,31 +32,6 @@ public override async Task DisposeAsync() await base.DisposeAsync(); } - // Seeds `count` PageNodes under /help whose titles all contain a fresh unique - // token, then returns the token. A search for that token is guaranteed to hit - // exactly those pages — makes the pagination test independent of whatever content - // the deployed env happens to carry. Each seeded page's Title contributes to - // PageNode.SearchVector at weight B. - private async Task SeedSearchableFixturesAsync(int count) - { - // Lowercase hex chunk: tsvector-safe (no stopword collision) and short enough - // to keep the test title readable. - var token = "cypde2e" + Guid.NewGuid().ToString("N")[..12].ToLowerInvariant(); - for (int i = 0; i < count; i++) - { - var segment = $"e2e-fixture-{i}-{Guid.NewGuid():N}"; - var id = await CmsSeedHelpers.CreatePageNodeAsync( - Fixture.SeedClient, - parentId: CmsSeedHelpers.HelpRootId, - pageType: "content", - segment: segment, - title: $"E2E fixture {token} number {i}"); - _createdPages.Add(id); - await CmsSeedHelpers.PublishDraftAsync(Fixture.SeedClient, id); - } - return token; - } - // Reader-path pagination smoke over /search. Seeds 25 hits and lets the shared // 20-per-page default drive the paginator into a 2-page state, navigates the // pager, and asserts the URL + hit set both change. @@ -64,7 +39,7 @@ private async Task SeedSearchableFixturesAsync(int count) [Trait("search-case", "pagination")] public async Task Search_PaginatesAcrossMultiplePages_ForHighVolumeQuery() { - var token = await SeedSearchableFixturesAsync(count: 25); + var token = await CmsSeedHelpers.SeedSearchableFixturesAsync(Fixture.SeedClient, count: 25, _createdPages); await Page.GotoAsync($"{Fixture.BaseUrl}/search?q={token}"); From 4470512df0057da3284d0e9eceeb560ae10ef66a Mon Sep 17 00:00:00 2001 From: Lance Keay Date: Tue, 15 Sep 2026 18:49:35 +0100 Subject: [PATCH 3/3] Say what the seed tile actually seeds The button now refreshes the fixture pages as well as adding the sample ones, so the tile's description should say so rather than leaving someone to discover it from the banner afterwards. AB#303114 Refs #412 --- .../Admin/Nav/SeedSamplePagesNavEntry.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/DfE.CheckPerformanceData.Web/Admin/Nav/SeedSamplePagesNavEntry.cs b/src/DfE.CheckPerformanceData.Web/Admin/Nav/SeedSamplePagesNavEntry.cs index 797c91b44..182bee751 100644 --- a/src/DfE.CheckPerformanceData.Web/Admin/Nav/SeedSamplePagesNavEntry.cs +++ b/src/DfE.CheckPerformanceData.Web/Admin/Nav/SeedSamplePagesNavEntry.cs @@ -1,7 +1,8 @@ namespace DfE.CheckPerformanceData.Web.Admin.Nav; // Live admin nav entry that posts to /admin/pages/sample-seed to seed a small set of -// published content pages under each of the four default root nodes. HttpMethod is POST so +// published content pages under each of the four default root nodes, and — outside Production — +// to refresh the fixture pages the automated browser tests navigate to. HttpMethod is POST so // the landing-page view renders this tile as a form-button rather than an anchor; double-click // prevention is wired in the view markup. ParentKey moved from CMS admin to the Test data // sub-group so both seed tiles cluster together; the route + controller action stay put so @@ -12,7 +13,8 @@ public sealed record SeedSamplePagesNavEntry : IAdminNavEntry public string? ParentKey => AdminNavKeys.TestDataGroup; public string Title => "Seed sample CMS pages"; public string Description => - "Add a handful of published sample content pages under /wiki, /help, /support and /guidance for testing and demonstration."; + "Add a handful of published sample content pages under /wiki, /help, /support and /guidance for testing and demonstration, " + + "and restore the test fixture pages under /development-testing."; public string Url => "/admin/pages/sample-seed"; public string HttpMethod => "POST"; public bool Enabled => true;