Amend root content routing and ensure trailing slashes as configured (#18958)

* Amend root content routing and ensure trailing slashes as configured

* Fix false positives at root + add more tests

* Awaited async method and resolved warning around readonly.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
This commit is contained in:
Kenn Jacobsen
2025-04-09 07:46:24 +02:00
committed by GitHub
parent 134c8006c0
commit 947afdbc1e
16 changed files with 1341 additions and 40 deletions

View File

@@ -0,0 +1,258 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Builders.Extensions;
using Umbraco.Cms.Tests.Integration.Attributes;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.DeliveryApi.Request;
public class ApiContentPathResolverInvariantTests : ApiContentPathResolverTestBase
{
private Dictionary<string, IContent> _contentByName = new ();
public static void ConfigureIncludeTopLevelNodeInPath(IUmbracoBuilder builder)
=> builder.Services.Configure<GlobalSettings>(config => config.HideTopLevelNodeFromPath = false);
[SetUp]
public async Task SetUpTest()
{
UmbracoContextFactory.EnsureUmbracoContext();
SetRequestHost("localhost");
if (_contentByName.Any())
{
// these tests all run on the same DB to make them run faster, so we need to get the cache in a
// predictable state with each test run.
RefreshContentCache();
return;
}
await DocumentUrlService.InitAsync(true, CancellationToken.None);
var contentType = new ContentTypeBuilder()
.WithAlias("theContentType")
.Build();
contentType.AllowedAsRoot = true;
await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey);
contentType.AllowedContentTypes = [new() { Alias = contentType.Alias, Key = contentType.Key }];
await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey);
foreach (var rootNumber in Enumerable.Range(1, 3))
{
var root = new ContentBuilder()
.WithContentType(contentType)
.WithName($"Root {rootNumber}")
.Build();
ContentService.Save(root);
ContentService.Publish(root, ["*"]);
_contentByName[root.Name!] = root;
foreach (var childNumber in Enumerable.Range(1, 3))
{
var child = new ContentBuilder()
.WithContentType(contentType)
.WithParent(root)
.WithName($"Child {childNumber}")
.Build();
ContentService.Save(child);
ContentService.Publish(child, ["*"]);
_contentByName[$"{root.Name!}/{child.Name!}"] = child;
foreach (var grandchildNumber in Enumerable.Range(1, 3))
{
var grandchild = new ContentBuilder()
.WithContentType(contentType)
.WithParent(child)
.WithName($"Grandchild {grandchildNumber}")
.Build();
ContentService.Save(grandchild);
ContentService.Publish(grandchild, ["*"]);
_contentByName[$"{root.Name!}/{child.Name!}/{grandchild.Name!}"] = grandchild;
}
}
}
}
[Test]
public void First_Root_Without_StartItem()
{
Assert.IsEmpty(GetRequiredService<IHttpContextAccessor>().HttpContext!.Request.Headers["Start-Item"].ToString());
var content = ApiContentPathResolver.ResolveContentPath("/");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName["Root 1"].Key, content.Key);
}
[Test]
[ConfigureBuilder(ActionName = nameof(ConfigureIncludeTopLevelNodeInPath))]
public void First_Root_Without_StartItem_With_Top_Level_Node_Included()
{
Assert.IsEmpty(GetRequiredService<IHttpContextAccessor>().HttpContext!.Request.Headers["Start-Item"].ToString());
var content = ApiContentPathResolver.ResolveContentPath("/");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName["Root 1"].Key, content.Key);
}
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public void First_Root_Child_Without_StartItem(int child)
{
Assert.IsEmpty(GetRequiredService<IHttpContextAccessor>().HttpContext!.Request.Headers["Start-Item"].ToString());
var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root 1/Child {child}"].Key, content.Key);
}
[TestCase(1, 1)]
[TestCase(2, 2)]
[TestCase(3, 3)]
[TestCase(1, 2)]
[TestCase(2, 3)]
[TestCase(3, 1)]
public void First_Root_Grandchild_Without_StartItem(int child, int grandchild)
{
Assert.IsEmpty(GetRequiredService<IHttpContextAccessor>().HttpContext!.Request.Headers["Start-Item"].ToString());
var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}/grandchild-{grandchild}");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root 1/Child {child}/Grandchild {grandchild}"].Key, content.Key);
}
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public void Root_With_StartItem(int root)
{
SetRequestStartItem($"root-{root}");
var content = ApiContentPathResolver.ResolveContentPath("/");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key);
}
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
[ConfigureBuilder(ActionName = nameof(ConfigureIncludeTopLevelNodeInPath))]
public void Root_With_StartItem_With_Top_Level_Node_Included(int root)
{
SetRequestStartItem($"root-{root}");
var content = ApiContentPathResolver.ResolveContentPath("/");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key);
}
[TestCase(1, 1)]
[TestCase(2, 2)]
[TestCase(3, 3)]
[TestCase(1, 2)]
[TestCase(2, 3)]
[TestCase(3, 1)]
public void Child_With_StartItem(int root, int child)
{
SetRequestStartItem($"root-{root}");
var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root {root}/Child {child}"].Key, content.Key);
}
[TestCase(1, 1)]
[TestCase(2, 2)]
[TestCase(3, 3)]
[TestCase(1, 2)]
[TestCase(2, 3)]
[TestCase(3, 1)]
[ConfigureBuilder(ActionName = nameof(ConfigureIncludeTopLevelNodeInPath))]
public void Child_With_StartItem_With_Top_Level_Node_Included(int root, int child)
{
SetRequestStartItem($"root-{root}");
var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root {root}/Child {child}"].Key, content.Key);
}
[TestCase(1, 1, 1)]
[TestCase(2, 2, 2)]
[TestCase(3, 3, 3)]
[TestCase(1, 2, 3)]
[TestCase(2, 3, 1)]
[TestCase(3, 1, 2)]
public void Grandchild_With_StartItem(int root, int child, int grandchild)
{
SetRequestStartItem($"root-{root}");
var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}/grandchild-{grandchild}");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root {root}/Child {child}/Grandchild {grandchild}"].Key, content.Key);
}
[TestCase("/", 1)]
[TestCase("/root-2", 2)]
[TestCase("/root-3", 3)]
public void Root_By_Path_With_StartItem(string path, int root)
{
SetRequestStartItem($"root-{root}");
var content = ApiContentPathResolver.ResolveContentPath(path);
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key);
}
[TestCase("/", 1)]
[TestCase("/root-2", 2)]
[TestCase("/root-3", 3)]
public void Root_By_Path_Without_StartItem(string path, int root)
{
var content = ApiContentPathResolver.ResolveContentPath(path);
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key);
}
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public async Task Root_With_Domain_Bindings(int root)
{
await SetContentHost(_contentByName[$"Root {root}"], "some.host", "en-US");
SetRequestHost("some.host");
var content = ApiContentPathResolver.ResolveContentPath("/");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key);
}
[TestCase("/a", 1)]
[TestCase("/123", 2)]
[TestCase("/no-such-child", 3)]
[TestCase("/a/b", 1)]
[TestCase("/123/456", 2)]
[TestCase("/no-such-child/no-such-grandchild", 3)]
public void Non_Existant_Descendant_By_Path_With_StartItem(string path, int root)
{
SetRequestStartItem($"root-{root}");
var content = ApiContentPathResolver.ResolveContentPath(path);
Assert.IsNull(content);
}
[TestCase("/a")]
[TestCase("/123")]
[TestCase("/a/b")]
[TestCase("/123/456")]
public void Non_Existant_Descendant_By_Path_Without_StartItem(string path)
{
var content = ApiContentPathResolver.ResolveContentPath(path);
Assert.IsNull(content);
}
}

