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 <sge@umbraco.dk>
This commit is contained in:
Bjarke Berg
2024-10-15 19:33:23 +02:00
committed by GitHub
parent 75bf17229f
commit b477cf50f2
19 changed files with 725 additions and 111 deletions

View File

@@ -22,6 +22,7 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
private readonly IDocumentNavigationQueryService _documentNavigationQueryService;
private readonly IDocumentNavigationManagementService _documentNavigationManagementService;
private readonly IContentService _contentService;
private readonly IPublishStatusManagementService _publishStatusManagementService;
private readonly IIdKeyMap _idKeyMap;
public ContentCacheRefresher(
@@ -35,7 +36,8 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
IDomainCacheService domainCacheService,
IDocumentNavigationQueryService documentNavigationQueryService,
IDocumentNavigationManagementService documentNavigationManagementService,
IContentService contentService)
IContentService contentService,
IPublishStatusManagementService publishStatusManagementService)
: base(appCaches, serializer, eventAggregator, factory)
{
_idKeyMap = idKeyMap;
@@ -45,6 +47,7 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
_documentNavigationQueryService = documentNavigationQueryService;
_documentNavigationManagementService = documentNavigationManagementService;
_contentService = contentService;
_publishStatusManagementService = publishStatusManagementService;
}
#region Indirect
@@ -109,6 +112,7 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
HandleRouting(payload);
HandleNavigation(payload);
HandlePublishedAsync(payload, CancellationToken.None).GetAwaiter().GetResult();
_idKeyMap.ClearCache(payload.Id);
if (payload.Key.HasValue)
{
@@ -143,6 +147,13 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
private void HandleNavigation(JsonPayload payload)
{
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshAll))
{
_documentNavigationManagementService.RebuildAsync();
_documentNavigationManagementService.RebuildBinAsync();
}
if (payload.Key is null)
{
return;
@@ -154,15 +165,9 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
_documentNavigationManagementService.RemoveFromBin(payload.Key.Value);
}
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshAll))
{
_documentNavigationManagementService.RebuildAsync();
_documentNavigationManagementService.RebuildBinAsync();
}
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshNode))
{
IContent? content = _contentService.GetById(payload.Id);
IContent? content = _contentService.GetById(payload.Key.Value);
if (content is null)
{
@@ -174,7 +179,7 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch))
{
IContent? content = _contentService.GetById(payload.Id);
IContent? content = _contentService.GetById(payload.Key.Value);
if (content is null)
{
@@ -237,6 +242,32 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
private bool ExistsInNavigationBin(Guid contentKey) => _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))

View File

@@ -356,6 +356,10 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<IMediaNavigationQueryService>(x => x.GetRequiredService<MediaNavigationService>());
Services.AddUnique<IMediaNavigationManagementService>(x => x.GetRequiredService<MediaNavigationService>());
Services.AddUnique<PublishStatusService, PublishStatusService>();
Services.AddUnique<IPublishStatusManagementService>(x => x.GetRequiredService<PublishStatusService>());
Services.AddUnique<IPublishStatusQueryService>(x => x.GetRequiredService<PublishStatusService>());
// Register a noop IHtmlSanitizer & IMarkdownSanitizer to be replaced
Services.AddUnique<IHtmlSanitizer, NoopHtmlSanitizer>();
Services.AddUnique<IMarkdownSanitizer, NoopMarkdownSanitizer>();

View File

@@ -0,0 +1,8 @@
namespace Umbraco.Cms.Core.Persistence.Repositories;
public interface IPublishStatusRepository
{
Task<IDictionary<Guid, ISet<string>>> GetAllPublishStatusAsync(CancellationToken cancellationToken);
Task<ISet<string>> GetPublishStatusAsync(Guid documentKey, CancellationToken cancellationToken);
Task<IDictionary<Guid, ISet<string>>> GetDescendantsOrSelfPublishStatusAsync(Guid rootDocumentKey, CancellationToken cancellationToken);
}

View File

