diff --git a/src/Umbraco.Core/Services/RedirectUrlService.cs b/src/Umbraco.Core/Services/RedirectUrlService.cs index c2050f7ff0d6..e5c286caaf76 100644 --- a/src/Umbraco.Core/Services/RedirectUrlService.cs +++ b/src/Umbraco.Core/Services/RedirectUrlService.cs @@ -1,4 +1,3 @@ -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; @@ -7,10 +6,16 @@ namespace Umbraco.Cms.Core.Services; +/// +/// Provides services for managing redirect URLs. +/// internal sealed class RedirectUrlService : RepositoryService, IRedirectUrlService { private readonly IRedirectUrlRepository _redirectUrlRepository; + /// + /// Initializes a new instance of the class. + /// public RedirectUrlService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, @@ -19,109 +24,99 @@ public RedirectUrlService( : base(provider, loggerFactory, eventMessagesFactory) => _redirectUrlRepository = redirectUrlRepository; + /// public void Register(string url, Guid contentKey, string? culture = null) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + IRedirectUrl? redir = _redirectUrlRepository.Get(url, contentKey, culture); + if (redir != null) { - IRedirectUrl? redir = _redirectUrlRepository.Get(url, contentKey, culture); - if (redir != null) - { - redir.CreateDateUtc = DateTime.UtcNow; - } - else - { - redir = new RedirectUrl { Key = Guid.NewGuid(), Url = url, ContentKey = contentKey, Culture = culture }; - } - - _redirectUrlRepository.Save(redir); - scope.Complete(); + redir.CreateDateUtc = DateTime.UtcNow; } + else + { + redir = new RedirectUrl { Key = Guid.NewGuid(), Url = url, ContentKey = contentKey, Culture = culture }; + } + + _redirectUrlRepository.Save(redir); + scope.Complete(); } + /// public void Delete(IRedirectUrl redirectUrl) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - _redirectUrlRepository.Delete(redirectUrl); - scope.Complete(); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + _redirectUrlRepository.Delete(redirectUrl); + scope.Complete(); } + /// public void Delete(Guid id) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - _redirectUrlRepository.Delete(id); - scope.Complete(); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + _redirectUrlRepository.Delete(id); + scope.Complete(); } + /// public void DeleteContentRedirectUrls(Guid contentKey) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - _redirectUrlRepository.DeleteContentUrls(contentKey); - scope.Complete(); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + _redirectUrlRepository.DeleteContentUrls(contentKey); + scope.Complete(); } + /// public void DeleteAll() { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - _redirectUrlRepository.DeleteAll(); - scope.Complete(); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + _redirectUrlRepository.DeleteAll(); + scope.Complete(); } + /// public IRedirectUrl? GetMostRecentRedirectUrl(string url) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _redirectUrlRepository.GetMostRecentUrl(url); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _redirectUrlRepository.GetMostRecentUrl(url); } + /// public async Task GetMostRecentRedirectUrlAsync(string url) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return await _redirectUrlRepository.GetMostRecentUrlAsync(url); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return await _redirectUrlRepository.GetMostRecentUrlAsync(url); } + /// public IEnumerable GetContentRedirectUrls(Guid contentKey) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _redirectUrlRepository.GetContentUrls(contentKey); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _redirectUrlRepository.GetContentUrls(contentKey); } + /// public IEnumerable GetAllRedirectUrls(long pageIndex, int pageSize, out long total) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _redirectUrlRepository.GetAllUrls(pageIndex, pageSize, out total); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _redirectUrlRepository.GetAllUrls(pageIndex, pageSize, out total); } + /// public IEnumerable GetAllRedirectUrls(int rootContentId, long pageIndex, int pageSize, out long total) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _redirectUrlRepository.GetAllUrls(rootContentId, pageIndex, pageSize, out total); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _redirectUrlRepository.GetAllUrls(rootContentId, pageIndex, pageSize, out total); } + /// public IEnumerable SearchRedirectUrls(string searchTerm, long pageIndex, int pageSize, out long total) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _redirectUrlRepository.SearchUrls(searchTerm, pageIndex, pageSize, out total); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _redirectUrlRepository.SearchUrls(searchTerm, pageIndex, pageSize, out total); } + /// public IRedirectUrl? GetMostRecentRedirectUrl(string url, string? culture) { if (string.IsNullOrWhiteSpace(culture)) @@ -129,12 +124,11 @@ public IEnumerable SearchRedirectUrls(string searchTerm, long page return GetMostRecentRedirectUrl(url); } - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _redirectUrlRepository.GetMostRecentUrl(url, culture); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _redirectUrlRepository.GetMostRecentUrl(url, culture); } + /// public async Task GetMostRecentRedirectUrlAsync(string url, string? culture) { if (string.IsNullOrWhiteSpace(culture)) @@ -142,9 +136,7 @@ public IEnumerable SearchRedirectUrls(string searchTerm, long page return await GetMostRecentRedirectUrlAsync(url); } - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return await _redirectUrlRepository.GetMostRecentUrlAsync(url, culture); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return await _redirectUrlRepository.GetMostRecentUrlAsync(url, culture); } } diff --git a/src/Umbraco.Infrastructure/Routing/RedirectTracker.cs b/src/Umbraco.Infrastructure/Routing/RedirectTracker.cs index 18a4d45e67d9..121014104a77 100644 --- a/src/Umbraco.Infrastructure/Routing/RedirectTracker.cs +++ b/src/Umbraco.Infrastructure/Routing/RedirectTracker.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Extensions; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; @@ -9,114 +10,137 @@ using Umbraco.Cms.Core.Services.Navigation; using Umbraco.Extensions; -namespace Umbraco.Cms.Infrastructure.Routing +namespace Umbraco.Cms.Infrastructure.Routing; + +/// +/// Tracks and manages URL redirects for content items, ensuring that old routes are stored and appropriate redirects +/// are created when content URLs change. +/// +internal sealed class RedirectTracker : IRedirectTracker { - internal sealed class RedirectTracker : IRedirectTracker + private readonly ILanguageService _languageService; + private readonly IRedirectUrlService _redirectUrlService; + private readonly IPublishedContentCache _contentCache; + private readonly IDocumentNavigationQueryService _navigationQueryService; + private readonly ILogger _logger; + private readonly IPublishedUrlProvider _publishedUrlProvider; + private readonly IPublishedContentStatusFilteringService _publishedContentStatusFilteringService; + + /// + /// Initializes a new instance of the class. + /// + public RedirectTracker( + ILanguageService languageService, + IRedirectUrlService redirectUrlService, + IPublishedContentCache contentCache, + IDocumentNavigationQueryService navigationQueryService, + ILogger logger, + IPublishedUrlProvider publishedUrlProvider, + IPublishedContentStatusFilteringService publishedContentStatusFilteringService) + { + _languageService = languageService; + _redirectUrlService = redirectUrlService; + _contentCache = contentCache; + _navigationQueryService = navigationQueryService; + _logger = logger; + _publishedUrlProvider = publishedUrlProvider; + _publishedContentStatusFilteringService = publishedContentStatusFilteringService; + } + + /// + public void StoreOldRoute(IContent entity, Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> oldRoutes) { - private readonly ILocalizationService _localizationService; - private readonly IRedirectUrlService _redirectUrlService; - private readonly IPublishedContentCache _contentCache; - private readonly IDocumentNavigationQueryService _navigationQueryService; - private readonly ILogger _logger; - private readonly IPublishedUrlProvider _publishedUrlProvider; - private readonly IPublishedContentStatusFilteringService _publishedContentStatusFilteringService; - - public RedirectTracker( - ILocalizationService localizationService, - IRedirectUrlService redirectUrlService, - IPublishedContentCache contentCache, - IDocumentNavigationQueryService navigationQueryService, - ILogger logger, - IPublishedUrlProvider publishedUrlProvider, - IPublishedContentStatusFilteringService publishedContentStatusFilteringService) + IPublishedContent? entityContent = _contentCache.GetById(entity.Id); + if (entityContent is null) { - _localizationService = localizationService; - _redirectUrlService = redirectUrlService; - _contentCache = contentCache; - _navigationQueryService = navigationQueryService; - _logger = logger; - _publishedUrlProvider = publishedUrlProvider; - _publishedContentStatusFilteringService = publishedContentStatusFilteringService; + return; } - /// - public void StoreOldRoute(IContent entity, Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> oldRoutes) - { - IPublishedContent? entityContent = _contentCache.GetById(entity.Id); - if (entityContent is null) - { - return; - } + // Get the default affected cultures by going up the tree until we find the first culture variant entity (default to no cultures) + var defaultCultures = new Lazy(() => entityContent.AncestorsOrSelf(_navigationQueryService, _publishedContentStatusFilteringService).FirstOrDefault(a => a.Cultures.Any())?.Cultures.Keys.ToArray() ?? Array.Empty()); - // Get the default affected cultures by going up the tree until we find the first culture variant entity (default to no cultures) - var defaultCultures = new Lazy(() => entityContent.AncestorsOrSelf(_navigationQueryService, _publishedContentStatusFilteringService).FirstOrDefault(a => a.Cultures.Any())?.Cultures.Keys.ToArray() ?? Array.Empty()); + // Get all language ISO codes (in case we're dealing with invariant content with variant ancestors) + var languageIsoCodes = new Lazy(() => _languageService.GetAllIsoCodesAsync().GetAwaiter().GetResult().ToArray()); - // Get all language ISO codes (in case we're dealing with invariant content with variant ancestors) - var languageIsoCodes = new Lazy(() => _localizationService.GetAllLanguages().Select(x => x.IsoCode).ToArray()); + foreach (IPublishedContent publishedContent in entityContent.DescendantsOrSelf(_navigationQueryService, _publishedContentStatusFilteringService)) + { + // If this entity defines specific cultures, use those instead of the default ones + IEnumerable cultures = publishedContent.Cultures.Any() ? publishedContent.Cultures.Keys : defaultCultures.Value; - foreach (IPublishedContent publishedContent in entityContent.DescendantsOrSelf(_navigationQueryService, _publishedContentStatusFilteringService)) + foreach (var culture in cultures) { - // If this entity defines specific cultures, use those instead of the default ones - IEnumerable cultures = publishedContent.Cultures.Any() ? publishedContent.Cultures.Keys : defaultCultures.Value; - - foreach (var culture in cultures) + try { - try - { - var route = _publishedUrlProvider.GetUrl(publishedContent.Id, UrlMode.Relative, culture).TrimEnd(Constants.CharArrays.ForwardSlash); + var route = _publishedUrlProvider.GetUrl(publishedContent.Id, UrlMode.Relative, culture).TrimEnd(Constants.CharArrays.ForwardSlash); - if (IsValidRoute(route)) - { - oldRoutes[(publishedContent.Id, culture)] = (publishedContent.Key, route); - } - else if (string.IsNullOrEmpty(culture)) + if (IsValidRoute(route)) + { + oldRoutes[(publishedContent.Id, culture)] = (publishedContent.Key, route); + } + else if (string.IsNullOrEmpty(culture)) + { + // Retry using all languages, if this is invariant but has a variant ancestor. + foreach (string languageIsoCode in languageIsoCodes.Value) { - // Retry using all languages, if this is invariant but has a variant ancestor. - foreach (string languageIsoCode in languageIsoCodes.Value) + route = _publishedUrlProvider.GetUrl(publishedContent.Id, UrlMode.Relative, languageIsoCode).TrimEnd(Constants.CharArrays.ForwardSlash); + if (IsValidRoute(route)) { - route = _publishedUrlProvider.GetUrl(publishedContent.Id, UrlMode.Relative, languageIsoCode).TrimEnd(Constants.CharArrays.ForwardSlash); - if (IsValidRoute(route)) - { - oldRoutes[(publishedContent.Id, languageIsoCode)] = (publishedContent.Key, route); - } + oldRoutes[(publishedContent.Id, languageIsoCode)] = (publishedContent.Key, route); } } } - catch (Exception ex) - { - _logger.LogWarning(ex, "Could not register redirects because the old route couldn't be retrieved for content ID {ContentId} and culture '{Culture}'.", publishedContent.Id, culture); - } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not register redirects because the old route couldn't be retrieved for content ID {ContentId} and culture '{Culture}'.", publishedContent.Id, culture); } } } + } - /// - public void CreateRedirects(IDictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> oldRoutes) + /// + public void CreateRedirects(IDictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> oldRoutes) + { + if (!oldRoutes.Any()) { - if (!oldRoutes.Any()) - { - return; - } + return; + } - foreach (((int contentId, string culture), (Guid contentKey, string oldRoute)) in oldRoutes) + foreach (((int contentId, string culture), (Guid contentKey, string oldRoute)) in oldRoutes) + { + try { - try - { - var newRoute = _publishedUrlProvider.GetUrl(contentKey, UrlMode.Relative, culture).TrimEnd(Constants.CharArrays.ForwardSlash); - if (!IsValidRoute(newRoute) || oldRoute == newRoute) - { - continue; - } + var newRoute = _publishedUrlProvider.GetUrl(contentKey, UrlMode.Relative, culture).TrimEnd(Constants.CharArrays.ForwardSlash); - _redirectUrlService.Register(oldRoute, contentKey, culture); - } - catch (Exception ex) + if (!IsValidRoute(newRoute) || oldRoute == newRoute) { - _logger.LogWarning(ex, "Could not track redirects because the new route couldn't be retrieved for content ID {ContentId} and culture '{Culture}'.", contentId, culture); + continue; } + + // Ensure we don't create a self-referencing redirect. This can occur if a document is renamed and then the name is reverted back + // to the original. We resolve this by removing any existing redirect that points to the new route. + RemoveSelfReferencingRedirect(contentKey, newRoute); + + _redirectUrlService.Register(oldRoute, contentKey, culture); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not track redirects because the new route couldn't be retrieved for content ID {ContentId} and culture '{Culture}'.", contentId, culture); } } + } + + private static bool IsValidRoute([NotNullWhen(true)] string? route) => route is not null && !route.StartsWith("err/"); - private static bool IsValidRoute([NotNullWhen(true)] string? route) => route is not null && !route.StartsWith("err/"); + private void RemoveSelfReferencingRedirect(Guid contentKey, string route) + { + IEnumerable allRedirectUrls = _redirectUrlService.GetContentRedirectUrls(contentKey); + foreach (IRedirectUrl redirectUrl in allRedirectUrls) + { + if (redirectUrl.Url == route) + { + _redirectUrlService.Delete(redirectUrl.Key); + } + } } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Routing/RedirectTrackerTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Routing/RedirectTrackerTests.cs new file mode 100644 index 000000000000..2716cee8993d --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Routing/RedirectTrackerTests.cs @@ -0,0 +1,149 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Navigation; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +using Umbraco.Cms.Infrastructure.Routing; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Routing; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class RedirectTrackerTests : UmbracoIntegrationTestWithContent +{ + private IRedirectUrlService RedirectUrlService => GetRequiredService(); + + private IContent _testPage; + + private const string Url = "RedirectUrl"; + + public override void CreateTestData() + { + base.CreateTestData(); + + using var scope = ScopeProvider.CreateScope(); + var repository = CreateRedirectUrlRepository(); + var rootContent = ContentService.GetRootContent().First(); + var subPages = ContentService.GetPagedChildren(rootContent.Id, 0, 3, out _).ToList(); + _testPage = subPages[0]; + + repository.Save(new RedirectUrl { ContentKey = _testPage.Key, Url = Url, Culture = "en" }); + + scope.Complete(); + } + + [Test] + public void Can_Store_Old_Route() + { + Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> dict = + new Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> + { + [(_testPage.Id, "en")] = (_testPage.Key, "/old-route"), + }; + + var redirectTracker = CreateRedirectTracker(); + + redirectTracker.StoreOldRoute(_testPage, dict); + + Assert.That(dict.Count, Is.EqualTo(1)); + Assert.AreEqual(dict.Values.First().OldRoute, Url); + } + + [Test] + public void Can_Create_Redirects() + { + IDictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> dict = + new Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> + { + [(_testPage.Id, "en")] = (_testPage.Key, "/old-route"), + }; + var redirectTracker = CreateRedirectTracker(); + + redirectTracker.CreateRedirects(dict); + + var redirects = RedirectUrlService.GetContentRedirectUrls(_testPage.Key); + + Assert.IsTrue(redirects.Any(x => x.Url == "/old-route")); + } + + [Test] + public void Removes_Self_Referncing_Redirects() + { + const string newUrl = "newUrl"; + + var redirects = RedirectUrlService.GetContentRedirectUrls(_testPage.Key); + Assert.IsTrue(redirects.Any(x => x.Url == Url)); // Ensure self referencing redirect exists. + + IDictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> dict = + new Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> + { + [(_testPage.Id, "en")] = (_testPage.Key, newUrl), + }; + + var redirectTracker = CreateRedirectTracker(); + redirectTracker.CreateRedirects(dict); + redirects = RedirectUrlService.GetContentRedirectUrls(_testPage.Key); + + Assert.IsFalse(redirects.Any(x => x.Url == Url)); + Assert.IsTrue(redirects.Any(x => x.Url == newUrl)); + } + + private RedirectUrlRepository CreateRedirectUrlRepository() => + new( + (IScopeAccessor)ScopeProvider, + AppCaches.Disabled, + new NullLogger(), + Mock.Of(), + Mock.Of()); + + private IRedirectTracker CreateRedirectTracker() + { + var contentType = new Mock(); + contentType.SetupGet(c => c.Variations).Returns(ContentVariation.Nothing); + + var cultures = new Dictionary + { + { "en", new PublishedCultureInfo("en", "english", "/en/", DateTime.UtcNow) }, + }; + + var content = new Mock(); + + content.SetupGet(c => c.Key).Returns(_testPage.Key); + content.SetupGet(c => c.ContentType).Returns(contentType.Object); + content.SetupGet(c => c.Cultures).Returns(cultures); + content.SetupGet(c => c.Id).Returns(_testPage.Id); + + IPublishedContentCache contentCache = Mock.Of(); + Mock.Get(contentCache) + .Setup(x => x.GetById(_testPage.Id)) + .Returns(content.Object); + + IPublishedUrlProvider publishedUrlProvider = Mock.Of(); + Mock.Get(publishedUrlProvider) + .Setup(x => x.GetUrl(_testPage.Key, UrlMode.Relative, "en", null)) + .Returns(Url); + + Mock.Get(publishedUrlProvider) + .Setup(x => x.GetUrl(_testPage.Id, UrlMode.Relative, "en", null)) + .Returns(Url); + + return new RedirectTracker( + GetRequiredService(), + RedirectUrlService, + contentCache, + GetRequiredService(), + GetRequiredService>(), + publishedUrlProvider, + GetRequiredService()); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/RedirectUrlServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/RedirectUrlServiceTests.cs index 2e6bb099f2d5..962aa43bd9dd 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/RedirectUrlServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/RedirectUrlServiceTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Linq; -using System.Threading; using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; @@ -85,4 +83,16 @@ public void Can_Get_Most_Recent_RedirectUrl_With_Culture_When_No_CultureVariant_ var redirect = RedirectUrlService.GetMostRecentRedirectUrl(UrlAlt, UnusedCulture); Assert.AreEqual(redirect.ContentId, _thirdSubPage.Id); } + + [Test] + public void Can_Register_Redirect() + { + const string TestUrl = "testUrl"; + + RedirectUrlService.Register(TestUrl, _firstSubPage.Key); + + var redirect = RedirectUrlService.GetMostRecentRedirectUrl(TestUrl, CultureEnglish); + + Assert.AreEqual(redirect.ContentId, _firstSubPage.Id); + } }