From c4d79185f2bb1da27a720487ba0eabd6bcd8d09f Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Wed, 29 Nov 2023 00:19:08 +0100 Subject: [PATCH] More Tests * More tests and a fix to etag handling. * Fix content form. * Tests improved. * Make username and password optional. * Simplified tests again. * Fix API tests --- .../DeepDetect/DeepDetectActionHandler.cs | 2 +- .../Actions/DeepDetect/DeepDetectPlugin.cs | 2 +- .../Actions/Email/EmailActionHandler.cs | 6 +- backend/i18n/frontend_en.json | 1 + backend/i18n/frontend_fr.json | 1 + backend/i18n/frontend_it.json | 1 + backend/i18n/frontend_nl.json | 1 + backend/i18n/frontend_pt.json | 1 + backend/i18n/frontend_zh.json | 1 + backend/i18n/source/frontend_en.json | 1 + .../OldTriggers/AssetChangedTrigger.cs | 8 +- .../ContentChangedTriggerSchema.cs | 8 +- .../FieldDescriptions.Designer.cs | 18 +- .../FieldDescriptions.resx | 6 +- .../Scripting/AssetEntityScriptVars.cs | 10 +- .../ContentWrapper/ContentDataObject.cs | 4 +- .../ContentWrapper/ContentDataProperty.cs | 2 +- .../ContentWrapper/ContentFieldObject.cs | 2 +- .../Scripting/ScriptingCompleter.cs | 33 ++- .../Text/CommandFactory.cs | 2 +- .../Guards/ScriptingExtensions.cs | 9 +- .../Assets/Queries/Steps/ScriptAsset.cs | 5 +- .../Collaboration/EmailUserNotifications.cs | 7 +- .../Squidex.Domain.Apps.Entities.csproj | 3 - .../Email/SmtpEmailSender.cs | 2 +- .../Email/SmtpOptions.cs | 7 +- .../Squidex.Infrastructure/HashExtensions.cs | 73 ++++-- backend/src/Squidex.Web/BuilderExtensions.cs | 18 ++ backend/src/Squidex.Web/ETagExtensions.cs | 22 +- backend/src/Squidex.Web/ETagUtils.cs | 58 ----- backend/src/Squidex.Web/Extensions.cs | 20 -- .../src/Squidex.Web/Pipeline/CachingFilter.cs | 14 +- .../Pipeline/CachingKeysMiddleware.cs | 6 +- .../Squidex.Web/Pipeline/CachingManager.cs | 2 +- .../Frontend/Middlewares/IndexExtensions.cs | 10 +- backend/src/Squidex/Areas/Frontend/Startup.cs | 30 ++- backend/src/Squidex/Squidex.csproj | 7 +- backend/src/Squidex/Startup.cs | 4 +- .../Billing/ConfigPlansProviderTests.cs | 2 +- .../FileExtensionsTests.cs | 4 +- .../HashExtensionsTests.cs | 28 +++ .../LanguageTests.cs | 4 +- .../StringExtensionsTests.cs | 4 +- .../ETagCommandMiddlewareTests.cs | 4 +- .../Pipeline/CachingFilterTests.cs | 18 +- .../Pipeline/CachingKeysMiddlewareTests.cs | 16 +- .../content-history-page.component.html | 5 +- .../pages/content/content-page.component.html | 4 +- .../pages/content/content-page.component.ts | 1 + .../shared/list/content.component.html | 3 +- .../rules/pages/rule/rule-page.component.html | 3 +- .../rules/pages/rules/rule.component.html | 3 +- .../pages/schema/fields/field.component.html | 3 +- .../pages/schema/schema-page.component.html | 3 +- .../pages/schemas/schemas-page.component.html | 2 +- .../forms/editable-title.component.html | 6 +- .../framework/angular/layout.component.html | 3 +- .../contents/content-status.component.html | 4 +- .../contents/content-status.component.ts | 2 +- .../src/app/shared/state/contents.forms.ts | 12 +- .../TestSuite.ApiTests/AppClientsTests.cs | 12 +- .../AppContributorsTests.cs | 46 +++- .../TestSuite.ApiTests/AppLanguagesTests.cs | 16 +- .../TestSuite.ApiTests/AppRolesTests.cs | 16 +- .../TestSuite.ApiTests/AppWorkflowsTests.cs | 2 +- .../TestSuite.ApiTests/AssetFoldersTests.cs | 19 +- .../TestSuite.ApiTests/AssetFormatTests.cs | 16 +- .../TestSuite.ApiTests/AssetScriptingTests.cs | 151 ++++++++++++ .../TestSuite.ApiTests/AssetTests.cs | 228 ++++++++++-------- .../TestSuite.ApiTests/BackupTests.cs | 4 +- .../TestSuite/TestSuite.ApiTests/CDNTests.cs | 4 +- .../TestSuite.ApiTests/ContentCleanupTests.cs | 4 +- .../ContentLanguageTests.cs | 12 +- .../TestSuite.ApiTests/ContentQueryFixture.cs | 2 +- .../TestSuite.ApiTests/ContentQueryTests.cs | 10 +- .../ContentReferencesTests.cs | 2 +- .../ContentScriptingTests.cs | 6 +- .../TestSuite.ApiTests/ContentUpdateTests.cs | 128 +++++----- .../TestSuite.ApiTests/GraphQLFixture.cs | 2 +- .../GraphQLSubscriptionTests.cs | 2 +- .../TestSuite.ApiTests/GraphQLTests.cs | 50 ++-- .../TestSuite.ApiTests/RuleRunnerTests.cs | 16 +- .../TeamContributorTests.cs | 128 ++++++++++ .../TestSuite.ApiTests/TeamCreationTests.cs | 31 +-- .../TestSuite/TestSuite.ApiTests/TeamTests.cs | 86 +++++++ .../TestSuite.ApiTests.csproj | 1 + ...sts.Should_remove_contributor.verified.txt | 13 + ...sset_file_name_strategy=Bulk.verified.txt} | 0 ...set_file_name_strategy=Single.verified.txt | 47 ++++ ...asset_metadata_strategy=Bulk.verified.txt} | 0 ...sset_metadata_strategy=Single.verified.txt | 46 ++++ ...ote_asset_slug_strategy=Bulk.verified.txt} | 0 ...te_asset_slug_strategy=Single.verified.txt | 47 ++++ ...ote_asset_tags_strategy=Bulk.verified.txt} | 0 ...te_asset_tags_strategy=Single.verified.txt | 46 ++++ ..._protect_asset_strategy=Bulk.verified.txt} | 0 ...protect_asset_strategy=Single.verified.txt | 47 ++++ ...sts.Should_invite_contributor.verified.txt | 11 + ...sts.Should_remove_contributor.verified.txt | 23 ++ tools/TestSuite/TestSuite.LoadTests/Run.cs | 2 +- .../TestSuite.LoadTests.csproj | 1 + .../TestSuite.Shared/AssetStrategies.cs | 121 ++++++++++ .../TestSuite.Shared/ClientExtensions.cs | 24 +- .../{Strategies.cs => ContentStrategies.cs} | 84 +++---- tools/TestSuite/TestSuite.Shared/Factories.cs | 2 +- .../Fixtures/ClientFixture.cs | 14 +- .../Fixtures/WebhookCatcherClient.cs | 12 +- .../TestSuite.Shared/Model/TestEntity.cs | 20 +- .../Model/TestEntityWithReferences.cs | 10 +- .../TestSuite.Shared/TestSuite.Shared.csproj | 3 +- .../TestSuite.Shared/Utils/ProgressHandler.cs | 6 +- tools/e2e/playwright.config.ts | 19 ++ .../visual-test-1-given-app-linux.png | Bin .../visual-test-1-given-app-win32.png | Bin tools/e2e/tests/given-app/_fixture.ts | 4 +- ...shboard_page.spec.ts => dashboard.spec.ts} | 6 +- tools/e2e/tests/given-app/rules.spec.ts | 38 ++- tools/e2e/tests/given-app/schemas.spec.ts | 26 +- tools/e2e/tests/given-login/apps-page.spec.ts | 6 +- tools/e2e/tests/given-login/apps.spec.ts | 10 +- tools/e2e/tests/given-login/languages.spec.ts | 55 +++++ tools/e2e/tests/given-schema/_fixture.ts | 32 +++ tools/e2e/tests/given-schema/_setup.ts | 46 ++++ tools/e2e/tests/given-schema/contents.spec.ts | 150 ++++++++++++ tools/e2e/tests/login.spec.ts | 6 +- tools/e2e/tests/start-page.spec.ts | 6 +- tools/e2e/tests/utils.ts | 4 +- 127 files changed, 1855 insertions(+), 667 deletions(-) create mode 100644 backend/src/Squidex.Web/BuilderExtensions.cs delete mode 100644 backend/src/Squidex.Web/ETagUtils.cs create mode 100644 tools/TestSuite/TestSuite.ApiTests/AssetScriptingTests.cs create mode 100644 tools/TestSuite/TestSuite.ApiTests/TeamContributorTests.cs create mode 100644 tools/TestSuite/TestSuite.ApiTests/TeamTests.cs rename tools/TestSuite/TestSuite.ApiTests/Verify/{AssetTests.Should_annote_asset_file_name.verified.txt => AssetTests.Should_annote_asset_file_name_strategy=Bulk.verified.txt} (100%) create mode 100644 tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_file_name_strategy=Single.verified.txt rename tools/TestSuite/TestSuite.ApiTests/Verify/{AssetTests.Should_annote_asset_metadata.verified.txt => AssetTests.Should_annote_asset_metadata_strategy=Bulk.verified.txt} (100%) create mode 100644 tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_metadata_strategy=Single.verified.txt rename tools/TestSuite/TestSuite.ApiTests/Verify/{AssetTests.Should_annote_asset_slug.verified.txt => AssetTests.Should_annote_asset_slug_strategy=Bulk.verified.txt} (100%) create mode 100644 tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_slug_strategy=Single.verified.txt rename tools/TestSuite/TestSuite.ApiTests/Verify/{AssetTests.Should_annote_asset_tags.verified.txt => AssetTests.Should_annote_asset_tags_strategy=Bulk.verified.txt} (100%) create mode 100644 tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_tags_strategy=Single.verified.txt rename tools/TestSuite/TestSuite.ApiTests/Verify/{AssetTests.Should_protect_asset.verified.txt => AssetTests.Should_protect_asset_strategy=Bulk.verified.txt} (100%) create mode 100644 tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_protect_asset_strategy=Single.verified.txt create mode 100644 tools/TestSuite/TestSuite.ApiTests/Verify/TeamContributorTests.Should_invite_contributor.verified.txt create mode 100644 tools/TestSuite/TestSuite.ApiTests/Verify/TeamContributorTests.Should_remove_contributor.verified.txt create mode 100644 tools/TestSuite/TestSuite.Shared/AssetStrategies.cs rename tools/TestSuite/TestSuite.Shared/{Strategies.cs => ContentStrategies.cs} (83%) rename tools/e2e/snapshots/given-app/{dashboard_page.spec.ts-snapshots => dashboard.spec.ts-snapshots}/visual-test-1-given-app-linux.png (100%) rename tools/e2e/snapshots/given-app/{dashboard_page.spec.ts-snapshots => dashboard.spec.ts-snapshots}/visual-test-1-given-app-win32.png (100%) rename tools/e2e/tests/given-app/{dashboard_page.spec.ts => dashboard.spec.ts} (72%) create mode 100644 tools/e2e/tests/given-login/languages.spec.ts create mode 100644 tools/e2e/tests/given-schema/_fixture.ts create mode 100644 tools/e2e/tests/given-schema/_setup.ts create mode 100644 tools/e2e/tests/given-schema/contents.spec.ts diff --git a/backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectActionHandler.cs b/backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectActionHandler.cs index f611775e9..d8d44af72 100644 --- a/backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectActionHandler.cs +++ b/backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectActionHandler.cs @@ -23,7 +23,7 @@ namespace Squidex.Extensions.Actions.DeepDetect; #pragma warning disable MA0048 // File name must match type name -internal partial class DeepDetectActionHandler : RuleActionHandler +internal sealed partial class DeepDetectActionHandler : RuleActionHandler { private const string Description = "Analyze Image"; private readonly IHttpClientFactory httpClientFactory; diff --git a/backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectPlugin.cs b/backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectPlugin.cs index c614fc7a1..565f94ce7 100644 --- a/backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectPlugin.cs +++ b/backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectPlugin.cs @@ -11,7 +11,7 @@ using Squidex.Infrastructure.Plugins; namespace Squidex.Extensions.Actions.DeepDetect; -internal class DeepDetectPlugin : IPlugin +internal sealed class DeepDetectPlugin : IPlugin { public void ConfigureServices(IServiceCollection services, IConfiguration config) { diff --git a/backend/extensions/Squidex.Extensions/Actions/Email/EmailActionHandler.cs b/backend/extensions/Squidex.Extensions/Actions/Email/EmailActionHandler.cs index 1252a8dc6..d79521ca9 100644 --- a/backend/extensions/Squidex.Extensions/Actions/Email/EmailActionHandler.cs +++ b/backend/extensions/Squidex.Extensions/Actions/Email/EmailActionHandler.cs @@ -47,7 +47,11 @@ public sealed class EmailActionHandler : RuleActionHandler if (SendCreate) { - conditions.Add($"event.type == '{EnrichedAssetEventType.Created}'"); + conditions.Add($"event.type == '{nameof(EnrichedAssetEventType.Created)}'"); } if (SendUpdate) { - conditions.Add($"event.type == '{EnrichedAssetEventType.Updated}'"); + conditions.Add($"event.type == '{nameof(EnrichedAssetEventType.Updated)}'"); } if (SendRename) { - conditions.Add($"event.type == '{EnrichedAssetEventType.Annotated}'"); + conditions.Add($"event.type == '{nameof(EnrichedAssetEventType.Annotated)}'"); } if (SendDelete) { - conditions.Add($"event.type == '{EnrichedAssetEventType.Deleted}'"); + conditions.Add($"event.type == '{nameof(EnrichedAssetEventType.Deleted)}'"); } var condition = string.Empty; diff --git a/backend/src/Migrations/OldTriggers/ContentChangedTriggerSchema.cs b/backend/src/Migrations/OldTriggers/ContentChangedTriggerSchema.cs index 73ca29cd7..9b8567069 100644 --- a/backend/src/Migrations/OldTriggers/ContentChangedTriggerSchema.cs +++ b/backend/src/Migrations/OldTriggers/ContentChangedTriggerSchema.cs @@ -35,17 +35,17 @@ public sealed class ContentChangedTriggerSchema if (SendCreate) { - conditions.Add($"event.type == '{EnrichedContentEventType.Created}'"); + conditions.Add($"event.type == '{nameof(EnrichedContentEventType.Created)}'"); } if (SendUpdate) { - conditions.Add($"event.type == '{EnrichedContentEventType.Updated}'"); + conditions.Add($"event.type == '{nameof(EnrichedContentEventType.Updated)}'"); } if (SendPublish) { - conditions.Add($"event.type == '{EnrichedContentEventType.Published}'"); + conditions.Add($"event.type == '{nameof(EnrichedContentEventType.Published)}'"); } if (SendArchived) @@ -55,7 +55,7 @@ public sealed class ContentChangedTriggerSchema if (SendDelete) { - conditions.Add($"event.type == '{EnrichedAssetEventType.Deleted}'"); + conditions.Add($"event.type == '{nameof(EnrichedAssetEventType.Deleted)}'"); } var condition = string.Empty; diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs index 7f4b90c45..532fc5626 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs @@ -204,15 +204,6 @@ namespace Squidex.Domain.Apps.Core { } } - /// - /// Looks up a localized string similar to The asset metadata with name 'name'.. - /// - public static string AssetMetadataValue { - get { - return ResourceManager.GetString("AssetMetadataValue", resourceCulture); - } - } - /// /// Looks up a localized string similar to The mime type.. /// @@ -816,6 +807,15 @@ namespace Squidex.Domain.Apps.Core { } } + /// + /// Looks up a localized string similar to The value with name 'name'.. + /// + public static string ObjectValue { + get { + return ResourceManager.GetString("ObjectValue", resourceCulture); + } + } + /// /// Looks up a localized string similar to The current operation.. /// diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx index ed8b9d303..b3c3e7d3c 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx +++ b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx @@ -165,9 +165,6 @@ The type of the image. - - The asset metadata with name 'name'. - The mime type. @@ -369,6 +366,9 @@ The name part of this ID. + + The value with name 'name'. + The current operation. diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/AssetEntityScriptVars.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/AssetEntityScriptVars.cs index 698a3631f..de33970e4 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/AssetEntityScriptVars.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/AssetEntityScriptVars.cs @@ -5,9 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Collections.ObjectModel; using Squidex.Domain.Apps.Core.Assets; using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; using Squidex.Infrastructure.Json.Objects; namespace Squidex.Domain.Apps.Core.Scripting; @@ -51,15 +51,15 @@ public sealed class AssetEntityScriptVars : ScriptVars } [FieldDescription(nameof(FieldDescriptions.AssetMetadata))] - public AssetMetadata? Metadata + public ReadonlyDictionary? Metadata { - set => SetInitial(value != null ? new ReadOnlyDictionary(value) : null); + set => SetInitial(value); } [FieldDescription(nameof(FieldDescriptions.AssetTags))] - public HashSet? Tags + public ReadonlyList? Tags { - set => SetInitial(value != null ? new ReadOnlyCollection(value.ToList()) : null); + set => SetInitial(value); } [FieldDescription(nameof(FieldDescriptions.AssetFileSize))] diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs index 5b0bff140..80546fea1 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs @@ -70,7 +70,7 @@ public sealed class ContentDataObject : ObjectInstance { var propertyName = property.AsString(); - fieldsToDelete ??= new HashSet(); + fieldsToDelete ??= []; fieldsToDelete.Add(propertyName); fieldProperties?.Remove(propertyName); @@ -114,7 +114,7 @@ public sealed class ContentDataObject : ObjectInstance return PropertyDescriptor.Undefined; } - return fieldProperties.GetOrAdd(propertyName, (k, c) => new ContentDataProperty(c, new ContentFieldObject(c, new ContentFieldData(), false)), this); + return fieldProperties.GetOrAdd(propertyName, (k, c) => new ContentDataProperty(c, new ContentFieldObject(c, [], false)), this); } public override IEnumerable> GetOwnProperties() diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataProperty.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataProperty.cs index 97ebc597c..238ddf44e 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataProperty.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataProperty.cs @@ -35,7 +35,7 @@ public sealed class ContentDataProperty : CustomProperty var obj = value.AsObject(); - contentField = new ContentFieldObject(contentData, new ContentFieldData(), true); + contentField = new ContentFieldObject(contentData, [], true); foreach (var (key, propertyDescriptor) in obj.GetOwnProperties()) { diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs index 7af6d31d9..ce162f11c 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs @@ -85,7 +85,7 @@ public sealed class ContentFieldObject : ObjectInstance { var propertyName = property.AsString(); - valuesToDelete ??= new HashSet(); + valuesToDelete ??= []; valuesToDelete.Add(propertyName); valueProperties?.Remove(propertyName); diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptingCompleter.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptingCompleter.cs index 16be0cb20..7472f9208 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptingCompleter.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptingCompleter.cs @@ -11,7 +11,6 @@ using System.Reflection; using System.Security.Claims; using System.Text.RegularExpressions; using NodaTime; -using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Infrastructure; @@ -116,7 +115,7 @@ public sealed partial class ScriptingCompleter { private static readonly Regex RegexProperty = BuildPropertyRegex(); private readonly Stack prefixes = new Stack(); - private readonly Dictionary result = new Dictionary(); + private readonly Dictionary result = []; private readonly IEnumerable descriptors; private readonly FilterSchema? dataSchema; @@ -280,7 +279,7 @@ public sealed partial class ScriptingCompleter if (propertyType.IsEnum) { - var allowedValues = Enum.GetValues(propertyType).OfType().Select(x => x.ToString()!).Order().ToArray(); + var allowedValues = Enum.GetNames(propertyType); Add(JsonType.String, name, description, allowedValues); } @@ -304,14 +303,6 @@ public sealed partial class ScriptingCompleter { AddFunction(name, description); } - else if (propertyType == typeof(AssetMetadata)) - { - AddObject(name, description, () => - { - AddString("my-name", - FieldDescriptions.AssetMetadataValue); - }); - } else if (propertyType == typeof(NamedId)) { AddObject(name, description, () => @@ -371,13 +362,31 @@ public sealed partial class ScriptingCompleter AddType(propertyType); }); } - else if (propertyType.GetInterfaces().Contains(typeof(IEnumerable))) + else if (IsDictionary(propertyType)) + { + AddObject(name, description, () => + { + AddString("my-name", + FieldDescriptions.ObjectValue); + }); + } + else if (IsCollection(propertyType)) { AddArray(name, description); } } } + private static bool IsDictionary(Type propertyType) + { + return propertyType.GetInterfaces().Any(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>)); + } + + private static bool IsCollection(Type propertyType) + { + return propertyType.GetInterfaces().Contains(typeof(IEnumerable)); + } + private static IEnumerable<(string Name, string Description, Type Type)> GetFields(Type type) { foreach (var property in type.GetPublicProperties()) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Text/CommandFactory.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Text/CommandFactory.cs index 2acf5f86b..347be17e1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Text/CommandFactory.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Text/CommandFactory.cs @@ -57,7 +57,7 @@ public sealed class CommandFactory : MongoBase> where IsUpsert = true }); - if (upsert.GeoObjects?.Any() == true) + if (upsert.GeoObjects?.Count > 0) { if (!upsert.IsNew) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/Guards/ScriptingExtensions.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/Guards/ScriptingExtensions.cs index 54ffca3f3..494eed2a9 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/Guards/ScriptingExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/Guards/ScriptingExtensions.cs @@ -8,6 +8,7 @@ using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; using Squidex.Text; namespace Squidex.Domain.Apps.Entities.Assets.DomainObject.Guards; @@ -62,11 +63,11 @@ public static class ScriptingExtensions FileSize = create.File.FileSize, FileVersion = 0, IsProtected = false, - Metadata = create.Metadata, + Metadata = create.Metadata?.ToReadonlyDictionary(), MimeType = create.File.MimeType, ParentId = create.ParentId, ParentPath = await GetPathAsync(operation, create.ParentId, ct), - Tags = create.Tags + Tags = create.Tags?.ToReadonlyList() }; await ExecuteScriptAsync(operation, script, vars, asset, ct); @@ -194,11 +195,11 @@ public static class ScriptingExtensions FileSlug = snapshot.Slug, FileVersion = snapshot.FileVersion, IsProtected = snapshot.IsProtected, - Metadata = snapshot.Metadata, + Metadata = snapshot.Metadata?.ToReadonlyDictionary(), MimeType = snapshot.MimeType, ParentId = snapshot.ParentId, ParentPath = await GetPathAsync(operation, snapshot.ParentId, ct), - Tags = snapshot.Tags, + Tags = snapshot.Tags?.ToReadonlyList(), }; vars.AppId = operation.App.Id; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ScriptAsset.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ScriptAsset.cs index 46b175ce8..e814e18fa 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ScriptAsset.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ScriptAsset.cs @@ -6,6 +6,7 @@ // ========================================================================== using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Infrastructure.Collections; using Squidex.Shared; namespace Squidex.Domain.Apps.Entities.Assets.Queries.Steps; @@ -76,11 +77,11 @@ public sealed class ScriptAsset : IAssetEnricherStep FileSlug = asset.Slug, FileVersion = asset.FileVersion, IsProtected = asset.IsProtected, - Metadata = asset.Metadata, + Metadata = asset.Metadata?.ToReadonlyDictionary(), MimeType = asset.MimeType, ParentId = asset.ParentId, ParentPath = null, - Tags = asset.Tags + Tags = asset.Tags?.ToReadonlyList() } }; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Collaboration/EmailUserNotifications.cs b/backend/src/Squidex.Domain.Apps.Entities/Collaboration/EmailUserNotifications.cs index bf7721e00..02e1a7011 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Collaboration/EmailUserNotifications.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Collaboration/EmailUserNotifications.cs @@ -47,11 +47,8 @@ public sealed class EmailUserNotifications : IUserNotifications get => true; } - public EmailUserNotifications( - IOptions texts, - IEmailSender emailSender, - IUrlGenerator urlGenerator, - ILogger log) + public EmailUserNotifications(IOptions texts, IEmailSender emailSender, + IUrlGenerator urlGenerator, ILogger log) { this.texts = texts.Value; this.emailSender = emailSender; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj b/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj index be3c7e1cb..82513ac9e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj +++ b/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj @@ -54,7 +54,4 @@ Resources.Designer.cs - - - diff --git a/backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs b/backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs index 2d8bbfb7a..492189b9e 100644 --- a/backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs +++ b/backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs @@ -72,7 +72,7 @@ public sealed class SmtpEmailSender : IEmailSender await smtpClient.ConnectAsync(options.Server, options.Port, cancellationToken: ct); } - if (!smtpClient.IsAuthenticated) + if (!smtpClient.IsAuthenticated && options.IsAuthenticating()) { await smtpClient.AuthenticateAsync(options.Username, options.Password, ct); } diff --git a/backend/src/Squidex.Infrastructure/Email/SmtpOptions.cs b/backend/src/Squidex.Infrastructure/Email/SmtpOptions.cs index ade64c9b7..aab173f74 100644 --- a/backend/src/Squidex.Infrastructure/Email/SmtpOptions.cs +++ b/backend/src/Squidex.Infrastructure/Email/SmtpOptions.cs @@ -27,7 +27,12 @@ public sealed class SmtpOptions { return !string.IsNullOrWhiteSpace(Server) && - !string.IsNullOrWhiteSpace(Sender) && + !string.IsNullOrWhiteSpace(Sender); + } + + public bool IsAuthenticating() + { + return !string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password); } diff --git a/backend/src/Squidex.Infrastructure/HashExtensions.cs b/backend/src/Squidex.Infrastructure/HashExtensions.cs index e8a1ddac4..561526945 100644 --- a/backend/src/Squidex.Infrastructure/HashExtensions.cs +++ b/backend/src/Squidex.Infrastructure/HashExtensions.cs @@ -202,6 +202,15 @@ public static class HashExtensions return ToHexString(buffer); } + public static string GetQuotedHexStringAndReset(this IncrementalHash hash) + { + Span buffer = stackalloc byte[hash.HashLengthInBytes]; + + hash.GetHashAndReset(buffer); + + return ToQuotedHexString(buffer); + } + public static string ToHexString(this ReadOnlySpan data) { string result; @@ -213,7 +222,7 @@ public static class HashExtensions var buffer = ArrayPool.Shared.Rent(length); try { - result = ConvertCore(data, buffer); + result = ConvertCore(data, buffer, 0); } finally { @@ -224,29 +233,63 @@ public static class HashExtensions { Span buffer = stackalloc char[length]; - result = ConvertCore(data, buffer); + result = ConvertCore(data, buffer, 0); } return result; + } - static string ConvertCore(ReadOnlySpan data, Span hexBuffer) + public static string ToQuotedHexString(this ReadOnlySpan data) + { + string result; + + var length = (data.Length * 2) + 2; + + if (length > MaxStackSize) { - unchecked + var buffer = ArrayPool.Shared.Rent(length); + try { - int n = 0; - for (int i = 0; i < data.Length; i++) - { - byte b = data[i]; + buffer[0] = '"'; + buffer[^1] = '"'; - byte b1 = (byte)(b >> 4); - byte b2 = (byte)(b & 0xF); - - hexBuffer[n++] = (b1 < 10) ? (char)('0' + b1) : (char)('A' + (b1 - 10)); - hexBuffer[n++] = (b2 < 10) ? (char)('0' + b2) : (char)('A' + (b2 - 10)); - } + result = ConvertCore(data, buffer, 1); } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + else + { + Span buffer = stackalloc char[length]; + + buffer[0] = '"'; + buffer[^1] = '"'; + + result = ConvertCore(data, buffer, 1); + } + + return result; + } - return new string(hexBuffer); + private static string ConvertCore(ReadOnlySpan data, Span hexBuffer, int offset) + { + unchecked + { + int n = offset; + for (int i = 0; i < data.Length; i++) + { + byte b = data[i]; + + byte b1 = (byte)(b >> 4); + byte b2 = (byte)(b & 0xF); + + hexBuffer[n++] = (b1 < 10) ? (char)('0' + b1) : (char)('A' + (b1 - 10)); + hexBuffer[n++] = (b2 < 10) ? (char)('0' + b2) : (char)('A' + (b2 - 10)); + } } + + return new string(hexBuffer); } } diff --git a/backend/src/Squidex.Web/BuilderExtensions.cs b/backend/src/Squidex.Web/BuilderExtensions.cs new file mode 100644 index 000000000..d685f9738 --- /dev/null +++ b/backend/src/Squidex.Web/BuilderExtensions.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Builder; + +namespace Squidex.Web; + +public static class BuilderExtensions +{ + public static void UseWhenPath(this IApplicationBuilder builder, string path, Action configurator) + { + builder.UseWhen(c => c.Request.Path.StartsWithSegments(path, StringComparison.OrdinalIgnoreCase), configurator); + } +} diff --git a/backend/src/Squidex.Web/ETagExtensions.cs b/backend/src/Squidex.Web/ETagExtensions.cs index 557f9bdf7..81a9b2755 100644 --- a/backend/src/Squidex.Web/ETagExtensions.cs +++ b/backend/src/Squidex.Web/ETagExtensions.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Globalization; +using System.Net.Http.Headers; using System.Security.Cryptography; using Microsoft.AspNetCore.Http; using Squidex.Domain.Apps.Entities; @@ -60,21 +61,18 @@ public static class ETagExtensions { version = default; - if (httpContext.Request.Headers.TryGetString(header, out var etag)) + if (!httpContext.Request.Headers.TryGetValue(header, out var etagValue)) { - var span = etag.AsSpan(); - - if (span.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) - { - span = span[2..]; - } + return false; + } - if (long.TryParse(span, NumberStyles.Any, CultureInfo.InvariantCulture, out version)) - { - return true; - } + if (!EntityTagHeaderValue.TryParse(etagValue.ToString(), out var etag)) + { + return false; } - return false; + var tag = etag.Tag.AsSpan().Trim('"'); + + return long.TryParse(tag, NumberStyles.Any, CultureInfo.InvariantCulture, out version); } } diff --git a/backend/src/Squidex.Web/ETagUtils.cs b/backend/src/Squidex.Web/ETagUtils.cs deleted file mode 100644 index cadad5a56..000000000 --- a/backend/src/Squidex.Web/ETagUtils.cs +++ /dev/null @@ -1,58 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Web; - -public static class ETagUtils -{ - public static string ToWeakEtag(string? etag) - { - return $"W/{etag}"; - } - - public static bool IsStrongEtag(string etag) - { - return !IsWeakEtag(etag.AsSpan()); - } - - public static bool IsWeakEtag(string etag) - { - return IsWeakEtag(etag.AsSpan()); - } - - public static bool IsWeakEtag(ReadOnlySpan etag) - { - return etag.StartsWith("W/", StringComparison.OrdinalIgnoreCase); - } - - public static bool IsSameEtag(string lhs, string rhs) - { - return IsSameEtag(lhs.AsSpan(), rhs.AsSpan()); - } - - public static bool IsSameEtag(ReadOnlySpan lhs, ReadOnlySpan rhs) - { - var isMatch = lhs.Equals(rhs, StringComparison.Ordinal); - - if (isMatch) - { - return true; - } - - if (lhs.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) - { - lhs = lhs[2..]; - } - - if (rhs.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) - { - rhs = rhs[2..]; - } - - return lhs.Equals(rhs, StringComparison.Ordinal); - } -} diff --git a/backend/src/Squidex.Web/Extensions.cs b/backend/src/Squidex.Web/Extensions.cs index 4ffaffa73..19293aecb 100644 --- a/backend/src/Squidex.Web/Extensions.cs +++ b/backend/src/Squidex.Web/Extensions.cs @@ -5,9 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Diagnostics.CodeAnalysis; using System.Security.Claims; -using Microsoft.AspNetCore.Http; using Squidex.Infrastructure.Security; namespace Squidex.Web; @@ -56,22 +54,4 @@ public static class Extensions return string.Equals(subject, userId, StringComparison.OrdinalIgnoreCase); } - - public static bool TryGetString(this IHeaderDictionary headers, string header, [MaybeNullWhen(false)] out string result) - { - result = null!; - - if (headers.TryGetValue(header, out var value)) - { - string? valueString = value; - - if (!string.IsNullOrWhiteSpace(valueString)) - { - result = valueString; - return true; - } - } - - return false; - } } diff --git a/backend/src/Squidex.Web/Pipeline/CachingFilter.cs b/backend/src/Squidex.Web/Pipeline/CachingFilter.cs index b52a7e0d5..45f494d8a 100644 --- a/backend/src/Squidex.Web/Pipeline/CachingFilter.cs +++ b/backend/src/Squidex.Web/Pipeline/CachingFilter.cs @@ -38,26 +38,32 @@ public sealed class CachingFilter : IAsyncActionFilter cachingManager.Finish(context.HttpContext); if (context.HttpContext.Response.HasStarted == false && - context.HttpContext.Response.Headers.TryGetString(HeaderNames.ETag, out var etag) && + context.HttpContext.Response.Headers.TryGetValue(HeaderNames.ETag, out var etagValue) && + EntityTagHeaderValue.TryParse(etagValue.ToString(), out var etag) && IsCacheable(context.HttpContext, etag)) { resultContext.Result = new StatusCodeResult(304); } } - private static bool IsCacheable(HttpContext httpContext, string etag) + private static bool IsCacheable(HttpContext httpContext, EntityTagHeaderValue etag) { if (!HttpMethods.IsGet(httpContext.Request.Method) || httpContext.Response.StatusCode != 200) { return false; } - if (!httpContext.Request.Headers.TryGetString(HeaderNames.IfNoneMatch, out var noneMatchValue)) + if (!httpContext.Request.Headers.TryGetValue(HeaderNames.IfNoneMatch, out var noneMatchValue)) { return false; } - return ETagUtils.IsSameEtag(noneMatchValue, etag); + if (!EntityTagHeaderValue.TryParse(noneMatchValue.ToString(), out var noneMatch)) + { + return false; + } + + return etag.Compare(noneMatch, false); } private static bool IgnoreFilter(ActionExecutingContext context) diff --git a/backend/src/Squidex.Web/Pipeline/CachingKeysMiddleware.cs b/backend/src/Squidex.Web/Pipeline/CachingKeysMiddleware.cs index 2598cec6f..1c13e628a 100644 --- a/backend/src/Squidex.Web/Pipeline/CachingKeysMiddleware.cs +++ b/backend/src/Squidex.Web/Pipeline/CachingKeysMiddleware.cs @@ -37,11 +37,11 @@ public sealed class CachingKeysMiddleware cachingManager.Finish(httpContext); - if (httpContext.Response.Headers.TryGetString(HeaderNames.ETag, out var etag)) + if (httpContext.Response.Headers.TryGetValue(HeaderNames.ETag, out var value) && EntityTagHeaderValue.TryParse(value.ToString(), out var etag)) { - if (!cachingOptions.StrongETag && !ETagUtils.IsWeakEtag(etag)) + if (!cachingOptions.StrongETag && !etag.IsWeak) { - httpContext.Response.Headers[HeaderNames.ETag] = ETagUtils.ToWeakEtag(etag); + httpContext.Response.Headers[HeaderNames.ETag] = new EntityTagHeaderValue(etag.Tag, true).ToString(); } } diff --git a/backend/src/Squidex.Web/Pipeline/CachingManager.cs b/backend/src/Squidex.Web/Pipeline/CachingManager.cs index e8972f417..2d59152a7 100644 --- a/backend/src/Squidex.Web/Pipeline/CachingManager.cs +++ b/backend/src/Squidex.Web/Pipeline/CachingManager.cs @@ -117,7 +117,7 @@ public sealed class CachingManager : IRequestCache { using (Telemetry.Activities.StartActivity("CalculateEtag")) { - response.Headers[HeaderNames.ETag] = hasher.GetHexStringAndReset(); + response.Headers[HeaderNames.ETag] = new EntityTagHeaderValue(hasher.GetQuotedHexStringAndReset()).ToString(); } } diff --git a/backend/src/Squidex/Areas/Frontend/Middlewares/IndexExtensions.cs b/backend/src/Squidex/Areas/Frontend/Middlewares/IndexExtensions.cs index 81584e7e0..cd7ec6fc6 100644 --- a/backend/src/Squidex/Areas/Frontend/Middlewares/IndexExtensions.cs +++ b/backend/src/Squidex/Areas/Frontend/Middlewares/IndexExtensions.cs @@ -63,7 +63,7 @@ public static class IndexExtensions private static string GetText(string culture) { - if (!Texts.TryGetValue(culture, out var result)) + return Texts.GetOrAdd(culture, culture => { var assembly = typeof(IndexExtensions).Assembly; @@ -74,17 +74,15 @@ public static class IndexExtensions { using (var reader = new StreamReader(resourceStream)) { - result = reader.ReadToEnd(); + var result = reader.ReadToEnd(); - Texts[culture] = result; + return result; } } else { return GetText("en"); } - } - - return result; + }); } } diff --git a/backend/src/Squidex/Areas/Frontend/Startup.cs b/backend/src/Squidex/Areas/Frontend/Startup.cs index 98c313abe..b27128349 100644 --- a/backend/src/Squidex/Areas/Frontend/Startup.cs +++ b/backend/src/Squidex/Areas/Frontend/Startup.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.FileProviders; using Microsoft.Net.Http.Headers; using Squidex.Areas.Frontend.Middlewares; @@ -40,12 +41,12 @@ public static class Startup app.UseMiddleware(); - app.UseWhen(c => c.IsSpaFile(), builder => + app.UseWhen(IsSpaFile, builder => { builder.UseMiddleware(); }); - app.UseWhen(c => c.IsSpaFile() || c.IsHtmlPath(), builder => + app.UseWhen(IsSpaFileOrHtml, builder => { // Adjust the base for all potential html files. builder.UseHtmlTransform(new HtmlTransformOptions @@ -82,25 +83,42 @@ public static class Startup private static void UseSquidexStaticFiles(this IApplicationBuilder app, IFileProvider fileProvider) { + static bool HasQueryString(HttpContext context) + { + return !string.IsNullOrWhiteSpace(context.Request.QueryString.ToString()); + } + + static bool IsHtml(HttpContext context) + { + return string.Equals(context.Response.ContentType, "text/html", StringComparison.OrdinalIgnoreCase); + } + app.UseStaticFiles(new StaticFileOptions { OnPrepareResponse = context => { var response = context.Context.Response; - if (!string.IsNullOrWhiteSpace(context.Context.Request.QueryString.ToString())) + if (IsHtml(context.Context) || HasQueryString(context.Context)) { - response.Headers[HeaderNames.CacheControl] = "max-age=5184000"; + response.Headers[HeaderNames.CacheControl] = "no-cache"; + response.Headers.Remove(HeaderNames.ETag); + response.Headers.Remove(HeaderNames.LastModified); } - else if (string.Equals(response.ContentType, "text/html", StringComparison.OrdinalIgnoreCase)) + else { - response.Headers[HeaderNames.CacheControl] = "no-cache"; + response.Headers[HeaderNames.CacheControl] = "max-age=5184000"; } }, FileProvider = fileProvider }); } + private static bool IsSpaFileOrHtml(this HttpContext context) + { + return context.IsSpaFile() || context.IsHtmlPath(); + } + private static bool IsSpaFile(this HttpContext context) { return (context.IsIndex() || !Path.HasExtension(context.Request.Path)) && !context.IsDevServer(); diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index 0df8ea531..126df3e99 100644 --- a/backend/src/Squidex/Squidex.csproj +++ b/backend/src/Squidex/Squidex.csproj @@ -7,7 +7,7 @@ enable en enable - 1701;1702;NETSDK1206;NU1608 + 1701;1702;CS1591;NETSDK1206;NU1608 @@ -136,6 +136,7 @@ + @@ -156,8 +157,4 @@ Resources.Designer.cs - - - $(NoWarn);CS1591;1591;1573;1572;NU1605;IDE0060 - \ No newline at end of file diff --git a/backend/src/Squidex/Startup.cs b/backend/src/Squidex/Startup.cs index dc4a1bc17..1a692bd4c 100644 --- a/backend/src/Squidex/Startup.cs +++ b/backend/src/Squidex/Startup.cs @@ -96,13 +96,13 @@ public sealed class Startup if (!app.ApplicationServices.GetRequiredService().IsDevelopment()) { - app.UseWhen(c => c.Request.Path.StartsWithSegments(Constants.PrefixIdentityServer, StringComparison.OrdinalIgnoreCase), builder => + app.UseWhenPath(Constants.PrefixIdentityServer, builder => { builder.UseExceptionHandler("/identity-server/error"); }); } - app.UseWhen(c => c.Request.Path.StartsWithSegments(Constants.PrefixApi, StringComparison.OrdinalIgnoreCase), builder => + app.UseWhenPath(Constants.PrefixApi, builder => { builder.UseSquidexCacheKeys(); builder.UseSquidexExceptionHandling(); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/ConfigPlansProviderTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/ConfigPlansProviderTests.cs index 53a2088ac..02a782038 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/ConfigPlansProviderTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/ConfigPlansProviderTests.cs @@ -56,7 +56,7 @@ public class ConfigPlansProviderTests [Theory] [InlineData(null)] [InlineData("my-plan")] - public void Should_return_infinite_if_nothing_configured(string planId) + public void Should_return_infinite_if_nothing_configured(string? planId) { var sut = new ConfigPlansProvider(Enumerable.Empty()); diff --git a/backend/tests/Squidex.Infrastructure.Tests/FileExtensionsTests.cs b/backend/tests/Squidex.Infrastructure.Tests/FileExtensionsTests.cs index 2f41d7788..1a5489bfc 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/FileExtensionsTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/FileExtensionsTests.cs @@ -27,9 +27,9 @@ public class FileExtensionsTests [InlineData("")] [InlineData(" ")] [InlineData(null)] - public void Should_blob_for_invalid_file_types(string fileName) + public void Should_blob_for_invalid_file_types(string? fileName) { - var actual = fileName.FileType(); + var actual = fileName!.FileType(); Assert.Equal("blob", actual); } diff --git a/backend/tests/Squidex.Infrastructure.Tests/HashExtensionsTests.cs b/backend/tests/Squidex.Infrastructure.Tests/HashExtensionsTests.cs index e601b729c..571e8b79d 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/HashExtensionsTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/HashExtensionsTests.cs @@ -107,6 +107,34 @@ public class HashExtensionsTests Assert.Equal(hash1, hash2); } + [Fact] + public void Should_calculate_incremental_hash() + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.MD5); + + hash.AppendString($"{Guid.Empty}-{Guid.Empty}"); + hash.AppendString($"{Guid.Empty}-{Guid.Empty}-{Guid.Empty}-{Guid.Empty}"); + hash.AppendLong(42); + + var result = hash.GetHexStringAndReset(); + + Assert.Equal("62D6F977A8C2B79983A489AA5932C512", result); + } + + [Fact] + public void Should_calculate_incremental_quoted_hash() + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.MD5); + + hash.AppendString($"{Guid.Empty}-{Guid.Empty}"); + hash.AppendString($"{Guid.Empty}-{Guid.Empty}-{Guid.Empty}-{Guid.Empty}"); + hash.AppendLong(42); + + var result = hash.GetQuotedHexStringAndReset(); + + Assert.Equal("\"62D6F977A8C2B79983A489AA5932C512\"", result); + } + private delegate string HashFromBytes(ReadOnlySpan source); private static void AssertHashFromBytes(HashFromBytes hasher, int length) diff --git a/backend/tests/Squidex.Infrastructure.Tests/LanguageTests.cs b/backend/tests/Squidex.Infrastructure.Tests/LanguageTests.cs index b1db8aa8e..9acb9acd8 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/LanguageTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/LanguageTests.cs @@ -127,9 +127,9 @@ public class LanguageTests [InlineData("xx")] [InlineData("invalid")] [InlineData(null)] - public void Should_parse_invalid_languages(string input) + public void Should_parse_invalid_languages(string? input) { - var language = Language.ParseOrNull(input); + var language = Language.ParseOrNull(input!); Assert.Null(language); } diff --git a/backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs b/backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs index 581b8a528..2b17116d1 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs @@ -16,7 +16,7 @@ public class StringExtensionsTests [InlineData("me@@web.com", false)] [InlineData("me@web.com", true)] [InlineData("Me@web.com", true)] - public void Should_check_email(string email, bool isEmail) + public void Should_check_email(string? email, bool isEmail) { Assert.Equal(isEmail, email.IsEmail()); } @@ -25,7 +25,7 @@ public class StringExtensionsTests [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void Should_provide_fallback_if_invalid(string value) + public void Should_provide_fallback_if_invalid(string? value) { Assert.Equal("fallback", value.Or("fallback")); } diff --git a/backend/tests/Squidex.Web.Tests/CommandMiddlewares/ETagCommandMiddlewareTests.cs b/backend/tests/Squidex.Web.Tests/CommandMiddlewares/ETagCommandMiddlewareTests.cs index 73e93be97..5555f39d8 100644 --- a/backend/tests/Squidex.Web.Tests/CommandMiddlewares/ETagCommandMiddlewareTests.cs +++ b/backend/tests/Squidex.Web.Tests/CommandMiddlewares/ETagCommandMiddlewareTests.cs @@ -51,7 +51,7 @@ public class ETagCommandMiddlewareTests [Fact] public async Task Should_add_expected_version_to_command() { - httpContext.Request.Headers[HeaderNames.IfMatch] = "13"; + httpContext.Request.Headers[HeaderNames.IfMatch] = "\"13\""; var context = await HandleAsync(new CreateContent(), true); @@ -61,7 +61,7 @@ public class ETagCommandMiddlewareTests [Fact] public async Task Should_add_weak_etag_as_expected_version_to_command() { - httpContext.Request.Headers[HeaderNames.IfMatch] = "W/13"; + httpContext.Request.Headers[HeaderNames.IfMatch] = "W/\"13\""; var context = await HandleAsync(new CreateContent(), true); diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/CachingFilterTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/CachingFilterTests.cs index d93bad053..98dd4fe97 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/CachingFilterTests.cs +++ b/backend/tests/Squidex.Web.Tests/Pipeline/CachingFilterTests.cs @@ -45,10 +45,10 @@ public class CachingFilterTests } [Theory] - [InlineData("13", "13")] - [InlineData("13", "W/13")] - [InlineData("W/13", "13")] - [InlineData("W/13", "W/13")] + [InlineData("\"13\"", "\"13\"")] + [InlineData("\"13\"", "W/\"13\"")] + [InlineData("W/\"13\"", "\"13\"")] + [InlineData("W/\"13\"", "W/\"13\"")] public async Task Should_return_304_for_same_etags(string ifNoneMatch, string etag) { httpContext.Request.Method = HttpMethods.Get; @@ -62,10 +62,10 @@ public class CachingFilterTests } [Theory] - [InlineData("13", "13")] - [InlineData("13", "W/13")] - [InlineData("W/13", "13")] - [InlineData("W/13", "W/13")] + [InlineData("\"13\"", "\"13\"")] + [InlineData("\"13\"", "W/\"13\"")] + [InlineData("W/\"13\"", "\"13\"")] + [InlineData("W/\"13\"", "W/\"13\"")] public async Task Should_not_return_304_for_same_etags_when_disabled_via_metadata(string ifNoneMatch, string etag) { executingContext.ActionDescriptor.EndpointMetadata = new List @@ -87,7 +87,7 @@ public class CachingFilterTests public async Task Should_return_304_for_same_etags_from_cache_manager() { httpContext.Request.Method = HttpMethods.Get; - httpContext.Request.Headers[HeaderNames.IfNoneMatch] = "2C70E12B7A0646F92279F427C7B38E7334D8E5389CFF167A1DC30E73F826B683"; + httpContext.Request.Headers[HeaderNames.IfNoneMatch] = "\"2C70E12B7A0646F92279F427C7B38E7334D8E5389CFF167A1DC30E73F826B683\""; await sut.OnActionExecutionAsync(executingContext, () => { diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/CachingKeysMiddlewareTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/CachingKeysMiddlewareTests.cs index 23f8fd6a5..ff70c69df 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/CachingKeysMiddlewareTests.cs +++ b/backend/tests/Squidex.Web.Tests/Pipeline/CachingKeysMiddlewareTests.cs @@ -170,10 +170,10 @@ public class CachingKeysMiddlewareTests await MakeRequestAsync(() => { - httpContext.Response.Headers[HeaderNames.ETag] = "13"; + httpContext.Response.Headers[HeaderNames.ETag] = "\"13\""; }); - Assert.Equal("13", httpContext.Response.Headers[HeaderNames.ETag]); + Assert.Equal("\"13\"", httpContext.Response.Headers[HeaderNames.ETag]); } [Fact] @@ -181,10 +181,10 @@ public class CachingKeysMiddlewareTests { await MakeRequestAsync(() => { - httpContext.Response.Headers[HeaderNames.ETag] = "W/13"; + httpContext.Response.Headers[HeaderNames.ETag] = "W/\"13\""; }); - Assert.Equal("W/13", httpContext.Response.Headers[HeaderNames.ETag]); + Assert.Equal("W/\"13\"", httpContext.Response.Headers[HeaderNames.ETag]); } [Fact] @@ -192,10 +192,10 @@ public class CachingKeysMiddlewareTests { await MakeRequestAsync(() => { - httpContext.Response.Headers[HeaderNames.ETag] = "13"; + httpContext.Response.Headers[HeaderNames.ETag] = "\"13\""; }); - Assert.Equal("W/13", httpContext.Response.Headers[HeaderNames.ETag]); + Assert.Equal("W/\"13\"", httpContext.Response.Headers[HeaderNames.ETag]); } [Fact] @@ -358,7 +358,7 @@ public class CachingKeysMiddlewareTests var etag = httpContext.Response.Headers[HeaderNames.ETag].ToString(); - Assert.True(ETagUtils.IsWeakEtag(etag)); + Assert.True(EntityTagHeaderValue.TryParse(etag, out var parsed) && parsed.IsWeak); Assert.True(etag.Length > 20); } @@ -379,7 +379,7 @@ public class CachingKeysMiddlewareTests var etag = httpContext.Response.Headers[HeaderNames.ETag].ToString(); - Assert.True(ETagUtils.IsStrongEtag(etag)); + Assert.True(EntityTagHeaderValue.TryParse(etag, out var parsed) && !parsed.IsWeak); Assert.True(etag.Length > 20); } diff --git a/frontend/src/app/features/content/pages/content/content-history-page.component.html b/frontend/src/app/features/content/pages/content/content-history-page.component.html index 394bd86a3..42aa1c9c7 100644 --- a/frontend/src/app/features/content/pages/content/content-history-page.component.html +++ b/frontend/src/app/features/content/pages/content/content-history-page.component.html @@ -1,6 +1,6 @@ -
+
@@ -11,6 +11,9 @@
+
+ : {{content.version}} +
diff --git a/frontend/src/app/features/content/pages/content/content-page.component.html b/frontend/src/app/features/content/pages/content/content-page.component.html index 040ba2518..dd2bec1e5 100644 --- a/frontend/src/app/features/content/pages/content/content-page.component.html +++ b/frontend/src/app/features/content/pages/content/content-page.component.html @@ -4,8 +4,8 @@
- - + + diff --git a/frontend/src/app/features/content/pages/content/content-page.component.ts b/frontend/src/app/features/content/pages/content/content-page.component.ts index 1d2089ae0..c12b0b462 100644 --- a/frontend/src/app/features/content/pages/content/content-page.component.ts +++ b/frontend/src/app/features/content/pages/content/content-page.component.ts @@ -267,6 +267,7 @@ export class ContentPageComponent implements CanComponentDeactivate, OnInit { .subscribe({ next: content => { this.contentForm.submitCompleted({ noReset: true }); + this.contentForm.load(content.data, true); this.router.navigate([content.id, 'history'], { relativeTo: this.route.parent! }); }, diff --git a/frontend/src/app/features/content/shared/list/content.component.html b/frontend/src/app/features/content/shared/list/content.component.html index b4eaf2e57..fa2052385 100644 --- a/frontend/src/app/features/content/shared/list/content.component.html +++ b/frontend/src/app/features/content/shared/list/content.component.html @@ -22,8 +22,7 @@ - diff --git a/frontend/src/app/features/rules/pages/rule/rule-page.component.html b/frontend/src/app/features/rules/pages/rule/rule-page.component.html index db6a128d6..dcd67f829 100644 --- a/frontend/src/app/features/rules/pages/rule/rule-page.component.html +++ b/frontend/src/app/features/rules/pages/rule/rule-page.component.html @@ -5,8 +5,7 @@
- - + diff --git a/frontend/src/app/features/rules/pages/rules/rule.component.html b/frontend/src/app/features/rules/pages/rules/rule.component.html index d8ebfbc1f..4f15dcd54 100644 --- a/frontend/src/app/features/rules/pages/rules/rule.component.html +++ b/frontend/src/app/features/rules/pages/rules/rule.component.html @@ -12,8 +12,7 @@
- diff --git a/frontend/src/app/features/schemas/pages/schema/fields/field.component.html b/frontend/src/app/features/schemas/pages/schema/fields/field.component.html index b330c7e38..7708f48ac 100644 --- a/frontend/src/app/features/schemas/pages/schema/fields/field.component.html +++ b/frontend/src/app/features/schemas/pages/schema/fields/field.component.html @@ -41,8 +41,7 @@ - diff --git a/frontend/src/app/features/schemas/pages/schema/schema-page.component.html b/frontend/src/app/features/schemas/pages/schema/schema-page.component.html index 66b80d890..5a6a04cd1 100644 --- a/frontend/src/app/features/schemas/pages/schema/schema-page.component.html +++ b/frontend/src/app/features/schemas/pages/schema/schema-page.component.html @@ -41,8 +41,7 @@
- diff --git a/frontend/src/app/features/schemas/pages/schemas/schemas-page.component.html b/frontend/src/app/features/schemas/pages/schemas/schemas-page.component.html index fb064ff8a..21abe75fd 100644 --- a/frontend/src/app/features/schemas/pages/schemas/schemas-page.component.html +++ b/frontend/src/app/features/schemas/pages/schemas/schemas-page.component.html @@ -5,7 +5,7 @@
-
-
diff --git a/frontend/src/app/framework/angular/layout.component.html b/frontend/src/app/framework/angular/layout.component.html index ffaa498f5..543e8ff86 100644 --- a/frontend/src/app/framework/angular/layout.component.html +++ b/frontend/src/app/framework/angular/layout.component.html @@ -49,8 +49,7 @@
- diff --git a/frontend/src/app/shared/components/contents/content-status.component.html b/frontend/src/app/shared/components/contents/content-status.component.html index 1187817c8..f15c70c46 100644 --- a/frontend/src/app/shared/components/contents/content-status.component.html +++ b/frontend/src/app/shared/components/contents/content-status.component.html @@ -29,13 +29,13 @@ - + - + diff --git a/frontend/src/app/shared/components/contents/content-status.component.ts b/frontend/src/app/shared/components/contents/content-status.component.ts index 83cdfe7ee..e542b0baa 100644 --- a/frontend/src/app/shared/components/contents/content-status.component.ts +++ b/frontend/src/app/shared/components/contents/content-status.component.ts @@ -58,7 +58,7 @@ export class ContentStatusComponent { } public ngOnChanges(changes: TypedSimpleChanges) { - if (changes.scheduled) { + if (changes.scheduled || changes.status) { if (this.scheduled) { const args = { status: this.scheduled.status, time: this.scheduled.dueTime.toStringFormat('PPpp') }; diff --git a/frontend/src/app/shared/state/contents.forms.ts b/frontend/src/app/shared/state/contents.forms.ts index a599cd17a..3a72d34c4 100644 --- a/frontend/src/app/shared/state/contents.forms.ts +++ b/frontend/src/app/shared/state/contents.forms.ts @@ -131,9 +131,7 @@ export class EditContentForm extends Form { }); value$(this.form).pipe(debounceTimeSafe(debounce), distinctUntilChanged(Types.equals)).subscribe(value => { - this.valueChange$.next(value); - - this.updateState(value); + this.updateValue(value); }); this.updateInitialData(); @@ -161,6 +159,8 @@ export class EditContentForm extends Form { if (isInitial) { this.updateInitialData(); } + + this.updateValue(this.form.value); } protected disable() { @@ -197,6 +197,12 @@ export class EditContentForm extends Form { } } + private updateValue(value: any) { + this.valueChange$.next(value); + + this.updateState(value); + } + private updateInitialData() { this.initialData = this.form.value; } diff --git a/tools/TestSuite/TestSuite.ApiTests/AppClientsTests.cs b/tools/TestSuite/TestSuite.ApiTests/AppClientsTests.cs index 2d76ac730..f0fd0b015 100644 --- a/tools/TestSuite/TestSuite.ApiTests/AppClientsTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/AppClientsTests.cs @@ -70,11 +70,11 @@ public sealed class AppClientsTests : IClassFixture var client_2 = clients_2.Items.Find(x => x.Id == client.Id); // Should update client name. - Assert.Equal(updateNameRequest.Name, client_2.Name); - Assert.Equal(updateNameRequest.AllowAnonymous, client_2.AllowAnonymous); - Assert.Equal(updateNameRequest.ApiCallsLimit, client_2.ApiCallsLimit); - Assert.Equal(updateNameRequest.ApiTrafficLimit, client_2.ApiTrafficLimit); - Assert.Equal(updateNameRequest.Role, client_2.Role); + Assert.Equal(updateNameRequest.Name, client_2?.Name); + Assert.Equal(updateNameRequest.AllowAnonymous, client_2?.AllowAnonymous); + Assert.Equal(updateNameRequest.ApiCallsLimit, client_2?.ApiCallsLimit); + Assert.Equal(updateNameRequest.ApiTrafficLimit, client_2?.ApiTrafficLimit); + Assert.Equal(updateNameRequest.Role, client_2?.Role); await Verify(clients_2) .IgnoreMember(x => x.Secret); @@ -111,6 +111,6 @@ public sealed class AppClientsTests : IClassFixture var clients = await app.Apps.PostClientAsync(createRequest); var client = clients.Items.Find(x => x.Id == id); - return client; + return client!; } } diff --git a/tools/TestSuite/TestSuite.ApiTests/AppContributorsTests.cs b/tools/TestSuite/TestSuite.ApiTests/AppContributorsTests.cs index 322b22661..a879434fe 100644 --- a/tools/TestSuite/TestSuite.ApiTests/AppContributorsTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/AppContributorsTests.cs @@ -16,8 +16,6 @@ namespace TestSuite.ApiTests; [UsesVerify] public sealed class AppContributorsTests : IClassFixture { - private readonly string email = $"{Guid.NewGuid()}@squidex.io"; - public ClientFixture _ { get; } public AppContributorsTests(ClientFixture fixture) @@ -78,7 +76,7 @@ public sealed class AppContributorsTests : IClassFixture // STEP 1: Update contributor role. var updateRequest = new AssignContributorDto { - ContributorId = email, + ContributorId = contributor.ContributorId, // Test update of role. Role = "Owner" }; @@ -96,30 +94,56 @@ public sealed class AppContributorsTests : IClassFixture var (app, _) = await _.PostAppAsync(); - // STEP 1: Assign contributor. - var contributor = await InviteAsync(app); + // STEP 1: Assign first contributor. + await InviteAsync(app, "Owner"); + + + // STEP 2: Assign other contributor. + var contributor2 = await InviteAsync(app, "Owner"); // STEP 1: Remove contributor. - var contributors_2 = await app.Apps.DeleteContributorAsync(contributor.ContributorId); + var contributors_2 = await app.Apps.DeleteContributorAsync(contributor2.ContributorId); + + Assert.DoesNotContain(contributors_2.Items, x => x.ContributorId == contributor2.ContributorId); + + await Verify(contributors_2) + .IgnoreMember(x => x.ContributorId) + .IgnoreMember(x => x.ContributorEmail) + .IgnoreMember(x => x.ContributorName); + } + + [Fact] + public async Task Should_not_remove_single_owner() + { + // STEP 0: Create app. + var (app, _) = await _.PostAppAsync(); + - Assert.DoesNotContain(contributors_2.Items, x => x.ContributorId == contributor.ContributorId); + // STEP 1: Get contributors + var contributor = await InviteAsync(app, "Owner"); - await Verify(contributors_2); + + // STEP 2: Remove contributor. + await Assert.ThrowsAnyAsync(() => app.Apps.DeleteContributorAsync(contributor.ContributorId)); } - private async Task InviteAsync(ISquidexClient app) + private static async Task InviteAsync(ISquidexClient app, string? role = null) { + var email = $"{Guid.NewGuid()}@squidex.io"; + var createInviteRequest = new AssignContributorDto { ContributorId = email, // Invite must be true, otherwise new users are not created. - Invite = true + Invite = true, + // The initial role or editor otherwise. + Role = role }; var contributors = await app.Apps.PostContributorAsync(createInviteRequest); var contributor = contributors.Items.Find(x => x.ContributorName == email); - return contributor; + return contributor!; } } diff --git a/tools/TestSuite/TestSuite.ApiTests/AppLanguagesTests.cs b/tools/TestSuite/TestSuite.ApiTests/AppLanguagesTests.cs index 54e239390..e6b2c34b1 100644 --- a/tools/TestSuite/TestSuite.ApiTests/AppLanguagesTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/AppLanguagesTests.cs @@ -84,8 +84,8 @@ public sealed class AppLanguagesTests : IClassFixture var languages_2 = await app.Apps.PutLanguageAsync("de", updateRequest); var language_2_DE = languages_2.Items.Find(x => x.Iso2Code == "de"); - Assert.Equal(updateRequest.Fallback, language_2_DE.Fallback); - Assert.Equal(updateRequest.IsOptional, language_2_DE.IsOptional); + Assert.Equal(updateRequest.Fallback, language_2_DE?.Fallback); + Assert.Equal(updateRequest.IsOptional, language_2_DE?.IsOptional); await Verify(languages_2); } @@ -125,16 +125,16 @@ public sealed class AppLanguagesTests : IClassFixture var language_4_IT = languages_4.Items.Find(x => x.Iso2Code == "it"); var language_4_EN = languages_4.Items.Find(x => x.Iso2Code == "en"); - Assert.True(language_4_IT.IsMaster); + Assert.True(language_4_IT?.IsMaster); // Old master language is unset. - Assert.False(language_4_EN.IsMaster); + Assert.False(language_4_EN?.IsMaster); // Master language cannot be optional. - Assert.False(language_4_IT.IsOptional); + Assert.False(language_4_IT?.IsOptional); // Fallback for new master language must be removed. - Assert.Empty(language_4_IT.Fallback); + Assert.Empty(language_4_IT?.Fallback!); await Verify(languages_4); } @@ -169,8 +169,8 @@ public sealed class AppLanguagesTests : IClassFixture var language_2_IT = languages_2.Items.Find(x => x.Iso2Code == "it"); // Fallback language must be removed. - Assert.Empty(language_2_IT.Fallback); - Assert.Equal(new[] { "en", "it" }, languages_2.Items.Select(x => x.Iso2Code).ToArray()); + Assert.Empty(language_2_IT?.Fallback!); + Assert.Equal(new[] { "en", "it" }, languages_2?.Items.Select(x => x.Iso2Code).ToArray()); await Verify(languages_2); } diff --git a/tools/TestSuite/TestSuite.ApiTests/AppRolesTests.cs b/tools/TestSuite/TestSuite.ApiTests/AppRolesTests.cs index adfc82d33..111927b85 100644 --- a/tools/TestSuite/TestSuite.ApiTests/AppRolesTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/AppRolesTests.cs @@ -70,7 +70,7 @@ public sealed class AppRolesTests : IClassFixture var role_2 = roles_2.Items.Find(x => x.Name == roleName); // Should return role with correct name. - Assert.Equal(updateRequest.Permissions, role_2.Permissions); + Assert.Equal(updateRequest.Permissions, role_2?.Permissions); await Verify(role_2) .IgnoreMember(x => x.Name); @@ -97,8 +97,8 @@ public sealed class AppRolesTests : IClassFixture var role_2 = roles_2.Items.Find(x => x.Name == roleName); // Should return role with correct number of users and clients. - Assert.Equal(1, role_2.NumClients); - Assert.Equal(0, role_2.NumContributors); + Assert.Equal(1, role_2?.NumClients); + Assert.Equal(0, role_2?.NumContributors); // STEP 4: Try to delete role. @@ -132,8 +132,8 @@ public sealed class AppRolesTests : IClassFixture var role_2 = roles_2.Items.Find(x => x.Name == roleName); // Should return role with correct number of users and clients. - Assert.Equal(0, role_2.NumClients); - Assert.Equal(1, role_2.NumContributors); + Assert.Equal(0, role_2?.NumClients); + Assert.Equal(1, role_2?.NumContributors); // STEP 4: Try to delete role.. @@ -153,7 +153,7 @@ public sealed class AppRolesTests : IClassFixture Assert.DoesNotContain(roles_3.Items, x => x.Name == roleName); } - private async Task AssignContributor(string role = null) + private async Task AssignContributor(string? role = null) { var assignRequest = new AssignContributorDto { @@ -167,7 +167,7 @@ public sealed class AppRolesTests : IClassFixture await _.Client.Apps.PostContributorAsync(assignRequest); } - private async Task AssignClient(string role = null) + private async Task AssignClient(string? role = null) { var updateRequest = new UpdateClientDto { @@ -187,6 +187,6 @@ public sealed class AppRolesTests : IClassFixture var roles = await _.Client.Apps.PostRoleAsync(createRequest); var role = roles.Items.Find(x => x.Name == name); - return role; + return role!; } } diff --git a/tools/TestSuite/TestSuite.ApiTests/AppWorkflowsTests.cs b/tools/TestSuite/TestSuite.ApiTests/AppWorkflowsTests.cs index ac35ad691..a52df447c 100644 --- a/tools/TestSuite/TestSuite.ApiTests/AppWorkflowsTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/AppWorkflowsTests.cs @@ -110,6 +110,6 @@ public sealed class AppWorkflowsTests : IClassFixture var workflows = await app.Apps.PostWorkflowAsync(createRequest); var workflow = workflows.Items.Find(x => x.Name == workflowName); - return workflow; + return workflow!; } } diff --git a/tools/TestSuite/TestSuite.ApiTests/AssetFoldersTests.cs b/tools/TestSuite/TestSuite.ApiTests/AssetFoldersTests.cs index f7cf124c7..5ee8b0cc6 100644 --- a/tools/TestSuite/TestSuite.ApiTests/AssetFoldersTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/AssetFoldersTests.cs @@ -75,26 +75,25 @@ public class AssetFoldersTests : IClassFixture Assert.Equal(folder1.Id, folder2_1.ParentId); } - [Fact] - public async Task Should_move_asset() + [Theory] + [InlineData(ContentStrategies.Move.Single)] + [InlineData(ContentStrategies.Move.Bulk)] + public async Task Should_move_asset(ContentStrategies.Move strategy) { // STEP 1: Create folder. var folder1 = await CreateFolderAsync(Guid.NewGuid().ToString()); // STEP 2: Create asset. - var asset_0 = await _.Client.Assets.UploadFileAsync("Assets/logo-squared.png", "image/png"); + var asset_1 = await _.Client.Assets.UploadFileAsync("Assets/logo-squared.png", "image/png"); // STEP 2: Update folder - var moveRequest = new MoveAssetDto - { - ParentId = folder1.Id - }; + await _.Client.Assets.MoveAsync(asset_1, folder1, strategy); - var asset_1 = await _.Client.Assets.PutAssetParentAsync(asset_0.Id, moveRequest); + var asset_2 = await _.Client.Assets.GetAssetAsync(asset_1.Id); - Assert.Equal(folder1.Id, asset_1.ParentId); + Assert.Equal(folder1.Id, asset_2.ParentId); } [Fact] @@ -157,7 +156,7 @@ public class AssetFoldersTests : IClassFixture Assert.Contains(folder2.Id, folders3.Items.Select(x => x.Id)); } - private async Task CreateFolderAsync(string name, string parentId = null) + private async Task CreateFolderAsync(string name, string? parentId = null) { var createRequest = new CreateAssetFolderDto { diff --git a/tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs b/tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs index 2e93f51e9..3923257fa 100644 --- a/tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs @@ -30,9 +30,9 @@ public class AssetFormatTests : IClassFixture // Should parse image metadata. Assert.True(asset.IsImage); - Assert.Equal(600L, (long)asset.PixelWidth); + Assert.Equal(600L, (long)asset.PixelWidth!); Assert.Equal(600L, asset.Metadata["pixelWidth"]); - Assert.Equal(400L, (long)asset.PixelHeight); + Assert.Equal(400L, (long)asset.PixelHeight!); Assert.Equal(400L, asset.Metadata["pixelHeight"]); Assert.Equal(AssetType.Image, asset.Type); @@ -119,9 +119,9 @@ public class AssetFormatTests : IClassFixture // Should parse image metadata. Assert.True(asset.IsImage); - Assert.Equal(600L, (long)asset.PixelWidth); + Assert.Equal(600L, (long)asset.PixelWidth!); Assert.Equal(600L, asset.Metadata["pixelWidth"]); - Assert.Equal(400L, (long)asset.PixelHeight); + Assert.Equal(400L, (long)asset.PixelHeight!); Assert.Equal(400L, asset.Metadata["pixelHeight"]); Assert.Equal(AssetType.Image, asset.Type); @@ -137,9 +137,9 @@ public class AssetFormatTests : IClassFixture // Should parse image metadata and fix orientation. Assert.True(asset.IsImage); - Assert.Equal(600L, (long)asset.PixelWidth); + Assert.Equal(600L, (long)asset.PixelWidth!); Assert.Equal(600L, asset.Metadata["pixelWidth"]); - Assert.Equal(135L, (long)asset.PixelHeight); + Assert.Equal(135L, (long)asset.PixelHeight!); Assert.Equal(135L, asset.Metadata["pixelHeight"]); Assert.Equal(79L, asset.Metadata["imageQuality"]); Assert.Equal(AssetType.Image, asset.Type); @@ -232,7 +232,7 @@ public class AssetFormatTests : IClassFixture } } - private async Task<(long, string)> GetReformattedLength(string imageId, string format) + private async Task<(long, string?)> GetReformattedLength(string imageId, string format) { var url = $"{_.Client.GenerateImageUrl(imageId)}?format={format}"; @@ -246,7 +246,7 @@ public class AssetFormatTests : IClassFixture await stream.CopyToAsync(buffer); - return (buffer.Length, response.Content.Headers.ContentType.ToString()); + return (buffer.Length, response.Content.Headers.ContentType?.ToString()); } } } diff --git a/tools/TestSuite/TestSuite.ApiTests/AssetScriptingTests.cs b/tools/TestSuite/TestSuite.ApiTests/AssetScriptingTests.cs new file mode 100644 index 000000000..67f94c944 --- /dev/null +++ b/tools/TestSuite/TestSuite.ApiTests/AssetScriptingTests.cs @@ -0,0 +1,151 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.ClientLibrary; +using TestSuite.Fixtures; + +#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 + +namespace TestSuite.ApiTests; + +public class AssetScriptingTests : IClassFixture +{ + public ClientFixture _ { get; } + + public AssetScriptingTests(ClientFixture fixture) + { + _ = fixture; + } + + [Fact] + public async Task Should_disallow_jpeg_file_on_create() + { + var (client, _) = await _.PostAppAsync(); + + // STEP 0: Configure script. + var scriptRequest = new UpdateAssetScriptsDto + { + Create = @" + if (ctx.command.mimeType == 'image/jpg') { + disallow('We do not use jpeg anymore.'); + }" + }; + + await client.Apps.PutAssetScriptsAsync(scriptRequest); + + + // STEP 1: Upload jpeg. + var ex = await Assert.ThrowsAnyAsync(() => client.Assets.UploadFileAsync("Assets/logo-wide-rotated.jpg", "image/jpg")); + + Assert.Contains("We do not use jpeg anymore.", ex.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task Should_update_metadata_and_tags_on_create() + { + var (client, _) = await _.PostAppAsync(); + + // STEP 0: Configure script. + var scriptRequest = new UpdateAssetScriptsDto + { + Create = @" + ctx.command.metadata['key1'] = 'value1'; + ctx.command.metadata['key2'] = 'value2'; + ctx.command.tags.add('tag1'); + ctx.command.tags.add('tag2');" + }; + + await client.Apps.PutAssetScriptsAsync(scriptRequest); + + + // STEP 1: Upload jpeg. + var asset = await client.Assets.UploadFileAsync("Assets/logo-wide-rotated.jpg", "image/jpg"); + + Assert.Equal("value1", asset.Metadata["key1"]); + Assert.Equal("value2", asset.Metadata["key2"]); + Assert.Contains("tag1", asset.Tags); + Assert.Contains("tag2", asset.Tags); + } + + [Fact] + public async Task Should_disallow_jpeg_file_on_update() + { + var (client, _) = await _.PostAppAsync(); + + // STEP 0: Configure script. + var scriptRequest = new UpdateAssetScriptsDto + { + Update = @" + if (ctx.command.mimeType == 'image/jpg') { + disallow('We do not use jpeg anymore.'); + }" + }; + + await client.Apps.PutAssetScriptsAsync(scriptRequest); + + + // STEP 1. Upload initial png. + var asset_0 = await client.Assets.UploadFileAsync("Assets/logo-wide.png", "image/png"); + + + // STEP 2: Upload jpeg. + var ex = await Assert.ThrowsAnyAsync(() => client.Assets.ReplaceFileAsync(asset_0.Id, "Assets/logo-wide-rotated.jpg", "image/jpg")); + + Assert.Contains("We do not use jpeg anymore.", ex.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task Should_update_metadata_on_update() + { + var (client, _) = await _.PostAppAsync(); + + // STEP 0: Configure script. + var scriptRequest = new UpdateAssetScriptsDto + { + Update = @" + ctx.command.metadata['key1'] = 'value1'; + ctx.command.metadata['key2'] = 'value2';" + }; + + await client.Apps.PutAssetScriptsAsync(scriptRequest); + + + // STEP 1. Upload initial png. + var asset_0 = await client.Assets.UploadFileAsync("Assets/logo-wide.png", "image/png"); + + + // STEP 2: Upload jpeg. + var asset = await client.Assets.ReplaceFileAsync(asset_0.Id, "Assets/logo-wide-rotated.jpg", "image/jpg"); + + Assert.Equal("value1", asset.Metadata["key1"]); + Assert.Equal("value2", asset.Metadata["key2"]); + } + + [Fact] + public async Task Should_query_asset_with_scripting() + { + var (client, _) = await _.PostAppAsync(); + + // STEP 0: Configure script. + var scriptRequest = new UpdateAssetScriptsDto + { + Query = @" + if (ctx.asset.mimeType == 'image/jpg') { + disallow('We do not use jpeg anymore.'); + }" + }; + + await client.Apps.PutAssetScriptsAsync(scriptRequest); + + + // STEP 1. Upload initial jpg. + var ex = await Assert.ThrowsAnyAsync(() => client.Assets.UploadFileAsync("Assets/logo-wide-rotated.jpg", "image/jpg")); + + Assert.Contains("We do not use jpeg anymore.", ex.ToString(), StringComparison.Ordinal); + } +} diff --git a/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs b/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs index e442bc6fb..e49ad2369 100644 --- a/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs @@ -38,7 +38,7 @@ public class AssetTests : IClassFixture var downloaded = await _.DownloadAsync(asset_1); // Should dowload with correct size. - Assert.Equal(stream.Length, downloaded.Length); + Assert.Equal(stream.Length, downloaded?.Length); } await Verify(asset_1); @@ -64,7 +64,7 @@ public class AssetTests : IClassFixture var downloaded = await _.DownloadAsync(progress.Asset); // Should dowload with correct size. - Assert.Equal(stream.Length, downloaded.Length); + Assert.Equal(stream.Length, downloaded?.Length); } } @@ -90,7 +90,7 @@ public class AssetTests : IClassFixture var downloaded = await _.DownloadAsync(progress.Asset); // Should dowload with correct size. - Assert.Equal(stream.Length, downloaded.Length); + Assert.Equal(stream.Length, downloaded?.Length); } } } @@ -164,14 +164,14 @@ public class AssetTests : IClassFixture // STEP 2: Reupload asset. - var asset_2 = await _.Client.Assets.UploadFileAsync("Assets/logo-wide.png", asset_1); + var asset_2 = await _.Client.Assets.ReplaceFileAsync(asset_1.Id, "Assets/logo-wide.png", "image/png"); await using (var stream = new FileStream("Assets/logo-wide.png", FileMode.Open)) { var downloaded = await _.DownloadAsync(asset_2); // Should dowload with correct size. - Assert.Equal(stream.Length, downloaded.Length); + Assert.Equal(stream.Length, downloaded?.Length); } await Verify(asset_2); @@ -201,7 +201,7 @@ public class AssetTests : IClassFixture var downloaded = await _.DownloadAsync(progress.Asset); // Should dowload with correct size. - Assert.Equal(stream.Length, downloaded.Length); + Assert.Equal(stream.Length, downloaded?.Length); } } @@ -231,13 +231,78 @@ public class AssetTests : IClassFixture var downloaded = await _.DownloadAsync(progress.Asset); // Should dowload with correct size. - Assert.Equal(stream.Length, downloaded.Length); + Assert.Equal(stream.Length, downloaded?.Length); } } } [Fact] - public async Task Should_annote_asset_file_name() + public async Task Should_annotate_asset_in_parallel() + { + // STEP 1: Create asset. + var asset_1 = await _.Client.Assets.UploadFileAsync("Assets/logo-squared.png", "image/png"); + + + // STEP 3: Make parallel upserts. + await Parallel.ForEachAsync(Enumerable.Range(0, 20), async (i, ct) => + { + try + { + var randomTag1 = $"tag_{Guid.NewGuid()}"; + var randomTag2 = $"tag_{Guid.NewGuid()}"; + + var randomMetadataRequest = new AnnotateAssetDto + { + Tags = + [ + randomTag1, + randomTag2 + ] + }; + + await _.Client.Assets.PutAssetAsync(asset_1.Id, randomMetadataRequest, ct); + } + catch (SquidexException ex) when (ex.StatusCode is 409 or 412) + { + return; + } + }); + + + // STEP 3: Make an normal update to ensure nothing is corrupt. + var tag1 = $"tag_{Guid.NewGuid()}"; + var tag2 = $"tag_{Guid.NewGuid()}"; + + var metadataRequest = new AnnotateAssetDto + { + Tags = + [ + tag1, + tag2 + ] + }; + + var asset_2 = await _.Client.Assets.PutAssetAsync(asset_1.Id, metadataRequest); + + + // STEP 4: Check tags. + var tags = await _.Client.Assets.PollTagAsync(tag1); + + Assert.Contains(tag1, tags); + Assert.Contains(tag2, tags); + + Assert.Equal(1, tags[tag1]); + Assert.Equal(1, tags[tag2]); + + await Verify(asset_2) + .IgnoreMember(x => x.Version) + .IgnoreMember(x => x.Tags); + } + + [Theory] + [InlineData(ContentStrategies.Annotate.Single)] + [InlineData(ContentStrategies.Annotate.Bulk)] + public async Task Should_annote_asset_file_name(ContentStrategies.Annotate strategy) { // STEP 1: Create asset. var asset_1 = await _.Client.Assets.UploadFileAsync("Assets/logo-squared.png", "image/png"); @@ -249,16 +314,20 @@ public class AssetTests : IClassFixture FileName = "My Image" }; - var asset_2 = await _.Client.Assets.PutAssetAsync(asset_1.Id, fileNameRequest); + await _.Client.Assets.AnnotateAsync(asset_1, fileNameRequest, strategy); + + var asset_2 = await _.Client.Assets.GetAssetAsync(asset_1.Id); // Should provide updated file name. Assert.Equal(fileNameRequest.FileName, asset_2.FileName); - await Verify(asset_2); + await Verify(asset_2).UseParameters(strategy); } - [Fact] - public async Task Should_annote_asset_metadata() + [Theory] + [InlineData(ContentStrategies.Annotate.Single)] + [InlineData(ContentStrategies.Annotate.Bulk)] + public async Task Should_annote_asset_metadata(ContentStrategies.Annotate strategy) { // STEP 1: Create asset. var asset_1 = await _.Client.Assets.UploadFileAsync("Assets/logo-squared.png", "image/png"); @@ -274,16 +343,20 @@ public class AssetTests : IClassFixture } }; - var asset_2 = await _.Client.Assets.PutAssetAsync(asset_1.Id, metadataRequest); + await _.Client.Assets.AnnotateAsync(asset_1, metadataRequest, strategy); + + var asset_2 = await _.Client.Assets.GetAssetAsync(asset_1.Id); // Should provide metadata. Assert.Equal(metadataRequest.Metadata, asset_2.Metadata); - await Verify(asset_2); + await Verify(asset_2).UseParameters(strategy); } - [Fact] - public async Task Should_annote_asset_slug() + [Theory] + [InlineData(ContentStrategies.Annotate.Single)] + [InlineData(ContentStrategies.Annotate.Bulk)] + public async Task Should_annote_asset_slug(ContentStrategies.Annotate strategy) { // STEP 1: Create asset. var asset_1 = await _.Client.Assets.UploadFileAsync("Assets/logo-squared.png", "image/png"); @@ -295,16 +368,20 @@ public class AssetTests : IClassFixture Slug = "my-image" }; - var asset_2 = await _.Client.Assets.PutAssetAsync(asset_1.Id, slugRequest); + await _.Client.Assets.AnnotateAsync(asset_1, slugRequest, strategy); + + var asset_2 = await _.Client.Assets.GetAssetAsync(asset_1.Id); // Should provide updated slug. Assert.Equal(slugRequest.Slug, asset_2.Slug); - await Verify(asset_2); + await Verify(asset_2).UseParameters(strategy); } - [Fact] - public async Task Should_annote_asset_tags() + [Theory] + [InlineData(ContentStrategies.Annotate.Single)] + [InlineData(ContentStrategies.Annotate.Bulk)] + public async Task Should_annote_asset_tags(ContentStrategies.Annotate strategy) { // STEP 1: Create asset. var asset_1 = await _.Client.Assets.UploadFileAsync("Assets/logo-squared.png", "image/png"); @@ -320,79 +397,20 @@ public class AssetTests : IClassFixture ] }; - var asset_2 = await _.Client.Assets.PutAssetAsync(asset_1.Id, tagsRequest); + await _.Client.Assets.AnnotateAsync(asset_1, tagsRequest, strategy); + + var asset_2 = await _.Client.Assets.GetAssetAsync(asset_1.Id); // Should provide updated tags. Assert.Equal(tagsRequest.Tags, asset_2.Tags); - await Verify(asset_2); - } - - [Fact] - public async Task Should_annotate_asset_in_parallel() - { - // STEP 1: Create asset. - var asset_1 = await _.Client.Assets.UploadFileAsync("Assets/logo-squared.png", "image/png"); - - - // STEP 3: Make parallel upserts. - await Parallel.ForEachAsync(Enumerable.Range(0, 20), async (i, ct) => - { - try - { - var randomTag1 = $"tag_{Guid.NewGuid()}"; - var randomTag2 = $"tag_{Guid.NewGuid()}"; - - var randomMetadataRequest = new AnnotateAssetDto - { - Tags = - [ - randomTag1, - randomTag2 - ] - }; - - await _.Client.Assets.PutAssetAsync(asset_1.Id, randomMetadataRequest, ct); - } - catch (SquidexException ex) when (ex.StatusCode is 409 or 412) - { - return; - } - }); - - - // STEP 3: Make an normal update to ensure nothing is corrupt. - var tag1 = $"tag_{Guid.NewGuid()}"; - var tag2 = $"tag_{Guid.NewGuid()}"; - - var metadataRequest = new AnnotateAssetDto - { - Tags = - [ - tag1, - tag2 - ] - }; - - var asset_2 = await _.Client.Assets.PutAssetAsync(asset_1.Id, metadataRequest); - - - // STEP 4: Check tags. - var tags = await _.Client.Assets.PollTagAsync(tag1); - - Assert.Contains(tag1, tags); - Assert.Contains(tag2, tags); - - Assert.Equal(1, tags[tag1]); - Assert.Equal(1, tags[tag2]); - - await Verify(asset_2) - .IgnoreMember(x => x.Version) - .IgnoreMember(x => x.Tags); + await Verify(asset_2).UseParameters(strategy); } - [Fact] - public async Task Should_protect_asset() + [Theory] + [InlineData(ContentStrategies.Annotate.Single)] + [InlineData(ContentStrategies.Annotate.Bulk)] + public async Task Should_protect_asset(ContentStrategies.Annotate strategy) { // STEP 1: Create asset. var asset_1 = await _.Client.Assets.UploadFileAsync("Assets/logo-squared.png", "image/png"); @@ -404,7 +422,7 @@ public class AssetTests : IClassFixture var downloaded = await _.DownloadAsync(asset_1); // Should dowload with correct size. - Assert.Equal(stream.Length, downloaded.Length); + Assert.Equal(stream.Length, downloaded?.Length); } @@ -414,7 +432,9 @@ public class AssetTests : IClassFixture IsProtected = true }; - var asset_2 = await _.Client.Assets.PutAssetAsync(asset_1.Id, protectRequest); + await _.Client.Assets.AnnotateAsync(asset_1, protectRequest, strategy); + + var asset_2 = await _.Client.Assets.GetAssetAsync(asset_1.Id); // STEP 5: Download asset with authentication. @@ -457,7 +477,7 @@ public class AssetTests : IClassFixture Assert.Equal(HttpStatusCode.Forbidden, ex.StatusCode); } - await Verify(asset_2); + await Verify(asset_2).UseParameters(strategy); } [Fact] @@ -486,7 +506,7 @@ public class AssetTests : IClassFixture var downloaded = await _.DownloadAsync(asset_1); // Should dowload with correct size. - Assert.Equal(stream.Length, downloaded.Length); + Assert.Equal(stream.Length, downloaded?.Length); } @@ -558,7 +578,7 @@ public class AssetTests : IClassFixture // STEP 4: Create asset. - var asset_2 = await app.Assets.UpdateFileAsync(asset_1.Id, "Assets/logo-wide.png", "image/png"); + var asset_2 = await app.Assets.ReplaceFileAsync(asset_1.Id, "Assets/logo-wide.png", "image/png"); Assert.NotNull(asset_1.Metadata["blurHash"]); Assert.NotNull(asset_2.Metadata["blurHash"]); @@ -684,16 +704,18 @@ public class AssetTests : IClassFixture } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task Should_delete_asset(bool permanent) + [InlineData(ContentStrategies.Deletion.SingleSoft)] + [InlineData(ContentStrategies.Deletion.SinglePermanent)] + [InlineData(ContentStrategies.Deletion.BulkSoft)] + [InlineData(ContentStrategies.Deletion.BulkPermanent)] + public async Task Should_delete_asset(ContentStrategies.Deletion strategy) { // STEP 1: Create asset. var asset = await _.Client.Assets.UploadFileAsync("Assets/logo-squared.png", "image/png"); // STEP 2: Delete asset. - await _.Client.Assets.DeleteAssetAsync(asset.Id, permanent: permanent); + await _.Client.Assets.DeleteAsync(asset, strategy); // Should return 404 when asset deleted. var ex = await Assert.ThrowsAnyAsync(() => @@ -705,7 +727,7 @@ public class AssetTests : IClassFixture // STEP 3: Retrieve all items and ensure that the deleted item does not exist. - var updated = await _.Client.Assets.GetAssetsAsync((AssetQuery)null); + var updated = await _.Client.Assets.GetAssetsAsync((AssetQuery?)null); Assert.DoesNotContain(updated.Items, x => x.Id == asset.Id); @@ -716,20 +738,24 @@ public class AssetTests : IClassFixture Filter = "isDeleted eq true" }); + var permanent = strategy is ContentStrategies.Deletion.SinglePermanent or ContentStrategies.Deletion.BulkPermanent; + Assert.Equal(!permanent, deleted.Items.Exists(x => x.Id == asset.Id)); } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task Should_recreate_deleted_asset(bool permanent) + [InlineData(ContentStrategies.Deletion.SingleSoft)] + [InlineData(ContentStrategies.Deletion.SinglePermanent)] + [InlineData(ContentStrategies.Deletion.BulkSoft)] + [InlineData(ContentStrategies.Deletion.BulkPermanent)] + public async Task Should_recreate_deleted_asset(ContentStrategies.Deletion strategy) { // STEP 1: Create asset. var asset_1 = await _.Client.Assets.UploadFileAsync("Assets/logo-squared.png", "image/png"); // STEP 2: Delete asset. - await _.Client.Assets.DeleteAssetAsync(asset_1.Id, permanent: permanent); + await _.Client.Assets.DeleteAsync(asset_1, strategy); // STEP 3: Recreate asset. diff --git a/tools/TestSuite/TestSuite.ApiTests/BackupTests.cs b/tools/TestSuite/TestSuite.ApiTests/BackupTests.cs index 05d36ea02..5f61697d5 100644 --- a/tools/TestSuite/TestSuite.ApiTests/BackupTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/BackupTests.cs @@ -51,7 +51,7 @@ public class BackupTests : IClassFixture // STEP 3: Restore backup. - var uri = new Uri(new Uri(backupUrl), backup.Links["download"].Href); + var uri = new Uri(new Uri(backupUrl), backup?.Links["download"].Href); var restoreRequest = new RestoreRequestDto { @@ -96,7 +96,7 @@ public class BackupTests : IClassFixture // STEP 4: Restore backup. - var uri = new Uri(new Uri(backupUrl), backup.Links["download"].Href); + var uri = new Uri(new Uri(backupUrl), backup?.Links["download"].Href); var restoreRequest = new RestoreRequestDto { diff --git a/tools/TestSuite/TestSuite.ApiTests/CDNTests.cs b/tools/TestSuite/TestSuite.ApiTests/CDNTests.cs index d2db5c0e6..d7e74a842 100644 --- a/tools/TestSuite/TestSuite.ApiTests/CDNTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/CDNTests.cs @@ -39,7 +39,7 @@ public class CDNTests : IClassFixture var url = _.CDNClient.GenerateImageUrl(id); - await DownloadAsync(url); + await DownloadAsync(url!); } [Fact] @@ -59,7 +59,7 @@ public class CDNTests : IClassFixture var url = _.CloudClient.GenerateImageUrl(id); - await DownloadAsync(url); + await DownloadAsync(url!); } [Fact] diff --git a/tools/TestSuite/TestSuite.ApiTests/ContentCleanupTests.cs b/tools/TestSuite/TestSuite.ApiTests/ContentCleanupTests.cs index 0a698567c..6b25f25c2 100644 --- a/tools/TestSuite/TestSuite.ApiTests/ContentCleanupTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/ContentCleanupTests.cs @@ -76,7 +76,7 @@ public class ContentCleanupTests : IClassFixture // STEP 3: Create a content with a reference. var contentB_1 = await contents.CreateAsync(new TestEntityWithReferencesData { - References = new[] { contentA_1.Id } + References = [contentA_1.Id] }); @@ -91,6 +91,6 @@ public class ContentCleanupTests : IClassFixture }); // Should not return deleted field. - Assert.Empty(contentB_2.Data.References); + Assert.Empty(contentB_2.Data.References!); } } diff --git a/tools/TestSuite/TestSuite.ApiTests/ContentLanguageTests.cs b/tools/TestSuite/TestSuite.ApiTests/ContentLanguageTests.cs index e9b7ea8c6..014534d09 100644 --- a/tools/TestSuite/TestSuite.ApiTests/ContentLanguageTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/ContentLanguageTests.cs @@ -28,7 +28,7 @@ public class ContentLanguageTests : IClassFixture // STEP 1: Create content. var content = await _.Contents.CreateAsync(new TestEntityData { - Localized = new Dictionary + Localized = new Dictionary { ["de"] = "Hallo", ["en"] = "Hello" @@ -48,7 +48,7 @@ public class ContentLanguageTests : IClassFixture // STEP 1: Create content. var content = await _.Contents.CreateAsync(new TestEntityData { - Localized = new Dictionary + Localized = new Dictionary { ["de"] = "Hallo", ["en"] = "Hello", @@ -62,7 +62,7 @@ public class ContentLanguageTests : IClassFixture var contentFlatten = await contents.GetAsync(content.Id, QueryContext.Default.Flatten().WithLanguages(code)); - Assert.Equal(expected, (string)contentFlatten.Data["localized"]); + Assert.Equal(expected, (string?)contentFlatten.Data["localized"]); } [Fact] @@ -71,7 +71,7 @@ public class ContentLanguageTests : IClassFixture // STEP 1: Create content. var content = await _.Contents.CreateAsync(new TestEntityData { - Localized = new Dictionary + Localized = new Dictionary { ["de"] = "Hallo", ["en"] = "Hello" @@ -98,7 +98,7 @@ public class ContentLanguageTests : IClassFixture ["X-Flatten"] = "1" }); - static void AssertValue(string value, string not = null) + static void AssertValue(string? value, string? not = null) { Assert.NotNull(value); Assert.NotEmpty(value); @@ -111,7 +111,7 @@ public class ContentLanguageTests : IClassFixture AssertValue(etag4, etag1); } - private async Task<(string, string)> GetEtagAsync(string id, Dictionary headers) + private async Task<(string?, string)> GetEtagAsync(string id, Dictionary headers) { var url = $"{_.Client.Options.Url}api/content/{_.AppName}/{_.SchemaName}/{id}"; diff --git a/tools/TestSuite/TestSuite.ApiTests/ContentQueryFixture.cs b/tools/TestSuite/TestSuite.ApiTests/ContentQueryFixture.cs index c0d582b12..dbcd6452f 100644 --- a/tools/TestSuite/TestSuite.ApiTests/ContentQueryFixture.cs +++ b/tools/TestSuite/TestSuite.ApiTests/ContentQueryFixture.cs @@ -42,7 +42,7 @@ public sealed class ContentQueryFixture : TestSchemaFixtureBase index + 100, index, oldFormat: index % 2 == 1), - Localized = new Dictionary + Localized = new Dictionary { ["en"] = index.ToString(CultureInfo.InvariantCulture) }, diff --git a/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs b/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs index 4adba64cf..345d0dc1a 100644 --- a/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs @@ -432,7 +432,7 @@ public class ContentQueryTests : IClassFixture [Fact] public async Task Should_query_json_with_dot() { - TestEntity content = null; + TestEntity? content = null; try { // STEP 1: Create a content item with a text that caused a bug before. @@ -467,7 +467,7 @@ public class ContentQueryTests : IClassFixture var queried = await _.Contents.GetAsync(q); - Assert.Equal(42, (int)queried.Items[0].Data.Json["search.field.with.dot"]); + Assert.Equal(42, (int)queried.Items[0].Data.Json!["search.field.with.dot"]!); } finally { @@ -528,7 +528,7 @@ public class ContentQueryTests : IClassFixture var result = await _.Client.SharedDynamicContents.GraphQlAsync(query); - var value = result["createMyReadsContent"]["data"]["number"]["iv"].Value(); + var value = result?["createMyReadsContent"]?["data"]?["number"]?["iv"]?.Value(); Assert.Equal(555, value); } @@ -563,7 +563,7 @@ public class ContentQueryTests : IClassFixture var result = await _.Client.SharedDynamicContents.GraphQlAsync(query); - var value = result["createMyReadsContent"]["data"]["number"]["iv"].Value(); + var value = result?["createMyReadsContent"]?["data"]?["number"]?["iv"]?.Value(); Assert.Equal(998, value); } @@ -695,7 +695,7 @@ public class ContentQueryTests : IClassFixture var result = await _.Client.SharedDynamicContents.GraphQlAsync(query); var items = result["queryMyReadsContents"]; - Assert.Equal(new[] { 4, 5, 6 }, items.Select(x => x["data"]["number"]["iv"].Value()).ToArray()); + Assert.Equal(new[] { 4, 5, 6 }, items?.Select(x => x["data"]?["number"]?["iv"]?.Value() ?? -1).ToArray()); } [Fact] diff --git a/tools/TestSuite/TestSuite.ApiTests/ContentReferencesTests.cs b/tools/TestSuite/TestSuite.ApiTests/ContentReferencesTests.cs index 8c9d8b105..9a769e300 100644 --- a/tools/TestSuite/TestSuite.ApiTests/ContentReferencesTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/ContentReferencesTests.cs @@ -42,7 +42,7 @@ public class ContentReferencesTests : IClassFixture // STEP 3: Query new item. var contentB_2 = await _.Contents.GetAsync(contentB_1.Id); - Assert.Empty(contentB_2.Data.References); + Assert.Empty(contentB_2.Data.References!); // STEP 4: Publish reference. diff --git a/tools/TestSuite/TestSuite.ApiTests/ContentScriptingTests.cs b/tools/TestSuite/TestSuite.ApiTests/ContentScriptingTests.cs index 332db69e1..2024316f1 100644 --- a/tools/TestSuite/TestSuite.ApiTests/ContentScriptingTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/ContentScriptingTests.cs @@ -76,7 +76,7 @@ public class ContentScriptingTests : IClassFixture } [Fact] - public async Task Should_query_content_with_scripting_and_pre_query() + public async Task Should_query_content_with_scripting_and_prepare_script() { var scripts = new SchemaScriptsDto { @@ -151,7 +151,7 @@ public class ContentScriptingTests : IClassFixture } [Fact] - public async Task Should_create_bulk_content_with_scripting_but_disabled() + public async Task Should_create_bulk_content_with_disabled_scripting() { // STEP 1: Create a schema. var scripts = new SchemaScriptsDto @@ -183,7 +183,7 @@ public class ContentScriptingTests : IClassFixture iv = 99 } } - } + }, ], Publish = true }); diff --git a/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs b/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs index f840e3298..5c8483115 100644 --- a/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs @@ -125,7 +125,7 @@ public class ContentUpdateTests : IClassFixture // STEP 1: Create a content item with a text that caused a bug before. var content = await _.Contents.CreateAsync(new TestEntityData { - Localized = new Dictionary + Localized = new Dictionary { ["en"] = null } @@ -156,7 +156,7 @@ public class ContentUpdateTests : IClassFixture // STEP 2: Get the item and ensure that the text is the same. var queried = await _.Contents.GetAsync(content.Id, QueryContext.Default.IgnoreFallback()); - Assert.Equal(42, (int)queried.Data.Json["field.with.dot"]); + Assert.Equal(42, (int)queried.Data.Json!["field.with.dot"]!); await Verify(queried); } @@ -331,13 +331,13 @@ public class ContentUpdateTests : IClassFixture } [Theory] - [InlineData(Strategies.Update.Normal)] - [InlineData(Strategies.Update.Upsert)] - [InlineData(Strategies.Update.UpsertBulk)] - [InlineData(Strategies.Update.Bulk)] - [InlineData(Strategies.Update.BulkShared)] - [InlineData(Strategies.Update.BulkWithSchema)] - public async Task Should_update_content(Strategies.Update strategy) + [InlineData(ContentStrategies.Update.Normal)] + [InlineData(ContentStrategies.Update.Upsert)] + [InlineData(ContentStrategies.Update.UpsertBulk)] + [InlineData(ContentStrategies.Update.Bulk)] + [InlineData(ContentStrategies.Update.BulkShared)] + [InlineData(ContentStrategies.Update.BulkWithSchema)] + public async Task Should_update_content(ContentStrategies.Update strategy) { // STEP 1: Create a new item. var content = await _.Contents.CreateAsync(new TestEntityData @@ -361,13 +361,13 @@ public class ContentUpdateTests : IClassFixture } [Theory] - [InlineData(Strategies.Update.Normal)] - [InlineData(Strategies.Update.Upsert)] - [InlineData(Strategies.Update.UpsertBulk)] - [InlineData(Strategies.Update.Bulk)] - [InlineData(Strategies.Update.BulkShared)] - [InlineData(Strategies.Update.BulkWithSchema)] - public async Task Should_update_content_to_null(Strategies.Update strategy) + [InlineData(ContentStrategies.Update.Normal)] + [InlineData(ContentStrategies.Update.Upsert)] + [InlineData(ContentStrategies.Update.UpsertBulk)] + [InlineData(ContentStrategies.Update.Bulk)] + [InlineData(ContentStrategies.Update.BulkShared)] + [InlineData(ContentStrategies.Update.BulkWithSchema)] + public async Task Should_update_content_to_null(ContentStrategies.Update strategy) { // STEP 1: Create a new item. var content = await _.Contents.CreateAsync(new TestEntityData @@ -388,13 +388,13 @@ public class ContentUpdateTests : IClassFixture } [Theory] - [InlineData(Strategies.Patch.Normal)] - [InlineData(Strategies.Patch.Upsert)] - [InlineData(Strategies.Patch.UpsertBulk)] - [InlineData(Strategies.Patch.Bulk)] - [InlineData(Strategies.Patch.BulkShared)] - [InlineData(Strategies.Patch.BulkWithSchema)] - public async Task Should_patch_content(Strategies.Patch strategy) + [InlineData(ContentStrategies.Patch.Normal)] + [InlineData(ContentStrategies.Patch.Upsert)] + [InlineData(ContentStrategies.Patch.UpsertBulk)] + [InlineData(ContentStrategies.Patch.Bulk)] + [InlineData(ContentStrategies.Patch.BulkShared)] + [InlineData(ContentStrategies.Patch.BulkWithSchema)] + public async Task Should_patch_content(ContentStrategies.Patch strategy) { // STEP 1: Create a new item. var content = await _.Contents.CreateAsync(new TestEntityData @@ -418,13 +418,13 @@ public class ContentUpdateTests : IClassFixture } [Theory] - [InlineData(Strategies.Patch.Normal)] - [InlineData(Strategies.Patch.Upsert)] - [InlineData(Strategies.Patch.UpsertBulk)] - [InlineData(Strategies.Patch.Bulk)] - [InlineData(Strategies.Patch.BulkShared)] - [InlineData(Strategies.Patch.BulkWithSchema)] - public async Task Should_patch_id_data_value(Strategies.Patch strategy) + [InlineData(ContentStrategies.Patch.Normal)] + [InlineData(ContentStrategies.Patch.Upsert)] + [InlineData(ContentStrategies.Patch.UpsertBulk)] + [InlineData(ContentStrategies.Patch.Bulk)] + [InlineData(ContentStrategies.Patch.BulkShared)] + [InlineData(ContentStrategies.Patch.BulkWithSchema)] + public async Task Should_patch_id_data_value(ContentStrategies.Patch strategy) { // STEP 1: Create a new item. var content = await _.Contents.CreateAsync(new TestEntityData @@ -445,13 +445,13 @@ public class ContentUpdateTests : IClassFixture } [Theory] - [InlineData(Strategies.Patch.Normal)] - [InlineData(Strategies.Patch.Upsert)] - [InlineData(Strategies.Patch.UpsertBulk)] - [InlineData(Strategies.Patch.Bulk)] - [InlineData(Strategies.Patch.BulkShared)] - [InlineData(Strategies.Patch.BulkWithSchema)] - public async Task Should_patch_content_to_null(Strategies.Patch strategy) + [InlineData(ContentStrategies.Patch.Normal)] + [InlineData(ContentStrategies.Patch.Upsert)] + [InlineData(ContentStrategies.Patch.UpsertBulk)] + [InlineData(ContentStrategies.Patch.Bulk)] + [InlineData(ContentStrategies.Patch.BulkShared)] + [InlineData(ContentStrategies.Patch.BulkWithSchema)] + public async Task Should_patch_content_to_null(ContentStrategies.Patch strategy) { // STEP 1: Create a new item. var content = await _.Contents.CreateAsync(new TestEntityData @@ -463,7 +463,7 @@ public class ContentUpdateTests : IClassFixture // STEP 2: Patch with selected strategy. await _.Client.PatchAsync(content, new { - @string = new { iv = (string)null } + @string = new { iv = (string?)null } }, strategy); var updated = await _.Contents.GetAsync(content.Id); @@ -472,11 +472,11 @@ public class ContentUpdateTests : IClassFixture } [Theory] - [InlineData(Strategies.Deletion.SingleSoft)] - [InlineData(Strategies.Deletion.SinglePermanent)] - [InlineData(Strategies.Deletion.BulkSoft)] - [InlineData(Strategies.Deletion.BulkPermanent)] - public async Task Should_delete_content(Strategies.Deletion strategy) + [InlineData(ContentStrategies.Deletion.SingleSoft)] + [InlineData(ContentStrategies.Deletion.SinglePermanent)] + [InlineData(ContentStrategies.Deletion.BulkSoft)] + [InlineData(ContentStrategies.Deletion.BulkPermanent)] + public async Task Should_delete_content(ContentStrategies.Deletion strategy) { // STEP 1: Create a new item. var content = await _.Contents.CreateAsync(new TestEntityData @@ -500,15 +500,15 @@ public class ContentUpdateTests : IClassFixture var deleted = await _.Contents.GetAsync(q, QueryContext.Default.Unpublished(true)); - Assert.Equal(strategy is Strategies.Deletion.SingleSoft or Strategies.Deletion.BulkSoft, deleted.Items.Exists(x => x.Id == content.Id)); + Assert.Equal(strategy is ContentStrategies.Deletion.SingleSoft or ContentStrategies.Deletion.BulkSoft, deleted.Items.Exists(x => x.Id == content.Id)); } [Theory] - [InlineData(Strategies.Deletion.SingleSoft)] - [InlineData(Strategies.Deletion.SinglePermanent)] - [InlineData(Strategies.Deletion.BulkSoft)] - [InlineData(Strategies.Deletion.BulkPermanent)] - public async Task Should_create_content_with_custom_id_and_delete_it(Strategies.Deletion strategy) + [InlineData(ContentStrategies.Deletion.SingleSoft)] + [InlineData(ContentStrategies.Deletion.SinglePermanent)] + [InlineData(ContentStrategies.Deletion.BulkSoft)] + [InlineData(ContentStrategies.Deletion.BulkPermanent)] + public async Task Should_create_content_with_custom_id_and_delete_it(ContentStrategies.Deletion strategy) { var id = $"custom-{Guid.NewGuid()}"; @@ -534,11 +534,11 @@ public class ContentUpdateTests : IClassFixture } [Theory] - [InlineData(Strategies.Deletion.SingleSoft)] - [InlineData(Strategies.Deletion.SinglePermanent)] - [InlineData(Strategies.Deletion.BulkSoft)] - [InlineData(Strategies.Deletion.BulkPermanent)] - public async Task Should_recreate_deleted_content(Strategies.Deletion strategy) + [InlineData(ContentStrategies.Deletion.SingleSoft)] + [InlineData(ContentStrategies.Deletion.SinglePermanent)] + [InlineData(ContentStrategies.Deletion.BulkSoft)] + [InlineData(ContentStrategies.Deletion.BulkPermanent)] + public async Task Should_recreate_deleted_content(ContentStrategies.Deletion strategy) { // STEP 1: Create a new item. var content_1 = await _.Contents.CreateAsync(new TestEntityData @@ -571,11 +571,11 @@ public class ContentUpdateTests : IClassFixture } [Theory] - [InlineData(Strategies.Deletion.SingleSoft)] - [InlineData(Strategies.Deletion.SinglePermanent)] - [InlineData(Strategies.Deletion.BulkSoft)] - [InlineData(Strategies.Deletion.BulkPermanent)] - public async Task Should_recreate_deleted_content_with_upsert(Strategies.Deletion strategy) + [InlineData(ContentStrategies.Deletion.SingleSoft)] + [InlineData(ContentStrategies.Deletion.SinglePermanent)] + [InlineData(ContentStrategies.Deletion.BulkSoft)] + [InlineData(ContentStrategies.Deletion.BulkPermanent)] + public async Task Should_recreate_deleted_content_with_upsert(ContentStrategies.Deletion strategy) { // STEP 1: Create a new item. var content_1 = await _.Contents.CreateAsync(new TestEntityData @@ -598,11 +598,11 @@ public class ContentUpdateTests : IClassFixture } [Theory] - [InlineData(Strategies.Deletion.SingleSoft)] - [InlineData(Strategies.Deletion.SinglePermanent)] - [InlineData(Strategies.Deletion.BulkSoft)] - [InlineData(Strategies.Deletion.BulkPermanent)] - public async Task Should_delete_recreated_content(Strategies.Deletion strategy) + [InlineData(ContentStrategies.Deletion.SingleSoft)] + [InlineData(ContentStrategies.Deletion.SinglePermanent)] + [InlineData(ContentStrategies.Deletion.BulkSoft)] + [InlineData(ContentStrategies.Deletion.BulkPermanent)] + public async Task Should_delete_recreated_content(ContentStrategies.Deletion strategy) { var id = $"custom-{Guid.NewGuid()}"; diff --git a/tools/TestSuite/TestSuite.ApiTests/GraphQLFixture.cs b/tools/TestSuite/TestSuite.ApiTests/GraphQLFixture.cs index f5498f1ef..c4eefd01a 100644 --- a/tools/TestSuite/TestSuite.ApiTests/GraphQLFixture.cs +++ b/tools/TestSuite/TestSuite.ApiTests/GraphQLFixture.cs @@ -138,7 +138,7 @@ public sealed class GraphQLFixture : ContentFixture { SchemaIds = [citiesId] } - } + }, ], IsPublished = true }; diff --git a/tools/TestSuite/TestSuite.ApiTests/GraphQLSubscriptionTests.cs b/tools/TestSuite/TestSuite.ApiTests/GraphQLSubscriptionTests.cs index ddab65b87..22e361f66 100644 --- a/tools/TestSuite/TestSuite.ApiTests/GraphQLSubscriptionTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/GraphQLSubscriptionTests.cs @@ -130,7 +130,7 @@ public class GraphQLSubscriptionTests : IClassFixture var options = new GraphQLHttpClientOptions { - EndPoint = new Uri(_.Client.GenerateUrl($"/api/content/{_.AppName}/graphql?access_token={accessToken}")) + EndPoint = new Uri(_.Client.GenerateUrl($"/api/content/{_.AppName}/graphql?access_token={accessToken}")!) }; var client = new GraphQLHttpClient(options, new NewtonsoftJsonSerializer()); diff --git a/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs b/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs index e2efdf1e0..3ad15ea87 100644 --- a/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs @@ -62,8 +62,8 @@ public sealed class GraphQLTests : IClassFixture var result = await _.Client.SharedDynamicContents.GraphQlAsync(query); - Assert.Equal(1, result["findMyWritesContent"]["flatData"]["json"]["value"].Value()); - Assert.Equal(2, result["findMyWritesContent"]["flatData"]["json"]["obj"]["value"].Value()); + Assert.Equal(1, result?["findMyWritesContent"]?["flatData"]?["json"]?["value"]?.Value()); + Assert.Equal(2, result?["findMyWritesContent"]?["flatData"]?["json"]?["obj"]?["value"]?.Value()); } [Fact] @@ -89,7 +89,7 @@ public sealed class GraphQLTests : IClassFixture var result = await _.Client.SharedDynamicContents.GraphQlAsync(query); - var cities = result["cities"].ToObject>(); + var cities = result?["cities"]?.ToObject>(); cities.Should().BeEquivalentTo(new List { @@ -168,7 +168,7 @@ public sealed class GraphQLTests : IClassFixture var result = await _.Client.SharedDynamicContents.GraphQlAsync(query); var cityNames = - result["countries"].ToObject>()[0].Data.States + result!["countries"]!.ToObject>()![0].Data.States .SelectMany(x => x.Data.Cities) .Select(x => x.Data.Name) .Order(); @@ -204,10 +204,10 @@ public sealed class GraphQLTests : IClassFixture var result = await _.Client.SharedDynamicContents.GraphQlAsync(query); var cityNames = - result["countries"] - .SelectMany(x => x["data"]["states"]) - .SelectMany(x => x["cities"]) - .Select(x => x["data"]["name"].Value()) + result!["countries"]! + .SelectMany(x => x["data"]!["states"]!) + .SelectMany(x => x["cities"]!) + .Select(x => x["data"]!["name"]!.Value()) .Order(); Assert.Equal(new[] { "Leipzig", "Munich" }, cityNames); @@ -241,10 +241,10 @@ public sealed class GraphQLTests : IClassFixture var result = await _.Client.SharedDynamicContents.GraphQlAsync(query); var cityNames = - result["countries"] - .SelectMany(x => x["data"]["states"]) - .SelectMany(x => x["cities"]) - .Select(x => x["data"]["name"].Value()) + result["countries"]! + .SelectMany(x => x["data"]!["states"]!) + .SelectMany(x => x["cities"]!) + .Select(x => x["data"]!["name"]!.Value()) .Order(); Assert.Equal(new[] { "Leipzig" }, cityNames); @@ -270,9 +270,9 @@ public sealed class GraphQLTests : IClassFixture var result = await _.Client.SharedDynamicContents.GraphQlAsync(query); var stateNames = - result["cities"] - .SelectMany(x => x["states"]) - .Select(x => x["data"]["name"].Value()) + result["cities"]! + .SelectMany(x => x["states"]!) + .Select(x => x["data"]!["name"]!.Value()) .Order(); Assert.Equal(new[] { "Bavaria", "Saxony" }, stateNames); @@ -298,9 +298,9 @@ public sealed class GraphQLTests : IClassFixture var result = await _.Client.SharedDynamicContents.GraphQlAsync(query); var stateNames = - result["cities"] - .SelectMany(x => x["states"]) - .Select(x => x["data"]["name"].Value()) + result!["cities"]! + .SelectMany(x => x!["states"]!) + .Select(x => x!["data"]!["name"]!.Value()) .Order(); Assert.Equal(new[] { "Saxony" }, stateNames); @@ -322,8 +322,8 @@ public sealed class GraphQLTests : IClassFixture var result = await _.Client.SharedDynamicContents.GraphQlAsync(query); var cityNames = - result["cities"] - .Select(x => x["data__dynamic"]["name"]["iv"].Value()) + result["cities"]! + .Select(x => x["data__dynamic"]!["name"]!["iv"]!.Value()) .Order(); Assert.Equal(new[] { "Leipzig", "Munich" }, cityNames); @@ -347,7 +347,7 @@ public sealed class GraphQLTests : IClassFixture // Create the request manually to check the content type. var response = await httpClient.PostAsync(_.Client.GenerateUrl($"api/content/{_.AppName}/graphql/batch"), query.ToContent()); - Assert.Equal("application/json", response.Content.Headers.ContentType.MediaType); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); } [Fact] @@ -387,8 +387,8 @@ public sealed class GraphQLTests : IClassFixture var result = await _.Client.SharedDynamicContents.GraphQlAsync(query); var names = - result["queryContentsByIds"] - .Select(x => x["data"]["name"].Value()) + result["queryContentsByIds"]! + .Select(x => x["data"]!["name"]!.Value()) .Order(); Assert.Equal(new[] { "Bavaria", "Leipzig", "Munich", "Saxony" }, names); @@ -425,8 +425,8 @@ public sealed class GraphQLTests : IClassFixture var result = await _.Client.SharedDynamicContents.GraphQlAsync(query); - var city1Id = result["a"]["id"].ToString(); - var city2Id = result["b"]["id"].ToString(); + var city1Id = result!["a"]!["id"]!.ToString(); + var city2Id = result!["b"]!["id"]!.ToString(); Assert.Equal(allCities.Items[0].Id, city1Id); Assert.Equal(allCities.Items[1].Id, city2Id); diff --git a/tools/TestSuite/TestSuite.ApiTests/RuleRunnerTests.cs b/tools/TestSuite/TestSuite.ApiTests/RuleRunnerTests.cs index 04fadcd1f..7dd4c9a4c 100644 --- a/tools/TestSuite/TestSuite.ApiTests/RuleRunnerTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/RuleRunnerTests.cs @@ -106,10 +106,10 @@ public class RuleRunnerTests : IClassFixture, IClassFixture, IClassFixture, IClassFixture +{ + public ClientFixture _ { get; } + + public TeamContributorTests(ClientFixture fixture) + { + _ = fixture; + } + + [Fact] + public async Task Should_not_invite_contributor_if_flag_is_false() + { + // STEP 0: Create team. + var team = await _.PostTeamAsync(); + + + // STEP 1: Do not invite contributors when flag is false. + var createRequest = new AssignContributorDto + { + ContributorId = "test@squidex.io" + }; + + var ex = await Assert.ThrowsAnyAsync(() => + { + return _.Client.Teams.PostContributorAsync(team.Id, createRequest); + }); + + Assert.Equal(404, ex.StatusCode); + } + + [Fact] + public async Task Should_invite_contributor() + { + // STEP 0: Create team. + var team = await _.PostTeamAsync(); + + + // STEP 0: Create app. + var (app, _) = await _.PostAppAsync(); + + + // STEP 1: Assign contributor. + ContributorDto contributor_1 = await InviteAsync(team.Id); + + Assert.Equal("Owner", contributor_1?.Role); + + await Verify(contributor_1) + .IgnoreMember(x => x.ContributorId) + .IgnoreMember(x => x.ContributorEmail) + .IgnoreMember(x => x.ContributorName); + } + + [Fact] + public async Task Should_remove_contributor() + { + // STEP 0: Create team. + var team = await _.PostTeamAsync(); + + + // STEP 1: Assign first contributor. + await InviteAsync(team.Id); + + + // STEP 2: Assign other contributor. + var contributor2 = await InviteAsync(team.Id); + + + // STEP 2: Remove contributor. + var contributors_2 = await _.Client.Teams.DeleteContributorAsync(team.Id, contributor2.ContributorId); + + Assert.DoesNotContain(contributors_2.Items, x => x.ContributorId == contributor2.ContributorId); + + await Verify(contributors_2) + .IgnoreMember(x => x.ContributorId) + .IgnoreMember(x => x.ContributorEmail) + .IgnoreMember(x => x.ContributorName); + } + + [Fact] + public async Task Should_not_remove_single_owner() + { + // STEP 0: Create team. + var team = await _.PostTeamAsync(); + + + // STEP 1: Get contributors + var contributor = await InviteAsync(team.Id); + + + // STEP 2: Remove contributor. + await Assert.ThrowsAnyAsync(() => _.Client.Teams.DeleteContributorAsync(team.Id, contributor.ContributorId)); + } + + private async Task InviteAsync(string teamId) + { + var email = $"{Guid.NewGuid()}@squidex.io"; + + var createInviteRequest = new AssignContributorDto + { + ContributorId = email, + // Invite must be true, otherwise new users are not created. + Invite = true, + // This is the only allowed role for teams. + Role = "Owner" + }; + + var contributors = await _.Client.Teams.PostContributorAsync(teamId, createInviteRequest); + var contributor = contributors.Items.Find(x => x.ContributorName == email); + + return contributor!; + } +} diff --git a/tools/TestSuite/TestSuite.ApiTests/TeamCreationTests.cs b/tools/TestSuite/TestSuite.ApiTests/TeamCreationTests.cs index 0f8249765..87541bb1c 100644 --- a/tools/TestSuite/TestSuite.ApiTests/TeamCreationTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/TeamCreationTests.cs @@ -5,10 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.ClientLibrary; using TestSuite.Fixtures; #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 namespace TestSuite.ApiTests; @@ -26,12 +26,7 @@ public class TeamCreationTests : IClassFixture [Fact] public async Task Should_create_team() { - var request = new CreateTeamDto - { - Name = teamName - }; - - var team = await _.Client.Teams.PostTeamAsync(request); + var team = await _.PostTeamAsync(teamName); Assert.Equal(teamName, team.Name); } @@ -39,16 +34,24 @@ public class TeamCreationTests : IClassFixture [Fact] public async Task Should_create_team_with_duplicate_name() { - var request = new CreateTeamDto - { - Name = teamName - }; - - var team1 = await _.Client.Teams.PostTeamAsync(request); - var team2 = await _.Client.Teams.PostTeamAsync(request); + var team1 = await _.PostTeamAsync(teamName); + var team2 = await _.PostTeamAsync(teamName); Assert.Equal(teamName, team1.Name); Assert.Equal(teamName, team2.Name); Assert.NotEqual(team1.Id, team2.Id); } + + [Fact] + public async Task Should_get_team_by_id() + { + // STEP 0: Create team. + var team_0 = await _.PostTeamAsync(teamName); + + + // STEP 2: Get team. + var team_1 = await _.Client.Teams.GetTeamAsync(team_0.Id); + + Assert.Equal(team_0.Id, team_1.Id); + } } diff --git a/tools/TestSuite/TestSuite.ApiTests/TeamTests.cs b/tools/TestSuite/TestSuite.ApiTests/TeamTests.cs new file mode 100644 index 000000000..5fc3e20af --- /dev/null +++ b/tools/TestSuite/TestSuite.ApiTests/TeamTests.cs @@ -0,0 +1,86 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.ClientLibrary; +using TestSuite.Fixtures; + +#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 + +namespace TestSuite.ApiTests; + +[UsesVerify] +public class TeamTests : IClassFixture +{ + public CreatedTeamFixture _ { get; } + + public TeamTests(CreatedTeamFixture fixture) + { + _ = fixture; + } + + [Fact] + public async Task Should_set_name() + { + // STEP 1: Update app. + var updateRequest = new UpdateTeamDto + { + Name = Guid.NewGuid().ToString() + }; + + var app_1 = await _.Client.Teams.PutTeamAsync(_.TeamId, updateRequest); + + Assert.Equal(updateRequest.Name, app_1.Name); + } + + [Fact] + public async Task Should_transfer_app_to_team() + { + // STEP 0: Create app. + var (client, _) = await _.PostAppAsync(); + + + // STEP 1: Assign app to team. + var transferRequest = new TransferToTeamDto + { + TeamId = _.TeamId + }; + + var app_1 = await client.Apps.PutAppTeamAsync(transferRequest); + + Assert.Equal(_.TeamId, app_1.TeamId); + } + + [Fact] + public async Task Should_remove_app_from_team() + { + // STEP 0: Create app. + var (client, _) = await _.PostAppAsync(); + + + // STEP 1: Assign app to team. + var transferRequest = new TransferToTeamDto + { + TeamId = _.TeamId + }; + + var app_1 = await client.Apps.PutAppTeamAsync(transferRequest); + + Assert.Equal(_.TeamId, app_1.TeamId); + + + // STEP 2: Remove app from team. + var untransferRequest = new TransferToTeamDto + { + TeamId = null + }; + + var app_2 = await client.Apps.PutAppTeamAsync(untransferRequest); + + Assert.Null(app_2.TeamId); + } +} diff --git a/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj b/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj index 84c7b3c5d..ae37f2e32 100644 --- a/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj +++ b/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj @@ -4,6 +4,7 @@ net8.0 latest enable + enable diff --git a/tools/TestSuite/TestSuite.ApiTests/Verify/AppContributorsTests.Should_remove_contributor.verified.txt b/tools/TestSuite/TestSuite.ApiTests/Verify/AppContributorsTests.Should_remove_contributor.verified.txt index d72ac376a..17f19023c 100644 --- a/tools/TestSuite/TestSuite.ApiTests/Verify/AppContributorsTests.Should_remove_contributor.verified.txt +++ b/tools/TestSuite/TestSuite.ApiTests/Verify/AppContributorsTests.Should_remove_contributor.verified.txt @@ -1,4 +1,17 @@ { + Items: [ + { + Role: Owner, + Links: { + delete: { + Method: DELETE + }, + update: { + Method: POST + } + } + } + ], MaxContributors: -1, Links: { create: { diff --git a/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_file_name.verified.txt b/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_file_name_strategy=Bulk.verified.txt similarity index 100% rename from tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_file_name.verified.txt rename to tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_file_name_strategy=Bulk.verified.txt diff --git a/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_file_name_strategy=Single.verified.txt b/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_file_name_strategy=Single.verified.txt new file mode 100644 index 000000000..a3ff25c1e --- /dev/null +++ b/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_file_name_strategy=Single.verified.txt @@ -0,0 +1,47 @@ +{ + Id: Guid_1, + ParentId: Guid_Empty, + FileName: My Image, + FileHash: pAp0RDvipkoNCCTgey1HTJekRKKEWT6Ft5JdRLuAfAc=, + IsProtected: false, + Slug: logo-squared.png, + MimeType: image/png, + FileType: blob, + MetadataText: 600x600px, 19 kB, + Metadata: { + description: PNG File, + pixelHeight: 600, + pixelWidth: 600 + }, + Tags: [ + type/png, + image, + image/medium + ], + FileSize: 19430, + Type: Image, + Version: 1, + Links: { + content: { + Method: GET + }, + content/slug: { + Method: GET + }, + delete: { + Method: DELETE + }, + move: { + Method: PUT + }, + self: { + Method: GET + }, + update: { + Method: PUT + }, + upload: { + Method: PUT + } + } +} \ No newline at end of file diff --git a/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_metadata.verified.txt b/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_metadata_strategy=Bulk.verified.txt similarity index 100% rename from tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_metadata.verified.txt rename to tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_metadata_strategy=Bulk.verified.txt diff --git a/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_metadata_strategy=Single.verified.txt b/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_metadata_strategy=Single.verified.txt new file mode 100644 index 000000000..0c17d3a0a --- /dev/null +++ b/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_metadata_strategy=Single.verified.txt @@ -0,0 +1,46 @@ +{ + Id: Guid_1, + ParentId: Guid_Empty, + FileName: logo-squared.png, + FileHash: pAp0RDvipkoNCCTgey1HTJekRKKEWT6Ft5JdRLuAfAc=, + IsProtected: false, + Slug: logo-squared.png, + MimeType: image/png, + FileType: png, + MetadataText: 19 kB, + Metadata: { + ph: 20, + pw: 100 + }, + Tags: [ + type/png, + image, + image/medium + ], + FileSize: 19430, + Type: Image, + Version: 1, + Links: { + content: { + Method: GET + }, + content/slug: { + Method: GET + }, + delete: { + Method: DELETE + }, + move: { + Method: PUT + }, + self: { + Method: GET + }, + update: { + Method: PUT + }, + upload: { + Method: PUT + } + } +} \ No newline at end of file diff --git a/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_slug.verified.txt b/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_slug_strategy=Bulk.verified.txt similarity index 100% rename from tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_slug.verified.txt rename to tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_slug_strategy=Bulk.verified.txt diff --git a/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_slug_strategy=Single.verified.txt b/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_slug_strategy=Single.verified.txt new file mode 100644 index 000000000..4d98c2bdf --- /dev/null +++ b/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_slug_strategy=Single.verified.txt @@ -0,0 +1,47 @@ +{ + Id: Guid_1, + ParentId: Guid_Empty, + FileName: logo-squared.png, + FileHash: pAp0RDvipkoNCCTgey1HTJekRKKEWT6Ft5JdRLuAfAc=, + IsProtected: false, + Slug: my-image, + MimeType: image/png, + FileType: png, + MetadataText: 600x600px, 19 kB, + Metadata: { + description: PNG File, + pixelHeight: 600, + pixelWidth: 600 + }, + Tags: [ + type/png, + image, + image/medium + ], + FileSize: 19430, + Type: Image, + Version: 1, + Links: { + content: { + Method: GET + }, + content/slug: { + Method: GET + }, + delete: { + Method: DELETE + }, + move: { + Method: PUT + }, + self: { + Method: GET + }, + update: { + Method: PUT + }, + upload: { + Method: PUT + } + } +} \ No newline at end of file diff --git a/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_tags.verified.txt b/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_tags_strategy=Bulk.verified.txt similarity index 100% rename from tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_tags.verified.txt rename to tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_tags_strategy=Bulk.verified.txt diff --git a/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_tags_strategy=Single.verified.txt b/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_tags_strategy=Single.verified.txt new file mode 100644 index 000000000..736724527 --- /dev/null +++ b/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_annote_asset_tags_strategy=Single.verified.txt @@ -0,0 +1,46 @@ +{ + Id: Guid_1, + ParentId: Guid_Empty, + FileName: logo-squared.png, + FileHash: pAp0RDvipkoNCCTgey1HTJekRKKEWT6Ft5JdRLuAfAc=, + IsProtected: false, + Slug: logo-squared.png, + MimeType: image/png, + FileType: png, + MetadataText: 600x600px, 19 kB, + Metadata: { + description: PNG File, + pixelHeight: 600, + pixelWidth: 600 + }, + Tags: [ + tag1, + tag2 + ], + FileSize: 19430, + Type: Image, + Version: 1, + Links: { + content: { + Method: GET + }, + content/slug: { + Method: GET + }, + delete: { + Method: DELETE + }, + move: { + Method: PUT + }, + self: { + Method: GET + }, + update: { + Method: PUT + }, + upload: { + Method: PUT + } + } +} \ No newline at end of file diff --git a/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_protect_asset.verified.txt b/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_protect_asset_strategy=Bulk.verified.txt similarity index 100% rename from tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_protect_asset.verified.txt rename to tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_protect_asset_strategy=Bulk.verified.txt diff --git a/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_protect_asset_strategy=Single.verified.txt b/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_protect_asset_strategy=Single.verified.txt new file mode 100644 index 000000000..83154ca7e --- /dev/null +++ b/tools/TestSuite/TestSuite.ApiTests/Verify/AssetTests.Should_protect_asset_strategy=Single.verified.txt @@ -0,0 +1,47 @@ +{ + Id: Guid_1, + ParentId: Guid_Empty, + FileName: logo-squared.png, + FileHash: pAp0RDvipkoNCCTgey1HTJekRKKEWT6Ft5JdRLuAfAc=, + IsProtected: true, + Slug: logo-squared.png, + MimeType: image/png, + FileType: png, + MetadataText: 600x600px, 19 kB, + Metadata: { + description: PNG File, + pixelHeight: 600, + pixelWidth: 600 + }, + Tags: [ + type/png, + image, + image/medium + ], + FileSize: 19430, + Type: Image, + Version: 1, + Links: { + content: { + Method: GET + }, + content/slug: { + Method: GET + }, + delete: { + Method: DELETE + }, + move: { + Method: PUT + }, + self: { + Method: GET + }, + update: { + Method: PUT + }, + upload: { + Method: PUT + } + } +} \ No newline at end of file diff --git a/tools/TestSuite/TestSuite.ApiTests/Verify/TeamContributorTests.Should_invite_contributor.verified.txt b/tools/TestSuite/TestSuite.ApiTests/Verify/TeamContributorTests.Should_invite_contributor.verified.txt new file mode 100644 index 000000000..57eb897bc --- /dev/null +++ b/tools/TestSuite/TestSuite.ApiTests/Verify/TeamContributorTests.Should_invite_contributor.verified.txt @@ -0,0 +1,11 @@ +{ + Role: Owner, + Links: { + delete: { + Method: DELETE + }, + update: { + Method: POST + } + } +} \ No newline at end of file diff --git a/tools/TestSuite/TestSuite.ApiTests/Verify/TeamContributorTests.Should_remove_contributor.verified.txt b/tools/TestSuite/TestSuite.ApiTests/Verify/TeamContributorTests.Should_remove_contributor.verified.txt new file mode 100644 index 000000000..2da04bdc4 --- /dev/null +++ b/tools/TestSuite/TestSuite.ApiTests/Verify/TeamContributorTests.Should_remove_contributor.verified.txt @@ -0,0 +1,23 @@ +{ + Items: [ + { + Role: Owner, + Links: { + delete: { + Method: DELETE + }, + update: { + Method: POST + } + } + } + ], + Links: { + create: { + Method: POST + }, + self: { + Method: GET + } + } +} \ No newline at end of file diff --git a/tools/TestSuite/TestSuite.LoadTests/Run.cs b/tools/TestSuite/TestSuite.LoadTests/Run.cs index 0ab367439..dc6ce02fa 100644 --- a/tools/TestSuite/TestSuite.LoadTests/Run.cs +++ b/tools/TestSuite/TestSuite.LoadTests/Run.cs @@ -14,7 +14,7 @@ namespace TestSuite.LoadTests; public static class Run { - public static async Task Parallel(int numUsers, int numIterationsPerUser, Func action, int expectedAvg = 100, ITestOutputHelper testOutput = null) + public static async Task Parallel(int numUsers, int numIterationsPerUser, Func action, int expectedAvg = 100, ITestOutputHelper? testOutput = null) { await action(); diff --git a/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj b/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj index 65a658c57..4e6da4c7b 100644 --- a/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj +++ b/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj @@ -4,6 +4,7 @@ net8.0 11.0 enable + enable diff --git a/tools/TestSuite/TestSuite.Shared/AssetStrategies.cs b/tools/TestSuite/TestSuite.Shared/AssetStrategies.cs new file mode 100644 index 000000000..b5b719df5 --- /dev/null +++ b/tools/TestSuite/TestSuite.Shared/AssetStrategies.cs @@ -0,0 +1,121 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.ClientLibrary; + +#pragma warning disable MA0048 // File name must match type name + +namespace TestSuite; + +public static partial class ContentStrategies +{ + public static Task DeleteAsync(this IAssetsClient client, AssetDto asset, Deletion strategy) + { + switch (strategy) + { + case Deletion.SingleSoft: + return client.DeleteAssetAsync(asset.Id); + case Deletion.SinglePermanent: + return client.DeleteAssetAsync(asset.Id, permanent: true); + case Deletion.BulkSoft: + return client.BulkUpdateAssetsAsync(new BulkUpdateAssetsDto + { + Jobs = + [ + new BulkUpdateAssetsJobDto + { + Type = BulkUpdateAssetType.Delete, + Id = asset.Id, + Permanent = false, + }, + ] + }); + case Deletion.BulkPermanent: + return client.BulkUpdateAssetsAsync(new BulkUpdateAssetsDto + { + Jobs = + [ + new BulkUpdateAssetsJobDto + { + Type = BulkUpdateAssetType.Delete, + Id = asset.Id, + Permanent = true, + }, + ] + }); + default: + return Task.CompletedTask; + } + } + + public enum Move + { + Single, + Bulk + } + + public static Task MoveAsync(this IAssetsClient client, AssetDto asset, AssetFolderDto folder, Move strategy) + { + switch (strategy) + { + case Move.Single: + return client.PutAssetParentAsync(asset.Id, new MoveAssetDto + { + ParentId = folder.Id + }); + case Move.Bulk: + return client.BulkUpdateAssetsAsync(new BulkUpdateAssetsDto + { + Jobs = + [ + new BulkUpdateAssetsJobDto + { + Type = BulkUpdateAssetType.Move, + Id = asset.Id, + ParentId = folder.Id + }, + ] + }); + default: + return Task.CompletedTask; + } + } + + public enum Annotate + { + Single, + Bulk + } + + public static Task AnnotateAsync(this IAssetsClient client, AssetDto asset, AnnotateAssetDto request, Annotate strategy) + { + switch (strategy) + { + case Annotate.Single: + return client.PutAssetAsync(asset.Id, request); + case Annotate.Bulk: + return client.BulkUpdateAssetsAsync(new BulkUpdateAssetsDto + { + Jobs = + [ + new BulkUpdateAssetsJobDto + { + Type = BulkUpdateAssetType.Annotate, + FileName = request.FileName, + Id = asset.Id, + IsProtected = request.IsProtected, + Metadata = request.Metadata, + Slug = request.Slug, + Tags = request.Tags + }, + ] + }); + default: + return Task.CompletedTask; + } + } +} diff --git a/tools/TestSuite/TestSuite.Shared/ClientExtensions.cs b/tools/TestSuite/TestSuite.Shared/ClientExtensions.cs index ff1690bc7..b38b7557e 100644 --- a/tools/TestSuite/TestSuite.Shared/ClientExtensions.cs +++ b/tools/TestSuite/TestSuite.Shared/ClientExtensions.cs @@ -63,7 +63,7 @@ public static class ClientExtensions return false; } - public static async Task PollAsync(this IAssetsClient client, Func predicate, + public static async Task PollAsync(this IAssetsClient client, Func predicate, TimeSpan timeout = default) { try @@ -116,7 +116,7 @@ public static class ClientExtensions return new ContentsResult(); } - public static async Task PollAsync(this ISearchClient client, string query, Func predicate, + public static async Task PollAsync(this ISearchClient client, string query, Func predicate, TimeSpan timeout = default) { try @@ -143,7 +143,7 @@ public static class ClientExtensions return null; } - public static async Task PollAsync(this WebhookCatcherClient client, string sessionId, Func predicate, + public static async Task PollAsync(this WebhookCatcherClient client, string sessionId, Func predicate, TimeSpan timeout = default) { if (timeout == default) @@ -175,7 +175,7 @@ public static class ClientExtensions return null; } - public static async Task PollAsync(this IHistoryClient client, string channel, Func predicate, + public static async Task PollAsync(this IHistoryClient client, string? channel, Func predicate, TimeSpan timeout = default) { try @@ -202,7 +202,7 @@ public static class ClientExtensions return null; } - public static async Task PollAsync(this IBackupsClient client, Func predicate, + public static async Task PollAsync(this IBackupsClient client, Func predicate, TimeSpan timeout = default) { try @@ -255,7 +255,7 @@ public static class ClientExtensions return await client.GetTagsAsync(); } - public static async Task PollRestoreAsync(this IBackupsClient client, Func predicate, + public static async Task PollRestoreAsync(this IBackupsClient client, Func predicate, TimeSpan timeout = default) { try @@ -281,7 +281,7 @@ public static class ClientExtensions return null; } - public static async Task DownloadAsync(this ClientFixture fixture, AssetDto asset, int? version = null) + public static async Task DownloadAsync(this ClientFixture fixture, AssetDto asset, int? version = null) { var temp = new MemoryStream(); @@ -310,7 +310,7 @@ public static class ClientExtensions return temp; } - public static async Task UploadInChunksAsync(this IAssetsClient client, ProgressHandler progress, FileParameter fileParameter, string id = null) + public static async Task UploadInChunksAsync(this IAssetsClient client, ProgressHandler progress, FileParameter fileParameter, string? id = null) { var pausingStream = new PauseStream(fileParameter.Data, 0.25); var pausingFile = new FileParameter(pausingStream, fileParameter.FileName, fileParameter.ContentType) @@ -332,7 +332,7 @@ public static class ClientExtensions } } - public static async Task UploadFileAsync(this IAssetsClient client, string path, AssetDto asset, string fileName = null) + public static async Task UploadFileAsync(this IAssetsClient client, string path, AssetDto asset, string? fileName = null) { var fileInfo = new FileInfo(path); @@ -344,7 +344,7 @@ public static class ClientExtensions } } - public static async Task UploadFileAsync(this IAssetsClient client, string path, string fileType, string fileName = null, string parentId = null, string id = null) + public static async Task UploadFileAsync(this IAssetsClient client, string path, string fileType, string? fileName = null, string? parentId = null, string? id = null) { var fileInfo = new FileInfo(path); @@ -356,7 +356,7 @@ public static class ClientExtensions } } - public static async Task UpdateFileAsync(this IAssetsClient client, string id, string path, string fileType, string fileName = null) + public static async Task ReplaceFileAsync(this IAssetsClient client, string id, string path, string fileType, string? fileName = null) { var fileInfo = new FileInfo(path); @@ -368,7 +368,7 @@ public static class ClientExtensions } } - public static async Task UploadRandomFileAsync(this IAssetsClient client, int size, string parentId = null, string id = null) + public static async Task UploadRandomFileAsync(this IAssetsClient client, int size, string? parentId = null, string? id = null) { using (var stream = RandomAsset(size)) { diff --git a/tools/TestSuite/TestSuite.Shared/Strategies.cs b/tools/TestSuite/TestSuite.Shared/ContentStrategies.cs similarity index 83% rename from tools/TestSuite/TestSuite.Shared/Strategies.cs rename to tools/TestSuite/TestSuite.Shared/ContentStrategies.cs index 82899316d..4253c09fb 100644 --- a/tools/TestSuite/TestSuite.Shared/Strategies.cs +++ b/tools/TestSuite/TestSuite.Shared/ContentStrategies.cs @@ -7,9 +7,11 @@ using Squidex.ClientLibrary; +#pragma warning disable MA0048 // File name must match type name + namespace TestSuite; -public static class Strategies +public static partial class ContentStrategies { public enum Deletion { @@ -35,28 +37,28 @@ public static class Strategies case Deletion.BulkSoft: return GetClient().BulkUpdateAsync(new BulkUpdate { - Jobs = new List - { + Jobs = + [ new BulkUpdateJob { Type = BulkUpdateType.Delete, Id = content.Id, Permanent = false, - } - } + }, + ] }); case Deletion.BulkPermanent: return GetClient().BulkUpdateAsync(new BulkUpdate { - Jobs = new List - { + Jobs = + [ new BulkUpdateJob { Type = BulkUpdateType.Delete, Id = content.Id, Permanent = true, - } - } + }, + ] }); default: return Task.CompletedTask; @@ -89,56 +91,56 @@ public static class Strategies case Update.UpsertBulk: return GetClient().BulkUpdateAsync(new BulkUpdate { - Jobs = new List - { + Jobs = + [ new BulkUpdateJob { Id = content.Id, Data = data, Type = BulkUpdateType.Upsert - } - } + }, + ] }); case Update.Bulk: return GetClient().BulkUpdateAsync(new BulkUpdate { - Jobs = new List - { + Jobs = + [ new BulkUpdateJob { Id = content.Id, Data = data, Type = BulkUpdateType.Update, - } - } + }, + ] }); case Update.BulkWithSchema: return GetClient().BulkUpdateAsync(new BulkUpdate { - Jobs = new List - { + Jobs = + [ new BulkUpdateJob { Id = content.Id, Data = data, Type = BulkUpdateType.Update, Schema = content.SchemaName - } - } + }, + ] }); case Update.BulkShared: return client.SharedContents().BulkUpdateAsync(new BulkUpdate { - Jobs = new List - { + Jobs = + [ new BulkUpdateJob { Id = content.Id, Data = data, Type = BulkUpdateType.Update, Schema = content.SchemaName - } - } + }, + ] }); default: return Task.CompletedTask; @@ -171,56 +173,56 @@ public static class Strategies case Patch.UpsertBulk: return GetClient().BulkUpdateAsync(new BulkUpdate { - Jobs = new List - { + Jobs = + [ new BulkUpdateJob { Id = content.Id, Data = data, Patch = true, - } - } + }, + ] }); case Patch.Bulk: return GetClient().BulkUpdateAsync(new BulkUpdate { - Jobs = new List - { + Jobs = + [ new BulkUpdateJob { Id = content.Id, Data = data, Type = BulkUpdateType.Patch - } - } + }, + ] }); case Patch.BulkWithSchema: return GetClient().BulkUpdateAsync(new BulkUpdate { - Jobs = new List - { + Jobs = + [ new BulkUpdateJob { Id = content.Id, Data = data, Type = BulkUpdateType.Patch, Schema = content.SchemaName - } - } + }, + ] }); case Patch.BulkShared: return client.SharedContents().BulkUpdateAsync(new BulkUpdate { - Jobs = new List - { + Jobs = + [ new BulkUpdateJob { Id = content.Id, Data = data, Type = BulkUpdateType.Patch, Schema = content.SchemaName - } - } + }, + ] }); default: return Task.CompletedTask; diff --git a/tools/TestSuite/TestSuite.Shared/Factories.cs b/tools/TestSuite/TestSuite.Shared/Factories.cs index 2bba0dd37..4f66fcab8 100644 --- a/tools/TestSuite/TestSuite.Shared/Factories.cs +++ b/tools/TestSuite/TestSuite.Shared/Factories.cs @@ -13,7 +13,7 @@ public static class Factories { private static readonly ConcurrentDictionary> Instances = new ConcurrentDictionary>(); - public static async Task CreateAsync(string key, Func> factory) + public static async Task CreateAsync(string key, Func> factory) where T : notnull { return (T)await Instances.GetOrAdd(key, async (_, f) => await f(), factory); } diff --git a/tools/TestSuite/TestSuite.Shared/Fixtures/ClientFixture.cs b/tools/TestSuite/TestSuite.Shared/Fixtures/ClientFixture.cs index 62a32f827..c3bafe451 100644 --- a/tools/TestSuite/TestSuite.Shared/Fixtures/ClientFixture.cs +++ b/tools/TestSuite/TestSuite.Shared/Fixtures/ClientFixture.cs @@ -39,7 +39,7 @@ public class ClientFixture : IAsyncLifetime VerifierSettings.IgnoreMembersWithType(); } - public Task<(ISquidexClient, AppDto)> PostAppAsync(string name = null) + public Task<(ISquidexClient, AppDto)> PostAppAsync(string? name = null) { name ??= Guid.NewGuid().ToString(); @@ -51,6 +51,18 @@ public class ClientFixture : IAsyncLifetime return PostAppAsync(createRequest); } + public Task PostTeamAsync(string? name = null) + { + name ??= Guid.NewGuid().ToString(); + + var request = new CreateTeamDto + { + Name = name + }; + + return Client.Teams.PostTeamAsync(request); + } + public async Task<(ISquidexClient, AppDto)> PostAppAsync(CreateAppDto request) { var services = diff --git a/tools/TestSuite/TestSuite.Shared/Fixtures/WebhookCatcherClient.cs b/tools/TestSuite/TestSuite.Shared/Fixtures/WebhookCatcherClient.cs index 447ef38a6..859a29078 100644 --- a/tools/TestSuite/TestSuite.Shared/Fixtures/WebhookCatcherClient.cs +++ b/tools/TestSuite/TestSuite.Shared/Fixtures/WebhookCatcherClient.cs @@ -30,7 +30,7 @@ public sealed class WebhookRequest public string Content { get; set; } [JsonIgnore] - public Dictionary Headers { get; set; } = new Dictionary(); + public Dictionary Headers { get; set; } = []; [JsonPropertyName("headers")] public WebhookHeader[] HeaderValues { get; set; } @@ -81,17 +81,17 @@ public sealed class WebhookCatcherClient response.EnsureSuccessStatusCode(); - var responseObj = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + var responseObj = await response.Content.ReadFromJsonAsync(ct); - return ($"http://{EndpointHost}:{EndpointPort}/{responseObj.Uuid}", responseObj.Uuid); + return ($"http://{EndpointHost}:{EndpointPort}/{responseObj!.Uuid}", responseObj!.Uuid); } public async Task GetRequestsAsync(string sessionId, CancellationToken ct = default) { - var result = await httpClient.GetFromJsonAsync($"/api/session/{sessionId}/requests", ct); + var responseObj = await httpClient.GetFromJsonAsync($"/api/session/{sessionId}/requests", ct); - foreach (var request in result) + foreach (var request in responseObj!) { if (request.Content != null) { @@ -107,6 +107,6 @@ public sealed class WebhookCatcherClient } } - return result; + return responseObj; } } diff --git a/tools/TestSuite/TestSuite.Shared/Model/TestEntity.cs b/tools/TestSuite/TestSuite.Shared/Model/TestEntity.cs index fc99fced8..7b917ed99 100644 --- a/tools/TestSuite/TestSuite.Shared/Model/TestEntity.cs +++ b/tools/TestSuite/TestSuite.Shared/Model/TestEntity.cs @@ -18,13 +18,13 @@ public sealed class TestEntity : Content { public const int ScriptTrigger = -99; - public static async Task CreateSchemaAsync(ISchemasClient schemas, string name, SchemaScriptsDto scripts = null) + public static async Task CreateSchemaAsync(ISchemasClient schemas, string name, SchemaScriptsDto? scripts = null) { var schema = await schemas.PostSchemaAsync(new CreateSchemaDto { Name = name, - Fields = new List - { + Fields = + [ new UpsertSchemaFieldDto { Name = TestEntityData.NumberField, @@ -73,8 +73,8 @@ public sealed class TestEntity : Content { IsRequired = false } - } - }, + }, + ], Scripts = scripts, IsPublished = true }); @@ -133,20 +133,20 @@ public sealed class TestEntityData public static readonly string IdField = nameof(Id).ToLowerInvariant(); - public Dictionary Localized { get; set; } + public Dictionary Localized { get; set; } [JsonConverter(typeof(InvariantConverter))] public int Number { get; set; } [JsonConverter(typeof(InvariantConverter))] - public string Id { get; set; } + public string? Id { get; set; } [JsonConverter(typeof(InvariantConverter))] - public string String { get; set; } + public string? String { get; set; } [JsonConverter(typeof(InvariantConverter))] - public JToken Json { get; set; } + public JToken? Json { get; set; } [JsonConverter(typeof(InvariantConverter))] - public object Geo { get; set; } + public object? Geo { get; set; } } diff --git a/tools/TestSuite/TestSuite.Shared/Model/TestEntityWithReferences.cs b/tools/TestSuite/TestSuite.Shared/Model/TestEntityWithReferences.cs index dcf13e505..cd01740d9 100644 --- a/tools/TestSuite/TestSuite.Shared/Model/TestEntityWithReferences.cs +++ b/tools/TestSuite/TestSuite.Shared/Model/TestEntityWithReferences.cs @@ -19,8 +19,8 @@ public sealed class TestEntityWithReferences : Content - { + Fields = + [ new UpsertSchemaFieldDto { Name = TestEntityWithReferencesData.ReferencesField, @@ -28,8 +28,8 @@ public sealed class TestEntityWithReferences : Content net8.0 TestSuite - 11.0 + latest enable + enable diff --git a/tools/TestSuite/TestSuite.Shared/Utils/ProgressHandler.cs b/tools/TestSuite/TestSuite.Shared/Utils/ProgressHandler.cs index 68acd5ce5..3b3f9cc7b 100644 --- a/tools/TestSuite/TestSuite.Shared/Utils/ProgressHandler.cs +++ b/tools/TestSuite/TestSuite.Shared/Utils/ProgressHandler.cs @@ -13,15 +13,15 @@ public sealed class ProgressHandler : IAssetProgressHandler { public string FileId { get; private set; } = Guid.NewGuid().ToString(); - public List Progress { get; } = new List(); + public List Progress { get; } = []; - public List Uploads { get; } = new List(); + public List Uploads { get; } = []; public Exception Exception { get; private set; } public AssetDto Asset { get; private set; } - public AssetUploadOptions AsOptions(string id = null) + public AssetUploadOptions AsOptions(string? id = null) { var options = default(AssetUploadOptions); options.ProgressHandler = this; diff --git a/tools/e2e/playwright.config.ts b/tools/e2e/playwright.config.ts index edeb907f5..919d66349 100644 --- a/tools/e2e/playwright.config.ts +++ b/tools/e2e/playwright.config.ts @@ -47,6 +47,15 @@ export default defineConfig({ }, }, + { + name: 'schema', + testMatch: 'tests/given-schema/_setup.ts', + dependencies: ['app'], + use: { + storageState: STORAGE_STATE, + }, + }, + { name: 'given login', testMatch: 'tests/given-login/*.spec.ts', @@ -67,6 +76,16 @@ export default defineConfig({ }, }, + { + name: 'given schema', + testMatch: 'tests/given-schema/*.spec.ts', + dependencies: ['schema'], + use: { + ...devices['Desktop Chrome'], + storageState: STORAGE_STATE, + }, + }, + { name: 'logged out', testMatch: 'tests/*.spec.ts', diff --git a/tools/e2e/snapshots/given-app/dashboard_page.spec.ts-snapshots/visual-test-1-given-app-linux.png b/tools/e2e/snapshots/given-app/dashboard.spec.ts-snapshots/visual-test-1-given-app-linux.png similarity index 100% rename from tools/e2e/snapshots/given-app/dashboard_page.spec.ts-snapshots/visual-test-1-given-app-linux.png rename to tools/e2e/snapshots/given-app/dashboard.spec.ts-snapshots/visual-test-1-given-app-linux.png diff --git a/tools/e2e/snapshots/given-app/dashboard_page.spec.ts-snapshots/visual-test-1-given-app-win32.png b/tools/e2e/snapshots/given-app/dashboard.spec.ts-snapshots/visual-test-1-given-app-win32.png similarity index 100% rename from tools/e2e/snapshots/given-app/dashboard_page.spec.ts-snapshots/visual-test-1-given-app-win32.png rename to tools/e2e/snapshots/given-app/dashboard.spec.ts-snapshots/visual-test-1-given-app-win32.png diff --git a/tools/e2e/tests/given-app/_fixture.ts b/tools/e2e/tests/given-app/_fixture.ts index 76fe1c6cb..4df1f12f9 100644 --- a/tools/e2e/tests/given-app/_fixture.ts +++ b/tools/e2e/tests/given-app/_fixture.ts @@ -19,6 +19,4 @@ export const test = base.extend<{}, AppFixture>({ await use(config.appName); }, { scope: 'worker', auto: true }], -}); - - +}); \ No newline at end of file diff --git a/tools/e2e/tests/given-app/dashboard_page.spec.ts b/tools/e2e/tests/given-app/dashboard.spec.ts similarity index 72% rename from tools/e2e/tests/given-app/dashboard_page.spec.ts rename to tools/e2e/tests/given-app/dashboard.spec.ts index 64a17a50e..117cbc5b0 100644 --- a/tools/e2e/tests/given-app/dashboard_page.spec.ts +++ b/tools/e2e/tests/given-app/dashboard.spec.ts @@ -7,10 +7,10 @@ import { expect, test } from './_fixture'; -test('visual test', async ({ page, appName }) => { +test.beforeEach(async ({ page, appName }) => { await page.goto(`/app/${appName}`); +}); - await page.waitForLoadState('networkidle'); - +test('visual test', async ({ page }) => { await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0.05 }); }); \ No newline at end of file diff --git a/tools/e2e/tests/given-app/rules.spec.ts b/tools/e2e/tests/given-app/rules.spec.ts index abf678fb0..cd8ddee39 100644 --- a/tools/e2e/tests/given-app/rules.spec.ts +++ b/tools/e2e/tests/given-app/rules.spec.ts @@ -3,21 +3,21 @@ import { escapeRegex, getRandomId } from '../utils'; import { test } from './_fixture'; test.beforeEach(async ({ page, appName }) => { - await page.goto(`/app/${appName}`); + await page.goto(`/app/${appName}/rules`); }); test('create rule', async ({ page }) => { const ruleName = await createRandomRule(page); - const ruleCard = page.locator('div.card', { hasText: new RegExp(escapeRegex(ruleName)) }); + const ruleCard = page.locator('div.card', { hasText: escapeRegex(ruleName) }); await expect(ruleCard).toBeVisible(); }); test('delete rule', async ({ dropdown, page }) => { const ruleName = await createRandomRule(page); - const ruleCard = page.locator('div.card', { hasText: new RegExp(escapeRegex(ruleName)) }); + const ruleCard = page.locator('div.card', { hasText: escapeRegex(ruleName) }); - await ruleCard.getByTestId('options').click(); + await ruleCard.getByLabel('Options').click(); await dropdown.delete(); await expect(ruleCard).not.toBeVisible(); @@ -25,9 +25,9 @@ test('delete rule', async ({ dropdown, page }) => { test('disable rule', async ({ dropdown, page }) => { const ruleName = await createRandomRule(page); - const ruleCard = page.locator('div.card', { hasText: new RegExp(escapeRegex(ruleName)) }); + const ruleCard = page.locator('div.card', { hasText: escapeRegex(ruleName) }); - await ruleCard.getByTestId('options').click(); + await ruleCard.getByLabel('Options').click(); await dropdown.action('Disable'); await expect(ruleCard.locator('sqx-toggle .toggle-container')).toHaveAttribute('data-state', 'unchecked'); @@ -35,16 +35,14 @@ test('disable rule', async ({ dropdown, page }) => { test('enable rule', async ({ dropdown, page }) => { const ruleName = await createRandomRule(page); - const ruleCard = page.locator('div.card', { hasText: new RegExp(escapeRegex(ruleName)) }); + const ruleCard = page.locator('div.card', { hasText: escapeRegex(ruleName) }); - const disableRequest = page.waitForResponse(/rules/); - - await ruleCard.getByTestId('options').click(); + await ruleCard.getByLabel('Options').click(); await dropdown.action('Disable'); - await disableRequest; + await expect(ruleCard.locator('sqx-toggle .toggle-container')).toHaveAttribute('data-state', 'unchecked'); - await ruleCard.getByTestId('options').click(); + await ruleCard.getByLabel('Options').click(); await dropdown.action('Enable'); await expect(ruleCard.locator('sqx-toggle .toggle-container')).toHaveAttribute('data-state', 'checked'); @@ -52,9 +50,9 @@ test('enable rule', async ({ dropdown, page }) => { test('edit rule', async ({ dropdown, page }) => { const ruleName = await createRandomRule(page); - const ruleCard = page.locator('div.card', { hasText: new RegExp(escapeRegex(ruleName)) }); + const ruleCard = page.locator('div.card', { hasText: escapeRegex(ruleName) }); - await ruleCard.getByTestId('options').click(); + await ruleCard.getByLabel('Options').click(); await dropdown.action('Edit'); await expect(page.getByText('Enabled')).toBeVisible(); @@ -63,14 +61,14 @@ test('edit rule', async ({ dropdown, page }) => { async function createRandomRule(page: Page) { const ruleName = `rule-${getRandomId()}`; - await page.getByTestId('rules').click(); await page.getByRole('link', { name: /New Rule/ }).click(); - // Setup rule action + // Define rule action await page.getByText('Content changed').click(); - // Setup rule trigger + // Define rule trigger await page.getByText('Webhook').click(); + // This is the only required field, so we have to enter some text. await page.locator('sqx-formattable-input').first().getByRole('textbox').fill('https:/squidex.io'); await page.getByRole('button', { name: 'Save' }).click(); @@ -78,12 +76,12 @@ async function createRandomRule(page: Page) { await page.getByText('Enabled').waitFor({ state: 'visible' }); // Go back - await page.getByTestId('back').click(); + await page.getByLabel('Back').click(); - // Setup name. + // Define rule name. await page.locator('div.card', { hasText: /Unnamed Rule/ }).getByRole('heading').first().dblclick(); await page.locator('form').getByRole('textbox').fill(ruleName); - await page.locator('form').getByTestId('save').click(); + await page.locator('form').getByLabel('Save').click(); return ruleName; } \ No newline at end of file diff --git a/tools/e2e/tests/given-app/schemas.spec.ts b/tools/e2e/tests/given-app/schemas.spec.ts index fdd44ec52..8c3892e99 100644 --- a/tools/e2e/tests/given-app/schemas.spec.ts +++ b/tools/e2e/tests/given-app/schemas.spec.ts @@ -10,21 +10,21 @@ import { escapeRegex, getRandomId } from '../utils'; import { expect, test } from './_fixture'; test.beforeEach(async ({ page, appName }) => { - await page.goto(`/app/${appName}`); + await page.goto(`/app/${appName}/schemas`); }); test('create schema', async ({ page }) => { const schemaName = await createRandomSchema(page); - const schemaLink = page.locator('a.nav-link', { hasText: new RegExp(escapeRegex(schemaName)) }); + const schemaLink = page.locator('a.nav-link', { hasText: escapeRegex(schemaName) }); await expect(schemaLink).toBeVisible(); }); test('delete schema', async ({ dropdown, page }) => { const schemaName = await createRandomSchema(page); - const schemaLink = page.locator('a.nav-link', { hasText: new RegExp(escapeRegex(schemaName)) }); + const schemaLink = page.locator('a.nav-link', { hasText: escapeRegex(schemaName) }); - await page.getByTestId('options').click(); + await page.getByLabel('Options').click(); await dropdown.delete(); await expect(schemaLink).not.toBeVisible(); @@ -51,9 +51,9 @@ test('delete field', async ({ dropdown, page }) => { await createRandomSchema(page); const fieldName = await createRandomField(page); - const fieldRow = page.locator('div.table-items-row-summary', { hasText: new RegExp(escapeRegex(fieldName)) }); + const fieldRow = page.locator('div.table-items-row-summary', { hasText: escapeRegex(fieldName) }); - await fieldRow.getByTestId('options').click(); + await fieldRow.getByLabel('Options').click(); await dropdown.delete(); await expect(fieldRow).not.toBeVisible(); @@ -64,10 +64,10 @@ async function createRandomField(page: Page) { await page.locator('button').filter({ hasText: /^Add Field$/ }).click(); - // Setup name. + // Define field name. await page.getByPlaceholder('Enter field name').fill(fieldName); - // Save + // Save field. await page.getByRole('button', { name: 'Create and close' }).click(); return fieldName; @@ -76,15 +76,13 @@ async function createRandomField(page: Page) { async function createRandomSchema(page: Page) { const schemaName = `schema-${getRandomId()}`; - await page.getByTestId('schemas').click(); + await page.getByLabel('Create Schema').click(); - await page.getByTestId('new-schema').click(); - - // Setup name. + // Define schema name. await page.getByLabel('Name (required)').fill(schemaName); - // Save - await page.getByRole('button', { name: 'Create' }).click(); + // Save schema. + await page.getByRole('button', { name: 'Create', exact: true }).click(); return schemaName; } \ No newline at end of file diff --git a/tools/e2e/tests/given-login/apps-page.spec.ts b/tools/e2e/tests/given-login/apps-page.spec.ts index 5d77125a8..0857d2b5e 100644 --- a/tools/e2e/tests/given-login/apps-page.spec.ts +++ b/tools/e2e/tests/given-login/apps-page.spec.ts @@ -5,10 +5,12 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { expect, test } from '@playwright/test'; +import { expect, test } from './_fixture'; -test('has title', async ({ page }) => { +test.beforeEach(async ({ page }) => { await page.goto('/app'); +}); +test('has title', async ({ page }) => { await expect(page).toHaveTitle(/Apps/); }); \ No newline at end of file diff --git a/tools/e2e/tests/given-login/apps.spec.ts b/tools/e2e/tests/given-login/apps.spec.ts index 23a4dcffd..6e14ac7b8 100644 --- a/tools/e2e/tests/given-login/apps.spec.ts +++ b/tools/e2e/tests/given-login/apps.spec.ts @@ -5,13 +5,15 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { expect, test } from '@playwright/test'; import { getRandomId } from '../utils'; +import { expect, test } from './_fixture'; -test('should create app', async ({ page }) => { - const appName = `my-app-${getRandomId()}`; - +test.beforeEach(async ({ page }) => { await page.goto('/app'); +}); + +test('create app', async ({ page }) => { + const appName = `my-app-${getRandomId()}`; await page.getByTestId('new-app').click(); diff --git a/tools/e2e/tests/given-login/languages.spec.ts b/tools/e2e/tests/given-login/languages.spec.ts new file mode 100644 index 000000000..7a23b69d2 --- /dev/null +++ b/tools/e2e/tests/given-login/languages.spec.ts @@ -0,0 +1,55 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Page } from '@playwright/test'; +import { expect, test } from './_fixture'; + +test.beforeEach(async ({ page }) => { + await page.goto('/app'); +}); + +test('show proper English frontend', async ({ page }) => { + await changeLanguage(page, 'English'); + + await expect(page.getByText('Welcome to Squidex')).toBeVisible(); +}); + +test('show proper French frontend', async ({ page }) => { + await changeLanguage(page, 'Français'); + + await expect(page.getByText('Bienvenue sur Squidex.')).toBeVisible(); +}); + +test('show proper Dutch frontend', async ({ page }) => { + await changeLanguage(page, 'Nederlands'); + + await expect(page.getByText('Welkom bij Squidex.')).toBeVisible(); +}); + +test('show proper Italian frontend', async ({ page }) => { + await changeLanguage(page, 'Italiano'); + + await expect(page.getByText('Benvenuto su Squidex.')).toBeVisible(); +}); + +test('show proper Portugese frontend', async ({ page }) => { + await changeLanguage(page, 'Portuguese'); + + await expect(page.getByText('Bem-vindo a Squidex.')).toBeVisible(); +}); + +test('show proper Chinese frontend', async ({ page }) => { + await changeLanguage(page, '简体中文'); + + await expect(page.getByText('欢迎来到 Squidex。')).toBeVisible(); +}); + +async function changeLanguage(page: Page, name: string) { + await page.locator('sqx-profile-menu span').first().click(); + await page.getByText('Language').click(); + await page.getByText(name).click(); +} \ No newline at end of file diff --git a/tools/e2e/tests/given-schema/_fixture.ts b/tools/e2e/tests/given-schema/_fixture.ts new file mode 100644 index 000000000..1bbc89746 --- /dev/null +++ b/tools/e2e/tests/given-schema/_fixture.ts @@ -0,0 +1,32 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { readJsonAsync } from '../utils'; +import { test as base } from './../given-app/_fixture'; +export { expect } from '@playwright/test'; + +type SchemaFixture = { + schemaName: string; + + fields: [{ + name: string; + }]; +}; + +export const test = base.extend<{}, SchemaFixture>({ + schemaName: [async ({}, use) => { + const config = await readJsonAsync('schema', null!); + + await use(config.schemaName); + }, { scope: 'worker', auto: true }], + + fields: [async ({}, use) => { + const config = await readJsonAsync('fields', null!); + + await use(config.fields); + }, { scope: 'worker', auto: true }], +}); \ No newline at end of file diff --git a/tools/e2e/tests/given-schema/_setup.ts b/tools/e2e/tests/given-schema/_setup.ts new file mode 100644 index 000000000..b50231ddb --- /dev/null +++ b/tools/e2e/tests/given-schema/_setup.ts @@ -0,0 +1,46 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { expect, test as setup } from '../given-app/_fixture'; +import { getRandomId, writeJsonAsync } from '../utils'; + +setup('prepare schema', async ({ page, appName }) => { + const schemaName = `my-schema-${getRandomId()}`; + + const fields = [{ + name: 'my-string', + }]; + + await page.goto(`/app/${appName}/schemas`); + + await page.getByLabel('Create Schema').click(); + + // Define schema name. + await page.getByLabel('Name (required)').fill(schemaName); + + // Save schema. + await page.getByRole('button', { name: 'Create', exact: true }).click(); + + for (const field of fields) { + await page.locator('button').filter({ hasText: /^Add Field$/ }).click(); + + // Define field name. + await page.getByPlaceholder('Enter field name').fill(field.name); + + // Save field. + await page.getByRole('button', { name: 'Create and close' }).click(); + } + + // Publish schema. + await page.getByRole('button', { name: 'Published', exact: true }).click(); + + // Just wait for the publish operation to complete. + await expect(page.getByRole('button', { name: 'Published', exact: true })).toBeDisabled(); + + await writeJsonAsync('schema', { schemaName }); + await writeJsonAsync('fields', { fields }); +}); \ No newline at end of file diff --git a/tools/e2e/tests/given-schema/contents.spec.ts b/tools/e2e/tests/given-schema/contents.spec.ts new file mode 100644 index 000000000..a741484c3 --- /dev/null +++ b/tools/e2e/tests/given-schema/contents.spec.ts @@ -0,0 +1,150 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Page } from '@playwright/test'; +import { escapeRegex, getRandomId } from '../utils'; +import { expect, test } from './_fixture'; + +test.beforeEach(async ({ page, appName, schemaName }) => { + await page.goto(`/app/${appName}/content/${schemaName}`); +}); + +test('create content', async ({ page }) => { + const contentText = await createRandomContent(page); + const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) }); + + await expect(contentRow).toBeVisible(); +}); + +test('create content as published', async ({ page }) => { + const contentText = await createRandomContent(page, true); + const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) }); + + await expect(contentRow.getByLabel('Published')).toBeVisible(); +}); + +test('update content', async ({ page }) => { + const contentText = await createRandomContent(page); + const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) }); + + await contentRow.click(); + + const contentUpdate = `content-${getRandomId()}`; + + // Define content value. + await page.locator('sqx-field-editor').getByRole('textbox').fill(contentUpdate); + await saveContent(page, false); + + await page.getByRole('button', { name: 'Save', exact: true }).click(); + + // Wait for update of the version + await page.getByText('VersioN: 1').waitFor({ state: 'visible' }); + + // Go back + await page.getByLabel('Back').click(); + + const updateRow = page.locator('tr', { hasText: escapeRegex(contentUpdate) }); + + await expect(updateRow).toBeVisible(); +}); + +const states = [{ + state: 'Archived', + currentState: 'Draft', + initialPublished: false, +}, { + state: 'Draft', + currentState: 'Published', + initialPublished: true, +}, { + state: 'Published', + currentState: 'Draft', + initialPublished: false, +}]; + +states.forEach(({ state, currentState, initialPublished }) => { + test(`change content to ${state}`, async ({ dropdown, page }) => { + const contentText = await createRandomContent(page, initialPublished); + const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) }); + + await contentRow.getByLabel('Options').click(); + await dropdown.action(`Change to ${state}`); + await page.getByRole('button', { name: 'Confirm' }).click(); + + await expect(contentRow.getByLabel(state)).toBeVisible(); + }); + + test(`change content to ${state} by checkbox`, async ({ page }) => { + const contentText = await createRandomContent(page, initialPublished); + const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) }); + + await contentRow.getByRole('checkbox').click(); + await page.getByRole('button', { name: state }).click(); + await page.getByRole('button', { name: 'Confirm' }).click(); + + await expect(contentRow.getByLabel(state)).toBeVisible(); + }); + + test(`change content to ${state} by detail page`, async ({ page }) => { + const contentText = await createRandomContent(page, initialPublished); + const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) }); + + await contentRow.click(); + await page.getByRole('button', { name: currentState }).click(); + await page.getByText(`Change to ${state}`).click(); + await page.getByRole('button', { name: 'Confirm' }).click(); + + await expect(page.getByRole('button', { name: state })).toBeVisible(); + }); +}); + +test('delete content', async ({ dropdown, page }) => { + const contentText = await createRandomContent(page); + const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) }); + + await contentRow.getByLabel('Options').click(); + await dropdown.delete(); + + await expect(contentRow).not.toBeVisible(); +}); + +test('delete content by checkbox', async ({ page }) => { + const contentText = await createRandomContent(page); + const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) }); + + await contentRow.getByRole('checkbox').click(); + await page.getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Yes' }).click(); + + await expect(contentRow).not.toBeVisible(); +}); + +async function createRandomContent(page: Page, publish = false) { + const contentText = `content-${getRandomId()}`; + + await page.getByRole('button', { name: /New/ }).click(); + + // Define content value. + await page.locator('sqx-field-editor').getByRole('textbox').fill(contentText); + await saveContent(page, publish); + + // Wait for the success alert. + await page.getByLabel('Identity').waitFor({ state: 'visible' }); + + // Go back + await page.getByLabel('Back').click(); + + return contentText; +} + +async function saveContent(page: Page, publish: boolean) { + if (publish) { + await page.getByRole('button', { name: 'Save and Publish', exact: true }).click(); + } else { + await page.getByRole('button', { name: 'Save', exact: true }).click(); + } +} diff --git a/tools/e2e/tests/login.spec.ts b/tools/e2e/tests/login.spec.ts index 42b3ba656..7c9f28f42 100644 --- a/tools/e2e/tests/login.spec.ts +++ b/tools/e2e/tests/login.spec.ts @@ -7,9 +7,11 @@ import { expect, test } from '@playwright/test'; -test('login', async ({ page }) => { +test.beforeEach(async ({ page }) => { await page.goto('/'); +}); +test('login', async ({ page }) => { // Start waiting for popup before clicking. const popupPromise = page.waitForEvent('popup'); @@ -30,8 +32,6 @@ test('login', async ({ page }) => { }); test('visual test', async ({ page }) => { - await page.goto('/'); - // Start waiting for popup before clicking. const popupPromise = page.waitForEvent('popup'); diff --git a/tools/e2e/tests/start-page.spec.ts b/tools/e2e/tests/start-page.spec.ts index 395439586..dab028cfc 100644 --- a/tools/e2e/tests/start-page.spec.ts +++ b/tools/e2e/tests/start-page.spec.ts @@ -7,12 +7,14 @@ import { expect, test } from '@playwright/test'; -test('has title', async ({ page }) => { +test.beforeEach(async ({ page }) => { await page.goto('/'); +}); + +test('has title', async ({ page }) => { await expect(page).toHaveTitle(/Squidex/); }); test('visual test', async ({ page }) => { - await page.goto('/'); await expect(page).toHaveScreenshot(); }); \ No newline at end of file diff --git a/tools/e2e/tests/utils.ts b/tools/e2e/tests/utils.ts index 20108ccb2..2a9a6a84e 100644 --- a/tools/e2e/tests/utils.ts +++ b/tools/e2e/tests/utils.ts @@ -18,7 +18,7 @@ export function getRandomId() { export function escapeRegex(string: string) { const result = string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); - return result; + return new RegExp(result); } export async function writeJsonAsync(name: string, json: any) { @@ -33,7 +33,7 @@ export async function readJsonAsync(name: string, defaultValue: T) { const json = await fs.readFile(fullPath, 'utf8'); if (json) { - return JSON.parse(json); + return JSON.parse(json) as T; } else { return defaultValue; }