diff --git a/SiteImprove.Optimizely.Plugin/Controllers/SiteimproveController.cs b/SiteImprove.Optimizely.Plugin/Controllers/SiteimproveController.cs index 41b317b..36a8461 100644 --- a/SiteImprove.Optimizely.Plugin/Controllers/SiteimproveController.cs +++ b/SiteImprove.Optimizely.Plugin/Controllers/SiteimproveController.cs @@ -4,7 +4,6 @@ using EPiServer; using EPiServer.Core; using EPiServer.ServiceLocation; -using EPiServer.Web; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using SiteImprove.Optimizely.Plugin.Helper; @@ -48,20 +47,12 @@ public ActionResult PageUrl(string contentId, string locale) var contentRep = ServiceLocator.Current.GetInstance(); var content = contentRep.Get( new ContentReference(contentId), - new LanguageSelector(locale)); + LanguageSelector.Fallback(locale, false)); if (content is PageData page) { - //if (page.CheckPublishedStatus(PagePublishedStatus.Published)) - //{ - var externalUrl = _siteimproveHelper.GetExternalUrl(page); - return Json(new { url = externalUrl, isDomain = false }); - //} - //else - //{ - // var currentSiteUrl = SiteDefinition.Current.SiteUrl.ToString(); - // return Json(new { url = currentSiteUrl, isDomain = true }); - //} + var externalUrl = _siteimproveHelper.GetExternalUrl(page, locale); + return Json(new { url = externalUrl, isDomain = false }); } return StatusCode((int)HttpStatusCode.BadRequest); diff --git a/SiteImprove.Optimizely.Plugin/Helper/ISiteimproveHelper.cs b/SiteImprove.Optimizely.Plugin/Helper/ISiteimproveHelper.cs index 2472880..a58e510 100644 --- a/SiteImprove.Optimizely.Plugin/Helper/ISiteimproveHelper.cs +++ b/SiteImprove.Optimizely.Plugin/Helper/ISiteimproveHelper.cs @@ -9,7 +9,9 @@ public interface ISiteimproveHelper string RequestToken(); void PassEvent(string type, string url, string token); string GetExternalUrl(PageData page); + // Keep existing custom helpers compatible; they can override regional URL resolution. + string GetExternalUrl(PageData page, string language) => GetExternalUrl(page); bool GetPrepublishCheckEnabled(string apiUser, string apiKey); bool EnablePrepublishCheck(string apiUser, string apiKey); } -} \ No newline at end of file +} diff --git a/SiteImprove.Optimizely.Plugin/Helper/SiteimproveHelper.cs b/SiteImprove.Optimizely.Plugin/Helper/SiteimproveHelper.cs index e90238c..49ca3dd 100644 --- a/SiteImprove.Optimizely.Plugin/Helper/SiteimproveHelper.cs +++ b/SiteImprove.Optimizely.Plugin/Helper/SiteimproveHelper.cs @@ -54,10 +54,20 @@ public string GetSiteimprovePluginVersion() } public string GetExternalUrl(PageData page) + { + return GetExternalUrl(page, null); + } + + public string GetExternalUrl(PageData page, string language) { try { - var internalUrl = ServiceLocator.Current.GetInstance().GetUrl(page.ContentLink); + // Fallback content can have a different language from the editor's selected region. + var urlLanguage = string.IsNullOrEmpty(language) ? page.Language?.Name : language; + var internalUrl = ServiceLocator.Current.GetInstance().GetUrl( + page.ContentLink.ToReferenceWithoutVersion(), + urlLanguage, + new UrlResolverArguments { ContextMode = ContextMode.Default }); if (internalUrl == null) //can be null for special pages like settings { diff --git a/TESTING.md b/TESTING.md index edb856a..be9999f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,5 +1,7 @@ # Functional tests +See [CST-6191 regional-language validation](docs/cst-6191-validation.md) for the regression evidence and merge-risk assessment. + These tests cover the plugin's configuration, URL handling, authorization, publishing integration, and browser callbacks. No Siteimprove account, API key, Optimizely database, or running CMS site is needed. All credentials and content @@ -14,7 +16,7 @@ dotnet restore tests/Plugin.Tests/Plugin.Tests.csproj --locked-mode dotnet test tests/Plugin.Tests/Plugin.Tests.csproj --configuration Release --no-restore -p:GeneratePackageOnBuild=false --logger 'trx;LogFilePrefix=backend' --results-directory test-results/backend ``` -The 64 backend cases run on both .NET 6 and .NET 8. The tests build and reference +The 85 backend cases run on both .NET 6 and .NET 8. The tests build and reference the actual plugin project and locked Optimizely dependencies. They do not establish compatibility with every CMS 12 release. The plugin still targets .NET 6, which produces an end-of-support build warning; this PR does not change that target. @@ -23,6 +25,7 @@ produces an end-of-support build warning; this PR does not change that target. | --- | --- | | Page URL controller | Revision and language; direct Block request returns HTTP 400 without a typed Page lookup. | | URL mapping | Host/scheme/port matching; preservation of page paths and query strings; independent sites and language paths; missing URLs/sites and resolver errors. | +| Regional URLs | Selected locales across 11 regional branches; configured fallback, translated content, public references, missing locales/routes and existing custom helpers. Real CMS routing is covered separately in the integration suite. | | Settings | First save, update and reload; URL maps; token reuse; automatic/manual renewal; failed renewal preserves configuration; concurrent first requests generate one token; unavailable service does not create a blank record. | | Admin actions | Save inputs, valid and duplicate URL maps, empty mapping rows, and Prepublish enablement outcome; module redirects with and without a conventional route. | | Authorization | Real ASP.NET middleware and production policy/controller attributes; all four configured roles; anonymous and unauthorized callers; protected reads and writes. | @@ -50,7 +53,7 @@ npx playwright install --with-deps chromium firefox npm test ``` -There are 20 functional checks: ten scenarios in Chromium and Firefox. They load +There are 32 functional checks: sixteen scenarios in Chromium and Firefox. They load the complete JavaScript file from the checkout, not a copied implementation or downloaded snapshot. Two loopback HTTP servers provide real iframe navigation, redirects, origin boundaries, and browser-enforced frame restrictions. diff --git a/docs/cst-6191-validation.md b/docs/cst-6191-validation.md new file mode 100644 index 0000000..eb92002 --- /dev/null +++ b/docs/cst-6191-validation.md @@ -0,0 +1,86 @@ +# CST-6191: regional language URLs + +## Problem and fix + +When an editor selects a regional language such as `en-US`, the plugin previously loaded content with fallback disabled, then resolved its URL without passing the selected language. A page inheriting English content could therefore fail to load or be looked up under the master-language URL instead of its regional public URL. + +The controller now honors configured fallback and replacement rules and passes the editor's selected locale to the URL helper. It does not enable unconditional master-language fallback. The helper requests a public URL using a content reference without the editor revision. Existing calls without an editor locale, including publish events, use the page's own language. The content object and its revision are not modified. + +Optimizely's [language selector contract](https://world.optimizely.com/CsClassLibraries/cms/EPiServer.Core.LanguageSelector?version=12) supports configured fallback without unconditional master fallback. Its [URL resolver contract](https://world.optimizely.com/CsClassLibraries/cms/EPiServer.Web.Routing.IUrlResolver?version=12) accepts a language, and [URL arguments](https://world.optimizely.com/CsClassLibraries/cms/EPiServer.Web.Routing.UrlResolverArguments?version=12) let callers explicitly select public context instead of inheriting the request's context. + +URL paths remain the CMS resolver's responsibility; the plugin does not manufacture a locale prefix or change its existing host mappings. A missing route still returns a null URL. The original helper method remains available, and a default implementation of the new overload delegates to it for existing custom implementations. Custom helpers must implement the new overload to gain locale-aware behavior. + +## Validation on September 15, 2026 + +Base: local `origin/main`, commit `b23e48e7b12d8b3170d4595be28b61c16b39eeef`. Branch: `fix/cst-6191-regional-language-urls`. + +Environment: ARM64 macOS, .NET SDK 8.0.425, .NET/ASP.NET Core 6.0.36 and 8.0.31. The plugin and tests restored their checked-in dependency graphs in locked mode. The obsolete feed URL was overridden for this restore with the Optimizely v3 feed; no dependency or package target changes were made. The existing .NET 6 end-of-support warning remains. + +| Check | Result | +| --- | --- | +| Initial regional tests against unchanged production code | On each .NET target: 15 failed, 1 passed, none skipped | +| Same 16 cases after the fix | All passed on both targets | +| Additional missing-locale and custom-helper compatibility cases | All 3 passed on both targets | +| Full backend suite after the fix | 75 passed on .NET 6 and 75 passed on .NET 8; none failed or skipped | +| Existing Chromium/Firefox functional suite | 20 passed; none failed or skipped | +| Diff whitespace | Passed | + +The failures were runtime assertions, not compilation errors: all 11 reported regional branches produced master-language URLs; configured fallback loading, translated content, public routing from a revision, and calls without an editor locale also failed. The missing-route control already passed before the fix. + +The regional tests use the production controller and helper together, with test doubles for content loading, URL/site resolution and settings. They validate the language and routing contract, not Optimizely's actual fallback routing or Siteimprove crawl matching. Existing tests additionally cover host/scheme/port mapping, distinct sites, authorization, Block requests, settings, HTTP handling and publish events. One existing URL-mapping fixture was updated to expect the public reference instead of a draft revision; its output assertions are unchanged. + +Local test evidence is in `test-results/regional-before/` and `test-results/backend-final/`. The browser report is in `playwright-report/`. Test reports are ignored build artifacts. To rerun the completed backend suite with suitable .NET runtimes: + +```sh +dotnet restore tests/Plugin.Tests/Plugin.Tests.csproj --locked-mode --source https://api.nuget.org/v3/index.json --source https://nuget.optimizely.com/v3/index.json +dotnet test tests/Plugin.Tests/Plugin.Tests.csproj --configuration Release --no-restore -p:GeneratePackageOnBuild=false +``` + +Use `--filter FullyQualifiedName~RegionalLanguageUrlTests` for the 19 new cases. Browser setup and commands are in `TESTING.md`. + +## Real CMS validation on October 13, 2026 + +The validation branch combined the original fix (`f5ac7c7412c00647e4c015470d4b33e9d6f5b600`) with `main` at `2b9d571c4a339fa9cc7bf821d6b9f9af4bc36f82` and six new real-CMS regression cases. Both runs used the same test fixtures: baseline `424a64908c4b4055dd954930bf02eb9f4c86dcc6` contained the unchanged plugin, and `de4b3cc8b3250e674da187d7e1e0f5b3b74ba486` added the fix. These tests are now included in this PR, whose branch also incorporates the later manual-only release-workflow change from `main`. + +GitHub Actions builds a fresh candidate package, verifies its contents, and installs those exact DLL/module bytes into a disposable Optimizely CMS with SQL Server. Tests authenticate through the CMS and call the installed plugin's PageUrl action. Content loading, language fallback/replacement, translation, URL resolution and public rendering use the real CMS services and persisted database. External Siteimprove responses remain controlled; these runs do not check live reports. + +| Check | Before the fix | With the fix | +| --- | --- | --- | +| CMS UI 12.34.6 / core 12.24.0 | 9 passed, 4 failed | 13 passed | +| CMS UI 12.32.5 / core 12.22.6 | 9 passed, 4 failed | 13 passed | +| Backend functionality on .NET 6 | Not rerun for this baseline | 85 passed | +| Backend functionality on .NET 8 | Not rerun for this baseline | 85 passed | +| Chromium/Firefox functionality | Not rerun for this baseline | 32 passed | +| Package build/verification, unit checks and package upgrade | Passed | Passed | + +No CMS, backend or functional browser cases were skipped. The four failures were identical on both CMS profiles: inherited `en-US`, inherited `es-419`, translated `fr-CA` and replacement `en-GB`. The inherited and replacement lookups failed; the translated page returned `/translated-regional-page/` instead of the CMS route `/fr-CA/page-regionale-traduite/`. All four pass with the fix. The draft and missing-fallback controls, plus all seven existing CMS scenarios, passed before and after. + +The six added cases verify public HTTP 200 rendering in the loaded language, regional paths for inherited and translated content, public lookup from a draft revision, and the helper overload used by publish events without an editor locale. The missing-fallback control verifies that the CMS cannot load that language and returns HTTP 404 for its regional route. It does not claim improved controller error handling for missing content. The draft check confirms that the public response contains the published translation rather than draft text. + +Evidence: + +- [Baseline CMS run](https://github.com/Siteimprove/CMS-plugin-Optimizely/actions/runs/38386221440). +- [Passing package/CMS run](https://github.com/Siteimprove/CMS-plugin-Optimizely/actions/runs/38386308478). +- [Passing backend/browser run](https://github.com/Siteimprove/CMS-plugin-Optimizely/actions/runs/38386313515). + +The passing candidate was `4.3.5-ci.38386308478.1.gde4b3cc8b325`, SHA-256 `a45835773b4c88ab3b9f27936deab1b9273c353ce9b8ff938d05e260b0ae1d74`. These results identify the validation-branch build; the updated PR head receives its own CI run and candidate identity. Actions evidence artifacts have seven-day retention, while the run logs retain the test outcomes. + +To repeat the real-CMS suite, follow [package and CMS setup](testing.md) and run `python3 scripts/cms_smoke.py`. The six regression cases are in `tests/cms/regional.spec.ts` and their persisted fixtures are in `tests/CmsHost/RegionalFixture.cs`. + +## Likelihood of success and merge risk + +**High confidence in correcting the identified plugin defects; moderate confidence in resolving the entire customer report.** This is an engineering assessment, not a measured success probability. The failing/passing real-CMS tests establish that inherited and translated regional pages resolve to public routes on two CMS profiles. The ticket does not include a failing network request, its referenced screenshots, the customer's routing code or an exact comparison with the crawl inventory. A wrong canonical host, redirect, language-specific domain or missing crawl entry could still prevent a live report. + +**The automated CMS routing gate passes on both profiles.** Customer-specific routing and live regional report matching remain release acceptance items. URL resolution is shared by the editor and publish events. + +| Risk | Impact and mitigation | +| --- | --- | +| Site-specific fallback/replacement or custom routing | Explicit language and public context can change which URL a custom router returns. Check inherited and translated pages in the customer's staging configuration. | +| Editor revisions and unpublished pages | Public URL lookup from a draft of a published translation and rendering of its published content are tested in the real CMS. Never-published pages and the customer's actual draft Prepublish scan still need confirmation. | +| Existing custom helper implementations | The default overload preserves existing behavior, as verified with an implementation of only the original interface contract. Such helpers need their own locale-aware overload to benefit. Precompiled third-party implementations were not tested. | +| Crawl inventory and canonical URLs | Existing origin mappings are unchanged, including their limitations for language-specific hosts. Compare the returned URL with the exact crawled regional URL and verify the real live-page report. | +| Customer version coverage | The package is tested on CMS UI 12.34.6/core 12.24.0 and CMS UI 12.32.5/core 12.22.6, not the customer's exact 12.29.1/12.34.3 stack. Live regional Siteimprove report matching was not run for this fix. | + +Before release, compare inherited `en-US`, translated `fr-CA` and numeric-region `es-419` URLs with the customer's crawl inventory and verify the corresponding live reports, draft Prepublish capture, and publish-event recheck URL. The controlled CMS tests establish routing and rendering; they do not establish regional crawl/report matching. The protected live workflow accepts `main` only and requires an existing crawled regional URL plus a suitable fixture for this acceptance check. + +Merging this change does not migrate data or alter stored settings. Reverting it restores previous URL behavior. No package has been published or deployed. diff --git a/docs/testing.md b/docs/testing.md index 7db08cc..45ab53f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,6 +1,6 @@ # Build and CMS integration tests -Status: the package checks and all five real-CMS browser tests passed on x64 Linux. See [validation](validation.md) for the run, source commit, candidate checksum and remaining limits. +Status: the package checks and all 13 real-CMS browser tests passed on both locked CMS profiles on x64 Linux. See [regional-language validation](cst-6191-validation.md) for the before/after results, run links, candidate checksum and live-report limitations. Earlier integration results are in [validation](validation.md). ## Baseline and scope @@ -33,7 +33,7 @@ The old Optimizely feed URL returned 404 during inspection. `NuGet.config` now u The source registers `input`/`domain`, `onHighlight`, `registerPrepublishCallback`, `applyDefaultHighlighting` and `clear` queue commands. Publishing invokes the backend `cms-recheck` endpoint. There is no plugin-owned manual Recheck command or button. `Stub Recheck` verifies that the stub receives the selected URL and emits one simulated request; it does not test the real overlay's manual Recheck implementation. Prepublish coverage verifies current preview DOM delivery and stubbed configuration status, not an external content-check report. -The browser suite covers configuration persistence, two-page selection, mapped URLs, installed asset failures, repeated navigation/reload, delayed/failed CDN loading, preview callbacks, and a `WebEditors` user without an allowed plugin role. The plugin currently has no dedicated overlay error/retry UI; the failure scenario checks CMS usability and reload recovery. The five scenarios passed against the pinned CMS baseline. The constructor and initialization lifecycle are not redesigned by this change. +The browser suite covers configuration persistence, two-page selection, mapped URLs, installed asset failures, repeated navigation/reload, delayed/failed CDN loading, preview callbacks, and a `WebEditors` user without an allowed plugin role. It also covers persisted draft corrections, shared-block rejection and recovery, and six regional routing cases. The regional fixtures enable language branches, create translations and drafts, persist fallback/replacement settings, and check the installed PageUrl action against real public routes and rendered content. The plugin currently has no dedicated overlay error/retry UI; the failure scenario checks CMS usability and reload recovery. The constructor and initialization lifecycle are not redesigned by this change. ## Local reproduction diff --git a/tests/CmsHost/RegionalFixture.cs b/tests/CmsHost/RegionalFixture.cs new file mode 100644 index 0000000..6000f87 --- /dev/null +++ b/tests/CmsHost/RegionalFixture.cs @@ -0,0 +1,90 @@ +using System.Globalization; +using EPiServer; +using EPiServer.Core; +using EPiServer.DataAbstraction; +using EPiServer.DataAccess; +using EPiServer.Security; +using EPiServer.Web; +using EPiServer.Web.Routing; +using SiteImprove.Optimizely.Plugin.Helper; + +namespace CmsHost; + +public static class RegionalFixture +{ + public sealed record Target(string Name, string ContentId, string Locale, string SourceLanguage, string Path); + private static readonly List Targets = new(); + + public static void Seed(IServiceProvider services, StandardPage start) + { + var content = services.GetRequiredService(); + var languages = services.GetRequiredService(); + var settings = services.GetRequiredService(); + foreach (var locale in new[] { "en-US", "fr-CA", "es-419", "en-GB" }) + { + var culture = CultureInfo.GetCultureInfo(locale); + var branch = languages.Load(culture)?.CreateWritableClone() ?? new LanguageBranch(culture); + branch.Enabled = true; + languages.Save(branch); + settings.Save(new ContentLanguageSetting(start.ContentLink, locale, null, new[] { "en" }, true)); + var translatedStart = content.CreateLanguageBranch(start.ContentLink, culture); + translatedStart.Name = "First page"; + translatedStart.Heading = "First page"; + content.Save(translatedStart, SaveAction.Publish, AccessLevel.NoAccess); + } + + var inherited = Publish(content, start.ContentLink, "Inherited regional page", "inherited-regional-page"); + foreach (var locale in new[] { "en-US", "es-419" }) + Targets.Add(new Target("inherited", inherited.ContentLink.ID.ToString(), locale, "en", + $"/{locale.ToLowerInvariant()}/inherited-regional-page/")); + + var translated = Publish(content, start.ContentLink, "Translated regional page", "translated-regional-page"); + var french = content.CreateLanguageBranch(translated.ContentLink, CultureInfo.GetCultureInfo("fr-CA")); + french.Name = "Translated regional page"; + french.URLSegment = "page-regionale-traduite"; + french.Heading = "French Canadian translation"; + var frenchLink = content.Save(french, SaveAction.Publish, AccessLevel.NoAccess); + Targets.Add(new Target("translated", frenchLink.ID.ToString(), "fr-CA", "fr-CA", "/fr-ca/page-regionale-traduite/")); + + var draft = (StandardPage)french.CreateWritableClone(); + draft.Heading = "French Canadian draft"; + var draftLink = content.Save(draft, SaveAction.Save | SaveAction.ForceNewVersion, AccessLevel.NoAccess); + Targets.Add(new Target("draft", draftLink.ToString(), "fr-CA", "fr-CA", "/fr-ca/page-regionale-traduite/")); + + var replacement = Publish(content, start.ContentLink, "Replacement language page", "replacement-language-page"); + settings.Save(new ContentLanguageSetting(replacement.ContentLink, "en-GB", "en", Array.Empty(), true)); + Targets.Add(new Target("replacement", replacement.ContentLink.ID.ToString(), "en-GB", "en", "")); + + var unavailable = Publish(content, start.ContentLink, "No regional fallback", "no-regional-fallback"); + settings.Save(new ContentLanguageSetting(unavailable.ContentLink, "en-US", null, Array.Empty(), true)); + Targets.Add(new Target("no-fallback", unavailable.ContentLink.ID.ToString(), "en-US", "", "")); + } + + private static StandardPage Publish(IContentRepository content, ContentReference parent, string name, string segment) + { + var page = content.GetDefault(parent, CultureInfo.GetCultureInfo("en")); + page.Name = name; + page.URLSegment = segment; + page.Heading = name; + var reference = content.Save(page, SaveAction.Publish, AccessLevel.NoAccess); + return content.Get(reference, CultureInfo.GetCultureInfo("en")); + } + + public static object Describe(IContentRepository content, IUrlResolver urls, ISiteimproveHelper helper) + { + return Targets.Select(target => + { + var reference = new ContentReference(target.ContentId); + var loaded = content.TryGet(reference, LanguageSelector.Fallback(target.Locale, false), out var page); + return new + { + target.Name, target.ContentId, target.Locale, target.SourceLanguage, target.Path, + loadedLanguage = loaded ? page.Language.Name : null, + branches = content.GetLanguageBranches(reference.ToReferenceWithoutVersion()).Select(p => p.Language.Name), + cmsUrl = urls.GetUrl(reference.ToReferenceWithoutVersion(), target.Locale, + new UrlResolverArguments { ContextMode = ContextMode.Default }), + publishUrl = loaded ? helper.GetExternalUrl(page) : null + }; + }).ToArray(); + } +} diff --git a/tests/CmsHost/Seed.cs b/tests/CmsHost/Seed.cs index 7e28dc9..2025bb6 100644 --- a/tests/CmsHost/Seed.cs +++ b/tests/CmsHost/Seed.cs @@ -107,6 +107,8 @@ public static async Task RunAsync(IServiceProvider provider) } else { + if (Environment.GetEnvironmentVariable("CMS_UPGRADE_PHASE") != "before") + RegionalFixture.Seed(services, start); var target = content.GetDefault(start.ContentLink, CultureInfo.GetCultureInfo("en")); target.Name = "Draft test page"; target.URLSegment = "draft-test-page"; diff --git a/tests/CmsHost/StandardPage.cs b/tests/CmsHost/StandardPage.cs index b3561a4..7c170a7 100644 --- a/tests/CmsHost/StandardPage.cs +++ b/tests/CmsHost/StandardPage.cs @@ -8,6 +8,7 @@ namespace CmsHost; [ContentType(DisplayName = "Standard page", GUID = "ea668fae-6c3f-4a63-9311-54d1d9487d01")] public class StandardPage : PageData { + [CultureSpecific] public virtual string Heading { get; set; } = ""; public virtual string TestMarker { get; set; } = ""; public virtual bool IncludeTestImage { get; set; } diff --git a/tests/CmsHost/Startup.cs b/tests/CmsHost/Startup.cs index c468faf..e81868d 100644 --- a/tests/CmsHost/Startup.cs +++ b/tests/CmsHost/Startup.cs @@ -81,6 +81,10 @@ public void Configure(IApplicationBuilder app) }).RequireAuthorization(Constants.SiteImproveAuthorizationPolicy); if (Environment.GetEnvironmentVariable("CMS_SITEIMPROVE_MODE") != "live") { + endpoints.MapGet("/test/regional-targets", (EPiServer.IContentRepository content, + IUrlResolver urls, SiteImprove.Optimizely.Plugin.Helper.ISiteimproveHelper helper) => + RegionalFixture.Describe(content, urls, helper)) + .RequireAuthorization(Constants.SiteImproveAuthorizationPolicy); endpoints.MapGet("/test/block-errors", (BlockErrorLog log) => log.Snapshot()) .RequireAuthorization(Constants.SiteImproveAuthorizationPolicy); endpoints.MapPost("/test/block", (HttpContext context, EPiServer.IContentRepository repository) => diff --git a/tests/CmsHost/Views/StandardPage/Index.cshtml b/tests/CmsHost/Views/StandardPage/Index.cshtml index 7804386..5da7962 100644 --- a/tests/CmsHost/Views/StandardPage/Index.cshtml +++ b/tests/CmsHost/Views/StandardPage/Index.cshtml @@ -3,7 +3,7 @@ @Model.Name -

@Html.PropertyFor(p => p.Heading)

+

@Html.PropertyFor(p => p.Heading)

@if (!string.IsNullOrEmpty(Model.TestMarker)) {

@Model.TestMarker

diff --git a/tests/Plugin.Tests/ControllerTests.cs b/tests/Plugin.Tests/ControllerTests.cs index f66ad87..6aed947 100644 --- a/tests/Plugin.Tests/ControllerTests.cs +++ b/tests/Plugin.Tests/ControllerTests.cs @@ -31,7 +31,7 @@ public void Page_url_resolves_the_requested_revision_and_language() { var page = ContentFixture.Page(42, 7); content.Setup(x => x.Get(It.Is(r => r.ID == 42 && r.WorkID == 7), It.Is(l => l.Get().Language.Name == "da"))).Returns(page); - helper.Setup(x => x.GetExternalUrl(page)).Returns("https://public.example/da/news"); + helper.Setup(x => x.GetExternalUrl(page, "da")).Returns("https://public.example/da/news"); var result = Assert.IsType(new SiteimproveController(settings.Object, helper.Object).PageUrl("42_7", "da")); var json = JObject.FromObject(result.Value); Assert.Equal("https://public.example/da/news", (string)json["url"]); @@ -49,6 +49,7 @@ public void Direct_block_request_returns_bad_request_without_resolving_a_page_ur var result = Assert.IsType(new SiteimproveController(settings.Object, helper.Object).PageUrl("99", "en")); Assert.Equal(400, result.StatusCode); helper.Verify(x => x.GetExternalUrl(It.IsAny()), Times.Never); + helper.Verify(x => x.GetExternalUrl(It.IsAny(), It.IsAny()), Times.Never); } private const string AdminUrl = "/custom-ui/SiteImprove.Optimizely.Plugin/SiteimproveAdmin"; diff --git a/tests/Plugin.Tests/RegionalLanguageUrlTests.cs b/tests/Plugin.Tests/RegionalLanguageUrlTests.cs new file mode 100644 index 0000000..0aa2185 --- /dev/null +++ b/tests/Plugin.Tests/RegionalLanguageUrlTests.cs @@ -0,0 +1,168 @@ +using System.Globalization; +using EPiServer; +using EPiServer.Core; +using EPiServer.Web; +using EPiServer.Web.Routing; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Newtonsoft.Json.Linq; +using SiteImprove.Optimizely.Plugin.Controllers; +using SiteImprove.Optimizely.Plugin.Helper; +using SiteImprove.Optimizely.Plugin.Models; +using SiteImprove.Optimizely.Plugin.Repositories; +using Xunit; + +namespace Plugin.Tests; + +public class RegionalLanguageUrlTests : ServiceFixture +{ + private readonly Mock content = new(); + private readonly Mock urls = new(); + private readonly Mock sites = new(); + private readonly Mock settings = new(); + private readonly PageData master = ContentFixture.Page(42, 7); + private readonly SiteimproveHelper helper; + + public RegionalLanguageUrlTests() + { + master.Property.Add("PageLanguageBranch", new PropertyString("en")); + content.Setup(x => x.Get(It.IsAny(), It.IsAny())).Returns(master); + sites.Setup(x => x.GetByContent(It.IsAny(), false)) + .Returns(new SiteDefinition { SiteUrl = new Uri("https://cms.example") }); + settings.Setup(x => x.GetSetting()).Returns(new Settings + { + UrlMap = new() { ["https://cms.example"] = "https://www.example" } + }); + Services.AddSingleton(content.Object); + Services.AddSingleton(urls.Object); + Services.AddSingleton(sites.Object); + UseServices(); + helper = new SiteimproveHelper(settings.Object); + } + + // These are the regional language branches reported in CST-6191. + [Theory] + [InlineData("en-001")] + [InlineData("en-GB")] + [InlineData("en-US")] + [InlineData("en-150")] + [InlineData("en-CA")] + [InlineData("en-SG")] + [InlineData("fr-FR")] + [InlineData("fr-CA")] + [InlineData("de-DE")] + [InlineData("es-ES")] + [InlineData("es-419")] + public void Inherited_page_uses_the_selected_region_instead_of_the_master_language(string locale) + { + // The content is English; the editor and public route can still be regional. + Assert.Equal("en", master.Language.Name); + urls.Setup(x => x.GetUrl(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((ContentReference link, string language, UrlResolverArguments args) => + $"/{(language ?? "en").ToLowerInvariant()}/investor-relations/"); + + Assert.Equal($"https://www.example/{locale.ToLowerInvariant()}/investor-relations/", PageUrl("42_7", locale)); + content.Verify(x => x.Get( + It.Is(r => r.ID == 42 && r.WorkID == 7), + It.Is(o => o.Get().Language.Name == locale)), Times.Once); + } + + [Fact] + public void Page_without_a_regional_version_can_load_using_configured_fallback() + { + content.Setup(x => x.Get(It.IsAny(), It.IsAny())) + .Returns((ContentReference link, LoaderOptions options) => + options.Get().FallbackBehaviour == LanguageBehaviour.Fallback ? master : null); + urls.Setup(x => x.GetUrl(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns("/en-gb/investor-relations/"); + + Assert.Equal("https://www.example/en-gb/investor-relations/", PageUrl("42", "en-GB")); + } + + [Fact] + public void Live_page_url_uses_a_public_route_without_the_editor_revision() + { + urls.Setup(x => x.GetUrl(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((ContentReference link, string language, UrlResolverArguments args) => + link.WorkID == 0 && args?.ContextMode == ContextMode.Default + ? "/en-us/investor-relations/" + : "/EPiServer/CMS/Content/investor-relations,,42_7/?epieditmode=True"); + + Assert.Equal("https://www.example/en-us/investor-relations/", PageUrl("42_7", "en-US")); + Assert.Equal(7, master.ContentLink.WorkID); + } + + [Fact] + public void Locally_translated_page_keeps_its_regional_url() + { + master.Language = CultureInfo.GetCultureInfo("fr-CA"); + urls.Setup(x => x.GetUrl(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((ContentReference link, string language, UrlResolverArguments args) => + language == "fr-CA" ? "/fr-ca/relations-investisseurs/" : "/en/investor-relations/"); + + Assert.Equal("https://www.example/fr-ca/relations-investisseurs/", PageUrl("42_7", "fr-CA")); + } + + [Fact] + public void Calls_without_an_editor_locale_use_the_pages_own_language() + { + master.Language = CultureInfo.GetCultureInfo("es-419"); + urls.Setup(x => x.GetUrl(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((ContentReference link, string language, UrlResolverArguments args) => + language == "es-419" ? "/es-419/inversores/" : "/en/investors/"); + + Assert.Equal("https://www.example/es-419/inversores/", helper.GetExternalUrl(master)); + } + + [Fact] + public void Missing_public_route_does_not_invent_a_regional_url() + { + urls.Setup(x => x.GetUrl(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string)null); + + Assert.Null(PageUrl("42_7", "en-US")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Missing_editor_locale_keeps_the_pages_language(string locale) + { + master.Language = CultureInfo.GetCultureInfo("fr-CA"); + urls.Setup(x => x.GetUrl(It.IsAny(), "fr-CA", It.IsAny())) + .Returns("/fr-ca/relations-investisseurs/"); + + Assert.Equal("https://www.example/fr-ca/relations-investisseurs/", helper.GetExternalUrl(master, locale)); + } + + [Fact] + public void Custom_helper_implementing_only_the_original_contract_still_works() + { + ISiteimproveHelper customHelper = new ExistingCustomHelper(); + var controller = new SiteimproveController(settings.Object, customHelper); + var response = Assert.IsType(controller.PageUrl("42_7", "en-US")); + + Assert.Equal("https://custom.example/page/42", (string)JObject.FromObject(response.Value)["url"]); + } + + private sealed class ExistingCustomHelper : ISiteimproveHelper + { + public string GetExternalUrl(PageData page) => $"https://custom.example/page/{page.ContentLink.ID}"; + public string GetOptimizelyVersion() => throw new NotSupportedException(); + public string GetSiteimprovePluginVersion() => throw new NotSupportedException(); + public string RequestToken() => throw new NotSupportedException(); + public void PassEvent(string type, string url, string token) => throw new NotSupportedException(); + public bool GetPrepublishCheckEnabled(string apiUser, string apiKey) => throw new NotSupportedException(); + public bool EnablePrepublishCheck(string apiUser, string apiKey) => throw new NotSupportedException(); + } + + private string PageUrl(string contentId, string locale) + { + var controller = new SiteimproveController(settings.Object, helper); + var response = Assert.IsType(controller.PageUrl(contentId, locale)); + var json = JObject.FromObject(response.Value); + Assert.False((bool)json["isDomain"]); + return (string)json["url"]; + } +} diff --git a/tests/Plugin.Tests/UrlMappingTests.cs b/tests/Plugin.Tests/UrlMappingTests.cs index c33615b..05b04da 100644 --- a/tests/Plugin.Tests/UrlMappingTests.cs +++ b/tests/Plugin.Tests/UrlMappingTests.cs @@ -44,8 +44,8 @@ public void Different_sites_and_language_paths_keep_their_own_public_urls() { var a = ContentFixture.Page(42, 7); var b = ContentFixture.Page(43, 9); - urls.Setup(x => x.GetUrl(a.ContentLink, It.IsAny(), It.IsAny())).Returns("/en/news"); - urls.Setup(x => x.GetUrl(b.ContentLink, It.IsAny(), It.IsAny())).Returns("/da/nyheder"); + urls.Setup(x => x.GetUrl(new ContentReference(42), It.IsAny(), It.IsAny())).Returns("/en/news"); + urls.Setup(x => x.GetUrl(new ContentReference(43), It.IsAny(), It.IsAny())).Returns("/da/nyheder"); sites.Setup(x => x.GetByContent(a.ContentLink, false)).Returns(new SiteDefinition { SiteUrl = new Uri("https://cms-a.example") }); sites.Setup(x => x.GetByContent(b.ContentLink, false)).Returns(new SiteDefinition { SiteUrl = new Uri("https://cms-b.example") }); settings.Setup(x => x.GetSetting()).Returns(new Settings { UrlMap = new() { diff --git a/tests/cms/regional.spec.ts b/tests/cms/regional.spec.ts new file mode 100644 index 0000000..b13922a --- /dev/null +++ b/tests/cms/regional.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; + +// This suite uses the installed plugin and CMS router; only external overlay requests are blocked. +test.beforeEach(async ({ page, context }) => { + await context.route('**/*', route => new URL(route.request().url()).hostname === 'localhost' + ? route.continue() : route.abort('blockedbyclient')); + await page.goto('/episerver/cms'); + await page.locator('input[name="Username"], input[name="UserName"]').fill('editor'); + await page.locator('input[name="Password"]').fill(process.env.CMS_EDITOR_PASSWORD!); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page.locator('input[name="Password"]')).toHaveCount(0); +}); + +for (const [name, locale] of [['inherited', 'en-US'], ['inherited', 'es-419'], + ['translated', 'fr-CA'], ['draft', 'fr-CA'], ['replacement', 'en-GB']]) { + test(`real CMS routing: ${name} ${locale}`, async ({ page }, info) => { + const response = await page.request.get('/test/regional-targets'); + expect(response.ok()).toBe(true); + const target = (await response.json()).find((item: any) => item.name === name && item.locale === locale); + expect(target).toBeDefined(); + expect(target.loadedLanguage).toBe(target.sourceLanguage); + if (name === 'inherited' || name === 'replacement') expect(target.branches).toEqual(['en']); + const { plugin } = await (await page.request.get('/test/routes')).json(); + const mapping = await page.request.get(`${plugin}/PageUrl`, { params: { contentId: target.contentId, locale } }); + expect(mapping.ok()).toBe(true); + const body = await mapping.json(); + expect(body.isDomain).toBe(false); + expect(body.url).toBeTruthy(); + const returned = new URL(body.url); + const actual = new URL(target.cmsUrl, 'http://localhost:5000'); + // Mapping may change the origin; the path must still be the real CMS public route. + expect(returned.pathname).toBe(actual.pathname); + if (target.path) expect(returned.pathname.toLowerCase()).toBe(target.path); + expect(returned.search).toBe(''); + const published = await page.request.get(actual.href, { maxRedirects: 0 }); + expect(published.status()).toBe(200); + expect(await published.text()).toContain(`data-content-language="${target.sourceLanguage}"`); + if (name === 'draft') { + expect(target.contentId).toMatch(/_\d+/); + expect(await published.text()).toContain('French Canadian translation'); + expect(await published.text()).not.toContain('French Canadian draft'); + } + // Publish-event callers use this same helper overload without an editor locale. + expect(new URL(target.publishUrl).pathname.toLowerCase()).toBe(name === 'inherited' + ? '/inherited-regional-page/' : name === 'replacement' ? '/replacement-language-page/' : target.path); + await info.attach('regional-routing', { body: JSON.stringify({ target, returnedUrl: body.url }, null, 2), contentType: 'application/json' }); + }); +} + +test('real CMS does not serve a regional page without configured fallback', async ({ page }) => { + const targets = await (await page.request.get('/test/regional-targets')).json(); + const target = targets.find((item: any) => item.name === 'no-fallback'); + expect(target.branches).toEqual(['en']); + expect(target.loadedLanguage).toBeNull(); + const response = await page.request.get('/en-us/no-regional-fallback/', { maxRedirects: 0 }); + expect(response.status()).toBe(404); +});