View File

@@ -0,0 +1,15 @@
using NUnit.Framework;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Tests.Common.Testing;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.DeliveryApi.Request;
[TestFixture]
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)]
public abstract class ApiContentPathResolverTestBase : ApiContentRequestTestBase
{
protected IApiContentPathResolver ApiContentPathResolver => GetRequiredService<IApiContentPathResolver>();
protected IDocumentUrlService DocumentUrlService => GetRequiredService<IDocumentUrlService>();
}

View File

@@ -0,0 +1,326 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Builders.Extensions;
using Umbraco.Cms.Tests.Integration.Attributes;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.DeliveryApi.Request;
public class ApiContentPathResolverVariantTests : ApiContentPathResolverTestBase
{
private readonly Dictionary<string, IContent> _contentByName = new ();
public static void ConfigureIncludeTopLevelNodeInPath(IUmbracoBuilder builder)
=> builder.Services.Configure<GlobalSettings>(config => config.HideTopLevelNodeFromPath = false);
[SetUp]
public async Task SetUpTest()
{
UmbracoContextFactory.EnsureUmbracoContext();
SetRequestHost("localhost");
if (_contentByName.Any())
{
// these tests all run on the same DB to make them run faster, so we need to get the cache in a
// predictable state with each test run.
RefreshContentCache();
return;
}
await DocumentUrlService.InitAsync(true, CancellationToken.None);
await GetRequiredService<ILanguageService>().CreateAsync(new Language("da-DK", "Danish"), Constants.Security.SuperUserKey);
var contentType = new ContentTypeBuilder()
.WithAlias("theContentType")
.WithContentVariation(ContentVariation.Culture)
.Build();
contentType.AllowedAsRoot = true;
await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey);
contentType.AllowedContentTypes = [new() { Alias = contentType.Alias, Key = contentType.Key }];
await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey);
foreach (var rootNumber in Enumerable.Range(1, 3))
{
var root = new ContentBuilder()
.WithContentType(contentType)
.WithCultureName("en-US", $"Root {rootNumber} en-US")
.WithCultureName("da-DK", $"Root {rootNumber} da-DK")
.Build();
ContentService.Save(root);
ContentService.Publish(root, ["*"]);
_contentByName[$"Root {rootNumber}"] = root;
foreach (var childNumber in Enumerable.Range(1, 3))
{
var child = new ContentBuilder()
.WithContentType(contentType)
.WithParent(root)
.WithCultureName("en-US", $"Child {childNumber} en-US")
.WithCultureName("da-DK", $"Child {childNumber} da-DK")
.Build();
ContentService.Save(child);
ContentService.Publish(child, ["*"]);
_contentByName[$"Root {rootNumber}/Child {childNumber}"] = child;
foreach (var grandchildNumber in Enumerable.Range(1, 3))
{
var grandchild = new ContentBuilder()
.WithContentType(contentType)
.WithParent(child)
.WithCultureName("en-US", $"Grandchild {grandchildNumber} en-US")
.WithCultureName("da-DK", $"Grandchild {grandchildNumber} da-DK")
.Build();
ContentService.Save(grandchild);
ContentService.Publish(grandchild, ["*"]);
_contentByName[$"Root {rootNumber}/Child {childNumber}/Grandchild {grandchildNumber}"] = grandchild;
}
}
}
}
[TestCase("en-US")]
[TestCase("da-DK")]
public void First_Root_Without_StartItem(string culture)
{
Assert.IsEmpty(GetRequiredService<IHttpContextAccessor>().HttpContext!.Request.Headers["Start-Item"].ToString());
SetVariationContext(culture);
var content = ApiContentPathResolver.ResolveContentPath("/");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName["Root 1"].Key, content.Key);
}
[TestCase("en-US")]
[TestCase("da-DK")]
[ConfigureBuilder(ActionName = nameof(ConfigureIncludeTopLevelNodeInPath))]
public void First_Root_Without_StartItem_With_Top_Level_Node_Included(string culture)
{
Assert.IsEmpty(GetRequiredService<IHttpContextAccessor>().HttpContext!.Request.Headers["Start-Item"].ToString());
SetVariationContext(culture);
var content = ApiContentPathResolver.ResolveContentPath("/");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName["Root 1"].Key, content.Key);
}
[TestCase(1, "en-US")]
[TestCase(2, "en-US")]
[TestCase(3, "en-US")]
[TestCase(1, "da-DK")]
[TestCase(2, "da-DK")]
[TestCase(3, "da-DK")]
public void First_Root_Child_Without_StartItem(int child, string culture)
{
Assert.IsEmpty(GetRequiredService<IHttpContextAccessor>().HttpContext!.Request.Headers["Start-Item"].ToString());
SetVariationContext(culture);
var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}-{culture.ToLowerInvariant()}");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root 1/Child {child}"].Key, content.Key);
}
[TestCase(1, 1, "en-US")]
[TestCase(2, 2, "en-US")]
[TestCase(3, 3, "en-US")]
[TestCase(1, 2, "en-US")]
[TestCase(2, 3, "en-US")]
[TestCase(3, 1, "en-US")]
[TestCase(1, 1, "da-DK")]
[TestCase(2, 2, "da-DK")]
[TestCase(3, 3, "da-DK")]
[TestCase(1, 2, "da-DK")]
[TestCase(2, 3, "da-DK")]
[TestCase(3, 1, "da-DK")]
public void First_Root_Grandchild_Without_StartItem(int child, int grandchild, string culture)
{
Assert.IsEmpty(GetRequiredService<IHttpContextAccessor>().HttpContext!.Request.Headers["Start-Item"].ToString());
SetVariationContext(culture);
var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}-{culture.ToLowerInvariant()}/grandchild-{grandchild}-{culture.ToLowerInvariant()}");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root 1/Child {child}/Grandchild {grandchild}"].Key, content.Key);
}
[TestCase(1, "en-US")]
[TestCase(2, "en-US")]
[TestCase(3, "en-US")]
[TestCase(1, "da-DK")]
[TestCase(2, "da-DK")]
[TestCase(3, "da-DK")]
public void Root_With_StartItem(int root, string culture)
{
SetRequestStartItem($"root-{root}-{culture.ToLowerInvariant()}");
SetVariationContext(culture);
var content = ApiContentPathResolver.ResolveContentPath("/");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key);
}
[TestCase(1, "en-US")]
[TestCase(2, "en-US")]
[TestCase(3, "en-US")]
[TestCase(1, "da-DK")]
[TestCase(2, "da-DK")]
[TestCase(3, "da-DK")]
[ConfigureBuilder(ActionName = nameof(ConfigureIncludeTopLevelNodeInPath))]
public void Root_With_StartItem_With_Top_Level_Node_Included(int root, string culture)
{
SetRequestStartItem($"root-{root}-{culture.ToLowerInvariant()}");
SetVariationContext(culture);
var content = ApiContentPathResolver.ResolveContentPath("/");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key);
}
[TestCase(1, 1, "en-US")]
[TestCase(2, 2, "en-US")]
[TestCase(3, 3, "en-US")]
[TestCase(1, 2, "en-US")]
[TestCase(2, 3, "en-US")]
[TestCase(3, 1, "en-US")]
[TestCase(1, 1, "da-DK")]
[TestCase(2, 2, "da-DK")]
[TestCase(3, 3, "da-DK")]
[TestCase(1, 2, "da-DK")]
[TestCase(2, 3, "da-DK")]
[TestCase(3, 1, "da-DK")]
public void Child_With_StartItem(int root, int child, string culture)
{
SetRequestStartItem($"root-{root}-{culture.ToLowerInvariant()}");
SetVariationContext(culture);
var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}-{culture.ToLowerInvariant()}");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root {root}/Child {child}"].Key, content.Key);
}
[TestCase(1, 1, "en-US")]
[TestCase(2, 2, "en-US")]
[TestCase(3, 3, "en-US")]
[TestCase(1, 2, "en-US")]
[TestCase(2, 3, "en-US")]
[TestCase(3, 1, "en-US")]
[TestCase(1, 1, "da-DK")]
[TestCase(2, 2, "da-DK")]
[TestCase(3, 3, "da-DK")]
[TestCase(1, 2, "da-DK")]
[TestCase(2, 3, "da-DK")]
[TestCase(3, 1, "da-DK")]
[ConfigureBuilder(ActionName = nameof(ConfigureIncludeTopLevelNodeInPath))]
public void Child_With_StartItem_With_Top_Level_Node_Included(int root, int child, string culture)
{
SetRequestStartItem($"root-{root}-{culture.ToLowerInvariant()}");
SetVariationContext(culture);
var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}-{culture.ToLowerInvariant()}");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root {root}/Child {child}"].Key, content.Key);
}
[TestCase(1, 1, 1, "en-US")]
[TestCase(2, 2, 2, "en-US")]
[TestCase(3, 3, 3, "en-US")]
[TestCase(1, 2, 3, "en-US")]
[TestCase(2, 3, 1, "en-US")]
[TestCase(3, 1, 2, "en-US")]
[TestCase(1, 1, 1, "da-DK")]
[TestCase(2, 2, 2, "da-DK")]
[TestCase(3, 3, 3, "da-DK")]
[TestCase(1, 2, 3, "da-DK")]
[TestCase(2, 3, 1, "da-DK")]
[TestCase(3, 1, 2, "da-DK")]
public void Grandchild_With_StartItem(int root, int child, int grandchild, string culture)
{
SetRequestStartItem($"root-{root}-{culture.ToLowerInvariant()}");
SetVariationContext(culture);
var content = ApiContentPathResolver.ResolveContentPath($"/child-{child}-{culture.ToLowerInvariant()}/grandchild-{grandchild}-{culture.ToLowerInvariant()}");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root {root}/Child {child}/Grandchild {grandchild}"].Key, content.Key);
}
[TestCase("/", 1, "en-US")]
[TestCase("/root-2-en-us", 2, "en-US")]
[TestCase("/root-3-en-us", 3, "en-US")]
[TestCase("/", 1, "da-DK")]
[TestCase("/root-2-da-dk", 2, "da-DK")]
[TestCase("/root-3-da-dk", 3, "da-DK")]
public void Root_By_Path_With_StartItem(string path, int root, string culture)
{
SetRequestStartItem($"root-{root}-{culture.ToLowerInvariant()}");
SetVariationContext(culture);
var content = ApiContentPathResolver.ResolveContentPath(path);
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key);
}
[TestCase("/", 1, "en-US")]
[TestCase("/root-2-en-us", 2, "en-US")]
[TestCase("/root-3-en-us", 3, "en-US")]
[TestCase("/", 1, "da-DK")]
[TestCase("/root-2-da-dk", 2, "da-DK")]
[TestCase("/root-3-da-dk", 3, "da-DK")]
public void Root_By_Path_Without_StartItem(string path, int root, string culture)
{
SetVariationContext(culture);
var content = ApiContentPathResolver.ResolveContentPath(path);
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key);
}
[TestCase(1, "en-US")]
[TestCase(2, "en-US")]
[TestCase(3, "en-US")]
[TestCase(1, "da-DK")]
[TestCase(2, "da-DK")]
[TestCase(3, "da-DK")]
public async Task Root_With_Domain_Bindings(int root, string culture)
{
await SetContentHost(_contentByName[$"Root {root}"], "some.host", "en-US");
SetRequestHost("some.host");
SetVariationContext(culture);
var content = ApiContentPathResolver.ResolveContentPath("/");
Assert.IsNotNull(content);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, content.Key);
}
[TestCase("/a", 1, "en-US")]
[TestCase("/b", 1, "da-DK")]
[TestCase("/123", 2, "en-US")]
[TestCase("/456", 2, "da-DK")]
[TestCase("/no-such-child", 3, "en-US")]
[TestCase("/not-at-all", 3, "da-DK")]
[TestCase("/a/b", 1, "en-US")]
[TestCase("/c/d", 1, "da-DK")]
[TestCase("/123/456", 2, "en-US")]
[TestCase("/789/012", 2, "da-DK")]
[TestCase("/no-such-child/no-such-grandchild", 3, "en-US")]
[TestCase("/not-at-all/aint-no-way", 3, "da-DK")]
public void Non_Existant_Descendant_By_Path_With_StartItem(string path, int root, string culture)
{
SetRequestStartItem($"root-{root}-{culture.ToLowerInvariant()}");
var content = ApiContentPathResolver.ResolveContentPath(path);
Assert.IsNull(content);
}
[TestCase("/a")]
[TestCase("/123")]
[TestCase("/a/b")]
[TestCase("/123/456")]
public void Non_Existant_Descendant_By_Path_Without_StartItem(string path)
{
var content = ApiContentPathResolver.ResolveContentPath(path);
Assert.IsNull(content);
}
}