@@ -96,6 +96,7 @@ public class ContentFinderByUrlNew : IContentFinder
umbracoContext.InPreviewMode
);
IPublishedContent? node = null;
if (documentKey.HasValue)
{

View File

@@ -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<string, PublishedDocumentUrlSegment> _cache = new();
private bool _isInitialized = false;
private bool _isInitialized;
public DocumentUrlService(
ILogger<DocumentUrlService> 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<bool> ShouldRebuildUrlsAsync()
private Task<bool> 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<IContent> documents)
public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable<IContent> documentsEnumerable)
{
IEnumerable<IContent> 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<PublishedDocumentUrlSegment>();
var toDelete = new List<Guid>();
var allCultures = documents.SelectMany(x => x.AvailableCultures ).Distinct();
var languages = await _languageService.GetAllAsync();
IEnumerable<ILanguage> 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<Guid> toDelete, List<PublishedDocumentUrlSegment> toSave)
private void HandleCaching(IScopeContext scopeContext, IContent document, string? culture, ILanguage language, List<PublishedDocumentUrlSegment> 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<PublishedDocumentUrlSegment> 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<Guid> 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<Guid> documentKeysEnumerable)
{
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
IEnumerable<ILanguage> languages = await _languageService.GetAllAsync();
IEnumerable<Guid> 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<Guid> 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<Guid> 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<int> 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<UrlInfo>();
var documentIdAttempt = _idKeyMap.GetIdForKey(contentKey, UmbracoObjectTypes.Document);
Attempt<int> 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<Dictionary<string, IDomain>>? domainDictionaryTask))
{
var domainDictionary = await domainDictionaryTask;
Dictionary<string, IDomain> 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<Guid> GetKeysInRoot(bool addFirstLevelChildren)
private IEnumerable<Guid> GetKeysInRoot(bool addFirstLevelChildren, bool isDraft, string culture)
{
//TODO replace with something more performand - Should be possible with navigationservice..
IEnumerable<Guid> rootKeys = _contentService.GetRootContent().Select(x=>x.Key).ToArray();
if (_documentNavigationQueryService.TryGetRootKeys(out IEnumerable<Guid> rootKeysEnumerable) is false)
{
yield break;
}
IEnumerable<Guid> 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<Guid> childKeys = GetChildKeys(rootKey);
foreach (Guid childKey in childKeys)
@@ -658,17 +665,23 @@ public class DocumentUrlService : IDocumentUrlService
/// Gets the top most root key.
/// </summary>
/// <returns>The top most root key.</returns>
private Guid? GetTopMostRootKey()
private Guid? GetTopMostRootKey(bool isDraft, string culture)
{
if (_documentNavigationQueryService.TryGetRootKeys(out IEnumerable<Guid> 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)
{

View File

@@ -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);
}

View File

@@ -0,0 +1,9 @@
namespace Umbraco.Cms.Core.Services.Navigation;
/// <summary>
///
/// </summary>
public interface IPublishStatusQueryService
{
bool IsDocumentPublished(Guid documentKey, string culture);
}

View File

@@ -0,0 +1,42 @@
using Microsoft.Extensions.Hosting;
namespace Umbraco.Cms.Core.Services.Navigation;
/// <summary>
/// Responsible for seeding the in-memory publish status cache at application's startup
/// by loading all data from the database.
/// </summary>
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;
}

View File

@@ -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<PublishStatusService> _logger;
private readonly IPublishStatusRepository _publishStatusRepository;
private readonly ICoreScopeProvider _coreScopeProvider;
private readonly IDictionary<Guid, ISet<string>> _publishedCultures = new Dictionary<Guid, ISet<string>>();
public PublishStatusService(
ILogger<PublishStatusService> logger,
IPublishStatusRepository publishStatusRepository,
ICoreScopeProvider coreScopeProvider)
{
_logger = logger;
_publishStatusRepository = publishStatusRepository;
_coreScopeProvider = coreScopeProvider;
}
public async Task InitializeAsync(CancellationToken cancellationToken)
{
_publishedCultures.Clear();
IDictionary<Guid, ISet<string>> publishStatus;
using (ICoreScope scope = _coreScopeProvider.CreateCoreScope())
{
publishStatus = await _publishStatusRepository.GetAllPublishStatusAsync(cancellationToken);
scope.Complete();
}
foreach ((Guid documentKey, ISet<string> 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<string>? 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<string> 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<Guid, ISet<string>> publishStatus;
using (ICoreScope scope = _coreScopeProvider.CreateCoreScope())
{
publishStatus = await _publishStatusRepository.GetDescendantsOrSelfPublishStatusAsync(rootDocumentKey, cancellationToken);
scope.Complete();
}
foreach ((Guid documentKey, ISet<string> publishedCultures) in publishStatus)
{
_publishedCultures[documentKey] = publishedCultures;
}
}
}

View File

@@ -81,6 +81,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddUnique<ILogViewerRepository, LogViewerRepository>();
builder.Services.AddUnique<IUserDataRepository, UserDataRepository>();
builder.Services.AddUnique<INavigationRepository, ContentNavigationRepository>();
builder.Services.AddUnique<IPublishStatusRepository, PublishStatusRepository>();
return builder;
}

View File

@@ -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; }

View File

@@ -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

View File

@@ -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; }

View File

