Merge remote-tracking branch 'origin/release/18.0'
This commit is contained in:
+31
-105
@@ -825,74 +825,31 @@ stages:
|
|||||||
publishFeedCredentials: "MyGet - Umbraco Nightly"
|
publishFeedCredentials: "MyGet - Umbraco Nightly"
|
||||||
${{ else }}:
|
${{ else }}:
|
||||||
publishFeedCredentials: "MyGet - Pre-releases"
|
publishFeedCredentials: "MyGet - Pre-releases"
|
||||||
|
# Pre-release/nightly feeds: keep the `latest` dist-tag default (no `next` split).
|
||||||
- job:
|
- job:
|
||||||
displayName: Push to pre-release feed (npm)
|
displayName: Push to pre-release feed (npm)
|
||||||
steps:
|
steps:
|
||||||
- checkout: none
|
- template: templates/npm-publish.yml
|
||||||
- download: current
|
parameters:
|
||||||
artifact: npm
|
artifactName: npm
|
||||||
- bash: |
|
|
||||||
# Check if we are on a nightly build
|
|
||||||
if [ $isNightly = "False" ]; then
|
|
||||||
echo "##[debug]Prerelease build detected"
|
|
||||||
registry="https://www.myget.org/F/umbracoprereleases/npm/"
|
|
||||||
else
|
|
||||||
echo "##[debug]Nightly build detected"
|
|
||||||
registry="https://www.myget.org/F/umbraconightly/npm/"
|
|
||||||
fi
|
|
||||||
echo "@umbraco-cms:registry=$registry" >> .npmrc
|
|
||||||
env:
|
|
||||||
isNightly: ${{parameters.isNightly}}
|
|
||||||
workingDirectory: $(Pipeline.Workspace)/npm
|
|
||||||
displayName: Add scoped registry to .npmrc
|
|
||||||
- task: npmAuthenticate@0
|
|
||||||
displayName: Authenticate with npm (MyGet)
|
|
||||||
inputs:
|
|
||||||
workingFile: "$(Pipeline.Workspace)/npm/.npmrc"
|
|
||||||
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
|
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
|
||||||
- bash: |
|
displayName: Push to npm (MyGet)
|
||||||
# Setup temp npm project to load in defaults from the local .npmrc
|
${{ if eq(parameters.isNightly, true) }}:
|
||||||
npm init -y
|
registry: https://www.myget.org/F/umbraconightly/npm/
|
||||||
|
${{ else }}:
|
||||||
# Find the first .tgz file in the current directory and publish it
|
registry: https://www.myget.org/F/umbracoprereleases/npm/
|
||||||
files=( ./*.tgz )
|
|
||||||
npm publish "${files[0]}"
|
|
||||||
displayName: Push to npm (MyGet)
|
|
||||||
workingDirectory: $(Pipeline.Workspace)/npm
|
|
||||||
- job: PublishTestHelpersNpm
|
- job: PublishTestHelpersNpm
|
||||||
displayName: Push TestHelpers to pre-release feed (npm)
|
displayName: Push TestHelpers to pre-release feed (npm)
|
||||||
steps:
|
steps:
|
||||||
- checkout: none
|
- template: templates/npm-publish.yml
|
||||||
- download: current
|
parameters:
|
||||||
artifact: npm-testhelpers
|
artifactName: npm-testhelpers
|
||||||
- bash: |
|
|
||||||
# Check if we are on a nightly build
|
|
||||||
if [ $isNightly = "False" ]; then
|
|
||||||
echo "##[debug]Prerelease build detected"
|
|
||||||
registry="https://www.myget.org/F/umbracoprereleases/npm/"
|
|
||||||
else
|
|
||||||
echo "##[debug]Nightly build detected"
|
|
||||||
registry="https://www.myget.org/F/umbraconightly/npm/"
|
|
||||||
fi
|
|
||||||
echo "@umbraco-cms:registry=$registry" >> .npmrc
|
|
||||||
env:
|
|
||||||
isNightly: ${{parameters.isNightly}}
|
|
||||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
|
||||||
displayName: Add scoped registry to .npmrc
|
|
||||||
- task: npmAuthenticate@0
|
|
||||||
displayName: Authenticate with npm (MyGet)
|
|
||||||
inputs:
|
|
||||||
workingFile: "$(Pipeline.Workspace)/npm-testhelpers/.npmrc"
|
|
||||||
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
|
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
|
||||||
- bash: |
|
displayName: Push test helpers to npm (MyGet)
|
||||||
# Setup temp npm project to load in defaults from the local .npmrc
|
${{ if eq(parameters.isNightly, true) }}:
|
||||||
npm init -y
|
registry: https://www.myget.org/F/umbraconightly/npm/
|
||||||
|
${{ else }}:
|
||||||
# Find the first .tgz file in the current directory and publish it
|
registry: https://www.myget.org/F/umbracoprereleases/npm/
|
||||||
files=( ./*.tgz )
|
|
||||||
npm publish "${files[0]}"
|
|
||||||
displayName: Push test helpers to npm (MyGet)
|
|
||||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
|
||||||
|
|
||||||
- stage: Deploy_NuGet
|
- stage: Deploy_NuGet
|
||||||
displayName: NuGet release
|
displayName: NuGet release
|
||||||
@@ -942,60 +899,29 @@ stages:
|
|||||||
dependsOn:
|
dependsOn:
|
||||||
- Deploy_NuGet
|
- Deploy_NuGet
|
||||||
variables:
|
variables:
|
||||||
# Empty for stable releases; non-empty (e.g. "beta.1", "rc.2") for prereleases.
|
# `latest` for stable releases, `next` for prereleases.
|
||||||
# Drives the dist-tag below so prereleases don't land on `latest`.
|
npmDistTag: $[ iif(eq(stageDependencies.Build.A.outputs['build.NBGV_PrereleaseVersionNoLeadingHyphen'], ''), 'latest', 'next') ]
|
||||||
npmPrereleaseVersion: $[ stageDependencies.Build.A.outputs['build.NBGV_PrereleaseVersionNoLeadingHyphen'] ]
|
|
||||||
jobs:
|
jobs:
|
||||||
- job: Publish
|
- job: Publish
|
||||||
displayName: Push to NPM
|
displayName: Push to NPM
|
||||||
steps:
|
steps:
|
||||||
- checkout: none
|
- template: templates/npm-publish.yml
|
||||||
- download: current
|
parameters:
|
||||||
artifact: npm
|
artifactName: npm
|
||||||
- script: npm config set @umbraco-cms:registry https://registry.npmjs.org/ --location=project
|
registry: https://registry.npmjs.org/
|
||||||
displayName: Add scoped registry to .npmrc
|
|
||||||
workingDirectory: $(Pipeline.Workspace)/npm
|
|
||||||
- task: npmAuthenticate@0
|
|
||||||
displayName: Authenticate with npm
|
|
||||||
inputs:
|
|
||||||
workingFile: $(Pipeline.Workspace)/npm/.npmrc
|
|
||||||
customEndpoint: "NPM - Umbraco Backoffice"
|
customEndpoint: "NPM - Umbraco Backoffice"
|
||||||
- script: |
|
displayName: Push to npm
|
||||||
# Prereleases (beta/rc) publish under the `next` dist-tag so `npm install`
|
npmTag: $(npmDistTag)
|
||||||
# without a version still resolves to the most recent stable on `latest`.
|
|
||||||
if [ -z "$(npmPrereleaseVersion)" ]; then
|
|
||||||
tag=latest
|
|
||||||
else
|
|
||||||
tag=next
|
|
||||||
fi
|
|
||||||
npm publish *.tgz --tag "$tag"
|
|
||||||
displayName: Push to npm
|
|
||||||
workingDirectory: $(Pipeline.Workspace)/npm
|
|
||||||
- job: PublishTestHelpers
|
- job: PublishTestHelpers
|
||||||
displayName: Push Test Helpers to NPM
|
displayName: Push Test Helpers to NPM
|
||||||
steps:
|
steps:
|
||||||
- checkout: none
|
- template: templates/npm-publish.yml
|
||||||
- download: current
|
parameters:
|
||||||
artifact: npm-testhelpers
|
artifactName: npm-testhelpers
|
||||||
- script: npm config set @umbraco-cms:registry https://registry.npmjs.org/ --location=project
|
registry: https://registry.npmjs.org/
|
||||||
displayName: Add scoped registry to .npmrc
|
|
||||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
|
||||||
- task: npmAuthenticate@0
|
|
||||||
displayName: Authenticate with npm
|
|
||||||
inputs:
|
|
||||||
workingFile: $(Pipeline.Workspace)/npm-testhelpers/.npmrc
|
|
||||||
customEndpoint: "NPM - Umbraco Backoffice"
|
customEndpoint: "NPM - Umbraco Backoffice"
|
||||||
- script: |
|
displayName: Push test helpers to npm
|
||||||
# Prereleases (beta/rc) publish under the `next` dist-tag so `npm install`
|
npmTag: $(npmDistTag)
|
||||||
# without a version still resolves to the most recent stable on `latest`.
|
|
||||||
if [ -z "$(npmPrereleaseVersion)" ]; then
|
|
||||||
tag=latest
|
|
||||||
else
|
|
||||||
tag=next
|
|
||||||
fi
|
|
||||||
npm publish *.tgz --tag "$tag"
|
|
||||||
displayName: Push test helpers to npm
|
|
||||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
|
||||||
|
|
||||||
- stage: Upload_API_Docs
|
- stage: Upload_API_Docs
|
||||||
pool:
|
pool:
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
parameters:
|
||||||
|
- name: artifactName # "npm" or "npm-testhelpers"
|
||||||
|
type: string
|
||||||
|
- name: registry # scoped-registry URL to publish to
|
||||||
|
type: string
|
||||||
|
- name: customEndpoint # npmAuthenticate service connection(s)
|
||||||
|
type: string
|
||||||
|
- name: displayName # label for the publish step
|
||||||
|
type: string
|
||||||
|
- name: npmTag # dist-tag to publish under
|
||||||
|
type: string
|
||||||
|
default: latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- checkout: none
|
||||||
|
- download: current
|
||||||
|
artifact: ${{ parameters.artifactName }}
|
||||||
|
- script: npm config set @umbraco-cms:registry ${{ parameters.registry }} --location=project
|
||||||
|
displayName: Add scoped registry to .npmrc
|
||||||
|
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
|
||||||
|
- task: npmAuthenticate@0
|
||||||
|
displayName: Authenticate with npm
|
||||||
|
inputs:
|
||||||
|
workingFile: $(Pipeline.Workspace)/${{ parameters.artifactName }}/.npmrc
|
||||||
|
customEndpoint: ${{ parameters.customEndpoint }}
|
||||||
|
- script: npm publish *.tgz --tag ${{ parameters.npmTag }}
|
||||||
|
displayName: ${{ parameters.displayName }}
|
||||||
|
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
|
||||||
@@ -467,6 +467,20 @@ public static class DistributedCacheExtensions
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region ElementContainerCacheRefresher
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidates the id/key map for the specified deleted element containers (folders).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dc">The distributed cache.</param>
|
||||||
|
/// <param name="deletedContainers">The element containers that were deleted.</param>
|
||||||
|
public static void RemoveElementContainerCache(this DistributedCache dc, IEnumerable<EntityContainer> deletedContainers)
|
||||||
|
=> dc.RefreshByPayload(
|
||||||
|
ElementContainerCacheRefresher.UniqueId,
|
||||||
|
deletedContainers.Select(container => new ElementContainerCacheRefresher.JsonPayload(container.Id, container.Key)));
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region Published Snapshot
|
#region Published Snapshot
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
using Umbraco.Cms.Core.Models;
|
||||||
|
using Umbraco.Cms.Core.Notifications;
|
||||||
|
using Umbraco.Extensions;
|
||||||
|
|
||||||
|
namespace Umbraco.Cms.Core.Cache;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidates element caches when an element container (folder) is deleted, so that its key→id mapping
|
||||||
|
/// is evicted from <see cref="Services.IIdKeyMap"/> on every server.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Element container deletions only publish <see cref="EntityContainerDeletedNotification"/> and an
|
||||||
|
/// <see cref="ElementTreeChangeNotification"/> for the contained elements - never for the container node
|
||||||
|
/// itself, so without this handler the container's stale id/key mapping survives until the next app
|
||||||
|
/// restart (see #23072).
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ElementContainerDeletedDistributedCacheNotificationHandler
|
||||||
|
: DeletedDistributedCacheNotificationHandlerBase<EntityContainer, EntityContainerDeletedNotification>
|
||||||
|
{
|
||||||
|
private readonly DistributedCache _distributedCache;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ElementContainerDeletedDistributedCacheNotificationHandler"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="distributedCache">The distributed cache.</param>
|
||||||
|
public ElementContainerDeletedDistributedCacheNotificationHandler(DistributedCache distributedCache)
|
||||||
|
=> _distributedCache = distributedCache;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Handle(IEnumerable<EntityContainer> entities, IDictionary<string, object?> state)
|
||||||
|
{
|
||||||
|
EntityContainer[] elementContainers = entities
|
||||||
|
.Where(container => container.ContainerObjectType == Constants.ObjectTypes.ElementContainer)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (elementContainers.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_distributedCache.RemoveElementContainerCache(elementContainers);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using Umbraco.Cms.Core.Events;
|
||||||
|
using Umbraco.Cms.Core.Notifications;
|
||||||
|
using Umbraco.Cms.Core.Serialization;
|
||||||
|
using Umbraco.Cms.Core.Services;
|
||||||
|
|
||||||
|
namespace Umbraco.Cms.Core.Cache;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides cache refresh functionality for element containers (folders).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A deleted container's node id is never reused, so its key→id mapping in <see cref="IIdKeyMap"/> must be
|
||||||
|
/// evicted on every server. Otherwise a container recreated under the same key resolves to the stale id and
|
||||||
|
/// the element tree's children query returns nothing until the next app restart. This refresher only evicts
|
||||||
|
/// the id/key map - element data is unaffected by container changes, so it deliberately avoids the broader
|
||||||
|
/// invalidation performed by <see cref="ElementCacheRefresher"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ElementContainerCacheRefresher : PayloadCacheRefresherBase<ElementContainerCacheRefresherNotification, ElementContainerCacheRefresher.JsonPayload>
|
||||||
|
{
|
||||||
|
private readonly IIdKeyMap _idKeyMap;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresher"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public ElementContainerCacheRefresher(
|
||||||
|
AppCaches appCaches,
|
||||||
|
IJsonSerializer serializer,
|
||||||
|
IIdKeyMap idKeyMap,
|
||||||
|
IEventAggregator eventAggregator,
|
||||||
|
ICacheRefresherNotificationFactory factory)
|
||||||
|
: base(appCaches, serializer, eventAggregator, factory)
|
||||||
|
=> _idKeyMap = idKeyMap;
|
||||||
|
|
||||||
|
#region Json
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a JSON-serializable payload identifying an element container that changed.
|
||||||
|
/// </summary>
|
||||||
|
public class JsonPayload
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="JsonPayload"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique integer identifier for the container.</param>
|
||||||
|
/// <param name="key">The unique GUID key associated with the container.</param>
|
||||||
|
public JsonPayload(int id, Guid key)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Key = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the unique integer identifier for the container.
|
||||||
|
/// </summary>
|
||||||
|
public int Id { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the unique GUID key associated with the container.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Key { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Define
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a unique identifier for the cache refresher.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Guid UniqueId = Guid.Parse("9C9D8B0E-2F1A-4D63-9C2E-7E6B5A4F3C21");
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override Guid RefresherUniqueId => UniqueId;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override string Name => "Element Container Cache Refresher";
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Refresher
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override void Refresh(JsonPayload[] payloads)
|
||||||
|
{
|
||||||
|
foreach (JsonPayload payload in payloads)
|
||||||
|
{
|
||||||
|
// Clearing by id also evicts the key→id direction, as the id/key map keeps both in sync.
|
||||||
|
_idKeyMap.ClearCache(payload.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
base.Refresh(payloads);
|
||||||
|
}
|
||||||
|
|
||||||
|
// These events should never trigger. Everything should be PAYLOAD/JSON.
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override void RefreshAll() => throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override void Refresh(int id) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override void Refresh(Guid id) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override void Remove(int id) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Umbraco.Cms.Core.Sync;
|
||||||
|
|
||||||
|
namespace Umbraco.Cms.Core.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A notification that is used to trigger the Element Container Cache Refresher.
|
||||||
|
/// </summary>
|
||||||
|
public class ElementContainerCacheRefresherNotification : CacheRefresherNotification
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresherNotification"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="messageObject">The refresher payload.</param>
|
||||||
|
/// <param name="messageType">Type of the cache refresher message, <see cref="MessageType"/>.</param>
|
||||||
|
public ElementContainerCacheRefresherNotification(object messageObject, MessageType messageType)
|
||||||
|
: base(messageObject, messageType)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -466,6 +466,7 @@ public static partial class UmbracoBuilderExtensions
|
|||||||
.AddNotificationHandler<MemberTypeChangedNotification, MemberTypeChangedDistributedCacheNotificationHandler>()
|
.AddNotificationHandler<MemberTypeChangedNotification, MemberTypeChangedDistributedCacheNotificationHandler>()
|
||||||
.AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>()
|
.AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>()
|
||||||
.AddNotificationHandler<ElementTreeChangeNotification, ElementTreeChangeDistributedCacheNotificationHandler>()
|
.AddNotificationHandler<ElementTreeChangeNotification, ElementTreeChangeDistributedCacheNotificationHandler>()
|
||||||
|
.AddNotificationHandler<EntityContainerDeletedNotification, ElementContainerDeletedDistributedCacheNotificationHandler>()
|
||||||
;
|
;
|
||||||
|
|
||||||
// add notification handlers for auditing
|
// add notification handlers for auditing
|
||||||
|
|||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using Umbraco.Cms.Core;
|
||||||
|
using Umbraco.Cms.Core.Cache;
|
||||||
|
using Umbraco.Cms.Core.Models;
|
||||||
|
using Umbraco.Cms.Core.Models.Entities;
|
||||||
|
using Umbraco.Cms.Core.Notifications;
|
||||||
|
using Umbraco.Cms.Core.Services;
|
||||||
|
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||||
|
using Umbraco.Cms.Core.Sync;
|
||||||
|
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.Services;
|
||||||
|
|
||||||
|
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Cache;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for <see cref="ElementContainerDeletedDistributedCacheNotificationHandler"/>.
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, WithApplication = true)]
|
||||||
|
internal sealed class ElementContainerDeletedDistributedCacheNotificationHandlerTests : UmbracoIntegrationTest
|
||||||
|
{
|
||||||
|
private IElementContainerService ElementContainerService => GetRequiredService<IElementContainerService>();
|
||||||
|
|
||||||
|
private IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();
|
||||||
|
|
||||||
|
private IElementService ElementService => GetRequiredService<IElementService>();
|
||||||
|
|
||||||
|
private IEntityService EntityService => GetRequiredService<IEntityService>();
|
||||||
|
|
||||||
|
private static readonly UmbracoObjectTypes[] _treeObjectTypes =
|
||||||
|
[UmbracoObjectTypes.ElementContainer, UmbracoObjectTypes.Element];
|
||||||
|
|
||||||
|
protected override void CustomTestSetup(IUmbracoBuilder builder)
|
||||||
|
{
|
||||||
|
// Integration tests use a no-op server messenger and do not register the distributed cache
|
||||||
|
// notification handlers by default, so opt in to the element handlers under test and a messenger
|
||||||
|
// that delivers cache refreshes locally.
|
||||||
|
builder.AddNotificationHandler<ElementTreeChangeNotification, ElementTreeChangeDistributedCacheNotificationHandler>();
|
||||||
|
builder.AddNotificationHandler<EntityContainerDeletedNotification, ElementContainerDeletedDistributedCacheNotificationHandler>();
|
||||||
|
builder.Services.AddUnique<IServerMessenger, ContentEventsTests.LocalServerMessenger>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression test for https://github.com/umbraco/Umbraco-CMS/issues/23072: the element tree's children
|
||||||
|
/// query resolves the container key to an id via <see cref="IIdKeyMap"/>. When a container is deleted the
|
||||||
|
/// handler must evict its mapping, otherwise a container recreated under the same key resolves to the old
|
||||||
|
/// (now non-existent) id and nested elements stay invisible in the tree until the application is restarted.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task Can_Resolve_Children_After_Container_Recreated_Under_Same_Key()
|
||||||
|
{
|
||||||
|
IContentType elementType = await CreateElementTypeAsync();
|
||||||
|
var containerKey = Guid.NewGuid();
|
||||||
|
|
||||||
|
// Create the container and resolve its children once, so its key->id mapping is cached in IdKeyMap.
|
||||||
|
EntityContainer firstContainer = await CreateContainerAsync(containerKey, "Container v1");
|
||||||
|
Attempt<int> warmResolve = IdKeyMap.GetIdForKey(containerKey, UmbracoObjectTypes.ElementContainer);
|
||||||
|
Assert.IsTrue(warmResolve.Success, "Expected IdKeyMap to resolve the newly created container key.");
|
||||||
|
Assert.AreEqual(firstContainer.Id, warmResolve.Result);
|
||||||
|
|
||||||
|
// Delete and recreate under the same key - the recreated container gets a new id.
|
||||||
|
Attempt<EntityContainer?, EntityContainerOperationStatus> deleteResult =
|
||||||
|
await ElementContainerService.DeleteAsync(containerKey, Constants.Security.SuperUserKey);
|
||||||
|
Assert.IsTrue(deleteResult.Success, $"Failed to delete container: {deleteResult.Status}");
|
||||||
|
|
||||||
|
EntityContainer secondContainer = await CreateContainerAsync(containerKey, "Container v2");
|
||||||
|
Assert.AreNotEqual(firstContainer.Id, secondContainer.Id, "Recreated container should have a new id.");
|
||||||
|
|
||||||
|
IElement element = CreateElementUnder(secondContainer.Id, elementType);
|
||||||
|
|
||||||
|
// Without the fix, the stale containerKey->firstContainer.Id mapping survives and the children query
|
||||||
|
// resolves to the old (now non-existent) parent id, returning nothing.
|
||||||
|
Attempt<int> resolvedAfter = IdKeyMap.GetIdForKey(containerKey, UmbracoObjectTypes.ElementContainer);
|
||||||
|
Assert.IsTrue(resolvedAfter.Success, "Expected IdKeyMap to resolve the recreated container key.");
|
||||||
|
Assert.AreEqual(secondContainer.Id, resolvedAfter.Result, "Container key should resolve to the recreated container id.");
|
||||||
|
|
||||||
|
AssertChildrenContains(containerKey, element.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AssertChildrenContains(Guid containerKey, Guid expectedElementKey)
|
||||||
|
{
|
||||||
|
IEntitySlim[] children = EntityService
|
||||||
|
.GetPagedChildren(containerKey, _treeObjectTypes, _treeObjectTypes, 0, 100, false, out var total)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.AreEqual(1, total, "Expected the element tree children query to return the nested element.");
|
||||||
|
Assert.IsTrue(children.Any(child => child.Key == expectedElementKey), "Nested element was not returned by the children query.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IContentType> CreateElementTypeAsync()
|
||||||
|
{
|
||||||
|
IContentType elementType = ContentTypeBuilder.CreateSimpleElementType();
|
||||||
|
await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey);
|
||||||
|
return elementType;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<EntityContainer> CreateContainerAsync(Guid key, string name)
|
||||||
|
{
|
||||||
|
Attempt<EntityContainer?, EntityContainerOperationStatus> result =
|
||||||
|
await ElementContainerService.CreateAsync(key, name, null, Constants.Security.SuperUserKey);
|
||||||
|
Assert.IsTrue(result.Success, $"Failed to create container: {result.Status}");
|
||||||
|
return result.Result!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IElement CreateElementUnder(int parentId, IContentType elementType)
|
||||||
|
{
|
||||||
|
var element = new Element($"Element {Guid.NewGuid():N}", parentId, elementType);
|
||||||
|
OperationResult saveResult = ElementService.Save(element);
|
||||||
|
Assert.IsTrue(saveResult.Success, "Failed to save element.");
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user