View File

@@ -0,0 +1,93 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Tests.Integration.Testing;
using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.DeliveryApi.Request;
public abstract class ApiContentRequestTestBase : UmbracoIntegrationTest
{
protected IContentService ContentService => GetRequiredService<IContentService>();
protected IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();
protected IApiContentRouteBuilder ApiContentRouteBuilder => GetRequiredService<IApiContentRouteBuilder>();
protected IVariationContextAccessor VariationContextAccessor => GetRequiredService<IVariationContextAccessor>();
protected IUmbracoContextAccessor UmbracoContextAccessor => GetRequiredService<IUmbracoContextAccessor>();
protected IUmbracoContextFactory UmbracoContextFactory => GetRequiredService<IUmbracoContextFactory>();
protected override void CustomTestSetup(IUmbracoBuilder builder)
{
builder.AddUmbracoHybridCache();
builder.AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>();
builder.Services.AddUnique<IServerMessenger, ContentEventsTests.LocalServerMessenger>();
builder.AddDeliveryApi();
}
[TearDown]
public async Task CleanUpAfterTest()
{
var domainService = GetRequiredService<IDomainService>();
foreach (var content in ContentService.GetRootContent())
{
await domainService.UpdateDomainsAsync(content.Key, new DomainsUpdateModel { Domains = [] });
}
var httpContextAccessor = GetRequiredService<IHttpContextAccessor>();
httpContextAccessor.HttpContext?.Request.Headers.Clear();
}
protected void SetVariationContext(string? culture)
=> VariationContextAccessor.VariationContext = new VariationContext(culture: culture);
protected async Task SetContentHost(IContent content, string host, string culture)
=> await GetRequiredService<IDomainService>().UpdateDomainsAsync(
content.Key,
new DomainsUpdateModel { Domains = [new DomainModel { DomainName = host, IsoCode = culture }] });
protected void SetRequestHost(string host)
{
var httpContextAccessor = GetRequiredService<IHttpContextAccessor>();
httpContextAccessor.HttpContext = new DefaultHttpContext
{
Request =
{
Scheme = "https",
Host = new HostString(host),
Path = "/",
QueryString = new QueryString(string.Empty)
},
RequestServices = Services
};
}
protected void SetRequestStartItem(string startItem)
{
var httpContextAccessor = GetRequiredService<IHttpContextAccessor>();
if (httpContextAccessor.HttpContext is null)
{
throw new InvalidOperationException("HTTP context is null");
}
httpContextAccessor.HttpContext.Request.Headers["Start-Item"] = startItem;
}
protected void RefreshContentCache()
=> Services.GetRequiredService<ContentCacheRefresher>().Refresh([new ContentCacheRefresher.JsonPayload { ChangeTypes = TreeChangeTypes.RefreshAll }]);
}