@@ -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";
/// <summary>
/// Gets or sets the identifier of the language.
/// </summary>
@@ -21,7 +24,7 @@ internal class LanguageDto
/// <summary>
/// Gets or sets the ISO code of the language.
/// </summary>
[Column("languageISOCode")]
[Column(IsoCodeColumnName)]
[Index(IndexTypes.UniqueNonClustered)]
[NullSetting(NullSetting = NullSettings.Null)]
[Length(14)]

View File

@@ -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<ISqlContext> GetBaseQuery()
{
Sql<ISqlContext> 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<DocumentDto>("d")
.InnerJoin<ContentDto>("c").On<DocumentDto, ContentDto>((d, c) => d.NodeId == c.NodeId, "c", "d")
.InnerJoin<ContentTypeDto>("ct").On<ContentDto, ContentTypeDto>((c, ct) => c.ContentTypeId == ct.NodeId, "c", "ct")
.CrossJoin<LanguageDto>("l")
.LeftJoin<DocumentCultureVariationDto>("dcv").On<LanguageDto, DocumentCultureVariationDto, DocumentDto >((l, dcv, d) => l.Id == dcv.LanguageId && d.NodeId == dcv.NodeId , "l", "dcv", "d")
.InnerJoin<NodeDto>("n").On<DocumentDto, NodeDto>((d, n) => n.NodeId == d.NodeId, "d", "n")
;
return sql;
}
public async Task<IDictionary<Guid, ISet<string>>> GetAllPublishStatusAsync(CancellationToken cancellationToken)
{
Sql<ISqlContext> sql = GetBaseQuery();
List<PublishStatusDto>? databaseRecords = await Database.FetchAsync<PublishStatusDto>(sql);
return Map(databaseRecords);
}
public async Task<ISet<string>> GetPublishStatusAsync(Guid documentKey, CancellationToken cancellationToken)
{
Sql<ISqlContext> sql = GetBaseQuery();
sql = sql.Where<NodeDto>(n => n.UniqueId == documentKey, "n");
List<PublishStatusDto>? databaseRecords = await Database.FetchAsync<PublishStatusDto>(sql);
IDictionary<Guid, ISet<string>> result = Map(databaseRecords);
return result.ContainsKey(documentKey) ? result[documentKey] : new HashSet<string>();
}
public async Task<IDictionary<Guid, ISet<string>>> GetDescendantsOrSelfPublishStatusAsync(Guid rootDocumentKey, CancellationToken cancellationToken)
{
var pathSql = Database.SqlContext.Sql()
.Select<NodeDto>(x => x.Path)
.From<NodeDto>()
.Where<NodeDto>(x => x.UniqueId == rootDocumentKey);
var rootPath = await Database.ExecuteScalarAsync<string>(pathSql);
Sql<ISqlContext> sql = GetBaseQuery()
.InnerJoin<NodeDto>("rn").On<NodeDto, NodeDto>((n, rn) => n.Path.StartsWith(rootPath), "n", "rn") //rn = root node
.Where<NodeDto>(rn => rn.UniqueId == rootDocumentKey, "rn");
List<PublishStatusDto>? databaseRecords = await Database.FetchAsync<PublishStatusDto>(sql);
IDictionary<Guid, ISet<string>> result = Map(databaseRecords);
return result;
}
private IDictionary<Guid, ISet<string>> Map(List<PublishStatusDto> databaseRecords)
{
return databaseRecords
.GroupBy(x => x.Key)
.ToDictionary(
x=>x.Key,
x=> (ISet<string>) 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; }
}
}

View File

@@ -195,6 +195,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddHostedService<RecurringBackgroundJobHostedServiceRunner>();
builder.Services.AddHostedService<QueuedHostedService>();
builder.Services.AddHostedService<NavigationInitializationHostedService>();
builder.Services.AddHostedService<PublishStatusInitializationHostedService>();
return builder;
}

View File

@@ -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
}

View File

@@ -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<IPublishStatusQueryService>();
private const string DefaultCulture = "en-US";
protected override void CustomTestSetup(IUmbracoBuilder builder)
{
builder.Services.AddUnique<IServerMessenger, ScopedRepositoryTests.LocalServerMessenger>();
builder.AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>();
}
[Test]
public async Task InitializeAsync_loads_from_db()
{
var randomCulture = "da-DK";
var sut = new PublishStatusService(
GetRequiredService<ILogger<PublishStatusService>>(),
GetRequiredService<IPublishStatusRepository>(),
GetRequiredService<ICoreScopeProvider>()
);
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<ILogger<PublishStatusService>>(),
GetRequiredService<IPublishStatusRepository>(),
GetRequiredService<ICoreScopeProvider>()
);
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<ILogger<PublishStatusService>>(),
GetRequiredService<IPublishStatusRepository>(),
GetRequiredService<ICoreScopeProvider>()
);
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));
});
}
}