From b477cf50f21f4931f6c6e2ba0f63bcb5bda629b1 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Tue, 15 Oct 2024 19:33:23 +0200 Subject: [PATCH] Bugfix: Do not allow routing content that is unpublished (#17251) * Ensure routing respect publish status * Check published status per culture * Added PublishStatusService to get publish status for a given documentkey and culture * Added tests and fixed bug with a static fields that should not have been static * Make sure the write and read cache key is always the same no matter where the request comes from There is an edge case where the incomming culure is fully capitalized while the read is camelcase * Fixed review comments --------- Co-authored-by: Sven Geusens --- .../Implement/ContentCacheRefresher.cs | 49 +++- .../DependencyInjection/UmbracoBuilder.cs | 4 + .../Repositories/IPublishStatusRepository.cs | 8 + .../Routing/ContentFinderByUrlNew.cs | 1 + .../Services/DocumentUrlService.cs | 203 ++++++++-------- .../IPublishStatusManagementService.cs | 9 + .../IPublishStatusQueryService.cs | 9 + ...ublishStatusInitializationHostedService.cs | 42 ++++ .../PublishStatus/PublishStatusService.cs | 84 +++++++ .../UmbracoBuilder.Repositories.cs | 1 + .../Persistence/Dtos/ContentTypeDto.cs | 5 +- .../Dtos/DocumentCultureVariationDto.cs | 5 +- .../Persistence/Dtos/DocumentDto.cs | 6 +- .../Persistence/Dtos/LanguageDto.cs | 5 +- .../Implement/PublishStatusRepository.cs | 137 +++++++++++ .../UmbracoBuilderExtensions.cs | 1 + src/Umbraco.Web.UI.Client | 2 +- .../Services/DocumentUrlServiceTest.cs | 49 +++- .../Services/PublishStatusServiceTest.cs | 216 ++++++++++++++++++ 19 files changed, 725 insertions(+), 111 deletions(-) create mode 100644 src/Umbraco.Core/Persistence/Repositories/IPublishStatusRepository.cs create mode 100644 src/Umbraco.Core/Services/PublishStatus/IPublishStatusManagementService.cs create mode 100644 src/Umbraco.Core/Services/PublishStatus/IPublishStatusQueryService.cs create mode 100644 src/Umbraco.Core/Services/PublishStatus/PublishStatusInitializationHostedService.cs create mode 100644 src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs create mode 100644 src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PublishStatusRepository.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTest.cs diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs index 8a3147f022..3da0a0e1b4 100644 --- a/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs @@ -22,6 +22,7 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase _documentNavigationQueryService.TryGetParentKeyInBin(contentKey, out _); + private async Task HandlePublishedAsync(JsonPayload payload, CancellationToken cancellationToken) + { + + if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshAll)) + { + await _publishStatusManagementService.InitializeAsync(cancellationToken); + } + + if (payload.Key.HasValue is false) + { + return; + } + + if (payload.ChangeTypes.HasType(TreeChangeTypes.Remove)) + { + await _publishStatusManagementService.RemoveAsync(payload.Key.Value, cancellationToken); + } + else if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshNode)) + { + await _publishStatusManagementService.AddOrUpdateStatusAsync(payload.Key.Value, cancellationToken); + } + else if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch)) + { + await _publishStatusManagementService.AddOrUpdateStatusWithDescendantsAsync(payload.Key.Value, cancellationToken); + } + } private void HandleRouting(JsonPayload payload) { if(payload.ChangeTypes.HasType(TreeChangeTypes.Remove)) diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 829dbd7ae8..d702ce479e 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -356,6 +356,10 @@ namespace Umbraco.Cms.Core.DependencyInjection Services.AddUnique(x => x.GetRequiredService()); Services.AddUnique(x => x.GetRequiredService()); + Services.AddUnique(); + Services.AddUnique(x => x.GetRequiredService()); + Services.AddUnique(x => x.GetRequiredService()); + // Register a noop IHtmlSanitizer & IMarkdownSanitizer to be replaced Services.AddUnique(); Services.AddUnique(); diff --git a/src/Umbraco.Core/Persistence/Repositories/IPublishStatusRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IPublishStatusRepository.cs new file mode 100644 index 0000000000..3176b02a36 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IPublishStatusRepository.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Persistence.Repositories; + +public interface IPublishStatusRepository +{ + Task>> GetAllPublishStatusAsync(CancellationToken cancellationToken); + Task> GetPublishStatusAsync(Guid documentKey, CancellationToken cancellationToken); + Task>> GetDescendantsOrSelfPublishStatusAsync(Guid rootDocumentKey, CancellationToken cancellationToken); +} diff --git a/src/Umbraco.Core/Routing/ContentFinderByUrlNew.cs b/src/Umbraco.Core/Routing/ContentFinderByUrlNew.cs index eeaaeef9b9..76211530aa 100644 --- a/src/Umbraco.Core/Routing/ContentFinderByUrlNew.cs +++ b/src/Umbraco.Core/Routing/ContentFinderByUrlNew.cs @@ -96,6 +96,7 @@ public class ContentFinderByUrlNew : IContentFinder umbracoContext.InPreviewMode ); + IPublishedContent? node = null; if (documentKey.HasValue) { diff --git a/src/Umbraco.Core/Services/DocumentUrlService.cs b/src/Umbraco.Core/Services/DocumentUrlService.cs index 5f9f6a759f..0d6fb168fa 100644 --- a/src/Umbraco.Core/Services/DocumentUrlService.cs +++ b/src/Umbraco.Core/Services/DocumentUrlService.cs @@ -1,13 +1,11 @@ using System.Collections.Concurrent; using System.Globalization; using System.Runtime.CompilerServices; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Persistence.Repositories; -using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services.Navigation; @@ -33,9 +31,10 @@ public class DocumentUrlService : IDocumentUrlService private readonly IIdKeyMap _idKeyMap; private readonly IDocumentNavigationQueryService _documentNavigationQueryService; private readonly IDomainService _domainService; + private readonly IPublishStatusQueryService _publishStatusQueryService; private readonly ConcurrentDictionary _cache = new(); - private bool _isInitialized = false; + private bool _isInitialized; public DocumentUrlService( ILogger logger, @@ -50,7 +49,8 @@ public class DocumentUrlService : IDocumentUrlService IKeyValueService keyValueService, IIdKeyMap idKeyMap, IDocumentNavigationQueryService documentNavigationQueryService, - IDomainService domainService) + IDomainService domainService, + IPublishStatusQueryService publishStatusQueryService) { _logger = logger; _documentUrlRepository = documentUrlRepository; @@ -65,6 +65,7 @@ public class DocumentUrlService : IDocumentUrlService _idKeyMap = idKeyMap; _documentNavigationQueryService = documentNavigationQueryService; _domainService = domainService; + _publishStatusQueryService = publishStatusQueryService; } public async Task InitAsync(bool forceEmpty, CancellationToken cancellationToken) @@ -109,8 +110,7 @@ public class DocumentUrlService : IDocumentUrlService scopeContext.Enlist("UpdateCache_" + cacheKey, () => { - PublishedDocumentUrlSegment? existingValue = null; - _cache.TryGetValue(cacheKey, out existingValue); + _cache.TryGetValue(cacheKey, out PublishedDocumentUrlSegment? existingValue); if (existingValue is null) { @@ -135,13 +135,13 @@ public class DocumentUrlService : IDocumentUrlService } - private void RemoveFromCache(IScopeContext scopeContext, Guid documentKey, string isoCode) + private void RemoveFromCache(IScopeContext scopeContext, Guid documentKey, string isoCode, bool isDraft) { - var cacheKeyDraft = CreateCacheKey(documentKey, isoCode, true); + var cacheKey = CreateCacheKey(documentKey, isoCode, isDraft); - scopeContext.Enlist("RemoveFromCache_" + cacheKeyDraft, () => + scopeContext.Enlist("RemoveFromCache_" + cacheKey, () => { - if (_cache.TryRemove(cacheKeyDraft, out _) is false) + if (_cache.TryRemove(cacheKey, out _) is false) { _logger.LogDebug("Could not remove the document url cache. But the important thing is that it is not there."); return false; @@ -149,20 +149,6 @@ public class DocumentUrlService : IDocumentUrlService return true; }); - - var cacheKeyPublished = CreateCacheKey(documentKey, isoCode, false); - - scopeContext.Enlist("RemoveFromCache_" + cacheKeyPublished, () => - { - if (_cache.TryRemove(cacheKeyPublished, out _) is false) - { - _logger.LogDebug("Could not remove the document url cache. But the important thing is that it is not there."); - return false; - } - - return true; - }); - } public async Task RebuildAllUrlsAsync() @@ -179,7 +165,7 @@ public class DocumentUrlService : IDocumentUrlService scope.Complete(); } - public Task ShouldRebuildUrlsAsync() + private Task ShouldRebuildUrlsAsync() { var persistedValue = GetPersistedRebuildValue(); var currentValue = GetCurrentRebuildValue(); @@ -187,10 +173,7 @@ public class DocumentUrlService : IDocumentUrlService return Task.FromResult(string.Equals(persistedValue, currentValue) is false); } - private string GetCurrentRebuildValue() - { - return string.Join("|", _urlSegmentProviderCollection.Select(x => x.GetType().Name)); - } + private string GetCurrentRebuildValue() => string.Join("|", _urlSegmentProviderCollection.Select(x => x.GetType().Name)); private string? GetPersistedRebuildValue() => _keyValueService.GetValue(RebuildKey); @@ -212,8 +195,9 @@ public class DocumentUrlService : IDocumentUrlService } } - public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable documents) + public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable documentsEnumerable) { + IEnumerable documents = documentsEnumerable as IContent[] ?? documentsEnumerable.ToArray(); if(documents.Any() is false) { return; @@ -222,10 +206,8 @@ public class DocumentUrlService : IDocumentUrlService using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); var toSave = new List(); - var toDelete = new List(); - var allCultures = documents.SelectMany(x => x.AvailableCultures ).Distinct(); - var languages = await _languageService.GetAllAsync(); + IEnumerable languages = await _languageService.GetAllAsync(); var languageDictionary = languages.ToDictionary(x=>x.IsoCode); foreach (IContent document in documents) @@ -237,7 +219,7 @@ public class DocumentUrlService : IDocumentUrlService foreach ((string culture, ILanguage language) in languageDictionary) { - HandleCaching(_coreScopeProvider.Context!, document, document.ContentType.VariesByCulture() ? culture : null, language, toDelete, toSave); + HandleCaching(_coreScopeProvider.Context!, document, document.ContentType.VariesByCulture() ? culture : null, language, toSave); } } @@ -246,29 +228,18 @@ public class DocumentUrlService : IDocumentUrlService _documentUrlRepository.Save(toSave); } - if(toDelete.Any()) - { - _documentUrlRepository.DeleteByDocumentKey(toDelete); - } - scope.Complete(); } - private void HandleCaching(IScopeContext scopeContext, IContent document, string? culture, ILanguage language, List toDelete, List toSave) + private void HandleCaching(IScopeContext scopeContext, IContent document, string? culture, ILanguage language, List toSave) { - var models = GenerateModels(document, culture, language); + IEnumerable<(PublishedDocumentUrlSegment model, bool shouldCache)> modelsAndStatus = GenerateModels(document, culture, language); - foreach (PublishedDocumentUrlSegment model in models) + foreach ((PublishedDocumentUrlSegment model, bool shouldCache) in modelsAndStatus) { - if (document.Published is false && model.IsDraft is false) + if (shouldCache is false) { - continue; - } - - if (document.Trashed) - { - toDelete.Add(model.DocumentKey); - RemoveFromCache(scopeContext, model.DocumentKey, language.IsoCode); + RemoveFromCache(scopeContext, model.DocumentKey, language.IsoCode, model.IsDraft); } else { @@ -278,13 +249,13 @@ public class DocumentUrlService : IDocumentUrlService } } - private IEnumerable GenerateModels(IContent document, string? culture, ILanguage language) + private IEnumerable<(PublishedDocumentUrlSegment model, bool shouldCache)> GenerateModels(IContent document, string? culture, ILanguage language) { - if (document.ContentType.VariesByCulture() is false || document.PublishCultureInfos != null && document.PublishCultureInfos.Values.Any(x => x.Culture == culture)) + if (document.Trashed is false + && (IsInvariantAndPublished(document) || IsVariantAndPublishedForCulture(document, culture))) { - var publishedUrlSegment = - document.GetUrlSegment(_shortStringHelper, _urlSegmentProviderCollection, culture, true); + document.GetUrlSegment(_shortStringHelper, _urlSegmentProviderCollection, culture); if (publishedUrlSegment.IsNullOrWhiteSpace()) { _logger.LogWarning("No published url segment found for document {DocumentKey} in culture {Culture}", @@ -292,15 +263,25 @@ public class DocumentUrlService : IDocumentUrlService } else { - yield return new PublishedDocumentUrlSegment() + yield return (new PublishedDocumentUrlSegment() { DocumentKey = document.Key, LanguageId = language.Id, UrlSegment = publishedUrlSegment, IsDraft = false - }; + }, true); } } + else + { + yield return (new PublishedDocumentUrlSegment() + { + DocumentKey = document.Key, + LanguageId = language.Id, + UrlSegment = string.Empty, + IsDraft = false + }, false); + } var draftUrlSegment = document.GetUrlSegment(_shortStringHelper, _urlSegmentProviderCollection, culture, false); @@ -310,24 +291,36 @@ public class DocumentUrlService : IDocumentUrlService } else { - yield return new PublishedDocumentUrlSegment() + yield return (new PublishedDocumentUrlSegment() { DocumentKey = document.Key, LanguageId = language.Id, UrlSegment = draftUrlSegment, IsDraft = true - }; + }, document.Trashed is false); } } - public async Task DeleteUrlsFromCacheAsync(IEnumerable documentKeys) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsVariantAndPublishedForCulture(IContent document, string? culture) => + document.PublishCultureInfos?.Values.Any(x => x.Culture == culture) ?? false; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsInvariantAndPublished(IContent document) + => document.ContentType.VariesByCulture() is false // Is Invariant + && document.Published; // Is Published + + public async Task DeleteUrlsFromCacheAsync(IEnumerable documentKeysEnumerable) { using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); IEnumerable languages = await _languageService.GetAllAsync(); + IEnumerable documentKeys = documentKeysEnumerable as Guid[] ?? documentKeysEnumerable.ToArray(); + foreach (ILanguage language in languages) { foreach (Guid documentKey in documentKeys) { - RemoveFromCache(_coreScopeProvider.Context!, documentKey, language.IsoCode); + RemoveFromCache(_coreScopeProvider.Context!, documentKey, language.IsoCode, true); + RemoveFromCache(_coreScopeProvider.Context!, documentKey, language.IsoCode, false); } } @@ -353,6 +346,12 @@ public class DocumentUrlService : IDocumentUrlService // If a domain is assigned to this route, we need to follow the url segments if (runnerKey.HasValue) { + // if the domain node is unpublished, we need to return null. + if (isDraft is false && IsContentPublished(runnerKey.Value, culture) is false) + { + return null; + } + // If there is no url segments it means the domain root has been requested if (urlSegments.Length == 0) { @@ -363,14 +362,20 @@ public class DocumentUrlService : IDocumentUrlService foreach (var urlSegment in urlSegments) { //Get the children of the runnerKey and find the child (if any) with the correct url segment - var childKeys = GetChildKeys(runnerKey.Value); + IEnumerable childKeys = GetChildKeys(runnerKey.Value); runnerKey = GetChildWithUrlSegment(childKeys, urlSegment, culture, isDraft); + if (runnerKey is null) { break; } + //if part of the path is unpublished, we need to break + if (isDraft is false && IsContentPublished(runnerKey.Value, culture) is false) + { + return null; + } } return runnerKey; @@ -378,14 +383,14 @@ public class DocumentUrlService : IDocumentUrlService // If there is no parts, it means it is a root (and no assigned domain) if(urlSegments.Length == 0) { - // // if we do not hide the top level and no domain was found, it maens there is no content. + // // if we do not hide the top level and no domain was found, it mean there is no content. // // TODO we can remove this to keep consistency with the old routing, but it seems incorrect to allow that. // if (hideTopLevelNodeFromPath is false) // { // return null; // } - return GetTopMostRootKey(); + return GetTopMostRootKey(isDraft, culture); } // Otherwise we have to find the root items (or child of the first root when hideTopLevelNodeFromPath is true) and follow the url segments in them to get to correct document key @@ -395,7 +400,7 @@ public class DocumentUrlService : IDocumentUrlService IEnumerable runnerKeys; if (index == 0) { - runnerKeys = GetKeysInRoot(hideTopLevelNodeFromPath); + runnerKeys = GetKeysInRoot(hideTopLevelNodeFromPath, isDraft, culture); } else { @@ -410,12 +415,19 @@ public class DocumentUrlService : IDocumentUrlService runnerKey = GetChildWithUrlSegment(runnerKeys, urlSegment, culture, isDraft); } + if (isDraft is false && runnerKey.HasValue && IsContentPublished(runnerKey.Value, culture) is false) + { + return null; + } + return runnerKey; } + private bool IsContentPublished(Guid contentKey, string culture) => _publishStatusQueryService.IsDocumentPublished(contentKey, culture); + public string GetLegacyRouteFormat(Guid docuemntKey, string? culture, bool isDraft) { - var documentIdAttempt = _idKeyMap.GetIdForKey(docuemntKey, UmbracoObjectTypes.Document); + Attempt documentIdAttempt = _idKeyMap.GetIdForKey(docuemntKey, UmbracoObjectTypes.Document); if(documentIdAttempt.Success is false) { @@ -469,7 +481,7 @@ public class DocumentUrlService : IDocumentUrlService return foundDomain.RootContentId + "/" + string.Join("/", urlSegments); } - var isRootFirstItem = GetTopMostRootKey() == ancestorsOrSelfKeysArray.Last(); + var isRootFirstItem = GetTopMostRootKey(isDraft, cultureOrDefault) == ancestorsOrSelfKeysArray.Last(); return GetFullUrl(isRootFirstItem, urlSegments, null); } @@ -484,7 +496,7 @@ public class DocumentUrlService : IDocumentUrlService { var result = new List(); - var documentIdAttempt = _idKeyMap.GetIdForKey(contentKey, UmbracoObjectTypes.Document); + Attempt documentIdAttempt = _idKeyMap.GetIdForKey(contentKey, UmbracoObjectTypes.Document); if(documentIdAttempt.Success is false) { @@ -514,7 +526,7 @@ public class DocumentUrlService : IDocumentUrlService { if (ancestorOrSelfKeyToDomains.TryGetValue(ancestorOrSelfKey, out Task>? domainDictionaryTask)) { - var domainDictionary = await domainDictionaryTask; + Dictionary domainDictionary = await domainDictionaryTask; if (domainDictionary.TryGetValue(culture, out IDomain? domain)) { foundDomain = domain; @@ -530,7 +542,7 @@ public class DocumentUrlService : IDocumentUrlService { hasUrlInCulture = false; } - } + } //If we did not find a domain and this is not the default language, then the content is not routable if (foundDomain is null && language.IsDefault is false) @@ -538,12 +550,12 @@ public class DocumentUrlService : IDocumentUrlService continue; } - var isRootFirstItem = GetTopMostRootKey() == ancestorsOrSelfKeysArray.Last(); - result.Add(new UrlInfo( - text: GetFullUrl(isRootFirstItem, urlSegments, foundDomain), - isUrl: hasUrlInCulture, - culture: culture - )); + var isRootFirstItem = GetTopMostRootKey(false, culture) == ancestorsOrSelfKeysArray.Last(); + result.Add(new UrlInfo( + text: GetFullUrl(isRootFirstItem, urlSegments, foundDomain), + isUrl: hasUrlInCulture, + culture: culture + )); } @@ -585,24 +597,14 @@ public class DocumentUrlService : IDocumentUrlService } } - - //TODO test cases: - // - Find the root, when a domain is set - // - Find a nested child, when a domain is set - - // - Find the root when no domain is set and hideTopLevelNodeFromPath is true - // - Find a nested child of item in the root top when no domain is set and hideTopLevelNodeFromPath is true - // - Find a nested child of item in the root bottom when no domain is set and hideTopLevelNodeFromPath is true - // - Find the root when no domain is set and hideTopLevelNodeFromPath is false - // - Find a nested child of item in the root top when no domain is set and hideTopLevelNodeFromPath is false - // - Find a nested child of item in the root bottom when no domain is set and hideTopLevelNodeFromPath is false - - // - All of the above when having Constants.Conventions.Content.UrlName set to a value - - private IEnumerable GetKeysInRoot(bool addFirstLevelChildren) + private IEnumerable GetKeysInRoot(bool addFirstLevelChildren, bool isDraft, string culture) { - //TODO replace with something more performand - Should be possible with navigationservice.. - IEnumerable rootKeys = _contentService.GetRootContent().Select(x=>x.Key).ToArray(); + if (_documentNavigationQueryService.TryGetRootKeys(out IEnumerable rootKeysEnumerable) is false) + { + yield break; + } + + IEnumerable rootKeys = rootKeysEnumerable as Guid[] ?? rootKeysEnumerable.ToArray(); foreach (Guid rootKey in rootKeys) { @@ -613,6 +615,11 @@ public class DocumentUrlService : IDocumentUrlService { foreach (Guid rootKey in rootKeys) { + if (isDraft is false && IsContentPublished(rootKey, culture) is false) + { + continue; + } + IEnumerable childKeys = GetChildKeys(rootKey); foreach (Guid childKey in childKeys) @@ -658,17 +665,23 @@ public class DocumentUrlService : IDocumentUrlService /// Gets the top most root key. /// /// The top most root key. - private Guid? GetTopMostRootKey() + private Guid? GetTopMostRootKey(bool isDraft, string culture) { if (_documentNavigationQueryService.TryGetRootKeys(out IEnumerable rootKeys)) { - return rootKeys.FirstOrDefault(); + foreach (Guid rootKey in rootKeys) + { + if (isDraft || IsContentPublished(rootKey, culture)) + { + return rootKey; + } + } } return null; } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static string CreateCacheKey(Guid documentKey, string culture, bool isDraft) => $"{documentKey}|{culture}|{isDraft}"; + private static string CreateCacheKey(Guid documentKey, string culture, bool isDraft) => $"{documentKey}|{culture}|{isDraft}".ToLowerInvariant(); private Guid? GetStartNodeKey(int? documentStartNodeId) { diff --git a/src/Umbraco.Core/Services/PublishStatus/IPublishStatusManagementService.cs b/src/Umbraco.Core/Services/PublishStatus/IPublishStatusManagementService.cs new file mode 100644 index 0000000000..215564fe27 --- /dev/null +++ b/src/Umbraco.Core/Services/PublishStatus/IPublishStatusManagementService.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.Services.Navigation; + +public interface IPublishStatusManagementService +{ + Task InitializeAsync(CancellationToken cancellationToken); + Task AddOrUpdateStatusAsync(Guid documentKey, CancellationToken cancellationToken); + Task RemoveAsync(Guid documentKey, CancellationToken cancellationToken); + Task AddOrUpdateStatusWithDescendantsAsync(Guid rootDocumentKey, CancellationToken cancellationToken); +} diff --git a/src/Umbraco.Core/Services/PublishStatus/IPublishStatusQueryService.cs b/src/Umbraco.Core/Services/PublishStatus/IPublishStatusQueryService.cs new file mode 100644 index 0000000000..523bcdbfb1 --- /dev/null +++ b/src/Umbraco.Core/Services/PublishStatus/IPublishStatusQueryService.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.Services.Navigation; + +/// +/// +/// +public interface IPublishStatusQueryService +{ + bool IsDocumentPublished(Guid documentKey, string culture); +} diff --git a/src/Umbraco.Core/Services/PublishStatus/PublishStatusInitializationHostedService.cs b/src/Umbraco.Core/Services/PublishStatus/PublishStatusInitializationHostedService.cs new file mode 100644 index 0000000000..b0d3583a60 --- /dev/null +++ b/src/Umbraco.Core/Services/PublishStatus/PublishStatusInitializationHostedService.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Hosting; + +namespace Umbraco.Cms.Core.Services.Navigation; + +/// +/// Responsible for seeding the in-memory publish status cache at application's startup +/// by loading all data from the database. +/// +public sealed class PublishStatusInitializationHostedService : IHostedLifecycleService +{ + private readonly IRuntimeState _runtimeState; + private readonly IPublishStatusManagementService _publishStatusManagementService; + + public PublishStatusInitializationHostedService( + IRuntimeState runtimeState, + IPublishStatusManagementService publishStatusManagementService + ) + { + _runtimeState = runtimeState; + _publishStatusManagementService = publishStatusManagementService; + } + + public async Task StartingAsync(CancellationToken cancellationToken) + { + if(_runtimeState.Level < RuntimeLevel.Upgrade) + { + return; + } + + await _publishStatusManagementService.InitializeAsync(cancellationToken); + } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs b/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs new file mode 100644 index 0000000000..6e1cb2c384 --- /dev/null +++ b/src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs @@ -0,0 +1,84 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; + +namespace Umbraco.Cms.Core.Services.Navigation; + +public class PublishStatusService : IPublishStatusManagementService, IPublishStatusQueryService +{ + private readonly ILogger _logger; + private readonly IPublishStatusRepository _publishStatusRepository; + private readonly ICoreScopeProvider _coreScopeProvider; + private readonly IDictionary> _publishedCultures = new Dictionary>(); + public PublishStatusService( + ILogger logger, + IPublishStatusRepository publishStatusRepository, + ICoreScopeProvider coreScopeProvider) + { + _logger = logger; + _publishStatusRepository = publishStatusRepository; + _coreScopeProvider = coreScopeProvider; + } + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + _publishedCultures.Clear(); + IDictionary> publishStatus; + using (ICoreScope scope = _coreScopeProvider.CreateCoreScope()) + { + publishStatus = await _publishStatusRepository.GetAllPublishStatusAsync(cancellationToken); + scope.Complete(); + } + + foreach ((Guid documentKey, ISet publishedCultures) in publishStatus) + { + if (_publishedCultures.TryAdd(documentKey, publishedCultures) is false) + { + _logger.LogWarning("Failed to add published cultures for document {DocumentKey}", documentKey); + } + } + + + } + + public bool IsDocumentPublished(Guid documentKey, string culture) + { + if (_publishedCultures.TryGetValue(documentKey, out ISet? publishedCultures)) + { + return publishedCultures.Contains(culture); + } + + _logger.LogDebug("Document {DocumentKey} not found in the publish status cache", documentKey); + return false; + } + + public async Task AddOrUpdateStatusAsync(Guid documentKey, CancellationToken cancellationToken) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + ISet publishedCultures = await _publishStatusRepository.GetPublishStatusAsync(documentKey, cancellationToken); + _publishedCultures[documentKey] = publishedCultures; + scope.Complete(); + } + + public Task RemoveAsync(Guid documentKey, CancellationToken cancellationToken) + { + _publishedCultures.Remove(documentKey); + return Task.CompletedTask; + } + + public async Task AddOrUpdateStatusWithDescendantsAsync(Guid rootDocumentKey, CancellationToken cancellationToken) + { + IDictionary> publishStatus; + using (ICoreScope scope = _coreScopeProvider.CreateCoreScope()) + { + publishStatus = await _publishStatusRepository.GetDescendantsOrSelfPublishStatusAsync(rootDocumentKey, cancellationToken); + scope.Complete(); + } + + foreach ((Guid documentKey, ISet publishedCultures) in publishStatus) + { + _publishedCultures[documentKey] = publishedCultures; + } + } +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index 37e3c6063c..73ee0d263a 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -81,6 +81,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); return builder; } diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/ContentTypeDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/ContentTypeDto.cs index ba2530d40d..6abef6eeba 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/ContentTypeDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/ContentTypeDto.cs @@ -12,6 +12,9 @@ internal class ContentTypeDto public const string TableName = Constants.DatabaseSchema.Tables.ContentType; private string? _alias; + // Public constants to bind properties between DTOs + public const string VariationsColumnName = "variations"; + [Column("pk")] [PrimaryKeyColumn(IdentitySeed = 700)] public int PrimaryKey { get; set; } @@ -51,7 +54,7 @@ internal class ContentTypeDto [Constraint(Default = "0")] public bool AllowAtRoot { get; set; } - [Column("variations")] + [Column(VariationsColumnName)] [Constraint(Default = "1" /*ContentVariation.InvariantNeutral*/)] public byte Variations { get; set; } diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentCultureVariationDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentCultureVariationDto.cs index 2bd9f559ec..e89d9cae63 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentCultureVariationDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentCultureVariationDto.cs @@ -11,6 +11,9 @@ internal class DocumentCultureVariationDto { public const string TableName = Constants.DatabaseSchema.Tables.DocumentCultureVariation; + // Public constants to bind properties between DTOs + public const string PublishedColumnName = "published"; + [Column("id")] [PrimaryKeyColumn] public int Id { get; set; } @@ -40,7 +43,7 @@ internal class DocumentCultureVariationDto // de-normalized for perfs // (means there is a published content version culture variation for the language) - [Column("published")] + [Column(PublishedColumnName)] public bool Published { get; set; } // de-normalized for perfs diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentDto.cs index 715d588ff4..e50ed28de6 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentDto.cs @@ -11,12 +11,16 @@ public class DocumentDto { private const string TableName = Constants.DatabaseSchema.Tables.Document; + + // Public constants to bind properties between DTOs + public const string PublishedColumnName = "published"; + [Column("nodeId")] [PrimaryKeyColumn(AutoIncrement = false)] [ForeignKey(typeof(ContentDto))] public int NodeId { get; set; } - [Column("published")] + [Column(PublishedColumnName)] [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_Published")] public bool Published { get; set; } diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/LanguageDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/LanguageDto.cs index bcf8403b73..3fe65f8322 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/LanguageDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/LanguageDto.cs @@ -11,6 +11,9 @@ internal class LanguageDto { public const string TableName = Constants.DatabaseSchema.Tables.Language; + // Public constants to bind properties between DTOs + public const string IsoCodeColumnName = "languageISOCode"; + /// /// Gets or sets the identifier of the language. /// @@ -21,7 +24,7 @@ internal class LanguageDto /// /// Gets or sets the ISO code of the language. /// - [Column("languageISOCode")] + [Column(IsoCodeColumnName)] [Index(IndexTypes.UniqueNonClustered)] [NullSetting(NullSetting = NullSettings.Null)] [Length(14)] diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PublishStatusRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PublishStatusRepository.cs new file mode 100644 index 0000000000..c4889df880 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PublishStatusRepository.cs @@ -0,0 +1,137 @@ +using NPoco; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Media.EmbedProviders; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +public class PublishStatusRepository: IPublishStatusRepository +{ + private readonly IScopeAccessor _scopeAccessor; + + public PublishStatusRepository(IScopeAccessor scopeAccessor) + => _scopeAccessor = scopeAccessor; + + private IUmbracoDatabase Database + { + get + { + if (_scopeAccessor.AmbientScope is null) + { + throw new NotSupportedException("Need to be executed in a scope"); + } + + return _scopeAccessor.AmbientScope.Database; + } + } + + private Sql GetBaseQuery() + { + Sql sql = Database.SqlContext.Sql() + .Select( + $"n.{NodeDto.KeyColumnName}", + $"l.{LanguageDto.IsoCodeColumnName}", + $"ct.{ContentTypeDto.VariationsColumnName}", + $"d.{DocumentDto.PublishedColumnName}", + $"COALESCE(dcv.{DocumentCultureVariationDto.PublishedColumnName}, 0) as {PublishStatusDto.DocumentVariantPublishStatusColumnName}") + .From("d") + .InnerJoin("c").On((d, c) => d.NodeId == c.NodeId, "c", "d") + .InnerJoin("ct").On((c, ct) => c.ContentTypeId == ct.NodeId, "c", "ct") + .CrossJoin("l") + .LeftJoin("dcv").On((l, dcv, d) => l.Id == dcv.LanguageId && d.NodeId == dcv.NodeId , "l", "dcv", "d") + .InnerJoin("n").On((d, n) => n.NodeId == d.NodeId, "d", "n") + ; + + return sql; + } + + + public async Task>> GetAllPublishStatusAsync(CancellationToken cancellationToken) + { + Sql sql = GetBaseQuery(); + + List? databaseRecords = await Database.FetchAsync(sql); + + return Map(databaseRecords); + } + + public async Task> GetPublishStatusAsync(Guid documentKey, CancellationToken cancellationToken) + { + Sql sql = GetBaseQuery(); + sql = sql.Where(n => n.UniqueId == documentKey, "n"); + + List? databaseRecords = await Database.FetchAsync(sql); + + IDictionary> result = Map(databaseRecords); + return result.ContainsKey(documentKey) ? result[documentKey] : new HashSet(); + } + + public async Task>> GetDescendantsOrSelfPublishStatusAsync(Guid rootDocumentKey, CancellationToken cancellationToken) + { + var pathSql = Database.SqlContext.Sql() + .Select(x => x.Path) + .From() + .Where(x => x.UniqueId == rootDocumentKey); + var rootPath = await Database.ExecuteScalarAsync(pathSql); + + Sql sql = GetBaseQuery() + .InnerJoin("rn").On((n, rn) => n.Path.StartsWith(rootPath), "n", "rn") //rn = root node + .Where(rn => rn.UniqueId == rootDocumentKey, "rn"); + + List? databaseRecords = await Database.FetchAsync(sql); + + IDictionary> result = Map(databaseRecords); + + return result; + } + + private IDictionary> Map(List databaseRecords) + { + return databaseRecords + .GroupBy(x => x.Key) + .ToDictionary( + x=>x.Key, + x=> (ISet) x.Where(x=> IsPublished(x)).Select(y=>y.IsoCode).ToHashSet()); + } + + private bool IsPublished(PublishStatusDto publishStatusDto) + { + switch ((ContentVariation)publishStatusDto.ContentTypeVariation) + { + case ContentVariation.Culture: + case ContentVariation.CultureAndSegment: + return publishStatusDto.DocumentVariantPublishStatus; + case ContentVariation.Nothing: + case ContentVariation.Segment: + default: + return publishStatusDto.DocumentInvariantPublished; + } + } + + private class PublishStatusDto + { + + public const string DocumentVariantPublishStatusColumnName = "variantPublished"; + + + [Column(NodeDto.KeyColumnName)] + public Guid Key { get; set; } + + [Column(LanguageDto.IsoCodeColumnName)] + public string IsoCode { get; set; } = ""; + + [Column(ContentTypeDto.VariationsColumnName)] + public byte ContentTypeVariation { get; set; } + + [Column(DocumentDto.PublishedColumnName)] + public bool DocumentInvariantPublished { get; set; } + + [Column(DocumentVariantPublishStatusColumnName)] + public bool DocumentVariantPublishStatus { get; set; } + } + +} diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index ea404d9703..4ccaccfba5 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -195,6 +195,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); return builder; } diff --git a/src/Umbraco.Web.UI.Client b/src/Umbraco.Web.UI.Client index 722c508bcc..29583d3d34 160000 --- a/src/Umbraco.Web.UI.Client +++ b/src/Umbraco.Web.UI.Client @@ -1 +1 @@ -Subproject commit 722c508bcc8cad10848e388fe76240ea82b5a489 +Subproject commit 29583d3d34f57e98052450128435fcb06a0c1984 diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs index d4b02a8755..5ef6de9a92 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs @@ -63,9 +63,9 @@ public class DocumentUrlServiceTest : UmbracoIntegrationTestWithContent { var isoCode = (await LanguageService.GetDefaultLanguageAsync()).IsoCode; - var actual = DocumentUrlService.GetUrlSegment(Trashed.Key, isoCode, true); + Assert.IsNull(DocumentUrlService.GetUrlSegment(Trashed.Key, isoCode, true)); + Assert.IsNull(DocumentUrlService.GetUrlSegment(Trashed.Key, isoCode, false)); - Assert.IsNull(actual); } //TODO test with the urlsegment property value! @@ -120,9 +120,40 @@ public class DocumentUrlServiceTest : UmbracoIntegrationTestWithContent [Test] public void No_Published_Route_when_not_published() { + Assert.IsNotNull(DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, true)); Assert.IsNull(DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, false)); } + [Test] + public void Unpublished_Pages_Are_not_available() + { + //Arrange + ContentService.PublishBranch(Textpage, true, new[] { "*" }); + + Assert.Multiple(() => + { + Assert.IsNotNull(DocumentUrlService.GetDocumentKeyByRoute("/", "en-US", null, true)); + Assert.IsNotNull(DocumentUrlService.GetDocumentKeyByRoute("/", "en-US", null, false)); + Assert.IsNotNull(DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, true)); + Assert.IsNotNull(DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, false)); + }); + + //Act + ContentService.Unpublish(Textpage ); + + Assert.Multiple(() => + { + //The unpublished page self + Assert.IsNotNull(DocumentUrlService.GetDocumentKeyByRoute("/", "en-US", null, true)); + Assert.IsNull(DocumentUrlService.GetDocumentKeyByRoute("/", "en-US", null, false)); + + //A descendant of the unpublished page + Assert.IsNotNull(DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, true)); + Assert.IsNull(DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, false)); + }); + + } + [Test] [TestCase("/text-page-1/sub-page-1", "en-US", true, ExpectedResult = "DF49F477-12F2-4E33-8563-91A7CC1DCDBB")] @@ -181,10 +212,24 @@ public class DocumentUrlServiceTest : UmbracoIntegrationTestWithContent // Publish both the main root and the second root with descendants if (loadDraft is false) { + ContentService.PublishBranch(Textpage, true, new[] { "*" }); ContentService.PublishBranch(secondRoot, true, new[] { "*" }); } return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper(); } + + //TODO test cases: + // - Find the root, when a domain is set + // - Find a nested child, when a domain is set + + // - Find the root when no domain is set and hideTopLevelNodeFromPath is true + // - Find a nested child of item in the root top when no domain is set and hideTopLevelNodeFromPath is true + // - Find a nested child of item in the root bottom when no domain is set and hideTopLevelNodeFromPath is true + // - Find the root when no domain is set and hideTopLevelNodeFromPath is false + // - Find a nested child of item in the root top when no domain is set and hideTopLevelNodeFromPath is false + // - Find a nested child of item in the root bottom when no domain is set and hideTopLevelNodeFromPath is false + + // - All of the above when having Constants.Conventions.Content.UrlName set to a value } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTest.cs new file mode 100644 index 0000000000..f4d4d0f48d --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishStatusServiceTest.cs @@ -0,0 +1,216 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Handlers; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Navigation; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.DependencyInjection; +using Umbraco.Cms.Infrastructure.Examine; +using Umbraco.Cms.Infrastructure.Examine.DependencyInjection; +using Umbraco.Cms.Infrastructure.HostedServices; +using Umbraco.Cms.Infrastructure.Search; +using Umbraco.Cms.Tests.Common.Attributes; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; +using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Scoping; +using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Logger = UmbracoTestOptions.Logger.Mock)] +public class PublishStatusServiceTest : UmbracoIntegrationTestWithContent +{ + protected IPublishStatusQueryService PublishStatusQueryService => GetRequiredService(); + + private const string DefaultCulture = "en-US"; + protected override void CustomTestSetup(IUmbracoBuilder builder) + { + builder.Services.AddUnique(); + builder.AddNotificationHandler(); + } + + [Test] + public async Task InitializeAsync_loads_from_db() + { + var randomCulture = "da-DK"; + var sut = new PublishStatusService( + GetRequiredService>(), + GetRequiredService(), + GetRequiredService() + ); + + Assert.Multiple(() => + { + Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, DefaultCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, DefaultCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, DefaultCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage3.Key, DefaultCulture)); + + Assert.IsFalse(sut.IsDocumentPublished(Trashed.Key, DefaultCulture)); + + Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, randomCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, randomCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, randomCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, randomCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage3.Key, randomCulture)); + + Assert.IsFalse(sut.IsDocumentPublished(Trashed.Key, randomCulture)); + }); + + // Act + var publishResults = ContentService.PublishBranch(Textpage, true, new[] { "*" }); + await sut.InitializeAsync(CancellationToken.None); + + Assert.Multiple(() => + { + Assert.IsTrue(publishResults.All(x=>x.Result == PublishResultType.SuccessPublish)); + Assert.IsTrue(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); + Assert.IsTrue(sut.IsDocumentPublished(Subpage2.Key, DefaultCulture)); + Assert.IsTrue(sut.IsDocumentPublished(Subpage.Key, DefaultCulture)); + Assert.IsTrue(sut.IsDocumentPublished(Subpage2.Key, DefaultCulture)); + Assert.IsTrue(sut.IsDocumentPublished(Subpage3.Key, DefaultCulture)); + + Assert.IsFalse(sut.IsDocumentPublished(Trashed.Key, DefaultCulture)); + + Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, randomCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, randomCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, randomCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage2.Key, randomCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage3.Key, randomCulture)); + + Assert.IsFalse(sut.IsDocumentPublished(Trashed.Key, randomCulture)); + }); + } + + [Test] + public async Task AddOrUpdateStatusWithDescendantsAsync() + { + var randomCulture = "da-DK"; + var sut = new PublishStatusService( + GetRequiredService>(), + GetRequiredService(), + GetRequiredService() + ); + + Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); + + // Act + var publishResults = ContentService.PublishBranch(Textpage, true, new[] { "*" }); + await sut.AddOrUpdateStatusWithDescendantsAsync(Textpage.Key, CancellationToken.None); + Assert.IsTrue(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); + Assert.IsTrue(sut.IsDocumentPublished(Subpage.Key, DefaultCulture)); // Updated due to being an descendant + Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, randomCulture)); // Do not exist + Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, randomCulture)); // Do not exist + } + + [Test] + public async Task AddOrUpdateStatusAsync() + { + var randomCulture = "da-DK"; + var sut = new PublishStatusService( + GetRequiredService>(), + GetRequiredService(), + GetRequiredService() + ); + + Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); + + // Act + var publishResults = ContentService.PublishBranch(Textpage, true, new[] { "*" }); + await sut.AddOrUpdateStatusAsync(Textpage.Key, CancellationToken.None); + Assert.IsTrue(sut.IsDocumentPublished(Textpage.Key, DefaultCulture)); + Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, DefaultCulture)); // Not updated + Assert.IsFalse(sut.IsDocumentPublished(Textpage.Key, randomCulture)); // Do not exist + Assert.IsFalse(sut.IsDocumentPublished(Subpage.Key, randomCulture)); // Do not exist + } + + [Test] + public void When_Nothing_is_publised_all_return_false() + { + var randomCulture = "da-DK"; + Assert.Multiple(() => + { + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, DefaultCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, DefaultCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage.Key, DefaultCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, DefaultCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage3.Key, DefaultCulture)); + + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Trashed.Key, DefaultCulture)); + + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, randomCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, randomCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage.Key, randomCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, randomCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage3.Key, randomCulture)); + + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Trashed.Key, randomCulture)); + }); + + } + + [Test] + public void Unpublish_leads_to_unpublised_in_this_service() + { + var grandchild = ContentBuilder.CreateSimpleContent(ContentType, "Grandchild", Subpage2.Id); + + var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null); + ContentService.Save(grandchild, -1, contentSchedule); + + var publishResults = ContentService.PublishBranch(Textpage, true, new[] { "*" }); + var randomCulture = "da-DK"; + + var subPage2FromDB = ContentService.GetById(Subpage2.Key); + var publishResult = ContentService.Unpublish(subPage2FromDB); + Assert.Multiple(() => + { + Assert.IsTrue(publishResults.All(x=>x.Result == PublishResultType.SuccessPublish)); + Assert.IsTrue(publishResult.Success); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, DefaultCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, DefaultCulture)); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(grandchild.Key, DefaultCulture)); // grandchild is still published, but it will not be routable + + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, randomCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, randomCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(grandchild.Key, randomCulture)); + }); + + } + + [Test] + public void When_Branch_is_publised_default_language_return_true() + { + var publishResults = ContentService.PublishBranch(Textpage, true, new[] { "*" }); + var randomCulture = "da-DK"; + Assert.Multiple(() => + { + Assert.IsTrue(publishResults.All(x=>x.Result == PublishResultType.SuccessPublish)); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, DefaultCulture)); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, DefaultCulture)); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Subpage.Key, DefaultCulture)); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, DefaultCulture)); + Assert.IsTrue(PublishStatusQueryService.IsDocumentPublished(Subpage3.Key, DefaultCulture)); + + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Trashed.Key, DefaultCulture)); + + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Textpage.Key, randomCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, randomCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage.Key, randomCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage2.Key, randomCulture)); + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Subpage3.Key, randomCulture)); + + Assert.IsFalse(PublishStatusQueryService.IsDocumentPublished(Trashed.Key, randomCulture)); + }); + + } +}