View File

@@ -0,0 +1,242 @@
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Builders.Extensions;
using Umbraco.Cms.Tests.Integration.Attributes;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.DeliveryApi.Request;
public class ApiContentRouteBuilderInvariantTests : ApiContentRouteBuilderTestBase
{
private readonly Dictionary<string, IContent> _contentByName = new ();
public static void ConfigureIncludeTopLevelNodeInPath(IUmbracoBuilder builder)
=> builder.Services.Configure<GlobalSettings>(config => config.HideTopLevelNodeFromPath = false);
public static void ConfigureOmitTrailingSlash(IUmbracoBuilder builder)
=> builder.Services.Configure<RequestHandlerSettings>(config => config.AddTrailingSlash = false);
[SetUp]
public async Task SetUpTest()
{
SetRequestHost("localhost");
if (_contentByName.Any())
{
// these tests all run on the same DB to make them run faster, so we need to get the cache in a
// predictable state with each test run.
RefreshContentCache();
return;
}
var contentType = new ContentTypeBuilder()
.WithAlias("theContentType")
.Build();
contentType.AllowedAsRoot = true;
await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey);
contentType.AllowedContentTypes = [new() { Alias = contentType.Alias, Key = contentType.Key }];
await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey);
foreach (var rootNumber in Enumerable.Range(1, 3))
{
var root = new ContentBuilder()
.WithContentType(contentType)
.WithName($"Root {rootNumber}")
.Build();
ContentService.Save(root);
ContentService.Publish(root, ["*"]);
_contentByName[root.Name!] = root;
foreach (var childNumber in Enumerable.Range(1, 3))
{
var child = new ContentBuilder()
.WithContentType(contentType)
.WithParent(root)
.WithName($"Child {childNumber}")
.Build();
ContentService.Save(child);
ContentService.Publish(child, ["*"]);
_contentByName[$"{root.Name!}/{child.Name!}"] = child;
foreach (var grandchildNumber in Enumerable.Range(1, 3))
{
var grandchild = new ContentBuilder()
.WithContentType(contentType)
.WithParent(child)
.WithName($"Grandchild {grandchildNumber}")
.Build();
ContentService.Save(grandchild);
ContentService.Publish(grandchild, ["*"]);
_contentByName[$"{root.Name!}/{child.Name!}/{grandchild.Name!}"] = grandchild;
}
}
}
}
[Test]
public void First_Root()
{
var publishedContent = GetPublishedContent(_contentByName["Root 1"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual("/", route.Path);
Assert.AreEqual("root-1", route.StartItem.Path);
Assert.AreEqual(_contentByName["Root 1"].Key, route.StartItem.Id);
});
}
[Test]
public void Last_Root()
{
var publishedContent = GetPublishedContent(_contentByName["Root 3"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual("/root-3/", route.Path);
Assert.AreEqual("root-3", route.StartItem.Path);
Assert.AreEqual(_contentByName["Root 3"].Key, route.StartItem.Id);
});
}
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public void First_Child(int root)
{
var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child 1"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual("/child-1/", route.Path);
Assert.AreEqual($"root-{root}", route.StartItem.Path);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id);
});
}
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public void Last_Child(int root)
{
var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child 3"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual("/child-3/", route.Path);
Assert.AreEqual($"root-{root}", route.StartItem.Path);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id);
});
}
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public void First_Grandchild(int root)
{
var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child 1/Grandchild 1"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual("/child-1/grandchild-1/", route.Path);
Assert.AreEqual($"root-{root}", route.StartItem.Path);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id);
});
}
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public void Last_Grandchild(int root)
{
var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child 3/Grandchild 3"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual("/child-3/grandchild-3/", route.Path);
Assert.AreEqual($"root-{root}", route.StartItem.Path);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id);
});
}
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
[ConfigureBuilder(ActionName = nameof(ConfigureIncludeTopLevelNodeInPath))]
public void Root_With_Top_Level_Node_Included(int root)
{
var publishedContent = GetPublishedContent(_contentByName[$"Root {root}"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual("/", route.Path);
Assert.AreEqual($"root-{root}", route.StartItem.Path);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id);
});
}
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public async Task Root_With_Domain_Bindings(int root)
{
await SetContentHost(_contentByName[$"Root {root}"], "some.host", "en-US");
SetRequestHost("some.host");
var publishedContent = GetPublishedContent(_contentByName[$"Root {root}"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual("/", route.Path);
Assert.AreEqual($"root-{root}", route.StartItem.Path);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id);
});
}
[TestCase(1, "/")]
[TestCase(2, "/root-2")]
[TestCase(3, "/root-3")]
[ConfigureBuilder(ActionName = nameof(ConfigureOmitTrailingSlash))]
public void Root_Without_Trailing_Slash(int root, string expectedPath)
{
var publishedContent = GetPublishedContent(_contentByName[$"Root {root}"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual(expectedPath, route.Path);
Assert.AreEqual($"root-{root}", route.StartItem.Path);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id);
});
}
[TestCase(1, 1)]
[TestCase(2, 2)]
[TestCase(3, 3)]
[TestCase(1, 2)]
[TestCase(2, 3)]
[TestCase(3, 1)]
[ConfigureBuilder(ActionName = nameof(ConfigureOmitTrailingSlash))]
public void Child_Without_Trailing_Slash(int root, int child)
{
var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child {child}"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual($"/child-{child}", route.Path);
Assert.AreEqual($"root-{root}", route.StartItem.Path);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id);
});
}
}

