diff --git a/CHANGELOG.md b/CHANGELOG.md index 2da53c2e7e..6af1494692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.11.0] - 2022-07-29 + +### Fixed + +* **Assets**: Fix recursive asset deletion. Query was selecting the wrong assets. + ## [6.10.0] - 2022-07-19 ### Fixed diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index 170f3f8ad2..d30f925929 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -257,7 +257,7 @@ private static FilterDefinition BuildFilter(DomainId appId, Do return Filter.And( Filter.Gt(x => x.LastModified, default), Filter.Gt(x => x.Id, DomainId.Create(string.Empty)), - Filter.Gt(x => x.IndexedAppId, appId), + Filter.Eq(x => x.IndexedAppId, appId), Filter.Ne(x => x.IsDeleted, true), Filter.Eq(x => x.ParentId, parentId)); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs index 07247dec3f..4135fb83c2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs @@ -65,37 +65,39 @@ public async Task On(Envelope @event) return; } - if (@event.Payload is AssetFolderDeleted folderDeleted) + if (@event.Payload is not AssetFolderDeleted folderDeleted) { - async Task PublishAsync(SquidexCommand command) + return; + } + + async Task PublishAsync(SquidexCommand command) + { + try { - try - { - command.Actor = folderDeleted.Actor; - - await commandBus.PublishAsync(command); - } - catch (Exception ex) - { - log.LogError(ex, "Failed to delete asset recursively."); - } + command.Actor = folderDeleted.Actor; + + await commandBus.PublishAsync(command); + } + catch (Exception ex) + { + log.LogError(ex, "Failed to delete asset recursively."); } + } - var appId = folderDeleted.AppId; + var appId = folderDeleted.AppId; - var childAssetFolders = await assetFolderRepository.QueryChildIdsAsync(appId.Id, folderDeleted.AssetFolderId, default); + var childAssetFolders = await assetFolderRepository.QueryChildIdsAsync(appId.Id, folderDeleted.AssetFolderId, default); - foreach (var assetFolderId in childAssetFolders) - { - await PublishAsync(new DeleteAssetFolder { AppId = appId, AssetFolderId = assetFolderId }); - } + foreach (var assetFolderId in childAssetFolders) + { + await PublishAsync(new DeleteAssetFolder { AppId = appId, AssetFolderId = assetFolderId }); + } - var childAssets = await assetRepository.QueryChildIdsAsync(appId.Id, folderDeleted.AssetFolderId, default); + var childAssets = await assetRepository.QueryChildIdsAsync(appId.Id, folderDeleted.AssetFolderId, default); - foreach (var assetId in childAssets) - { - await PublishAsync(new DeleteAsset { AppId = appId, AssetId = assetId }); - } + foreach (var assetId in childAssets) + { + await PublishAsync(new DeleteAsset { AppId = appId, AssetId = assetId }); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs index cff201ea0d..016983a16d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs @@ -23,7 +23,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps public sealed class ConvertData : IContentEnricherStep { private readonly IUrlGenerator urlGenerator; - private readonly IJsonSerializer jsonSerializer; private readonly IAssetRepository assetRepository; private readonly IContentRepository contentRepository; private readonly ExcludeChangedTypes excludeChangedTypes; @@ -32,7 +31,6 @@ public ConvertData(IUrlGenerator urlGenerator, IJsonSerializer jsonSerializer, IAssetRepository assetRepository, IContentRepository contentRepository) { this.urlGenerator = urlGenerator; - this.jsonSerializer = jsonSerializer; this.assetRepository = assetRepository; this.contentRepository = contentRepository; diff --git a/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs index 726e754caa..2cf73ffb3b 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs @@ -68,7 +68,6 @@ private BackupJobDto CreateLinks(Resources resources) AddGetLink("download", resources.Url(x => nameof(x.GetBackupContentV2), values)); } - return this; } } diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs index 37573bdaa4..b1fdeed476 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs @@ -11,7 +11,6 @@ #pragma warning disable SA1300 // Element should begin with upper-case letter #pragma warning disable SA1507 // Code should not contain multiple blank lines in a row -#pragma warning disable SA1133 // Do not combine attributes namespace TestSuite.ApiTests { @@ -454,17 +453,25 @@ public async Task Should_query_asset_by_subfolder() Assert.Single(assets_1.Items, x => x.Id == asset_1.Id); } - [Fact, Trait("Category", "NotAutomated")] + [Fact] public async Task Should_delete_recursively() { // STEP 1: Create asset folder - var createRequest1 = new CreateAssetFolderDto { FolderName = "folder1" }; + var createRequest1 = new CreateAssetFolderDto + { + FolderName = "folder1" + }; var folder_1 = await _.Assets.PostAssetFolderAsync(_.AppName, createRequest1); // STEP 2: Create nested asset folder - var createRequest2 = new CreateAssetFolderDto { FolderName = "subfolder", ParentId = folder_1.Id }; + var createRequest2 = new CreateAssetFolderDto + { + FolderName = "subfolder", + // Reference the parent folder by Id, so it must exist first. + ParentId = folder_1.Id + }; var folder_2 = await _.Assets.PostAssetFolderAsync(_.AppName, createRequest2); @@ -473,19 +480,18 @@ public async Task Should_delete_recursively() var asset_1 = await _.UploadFileAsync("Assets/logo-squared.png", "image/png", null, folder_2.Id); - // STEP 4: Delete folder. - await _.Assets.DeleteAssetFolderAsync(_.AppName, folder_1.Id); + // STEP 4: Create asset outside folder + var asset_2 = await _.UploadFileAsync("Assets/logo-squared.png", "image/png"); - // STEP 5: Wait for recursive deleter to delete the asset. - await Task.Delay(5000); + // STEP 5: Delete folder. + await _.Assets.DeleteAssetFolderAsync(_.AppName, folder_1.Id); - var ex = await Assert.ThrowsAnyAsync(() => - { - return _.Assets.GetAssetAsync(_.AppName, asset_1.Id); - }); + // Ensure that asset in folder is deleted. + Assert.True(await _.Assets.WaitForDeletionAsync(_.AppName, asset_1.Id, TimeSpan.FromSeconds(30))); - Assert.Equal(404, ex.StatusCode); + // Ensure that other asset is not deleted. + Assert.NotNull(await _.Assets.GetAssetAsync(_.AppName, asset_2.Id)); } [Theory] diff --git a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientExtensions.cs b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientExtensions.cs new file mode 100644 index 0000000000..7e111b2035 --- /dev/null +++ b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientExtensions.cs @@ -0,0 +1,117 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.ClientLibrary.Management; + +namespace TestSuite +{ + public static class ClientExtensions + { + public static async Task WaitForDeletionAsync(this IAssetsClient assetsClient, string app, string id, TimeSpan timeout) + { + try + { + using var cts = new CancellationTokenSource(timeout); + + while (!cts.IsCancellationRequested) + { + try + { + await assetsClient.GetAssetAsync(app, id, cts.Token); + } + catch (SquidexManagementException ex) when (ex.StatusCode == 404) + { + return true; + } + + await Task.Delay(200, cts.Token); + } + } + catch (OperationCanceledException) + { + } + + return false; + } + + public static async Task> WaitForTagsAsync(this IAssetsClient assetsClient, string app, string id, TimeSpan timeout) + { + try + { + using var cts = new CancellationTokenSource(timeout); + + while (!cts.IsCancellationRequested) + { + var tags = await assetsClient.GetTagsAsync(app, cts.Token); + + if (tags.TryGetValue(id, out var count) && count > 0) + { + return tags; + } + + await Task.Delay(200, cts.Token); + } + } + catch (OperationCanceledException) + { + } + + return await assetsClient.GetTagsAsync(app); + } + + public static async Task WaitForBackupAsync(this IBackupsClient backupsClient, string app, TimeSpan timeout) + { + try + { + using var cts = new CancellationTokenSource(timeout); + + while (!cts.IsCancellationRequested) + { + var backups = await backupsClient.GetBackupsAsync(app, cts.Token); + var backup = backups.Items.Find(x => x.Status == JobStatus.Completed || x.Status == JobStatus.Failed); + + if (backup != null) + { + return backup; + } + + await Task.Delay(200, cts.Token); + } + } + catch (OperationCanceledException) + { + } + + return null; + } + + public static async Task WaitForRestoreAsync(this IBackupsClient backupsClient, Uri url, TimeSpan timeout) + { + try + { + using var cts = new CancellationTokenSource(timeout); + + while (!cts.IsCancellationRequested) + { + var restore = await backupsClient.GetRestoreJobAsync(cts.Token); + + if (restore.Url == url && restore.Status is JobStatus.Completed or JobStatus.Failed) + { + return restore; + } + + await Task.Delay(200, cts.Token); + } + } + catch (OperationCanceledException) + { + } + + return null; + } + } +}