View File

@@ -0,0 +1,20 @@
using NUnit.Framework;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Tests.Common.Testing;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.DeliveryApi.Request;
[TestFixture]
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)]
public abstract class ApiContentRouteBuilderTestBase : ApiContentRequestTestBase
{
protected IPublishedContent GetPublishedContent(Guid key)
{
UmbracoContextAccessor.Clear();
var umbracoContext = UmbracoContextFactory.EnsureUmbracoContext().UmbracoContext;
var publishedContent = umbracoContext.Content?.GetById(key);
Assert.IsNotNull(publishedContent);
return publishedContent;
}
}

View File

@@ -0,0 +1,287 @@
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Builders.Extensions;
using Umbraco.Cms.Tests.Integration.Attributes;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.DeliveryApi.Request;
public class ApiContentRouteBuilderVariantTests : ApiContentRouteBuilderTestBase
{
private readonly Dictionary<string, IContent> _contentByName = new ();
public static void ConfigureIncludeTopLevelNodeInPath(IUmbracoBuilder builder)
=> builder.Services.Configure<GlobalSettings>(config => config.HideTopLevelNodeFromPath = false);
public static void ConfigureOmitTrailingSlash(IUmbracoBuilder builder)
=> builder.Services.Configure<RequestHandlerSettings>(config => config.AddTrailingSlash = false);
[SetUp]
public async Task SetUpTest()
{
SetRequestHost("localhost");
if (_contentByName.Any())
{
// these tests all run on the same DB to make them run faster, so we need to get the cache in a
// predictable state with each test run.
RefreshContentCache();
return;
}
await GetRequiredService<ILanguageService>().CreateAsync(new Language("da-DK", "Danish"), Constants.Security.SuperUserKey);
var contentType = new ContentTypeBuilder()
.WithAlias("theContentType")
.WithContentVariation(ContentVariation.Culture)
.Build();
contentType.AllowedAsRoot = true;
await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey);
contentType.AllowedContentTypes = [new() { Alias = contentType.Alias, Key = contentType.Key }];
await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey);
foreach (var rootNumber in Enumerable.Range(1, 3))
{
var root = new ContentBuilder()
.WithContentType(contentType)
.WithCultureName("en-US", $"Root {rootNumber} en-US")
.WithCultureName("da-DK", $"Root {rootNumber} da-DK")
.Build();
ContentService.Save(root);
ContentService.Publish(root, ["*"]);
_contentByName[$"Root {rootNumber}"] = root;
foreach (var childNumber in Enumerable.Range(1, 3))
{
var child = new ContentBuilder()
.WithContentType(contentType)
.WithParent(root)
.WithCultureName("en-US", $"Child {childNumber} en-US")
.WithCultureName("da-DK", $"Child {childNumber} da-DK")
.Build();
ContentService.Save(child);
ContentService.Publish(child, ["*"]);
_contentByName[$"Root {rootNumber}/Child {childNumber}"] = child;
foreach (var grandchildNumber in Enumerable.Range(1, 3))
{
var grandchild = new ContentBuilder()
.WithContentType(contentType)
.WithParent(child)
.WithCultureName("en-US", $"Grandchild {grandchildNumber} en-US")
.WithCultureName("da-DK", $"Grandchild {grandchildNumber} da-DK")
.Build();
ContentService.Save(grandchild);
ContentService.Publish(grandchild, ["*"]);
_contentByName[$"Root {rootNumber}/Child {childNumber}/Grandchild {grandchildNumber}"] = grandchild;
}
}
}
}
[TestCase("da-DK")]
[TestCase("en-US")]
public void First_Root(string culture)
{
SetVariationContext(culture);
var publishedContent = GetPublishedContent(_contentByName[$"Root 1"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual("/", route.Path);
Assert.AreEqual($"root-1-{culture.ToLowerInvariant()}", route.StartItem.Path);
Assert.AreEqual(_contentByName[$"Root 1"].Key, route.StartItem.Id);
});
}
[TestCase("da-DK")]
[TestCase("en-US")]
public void Last_Root(string culture)
{
SetVariationContext(culture);
var publishedContent = GetPublishedContent(_contentByName["Root 3"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual($"/root-3-{culture.ToLowerInvariant()}/", route.Path);
Assert.AreEqual($"root-3-{culture.ToLowerInvariant()}", route.StartItem.Path);
Assert.AreEqual(_contentByName["Root 3"].Key, route.StartItem.Id);
});
}
[TestCase(1, "en-US")]
[TestCase(1, "da-DK")]
[TestCase(2, "en-US")]
[TestCase(2, "da-DK")]
[TestCase(3, "en-US")]
[TestCase(3, "da-DK")]
public void First_Child(int root, string culture)
{
SetVariationContext(culture);
var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child 1"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual($"/child-1-{culture.ToLowerInvariant()}/", route.Path);
Assert.AreEqual($"root-{root}-{culture.ToLowerInvariant()}", route.StartItem.Path);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id);
});
}
[TestCase(1, "en-US")]
[TestCase(1, "da-DK")]
[TestCase(2, "en-US")]
[TestCase(2, "da-DK")]
[TestCase(3, "en-US")]
[TestCase(3, "da-DK")]
public void Last_Child(int root, string culture)
{
SetVariationContext(culture);
var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child 3"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual($"/child-3-{culture.ToLowerInvariant()}/", route.Path);
Assert.AreEqual($"root-{root}-{culture.ToLowerInvariant()}", route.StartItem.Path);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id);
});
}
[TestCase(1, "en-US")]
[TestCase(1, "da-DK")]
[TestCase(2, "en-US")]
[TestCase(2, "da-DK")]
[TestCase(3, "en-US")]
[TestCase(3, "da-DK")]
public void First_Grandchild(int root, string culture)
{
SetVariationContext(culture);
var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child 1/Grandchild 1"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual($"/child-1-{culture.ToLowerInvariant()}/grandchild-1-{culture.ToLowerInvariant()}/", route.Path);
Assert.AreEqual($"root-{root}-{culture.ToLowerInvariant()}", route.StartItem.Path);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id);
});
}
[TestCase(1, "en-US")]
[TestCase(1, "da-DK")]
[TestCase(2, "en-US")]
[TestCase(2, "da-DK")]
[TestCase(3, "en-US")]
[TestCase(3, "da-DK")]
public void Last_Grandchild(int root, string culture)
{
SetVariationContext(culture);
var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child 3/Grandchild 3"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual($"/child-3-{culture.ToLowerInvariant()}/grandchild-3-{culture.ToLowerInvariant()}/", route.Path);
Assert.AreEqual($"root-{root}-{culture.ToLowerInvariant()}", route.StartItem.Path);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id);
});
}
[TestCase(1, "en-US")]
[TestCase(1, "da-DK")]
[TestCase(2, "en-US")]
[TestCase(2, "da-DK")]
[TestCase(3, "en-US")]
[TestCase(3, "da-DK")]
[ConfigureBuilder(ActionName = nameof(ConfigureIncludeTopLevelNodeInPath))]
public void Root_With_Top_Level_Node_Included(int root, string culture)
{
SetVariationContext(culture);
var publishedContent = GetPublishedContent(_contentByName[$"Root {root}"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual("/", route.Path);
Assert.AreEqual($"root-{root}-{culture.ToLowerInvariant()}", route.StartItem.Path);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id);
});
}
[TestCase(1, "en-US")]
[TestCase(1, "da-DK")]
[TestCase(2, "en-US")]
[TestCase(2, "da-DK")]
[TestCase(3, "en-US")]
[TestCase(3, "da-DK")]
public async Task Root_With_Domain_Bindings(int root, string culture)
{
await SetContentHost(_contentByName[$"Root {root}"], "some.host", culture);
SetRequestHost("some.host");
SetVariationContext(culture);
var publishedContent = GetPublishedContent(_contentByName[$"Root {root}"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual("/", route.Path);
Assert.AreEqual($"root-{root}-{culture.ToLowerInvariant()}", route.StartItem.Path);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id);
});
}
[TestCase(1, "en-US", "/")]
[TestCase(1, "da-DK", "/")]
[TestCase(2, "en-US", "/root-2-en-us")]
[TestCase(2, "da-DK", "/root-2-da-dk")]
[TestCase(3, "en-US", "/root-3-en-us")]
[TestCase(3, "da-DK", "/root-3-da-dk")]
[ConfigureBuilder(ActionName = nameof(ConfigureOmitTrailingSlash))]
public void Root_Without_Trailing_Slash(int root, string culture, string expectedPath)
{
SetVariationContext(culture);
var publishedContent = GetPublishedContent(_contentByName[$"Root {root}"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual(expectedPath, route.Path);
Assert.AreEqual($"root-{root}-{culture.ToLowerInvariant()}", route.StartItem.Path);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id);
});
}
[TestCase(1, 1, "en-US")]
[TestCase(1, 1, "da-DK")]
[TestCase(2, 2, "en-US")]
[TestCase(2, 2, "da-DK")]
[TestCase(3, 3, "en-US")]
[TestCase(3, 3, "da-DK")]
[TestCase(1, 2, "en-US")]
[TestCase(1, 2, "da-DK")]
[TestCase(2, 3, "en-US")]
[TestCase(2, 3, "da-DK")]
[TestCase(3, 1, "en-US")]
[TestCase(3, 1, "da-DK")]
[ConfigureBuilder(ActionName = nameof(ConfigureOmitTrailingSlash))]
public void Child_Without_Trailing_Slash(int root, int child, string culture)
{
SetVariationContext(culture);
var publishedContent = GetPublishedContent(_contentByName[$"Root {root}/Child {child}"].Key);
var route = ApiContentRouteBuilder.Build(publishedContent);
Assert.IsNotNull(route);
Assert.Multiple(() =>
{
Assert.AreEqual($"/child-{child}-{culture.ToLowerInvariant()}", route.Path);
Assert.AreEqual($"root-{root}-{culture.ToLowerInvariant()}", route.StartItem.Path);
Assert.AreEqual(_contentByName[$"Root {root}"].Key, route.StartItem.Id);
});
}
}