diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index bbeb800c1..8c5d40803 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -111,6 +111,7 @@ jobs: environment: | CONFIG__WAIT=60 CONFIG__SERVER__URL=http://localhost:8080 + WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher default_network: host options: --name test1 volumes: ${{ github.workspace }}:/src @@ -123,6 +124,7 @@ jobs: environment: | CONFIG__WAIT=60 CONFIG__SERVER__URL=http://localhost:8081/squidex + WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher default_network: host options: --name test2 volumes: ${{ github.workspace }}:/src diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a6d070b7..13c3f6c42 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,6 +94,7 @@ jobs: environment: | CONFIG__WAIT=60 CONFIG__SERVER__URL=http://localhost:8080 + WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher default_network: host options: --name test1 volumes: ${{ github.workspace }}:/src @@ -106,6 +107,7 @@ jobs: environment: | CONFIG__WAIT=60 CONFIG__SERVER__URL=http://localhost:8081/squidex + WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher default_network: host options: --name test2 volumes: ${{ github.workspace }}:/src diff --git a/backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs b/backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs index 79b7a5421..2afa53582 100644 --- a/backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs +++ b/backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs @@ -17,7 +17,6 @@ namespace Squidex.Extensions.Actions.Notification public sealed class NotificationActionHandler : RuleActionHandler { private const string Description = "Send a Notification"; - private static readonly NamedId NoApp = NamedId.Of(DomainId.Empty, "none"); private readonly ICommandBus commandBus; private readonly IUserResolver userResolver; @@ -49,9 +48,11 @@ namespace Squidex.Extensions.Actions.Notification var ruleJob = new CreateComment { + AppId = CommentsCommand.NoApp, Actor = actor, CommentId = DomainId.NewGuid(), CommentsId = DomainId.Create(user.Id), + FromRule = true, Text = await FormatAsync(action.Text, @event) }; @@ -81,9 +82,6 @@ namespace Squidex.Extensions.Actions.Notification return Result.Ignored(); } - command.AppId = NoApp; - command.FromRule = true; - await commandBus.PublishAsync(command, ct); return Result.Success($"Notified: {command.Text}"); diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleOptions.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleOptions.cs index 8b5926dda..029b3a292 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleOptions.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleOptions.cs @@ -10,5 +10,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules public sealed class RuleOptions { public int ExecutionTimeoutInSeconds { get; set; } = 3; + + public TimeSpan RuleCacheDuration { get; set; } = TimeSpan.FromSeconds(10); } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs index ce2262537..6acbae68f 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs @@ -69,7 +69,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules var rule = context.Rule; - if (!rule.IsEnabled) + if (!rule.IsEnabled && !context.IncludeSkipped) { yield break; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs index 1fc3c5639..089ed024a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs @@ -12,12 +12,17 @@ namespace Squidex.Domain.Apps.Entities.Comments.Commands { public abstract class CommentsCommand : SquidexCommand, IAppCommand, IAggregateCommand { + public static readonly NamedId NoApp = NamedId.Of(DomainId.NewGuid(), "none"); + public NamedId AppId { get; set; } public DomainId CommentsId { get; set; } public DomainId CommentId { get; set; } - DomainId IAggregateCommand.AggregateId => DomainId.Combine(AppId.Id, CommentsId); + DomainId IAggregateCommand.AggregateId + { + get => AppId.Id != default ? DomainId.Combine(AppId.Id, CommentsId) : CommentsId; + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsStream.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsStream.cs index 4a45dc0c6..5ae4ec39e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsStream.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsStream.cs @@ -13,8 +13,6 @@ using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Reflection; -#pragma warning disable MA0022 // Return Task.FromResult instead of returning null - namespace Squidex.Domain.Apps.Entities.Comments.DomainObject { public class CommentsStream : IAggregate @@ -24,8 +22,8 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject private readonly DomainId key; private readonly IEventFormatter eventFormatter; private readonly IEventStore eventStore; + private readonly string streamName; private long version = EtagVersion.Empty; - private string streamName; private long Version => version; @@ -37,13 +35,13 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject this.key = key; this.eventFormatter = eventFormatter; this.eventStore = eventStore; + + streamName = $"comments-{key}"; } public virtual async Task LoadAsync( CancellationToken ct) { - streamName = $"comments-{key}"; - var storedEvents = await eventStore.QueryReverseAsync(streamName, 100, ct); foreach (var @event in storedEvents) @@ -56,13 +54,15 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject } } - public virtual Task ExecuteAsync(IAggregateCommand command, + public virtual async Task ExecuteAsync(IAggregateCommand command, CancellationToken ct) { + await LoadAsync(ct); + switch (command) { case CreateComment createComment: - return Upsert(createComment, c => + return await Upsert(createComment, c => { GuardComments.CanCreate(c); @@ -70,7 +70,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject }, ct); case UpdateComment updateComment: - return Upsert(updateComment, c => + return await Upsert(updateComment, c => { GuardComments.CanUpdate(c, key.ToString(), events); @@ -78,7 +78,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject }, ct); case DeleteComment deleteComment: - return Upsert(deleteComment, c => + return await Upsert(deleteComment, c => { GuardComments.CanDelete(c, key.ToString(), events); @@ -87,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject default: ThrowHelper.NotSupportedException(); - return default!; + return null!; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs index 1621e98ad..f62f50142 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs @@ -6,6 +6,7 @@ // ========================================================================== using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; using Squidex.Caching; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules; @@ -18,25 +19,27 @@ namespace Squidex.Domain.Apps.Entities.Rules { public sealed class RuleEnqueuer : IEventConsumer, IRuleEnqueuer { - private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(10); private readonly IMemoryCache cache; private readonly IRuleEventRepository ruleEventRepository; private readonly IRuleService ruleService; private readonly IAppProvider appProvider; private readonly ILocalCache localCache; + private readonly TimeSpan cacheDuration; public string Name { get => GetType().Name; } - public RuleEnqueuer(IAppProvider appProvider, IMemoryCache cache, ILocalCache localCache, + public RuleEnqueuer(IMemoryCache cache, ILocalCache localCache, + IAppProvider appProvider, IRuleEventRepository ruleEventRepository, - IRuleService ruleService) + IRuleService ruleService, + IOptions options) { this.appProvider = appProvider; - this.cache = cache; + this.cacheDuration = options.Value.RuleCacheDuration; this.ruleEventRepository = ruleEventRepository; this.ruleService = ruleService; this.localCache = localCache; @@ -57,6 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Rules await foreach (var job in jobs) { + // We do not want to handle disabled rules in the normal flow. if (job.Job != null && job.SkipReason == SkipReason.None) { await ruleEventRepository.EnqueueAsync(job.Job, job.EnrichmentError); @@ -89,9 +93,10 @@ namespace Squidex.Domain.Apps.Entities.Rules { var cacheKey = $"{typeof(RuleEnqueuer)}_Rules_{appId}"; + // Cache the rules for performance reasons for a short period of time (usually 10 sec). return cache.GetOrCreateAsync(cacheKey, entry => { - entry.AbsoluteExpirationRelativeToNow = CacheDuration; + entry.AbsoluteExpirationRelativeToNow = cacheDuration; return appProvider.GetRulesAsync(appId); }); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs index 4566a2e98..cfa9a0d8c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs @@ -102,7 +102,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner { var context = GetContext(rule); - return context.Rule.IsEnabled && context.Rule.Trigger is not ManualTrigger; + return context.Rule.Trigger is not ManualTrigger; } public bool CanRunFromSnapshots(IRuleEntity rule) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerProcessor.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerProcessor.cs index be6a064ac..b33b29dbe 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerProcessor.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerProcessor.cs @@ -97,7 +97,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner { await state.LoadAsync(ct); - if (!state.Value.RunFromSnapshots) + if (!state.Value.RunFromSnapshots && state.Value.RuleId != default) { TaskHelper.Forget(RunAsync(state.Value.RuleId, false, default)); } @@ -178,12 +178,14 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner using (localCache.StartContext()) { + // Also run disabled rules, because we want to enable rules to be only used with manual trigger. run.Context = new RuleContext { AppId = rule.AppId, Rule = rule.RuleDef, RuleId = rule.Id, - IncludeStale = true + IncludeStale = true, + IncludeSkipped = true }; if (run.Job.RunFromSnapshots && ruleService.CanCreateSnapshotEvents(run.Context)) @@ -219,7 +221,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner await foreach (var job in ruleService.CreateSnapshotJobsAsync(run.Context, ct)) { - if (job.Job != null && job.SkipReason == SkipReason.None) + if (job.Job != null && job.SkipReason is SkipReason.None or SkipReason.Disabled) { await ruleEventRepository.EnqueueAsync(job.Job, job.EnrichmentError, ct); } @@ -258,7 +260,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner await foreach (var job in jobs.WithCancellation(ct)) { - if (job.Job != null && job.SkipReason == SkipReason.None) + if (job.Job != null && job.SkipReason is SkipReason.None or SkipReason.Disabled) { await ruleEventRepository.EnqueueAsync(job.Job, job.EnrichmentError, ct); } diff --git a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs index a2d23e6cc..839515a8c 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs @@ -163,7 +163,7 @@ namespace Squidex.Infrastructure.EventSourcing } public void WakeUp() - { + { } } } diff --git a/backend/src/Squidex.Infrastructure/Commands/DefaultDomainObjectCache.cs b/backend/src/Squidex.Infrastructure/Commands/DefaultDomainObjectCache.cs index fd1600f71..fffa37df5 100644 --- a/backend/src/Squidex.Infrastructure/Commands/DefaultDomainObjectCache.cs +++ b/backend/src/Squidex.Infrastructure/Commands/DefaultDomainObjectCache.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; using Squidex.Infrastructure.Json; using Squidex.Infrastructure.ObjectPool; @@ -14,20 +15,22 @@ namespace Squidex.Infrastructure.Commands { public sealed class DefaultDomainObjectCache : IDomainObjectCache { - private static readonly DistributedCacheEntryOptions CacheOptions = new DistributedCacheEntryOptions - { - SlidingExpiration = TimeSpan.FromMinutes(10) - }; - + private readonly DistributedCacheEntryOptions cacheOptions; private readonly IMemoryCache cache; private readonly IJsonSerializer serializer; private readonly IDistributedCache distributedCache; - public DefaultDomainObjectCache(IMemoryCache cache, IJsonSerializer serializer, IDistributedCache distributedCache) + public DefaultDomainObjectCache(IMemoryCache cache, IJsonSerializer serializer, IDistributedCache distributedCache, + IOptions options) { this.cache = cache; this.serializer = serializer; this.distributedCache = distributedCache; + + cacheOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = options.Value.CacheDuration + }; } public async Task GetAsync(DomainId id, long version, @@ -67,7 +70,7 @@ namespace Squidex.Infrastructure.Commands { var cacheKey = CacheKey(id, version); - cache.Set(cacheKey, snapshot, CacheOptions.SlidingExpiration!.Value); + cache.Set(cacheKey, snapshot, cacheOptions.AbsoluteExpirationRelativeToNow!.Value); try { @@ -75,7 +78,7 @@ namespace Squidex.Infrastructure.Commands { serializer.Serialize(snapshot, stream, true); - await distributedCache.SetAsync(cacheKey, stream.ToArray(), CacheOptions, ct); + await distributedCache.SetAsync(cacheKey, stream.ToArray(), cacheOptions, ct); } } catch diff --git a/backend/src/Squidex.Infrastructure/Commands/DomainObjectCacheOptions.cs b/backend/src/Squidex.Infrastructure/Commands/DomainObjectCacheOptions.cs new file mode 100644 index 000000000..22d93f580 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Commands/DomainObjectCacheOptions.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Infrastructure.Commands +{ + public sealed class DomainObjectCacheOptions + { + public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(10); + } +} diff --git a/backend/src/Squidex.Infrastructure/EventSourcing/Consume/BatchSubscription.cs b/backend/src/Squidex.Infrastructure/EventSourcing/Consume/BatchSubscription.cs index 9b2d604db..32f81d1fd 100644 --- a/backend/src/Squidex.Infrastructure/EventSourcing/Consume/BatchSubscription.cs +++ b/backend/src/Squidex.Infrastructure/EventSourcing/Consume/BatchSubscription.cs @@ -138,10 +138,12 @@ namespace Squidex.Infrastructure.EventSourcing.Consume // Forward the exception from one task only, but bypass the batch. await taskQueue.Writer.WriteAsync(exception, completed.Token); } + catch (OperationCanceledException) + { + // These exception are acceptable and happens when an exception has been thrown before. + } catch (ChannelClosedException) { - // This exception is acceptable and happens when an exception has been thrown before. - return; } } @@ -151,10 +153,12 @@ namespace Squidex.Infrastructure.EventSourcing.Consume { await batchQueue.Writer.WriteAsync(@event, completed.Token); } + catch (OperationCanceledException) + { + // These exception are acceptable and happens when an exception has been thrown before. + } catch (ChannelClosedException) { - // This exception is acceptable and happens when an exception has been thrown before. - return; } } } diff --git a/backend/src/Squidex.Infrastructure/EventSourcing/Consume/ParseSubscription.cs b/backend/src/Squidex.Infrastructure/EventSourcing/Consume/ParseSubscription.cs index 9520cf5fa..406c30464 100644 --- a/backend/src/Squidex.Infrastructure/EventSourcing/Consume/ParseSubscription.cs +++ b/backend/src/Squidex.Infrastructure/EventSourcing/Consume/ParseSubscription.cs @@ -127,10 +127,12 @@ namespace Squidex.Infrastructure.EventSourcing.Consume // Forward the exception from one task only. await deserializeQueue.Writer.WriteAsync(exception, completed.Token); } + catch (OperationCanceledException) + { + // These exception are acceptable and happens when an exception has been thrown before. + } catch (ChannelClosedException) { - // This exception is acceptable and happens when an exception has been thrown before. - return; } } @@ -140,10 +142,12 @@ namespace Squidex.Infrastructure.EventSourcing.Consume { await deserializeQueue.Writer.WriteAsync(@event, completed.Token); } + catch (OperationCanceledException) + { + // These exception are acceptable and happens when an exception has been thrown before. + } catch (ChannelClosedException) { - // This exception is acceptable and happens when an exception has been thrown before. - return; } } } diff --git a/backend/src/Squidex.Infrastructure/EventSourcing/RetrySubscription.cs b/backend/src/Squidex.Infrastructure/EventSourcing/RetrySubscription.cs index 52f91e719..72a716382 100644 --- a/backend/src/Squidex.Infrastructure/EventSourcing/RetrySubscription.cs +++ b/backend/src/Squidex.Infrastructure/EventSourcing/RetrySubscription.cs @@ -5,20 +5,19 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure.Tasks; - namespace Squidex.Infrastructure.EventSourcing { public sealed class RetrySubscription : IEventSubscription, IEventSubscriber { private readonly RetryWindow retryWindow = new RetryWindow(TimeSpan.FromMinutes(5), 5); - private readonly AsyncLock lockObject = new AsyncLock(); private readonly IEventSubscriber eventSubscriber; private readonly EventSubscriptionSource eventSource; private SubscriptionHolder? currentSubscription; public int ReconnectWaitMs { get; set; } = 5000; + public bool IsSubscribed => currentSubscription != null; + // Holds all information for a current subscription. Therefore we only have to maintain one reference. private sealed class SubscriptionHolder : IDisposable { @@ -53,33 +52,34 @@ namespace Squidex.Infrastructure.EventSourcing public void Dispose() { - using (lockObject.Enter()) - { - Unsubscribe(); - } - - lockObject.Dispose(); + Unsubscribe(); } private void Subscribe() { - if (currentSubscription != null) + lock (retryWindow) { - return; - } + if (currentSubscription != null) + { + return; + } - currentSubscription = new SubscriptionHolder(eventSource(this)); + currentSubscription = new SubscriptionHolder(eventSource(this)); + } } private void Unsubscribe() { - if (currentSubscription == null) + lock (retryWindow) { - return; - } + if (currentSubscription == null) + { + return; + } - currentSubscription.Dispose(); - currentSubscription = null; + currentSubscription.Dispose(); + currentSubscription = null; + } } public void WakeUp() @@ -94,16 +94,12 @@ namespace Squidex.Infrastructure.EventSourcing async ValueTask IEventSubscriber.OnNextAsync(IEventSubscription subscription, T @event) { - // It is not entirely sure, if the lock is needed, but it seems to work so far. - using (await lockObject.EnterAsync(default)) + if (!ReferenceEquals(subscription, currentSubscription?.Subscription)) { - if (!ReferenceEquals(subscription, currentSubscription?.Subscription)) - { - return; - } - - await eventSubscriber.OnNextAsync(this, @event); + return; } + + await eventSubscriber.OnNextAsync(this, @event); } async ValueTask IEventSubscriber.OnErrorAsync(IEventSubscription subscription, Exception exception) @@ -113,21 +109,17 @@ namespace Squidex.Infrastructure.EventSourcing return; } - using (await lockObject.EnterAsync(default)) + if (!ReferenceEquals(subscription, currentSubscription?.Subscription)) { - if (!ReferenceEquals(subscription, currentSubscription?.Subscription)) - { - return; - } + return; + } - // Unsubscribing is not an atomar operation, therefore the lock. - Unsubscribe(); + Unsubscribe(); - if (!retryWindow.CanRetryAfterFailure()) - { - await eventSubscriber.OnErrorAsync(this, exception); - return; - } + if (!retryWindow.CanRetryAfterFailure()) + { + await eventSubscriber.OnErrorAsync(this, exception); + return; } try @@ -139,11 +131,7 @@ namespace Squidex.Infrastructure.EventSourcing return; } - using (await lockObject.EnterAsync(default)) - { - // Subscribing is not an atomar operation, therefore the lock. - Subscribe(); - } + Subscribe(); } } } diff --git a/backend/src/Squidex.Infrastructure/Tasks/LimitedConcurrencyLevelTaskScheduler.cs b/backend/src/Squidex.Infrastructure/Tasks/LimitedConcurrencyLevelTaskScheduler.cs index 2fe606892..5748ce028 100644 --- a/backend/src/Squidex.Infrastructure/Tasks/LimitedConcurrencyLevelTaskScheduler.cs +++ b/backend/src/Squidex.Infrastructure/Tasks/LimitedConcurrencyLevelTaskScheduler.cs @@ -19,7 +19,7 @@ namespace Squidex.Infrastructure.Tasks public LimitedConcurrencyLevelTaskScheduler(int maxDegreeOfParallelism) { - Guard.GreaterEquals(maxDegreeOfParallelism, 1, nameof(maxDegreeOfParallelism)); + Guard.GreaterEquals(maxDegreeOfParallelism, 1); this.maxDegreeOfParallelism = maxDegreeOfParallelism; } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs index 2d7dcd5e2..5b8e12084 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs @@ -79,7 +79,7 @@ namespace Squidex.Areas.Api.Controllers.Comments [ApiCosts(0)] public async Task GetComments(string app, DomainId commentsId, [FromQuery] long version = EtagVersion.Any) { - var result = await commentsLoader.GetCommentsAsync(commentsId, version, HttpContext.RequestAborted); + var result = await commentsLoader.GetCommentsAsync(Id(commentsId), version, HttpContext.RequestAborted); var response = Deferred.Response(() => { @@ -159,13 +159,22 @@ namespace Squidex.Areas.Api.Controllers.Comments [ApiCosts(0)] public async Task DeleteComment(string app, DomainId commentsId, DomainId commentId) { - var command = new DeleteComment { CommentsId = commentsId, CommentId = commentId }; + var command = new DeleteComment + { + CommentsId = commentsId, + CommentId = commentId + }; await CommandBus.PublishAsync(command, HttpContext.RequestAborted); return NoContent(); } + private DomainId Id(DomainId commentsId) + { + return DomainId.Combine(App.Id, commentsId); + } + private string UserId() { var subject = User.OpenIdSubject(); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Comments/Notifications/UserNotificationsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Comments/Notifications/UserNotificationsController.cs index acbf37e9e..e92789a79 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Comments/Notifications/UserNotificationsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Comments/Notifications/UserNotificationsController.cs @@ -25,7 +25,6 @@ namespace Squidex.Areas.Api.Controllers.Comments.Notifications [ApiExplorerSettings(GroupName = nameof(Notifications))] public sealed class UserNotificationsController : ApiController { - private static readonly NamedId NoApp = NamedId.Of(DomainId.Empty, "none"); private readonly ICommentsLoader commentsLoader; public UserNotificationsController(ICommandBus commandBus, ICommentsLoader commentsLoader) @@ -83,7 +82,7 @@ namespace Squidex.Areas.Api.Controllers.Comments.Notifications var commmand = new DeleteComment { - AppId = NoApp, + AppId = CommentsCommand.NoApp, CommentsId = userId, CommentId = commentId }; diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs index 81612514a..6f3f26986 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs @@ -27,7 +27,7 @@ using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Rules { /// - /// Manages and retrieves information about schemas. + /// Manages and retrieves information about rules. /// [ApiExplorerSettings(GroupName = nameof(Rules))] public sealed class RulesController : ApiController diff --git a/backend/src/Squidex/Config/Domain/CommandsServices.cs b/backend/src/Squidex/Config/Domain/CommandsServices.cs index 07ca70c27..4d12531b7 100644 --- a/backend/src/Squidex/Config/Domain/CommandsServices.cs +++ b/backend/src/Squidex/Config/Domain/CommandsServices.cs @@ -37,6 +37,9 @@ namespace Squidex.Config.Domain services.Configure(config, "usage"); + services.Configure(config, + "caching:domainObjects"); + services.AddSingletonAs() .As(); diff --git a/backend/src/Squidex/appsettings.json b/backend/src/Squidex/appsettings.json index d7f40f3c3..edc1e214d 100644 --- a/backend/src/Squidex/appsettings.json +++ b/backend/src/Squidex/appsettings.json @@ -72,6 +72,11 @@ "replicated": { // Set to true to enable a replicated cache for app, schemas and rules. Increases performance but reduces consistency. "enable": true + }, + + "domainObjects": { + // The cache duration for domain objects. + "cacheDuration": "00:10:00" } }, @@ -94,7 +99,10 @@ "rules": { // The timeout to execute rule actions. - "executionTimeoutInSeconds": 10 + "executionTimeoutInSeconds": 10, + + // The cache duration for rules. + "rulesCacheDuration": "00:00:10" }, "ui": { diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs index 4c4c53393..d0fb53bf3 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs @@ -242,6 +242,29 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules .MustNotHaveHappened(); } + [Fact] + public async Task Should_create_jobs_from_snapshots_if_rule_disabled_but_included() + { + var context = Rule(disable: true, includeSkipped: true); + + A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) + .Returns(true); + + A.CallTo(() => ruleTriggerHandler.Trigger(A._, context)) + .Returns(true); + + A.CallTo(() => ruleTriggerHandler.CreateSnapshotEventsAsync(context, default)) + .Returns(new List + { + new EnrichedContentEvent { AppId = appId }, + new EnrichedContentEvent { AppId = appId } + }.ToAsyncEnumerable()); + + var jobs = await sut.CreateSnapshotJobsAsync(context).ToListAsync(); + + Assert.Equal(2, jobs.Count(x => x.Job != null && x.EnrichmentError == null)); + } + [Fact] public async Task Should_create_jobs_from_snapshots() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs index 2c3ce4ec5..0d6a840ef 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs @@ -39,12 +39,13 @@ namespace Squidex.Domain.Apps.Entities.Rules public RuleEnqueuerTests() { - sut = new RuleEnqueuer( + var options = Options.Create(new RuleOptions()); + + sut = new RuleEnqueuer(cache, localCache, appProvider, - cache, - localCache, ruleEventRepository, - ruleService); + ruleService, + options); } [Fact] diff --git a/backend/tests/Squidex.Infrastructure.Tests/Commands/DefaultDomainObjectCacheTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Commands/DefaultDomainObjectCacheTests.cs index d21edff6a..628b61fed 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Commands/DefaultDomainObjectCacheTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Commands/DefaultDomainObjectCacheTests.cs @@ -8,6 +8,7 @@ using FakeItEasy; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; using Squidex.Infrastructure.Json; using Xunit; @@ -27,7 +28,9 @@ namespace Squidex.Infrastructure.Commands { ct = cts.Token; - sut = new DefaultDomainObjectCache(cache, serializer, distributedCache); + var options = Options.Create(new DomainObjectCacheOptions()); + + sut = new DefaultDomainObjectCache(cache, serializer, distributedCache, options); } [Fact] diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/RetrySubscriptionTests.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/RetrySubscriptionTests.cs index e3a342417..a1a15e74a 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/RetrySubscriptionTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/RetrySubscriptionTests.cs @@ -24,7 +24,6 @@ namespace Squidex.Infrastructure.EventSourcing .Returns(eventSubscription); sut = new RetrySubscription(eventSubscriber, s => eventStore.CreateSubscription(s)) { ReconnectWaitMs = 50 }; - sutSubscriber = sut; } @@ -40,7 +39,9 @@ namespace Squidex.Infrastructure.EventSourcing [Fact] public async Task Should_reopen_subscription_once_if_exception_is_retrieved() { - await OnErrorAsync(eventSubscription, new InvalidOperationException()); + var ex = new InvalidOperationException(); + + await OnErrorAsync(eventSubscription, ex, times: 1); await Task.Delay(1000); @@ -61,12 +62,7 @@ namespace Squidex.Infrastructure.EventSourcing { var ex = new InvalidOperationException(); - await OnErrorAsync(eventSubscription, ex); - await OnErrorAsync(eventSubscription, ex); - await OnErrorAsync(eventSubscription, ex); - await OnErrorAsync(eventSubscription, ex); - await OnErrorAsync(eventSubscription, ex); - await OnErrorAsync(eventSubscription, ex); + await OnErrorAsync(eventSubscription, ex, times: 6); sut.Dispose(); @@ -74,12 +70,25 @@ namespace Squidex.Infrastructure.EventSourcing .MustHaveHappened(); } + [Fact] + public async Task Should_ignore_operation_cancelled_error_from_inner_subscription_if_failed_often() + { + var ex = new OperationCanceledException(); + + await OnErrorAsync(eventSubscription, ex, times: 6); + + sut.Dispose(); + + A.CallTo(() => eventSubscriber.OnErrorAsync(sut, ex)) + .MustNotHaveHappened(); + } + [Fact] public async Task Should_not_forward_error_if_exception_is_raised_after_unsubscribe() { var ex = new InvalidOperationException(); - await OnErrorAsync(eventSubscription, ex); + await OnErrorAsync(eventSubscription, ex, times: 1); sut.Dispose(); @@ -113,9 +122,38 @@ namespace Squidex.Infrastructure.EventSourcing .MustNotHaveHappened(); } - private ValueTask OnErrorAsync(IEventSubscription subscriber, Exception ex) + [Fact] + public async Task Should_be_able_to_unsubscribe_within_exception_handler() + { + var ex = new InvalidOperationException(); + + A.CallTo(() => eventSubscriber.OnErrorAsync(A._, A._)) + .Invokes(() => sut.Dispose()); + + await OnErrorAsync(eventSubscription, ex, times: 6); + + Assert.False(sut.IsSubscribed); + } + + [Fact] + public async Task Should_be_able_to_unsubscribe_within_event_handler() + { + var @event = new StoredEvent("Stream", "1", 2, new EventData("Type", new EnvelopeHeaders(), "Payload")); + + A.CallTo(() => eventSubscriber.OnNextAsync(A._, A._)) + .Invokes(() => sut.Dispose()); + + await OnNextAsync(eventSubscription, @event); + + Assert.False(sut.IsSubscribed); + } + + private async ValueTask OnErrorAsync(IEventSubscription subscriber, Exception ex, int times) { - return sutSubscriber.OnErrorAsync(subscriber, ex); + for (var i = 0; i < times; i++) + { + await sutSubscriber.OnErrorAsync(subscriber, ex); + } } private ValueTask OnNextAsync(IEventSubscription subscriber, StoredEvent ev) diff --git a/backend/tests/docker-compose.yml b/backend/tests/docker-compose.yml index 17e652353..8209ea489 100644 --- a/backend/tests/docker-compose.yml +++ b/backend/tests/docker-compose.yml @@ -18,6 +18,7 @@ services: - IDENTITY__ADMINCLIENTID=root - IDENTITY__ADMINCLIENTSECRET=xeLd6jFxqbXJrfmNLlO2j1apagGGGSyZJhFnIuHp4I0= - IDENTITY__MULTIPLEDOMAINS=true + - RULES__RULESCACHEDURATION=00:00:00 - SCRIPTING__TIMEOUTEXECUTION=00:00:10 - SCRIPTING__TIMEOUTSCRIPT=00:00:10 - STORE__MONGODB__CONFIGURATION=mongodb://mongo @@ -63,6 +64,14 @@ services: depends_on: - mongo + webhookcatcher: + image: tarampampam/webhook-tester + command: serve --port 1026 + ports: + - "1026:1026" + networks: + - internal + squidex_proxy1: image: squidex/caddy-proxy ports: diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/BackupTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/BackupTests.cs index a40e540fa..98a577480 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/BackupTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/BackupTests.cs @@ -7,6 +7,7 @@ using Squidex.ClientLibrary.Management; using TestSuite.Fixtures; +using TestSuite.Model; using Xunit; #pragma warning disable SA1300 // Element should begin with upper-case letter @@ -17,6 +18,9 @@ namespace TestSuite.ApiTests [Trait("Category", "NotAutomated")] public class BackupTests : IClassFixture { + private readonly string appName = Guid.NewGuid().ToString(); + private readonly string schemaName = $"schema-{Guid.NewGuid()}"; + public ClientFixture _ { get; } public BackupTests(ClientFixture fixture) @@ -27,9 +31,6 @@ namespace TestSuite.ApiTests [Fact] public async Task Should_backup_and_restore_app() { - var timeout = TimeSpan.FromMinutes(2); - - var appName = Guid.NewGuid().ToString(); var appNameRestore = $"{appName}-restore"; // STEP 1: Create app @@ -38,68 +39,57 @@ namespace TestSuite.ApiTests await _.Apps.PostAppAsync(createRequest); - // STEP 2: Create backup - await _.Backups.PostBackupAsync(appName); + // STEP 2: Prepare app. + await PrepareAppAsync(appName); - BackupJobDto backup = null; - try - { - using (var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2))) - { - while (true) - { - cts.Token.ThrowIfCancellationRequested(); + // STEP 3: Create backup + await _.Backups.PostBackupAsync(appNameRestore); - await Task.Delay(1000); + var backup = await _.Backups.WaitForBackupAsync(appName, TimeSpan.FromMinutes(2)); - var backups = await _.Backups.GetBackupsAsync(appName); + Assert.Equal(JobStatus.Completed, backup?.Status); - if (backups.Items.Count > 0) - { - backup = backups.Items.FirstOrDefault(); - break; - } - } - } - } - catch (OperationCanceledException) - { - Assert.True(false, $"Could not retrieve backup within {timeout}."); - } + // STEP 3: Restore backup + var uri = new Uri(new Uri(_.ServerUrl, UriKind.Absolute), backup._links["download"].Href); + var restore = await _.Backups.WaitForRestoreAsync(uri, TimeSpan.FromMinutes(2)); - // STEP 3: Restore backup - var uri = new Uri($"{_.ServerUrl}{backup._links["download"].Href}"); + Assert.Equal(JobStatus.Completed, restore?.Status); + } - var restoreRequest = new RestoreRequestDto { Url = uri, Name = appNameRestore }; + private async Task PrepareAppAsync(string appName) + { + // Create a test schema. + await TestEntity.CreateSchemaAsync(_.Schemas, appName, schemaName); - await _.Backups.PostRestoreJobAsync(restoreRequest); + var contents = _.ClientManager.CreateContentsClient(appName, schemaName); - try - { - using (var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2))) - { - while (true) - { - cts.Token.ThrowIfCancellationRequested(); - - await Task.Delay(1000); - - var job = await _.Backups.GetRestoreJobAsync(); - - if (job != null && job.Url == uri && job.Status == JobStatus.Completed) - { - break; - } - } - } - } - catch (OperationCanceledException) + await contents.CreateAsync(new TestEntityData { Number = 1 }); + + + // Upload a test asset + var fileInfo = new FileInfo("Assets/logo-squared.png"); + + await using (var stream = fileInfo.OpenRead()) { - Assert.True(false, $"Could not retrieve restored app within {timeout}."); + var upload = new FileParameter(stream, fileInfo.Name, "image/png"); + + await _.Assets.PostAssetAsync(appName, file: upload); } + + + // Create a workflow + var workflow = new AddWorkflowDto { Name = appName }; + + await _.Apps.PostWorkflowAsync(appName, workflow); + + + // Create a language + var language = new AddLanguageDto { Language = "de" }; + + await _.Apps.PostLanguageAsync(appName, language); } } } diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/CommentsTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/CommentsTests.cs new file mode 100644 index 000000000..d0a30c421 --- /dev/null +++ b/backend/tools/TestSuite/TestSuite.ApiTests/CommentsTests.cs @@ -0,0 +1,91 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.ClientLibrary.Management; +using TestSuite.Fixtures; +using Xunit; + +#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 CommentsTests : IClassFixture + { + private readonly string resource = Guid.NewGuid().ToString(); + + public CreatedAppFixture _ { get; } + + public CommentsTests(CreatedAppFixture fixture) + { + _ = fixture; + } + + [Fact] + public async Task Should_make_watch_request() + { + var result = await _.Comments.GetWatchingUsersAsync(_.AppName, resource); + + Assert.NotNull(result); + } + + [Fact] + public async Task Should_create_comment() + { + // STEP 1: Create the comment. + var createRequest = new UpsertCommentDto { Text = resource }; + + await _.Comments.PostCommentAsync(_.AppName, resource, createRequest); + + + // STEP 2: Get comments + var comments = await _.Comments.GetCommentsAsync(_.AppName, resource); + + Assert.Contains(comments.CreatedComments, x => x.Text == createRequest.Text); + } + + [Fact] + public async Task Should_update_comment() + { + // STEP 1: Create the comment. + var createRequest = new UpsertCommentDto { Text = resource }; + + var comment = await _.Comments.PostCommentAsync(_.AppName, resource, createRequest); + + + // STEP 2: Update comment. + var updateRequest = new UpsertCommentDto { Text = $"{resource}_Update" }; + + await _.Comments.PutCommentAsync(_.AppName, resource, comment.Id, updateRequest); + + + // STEP 3: Get comments since create. + var comments = await _.Comments.GetCommentsAsync(_.AppName, resource, 0); + + Assert.Contains(comments.UpdatedComments, x => x.Text == updateRequest.Text); + } + + [Fact] + public async Task Should_delete_comment() + { + // STEP 1: Create the comment. + var createRequest = new UpsertCommentDto { Text = resource }; + + var comment = await _.Comments.PostCommentAsync(_.AppName, resource, createRequest); + + + // STEP 2: Delete comment. + await _.Comments.DeleteCommentAsync(_.AppName, resource, comment.Id); + + + // STEP 3: Get comments since create. + var comments = await _.Comments.GetCommentsAsync(_.AppName, resource, 0); + + Assert.Contains(comment.Id, comments.DeletedComments); + } + } +} diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/ContentCleanupTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/ContentCleanupTests.cs index e9fe9b0e6..3937c5f19 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/ContentCleanupTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/ContentCleanupTests.cs @@ -17,6 +17,8 @@ namespace TestSuite.ApiTests { public class ContentCleanupTests : IClassFixture { + private readonly string schemaName = $"schema-{Guid.NewGuid()}"; + public CreatedAppFixture _ { get; } public ContentCleanupTests(CreatedAppFixture fixture) @@ -27,8 +29,6 @@ namespace TestSuite.ApiTests [Fact] public async Task Should_cleanup_old_data_from_update_response() { - var schemaName = $"schema-{Guid.NewGuid()}"; - // STEP 1: Create a schema. var schema = await TestEntity.CreateSchemaAsync(_.Schemas, _.AppName, schemaName); @@ -60,8 +60,6 @@ namespace TestSuite.ApiTests [Fact] public async Task Should_cleanup_old_references() { - var schemaName = $"schema-{Guid.NewGuid()}"; - // STEP 1: Create a schema. await TestEntityWithReferences.CreateSchemaAsync(_.Schemas, _.AppName, schemaName); diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/ContentScriptingTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/ContentScriptingTests.cs index d08f20cb7..b35ac96bd 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/ContentScriptingTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/ContentScriptingTests.cs @@ -18,6 +18,8 @@ namespace TestSuite.ApiTests { public class ContentScriptingTests : IClassFixture { + private readonly string schemaName = $"schema-{Guid.NewGuid()}"; + public CreatedAppFixture _ { get; } public ContentScriptingTests(CreatedAppFixture fixture) @@ -28,8 +30,6 @@ namespace TestSuite.ApiTests [Fact] public async Task Should_create_content_with_scripting() { - var schemaName = $"schema-{Guid.NewGuid()}"; - var scripts = new SchemaScriptsDto { Create = @$" @@ -52,8 +52,6 @@ namespace TestSuite.ApiTests [Fact] public async Task Should_query_content_with_scripting() { - var schemaName = $"schema-{Guid.NewGuid()}"; - var scripts = new SchemaScriptsDto { Query = @$" @@ -76,8 +74,6 @@ namespace TestSuite.ApiTests [Fact] public async Task Should_query_content_with_scripting_and_pre_query() { - var schemaName = $"schema-{Guid.NewGuid()}"; - var scripts = new SchemaScriptsDto { QueryPre = @$" @@ -102,8 +98,6 @@ namespace TestSuite.ApiTests [Fact] public async Task Should_create_bulk_content_with_scripting() { - var schemaName = $"schema-{Guid.NewGuid()}"; - // STEP 1: Create a schema. var scripts = new SchemaScriptsDto { @@ -152,8 +146,6 @@ namespace TestSuite.ApiTests [Fact] public async Task Should_create_bulk_content_with_scripting_but_disabled() { - var schemaName = $"schema-{Guid.NewGuid()}"; - // STEP 1: Create a schema. var scripts = new SchemaScriptsDto { diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs index 66c9f0de8..6f2d25e44 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs @@ -390,11 +390,11 @@ namespace TestSuite.ApiTests { await _.Contents.UpdateAsync(content.Id, new TestEntityData { Number = i }); - Interlocked.Increment(ref numErrors); + Interlocked.Increment(ref numSuccess); } - catch (SquidexException ex) when (ex.StatusCode == 412) + catch (SquidexException ex) when (ex.StatusCode is 409 or 412) { - Interlocked.Increment(ref numSuccess); + Interlocked.Increment(ref numErrors); return; } }); @@ -440,11 +440,11 @@ namespace TestSuite.ApiTests { await _.Contents.UpsertAsync(content.Id, new TestEntityData { Number = i }); - Interlocked.Increment(ref numErrors); + Interlocked.Increment(ref numSuccess); } - catch (SquidexException ex) when (ex.StatusCode == 409) + catch (SquidexException ex) when (ex.StatusCode is 409 or 412) { - Interlocked.Increment(ref numSuccess); + Interlocked.Increment(ref numErrors); return; } }); diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/RuleRunnerTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/RuleRunnerTests.cs new file mode 100644 index 000000000..5c1644a75 --- /dev/null +++ b/backend/tools/TestSuite/TestSuite.ApiTests/RuleRunnerTests.cs @@ -0,0 +1,193 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.ClientLibrary.Management; +using TestSuite.Fixtures; +using TestSuite.Model; +using Xunit; + +#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 RuleRunnerTests : IClassFixture, IClassFixture + { + private readonly string appName = Guid.NewGuid().ToString(); + private readonly string schemaName = $"schema-{Guid.NewGuid()}"; + private readonly string contentString = Guid.NewGuid().ToString(); + private readonly WebhookCatcherClient webhookCatcher; + + public ClientFixture _ { get; } + + public RuleRunnerTests(ClientFixture fixture, WebhookCatcherFixture webhookCatcher) + { + _ = fixture; + + this.webhookCatcher = webhookCatcher.Client; + } + + [Fact] + public async Task Should_run_rules() + { + // STEP 0: Create app. + await CreateAppAsync(); + + + // STEP 1: Start webhook session + var (url, sessionId) = await webhookCatcher.CreateSessionAsync(); + + + // STEP 2: Create rule + var createRule = new CreateRuleDto + { + Action = new WebhookRuleActionDto + { + Method = WebhookMethod.POST, + Payload = null, + PayloadType = null, + Url = new Uri(url) + }, + Trigger = new ContentChangedRuleTriggerDto + { + HandleAll = true + } + }; + + var rule = await _.Rules.PostRuleAsync(appName, createRule); + + + // STEP 3: Create test content + await CreateContentAsync(); + + // Get requests. + var requests = await webhookCatcher.WaitForRequestsAsync(sessionId, TimeSpan.FromMinutes(2)); + + Assert.Contains(requests, x => x.Method == "POST" && x.Content.Contains(schemaName, StringComparison.OrdinalIgnoreCase)); + + + // STEP 4: Get events + var eventsAll = await _.Rules.GetEventsAsync(appName, rule.Id); + var eventsRule = await _.Rules.GetEventsAsync(appName); + + Assert.Single(eventsAll.Items); + Assert.Single(eventsRule.Items); + } + + [Fact] + public async Task Should_run_rule_manually() + { + // STEP 0: Create app. + await CreateAppAsync(); + + + // STEP 1: Start webhook session + var (url, sessionId) = await webhookCatcher.CreateSessionAsync(); + + + // STEP 2: Create rule + var createRule = new CreateRuleDto + { + Action = new WebhookRuleActionDto + { + Method = WebhookMethod.POST, + Payload = null, + PayloadType = null, + Url = new Uri(url) + }, + Trigger = new ManualRuleTriggerDto() + }; + + var rule = await _.Rules.PostRuleAsync(appName, createRule); + + + // STEP 3: Trigger rule + await _.Rules.TriggerRuleAsync(appName, rule.Id); + + // Get requests. + var requests = await webhookCatcher.WaitForRequestsAsync(sessionId, TimeSpan.FromSeconds(30)); + + Assert.Contains(requests, x => x.Method == "POST"); + + + // STEP 4: Get events + var eventsAll = await _.Rules.GetEventsAsync(appName, rule.Id); + var eventsRule = await _.Rules.GetEventsAsync(appName); + + Assert.Single(eventsAll.Items); + Assert.Single(eventsRule.Items); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Should_rerun_rules(bool fromSnapshots) + { + // STEP 0: Create app. + await CreateAppAsync(); + + + // STEP 1: Start webhook session + var (url, sessionId) = await webhookCatcher.CreateSessionAsync(); + + + // STEP 2: Create disabled rule + var createRule = new CreateRuleDto + { + Action = new WebhookRuleActionDto + { + Method = WebhookMethod.POST, + Payload = null, + PayloadType = null, + Url = new Uri(url) + }, + Trigger = new ContentChangedRuleTriggerDto + { + HandleAll = true + } + }; + + var rule = await _.Rules.PostRuleAsync(appName, createRule); + + // Disable rule, so that we do not create the event from the rule itself. + await _.Rules.DisableRuleAsync(appName, rule.Id); + + + // STEP 3: Create test content before rule + await CreateContentAsync(); + + + // STEP 4: Run rule. + await _.Rules.PutRuleRunAsync(appName, rule.Id, fromSnapshots); + + // Get requests. + var requests = await webhookCatcher.WaitForRequestsAsync(sessionId, TimeSpan.FromSeconds(30)); + + Assert.Contains(requests, x => x.Method == "POST" && x.Content.Contains(schemaName, StringComparison.OrdinalIgnoreCase)); + } + + private async Task CreateContentAsync() + { + await TestEntity.CreateSchemaAsync(_.Schemas, appName, schemaName); + + // Create a test content. + var contents = _.ClientManager.CreateContentsClient(appName, schemaName); + + await contents.CreateAsync(new TestEntityData { String = contentString }); + } + + private async Task CreateAppAsync() + { + var createRequest = new CreateAppDto + { + Name = appName + }; + + await _.Apps.PostAppAsync(createRequest); + } + } +} diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/RuleTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/RuleTests.cs new file mode 100644 index 000000000..0290564fa --- /dev/null +++ b/backend/tools/TestSuite/TestSuite.ApiTests/RuleTests.cs @@ -0,0 +1,162 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.ClientLibrary.Management; +using TestSuite.Fixtures; +using Xunit; + +#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 RuleTests : IClassFixture + { + private readonly string appName = Guid.NewGuid().ToString(); + private readonly string ruleName = Guid.NewGuid().ToString(); + + public ClientFixture _ { get; } + + public RuleTests(ClientFixture fixture) + { + _ = fixture; + } + + [Fact] + public async Task Should_create_rule() + { + // STEP 0: Create app. + await CreateAppAsync(); + + + // STEP 1: Create rule + var createRule = new CreateRuleDto + { + Action = new WebhookRuleActionDto + { + Method = WebhookMethod.POST, + Payload = null, + PayloadType = null, + Url = new Uri("http://squidex.io") + }, + Trigger = new ContentChangedRuleTriggerDto + { + HandleAll = true + } + }; + + var rule = await _.Rules.PostRuleAsync(appName, createRule); + + Assert.IsType(rule.Action); + } + + [Fact] + public async Task Should_update_rule() + { + // STEP 0: Create app. + await CreateAppAsync(); + + + // STEP 1: Create rule + var createRequest = new CreateRuleDto + { + Action = new WebhookRuleActionDto + { + Method = WebhookMethod.POST, + Payload = null, + PayloadType = null, + Url = new Uri("http://squidex.io") + }, + Trigger = new ContentChangedRuleTriggerDto + { + HandleAll = true + } + }; + + var rule_0 = await _.Rules.PostRuleAsync(appName, createRequest); + + + // STEP 2: Update rule + var updateRequest = new UpdateRuleDto + { + Name = ruleName + }; + + var rule_1 = await _.Rules.PutRuleAsync(appName, rule_0.Id, updateRequest); + + Assert.Equal(ruleName, rule_1.Name); + } + + [Fact] + public async Task Should_delete_rule() + { + // STEP 0: Create app. + await CreateAppAsync(); + + + // STEP 1: Create rule + var createRequest = new CreateRuleDto + { + Action = new WebhookRuleActionDto + { + Method = WebhookMethod.POST, + Payload = null, + PayloadType = null, + Url = new Uri("http://squidex.io") + }, + Trigger = new ContentChangedRuleTriggerDto + { + HandleAll = true + } + }; + + var rule = await _.Rules.PostRuleAsync(appName, createRequest); + + + // STEP 2: Delete rule + await _.Rules.DeleteRuleAsync(appName, rule.Id); + + var rules = await _.Rules.GetRulesAsync(appName); + + Assert.DoesNotContain(rules.Items, x => x.Id == rule.Id); + } + + [Fact] + public async Task Should_get_actions() + { + var actions = await _.Rules.GetActionsAsync(); + + Assert.NotEmpty(actions); + } + + [Fact] + public async Task Should_get_event_schemas() + { + var schema = await _.Rules.GetEventSchemaAsync("EnrichedContentEvent"); + + Assert.NotNull(schema); + } + + [Fact] + public async Task Should_get_event_types() + { + var eventTypes = await _.Rules.GetEventTypesAsync(); + + Assert.NotEmpty(eventTypes); + } + + private async Task CreateAppAsync() + { + var createRequest = new CreateAppDto + { + Name = appName + }; + + await _.Apps.PostAppAsync(createRequest); + } + } +} diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/SchemaTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/SchemaTests.cs index 9b648b34f..944cd2007 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/SchemaTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/SchemaTests.cs @@ -17,6 +17,8 @@ namespace TestSuite.ApiTests { public class SchemaTests : IClassFixture { + private readonly string schemaName = $"schema-{Guid.NewGuid()}"; + public CreatedAppFixture _ { get; } public SchemaTests(CreatedAppFixture fixture) @@ -27,8 +29,6 @@ namespace TestSuite.ApiTests [Fact] public async Task Should_create_schema() { - var schemaName = $"schema-{Guid.NewGuid()}"; - // STEP 1: Create schema var createRequest = new CreateSchemaDto { Name = schemaName }; @@ -48,8 +48,6 @@ namespace TestSuite.ApiTests [Fact] public async Task Should_not_allow_creation_if_name_used() { - var schemaName = $"schema-{Guid.NewGuid()}"; - // STEP 1: Create schema var createRequest = new CreateSchemaDto { Name = schemaName }; @@ -68,8 +66,6 @@ namespace TestSuite.ApiTests [Fact] public async Task Should_create_singleton_schema() { - var schemaName = $"schema-{Guid.NewGuid()}"; - // STEP 1: Create schema var createRequest = new CreateSchemaDto { @@ -102,8 +98,6 @@ namespace TestSuite.ApiTests [Fact] public async Task Should_create_schema_with_checkboxes() { - var schemaName = $"schema-{Guid.NewGuid()}"; - // STEP 1: Create schema var createRequest = new CreateSchemaDto { @@ -141,8 +135,6 @@ namespace TestSuite.ApiTests [Fact] public async Task Should_delete_Schema() { - var schemaName = $"schema-{Guid.NewGuid()}"; - // STEP 1: Create schema var createRequest = new CreateSchemaDto { Name = schemaName }; @@ -164,8 +156,6 @@ namespace TestSuite.ApiTests [Fact] public async Task Should_recreate_after_deleted() { - var schemaName = $"schema-{Guid.NewGuid()}"; - // STEP 1: Create schema var createRequest = new CreateSchemaDto { Name = schemaName }; diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj b/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj index 0985440b8..2b5915c98 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj +++ b/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj @@ -21,7 +21,7 @@ - + diff --git a/backend/tools/TestSuite/TestSuite.Shared/ClientExtensions.cs b/backend/tools/TestSuite/TestSuite.Shared/ClientExtensions.cs new file mode 100644 index 000000000..a937e7bc2 --- /dev/null +++ b/backend/tools/TestSuite/TestSuite.Shared/ClientExtensions.cs @@ -0,0 +1,65 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.ClientLibrary.Management; + +namespace TestSuite +{ + public static class ClientExtensions + { + public static async Task WaitForBackupAsync(this IBackupsClient backupsClient, string app, TimeSpan timeout) + { + try + { + using var cts = new CancellationTokenSource(timeout); + + while (!cts.IsCancellationRequested) + { + var backups = await backupsClient.GetBackupsAsync(app, cts.Token); + var backup = backups.Items.Find(x => x.Status == JobStatus.Completed || x.Status == JobStatus.Failed); + + if (backup != null) + { + return backup; + } + + await Task.Delay(200, cts.Token); + } + } + catch (OperationCanceledException) + { + } + + return null; + } + + public static async Task WaitForRestoreAsync(this IBackupsClient backupsClient, Uri url, TimeSpan timeout) + { + try + { + using var cts = new CancellationTokenSource(timeout); + + while (!cts.IsCancellationRequested) + { + var restore = await backupsClient.GetRestoreJobAsync(cts.Token); + + if (restore.Url == url && restore.Status is JobStatus.Completed or JobStatus.Failed) + { + return restore; + } + + await Task.Delay(200, cts.Token); + } + } + catch (OperationCanceledException) + { + } + + return null; + } + } +} diff --git a/backend/tools/TestSuite/TestSuite.Shared/ClientManagerWrapper.cs b/backend/tools/TestSuite/TestSuite.Shared/ClientManagerWrapper.cs index 34c23b869..726486779 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/ClientManagerWrapper.cs +++ b/backend/tools/TestSuite/TestSuite.Shared/ClientManagerWrapper.cs @@ -17,6 +17,7 @@ namespace TestSuite { private readonly Lazy apps; private readonly Lazy assets; + private readonly Lazy comments; private readonly Lazy backups; private readonly Lazy languages; private readonly Lazy ping; @@ -41,6 +42,11 @@ namespace TestSuite get => backups.Value; } + public ICommentsClient Comments + { + get => comments.Value; + } + public ILanguagesClient Languages { get => languages.Value; @@ -68,10 +74,10 @@ namespace TestSuite public ClientManagerWrapper() { - var appName = TestHelpers.GetValue("config:app:name", "integration-tests"); - var clientId = TestHelpers.GetValue("config:client:id", "root"); - var clientSecret = TestHelpers.GetValue("config:client:secret", "xeLd6jFxqbXJrfmNLlO2j1apagGGGSyZJhFnIuHp4I0="); - var serverUrl = TestHelpers.GetValue("config:server:url", "https://localhost:5001"); + var appName = TestHelpers.GetAndPrintValue("config:app:name", "integration-tests"); + var clientId = TestHelpers.GetAndPrintValue("config:client:id", "root"); + var clientSecret = TestHelpers.GetAndPrintValue("config:client:secret", "xeLd6jFxqbXJrfmNLlO2j1apagGGGSyZJhFnIuHp4I0="); + var serverUrl = TestHelpers.GetAndPrintValue("config:server:url", "https://localhost:5001"); ClientManager = new SquidexClientManager(new SquidexOptions { @@ -99,6 +105,11 @@ namespace TestSuite return ClientManager.CreateBackupsClient(); }); + comments = new Lazy(() => + { + return ClientManager.CreateCommentsClient(); + }); + languages = new Lazy(() => { return ClientManager.CreateLanguagesClient(); @@ -134,23 +145,28 @@ namespace TestSuite Console.WriteLine("Waiting {0} seconds to access server", waitSeconds); var pingClient = ClientManager.CreatePingClient(); - - using (var cts = new CancellationTokenSource(waitSeconds * 1000)) + try { - while (!cts.IsCancellationRequested) + using (var cts = new CancellationTokenSource(waitSeconds * 1000)) { - try - { - await pingClient.GetPingAsync(cts.Token); - - break; - } - catch + while (!cts.IsCancellationRequested) { - await Task.Delay(100); + try + { + await pingClient.GetPingAsync(cts.Token); + break; + } + catch + { + await Task.Delay(100, cts.Token); + } } } } + catch (OperationCanceledException) + { + throw new InvalidOperationException("Cannot connect to test system."); + } Console.WriteLine("Connected to server."); } diff --git a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientFixture.cs b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientFixture.cs index cff272074..bdc0bffe7 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientFixture.cs +++ b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientFixture.cs @@ -17,6 +17,8 @@ namespace TestSuite.Fixtures public IBackupsClient Backups => Squidex.Backups; + public ICommentsClient Comments => Squidex.Comments; + public ILanguagesClient Languages => Squidex.Languages; public IPingClient Ping => Squidex.Ping; diff --git a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/WebhookCatcherClient.cs b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/WebhookCatcherClient.cs new file mode 100644 index 000000000..53e0e48a4 --- /dev/null +++ b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/WebhookCatcherClient.cs @@ -0,0 +1,117 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Net.Http.Json; +using System.Text; +using System.Text.Json.Serialization; + +#pragma warning disable MA0048 // File name must match type name + +namespace TestSuite.Fixtures +{ + public sealed class WebhookSession + { + public string Uuid { get; set; } + } + + public sealed class WebhookRequest + { + [JsonPropertyName("uuid")] + public string Uuid { get; set; } + + [JsonPropertyName("method")] + public string Method { get; set; } + + [JsonPropertyName("content_base64")] + public string Content { get; set; } + } + + public sealed class WebhookCatcherClient + { + private readonly HttpClient httpClient; + + public string EndpointHost { get; } + + public int EndpointPort { get; } + + public WebhookCatcherClient(string apiHost, int apiPort, string endpointHost, int endpointPort) + { + if (string.IsNullOrWhiteSpace(apiHost)) + { + apiHost = "localhost"; + } + + if (string.IsNullOrWhiteSpace(endpointHost)) + { + endpointHost = "localhost"; + } + + EndpointHost = endpointHost; + EndpointPort = endpointPort; + + httpClient = new HttpClient + { + BaseAddress = new Uri($"http://{apiHost}:{apiPort}") + }; + } + + public async Task<(string, string)> CreateSessionAsync( + CancellationToken ct = default) + { + var response = await httpClient.PostAsJsonAsync("/api/session", new { }, ct); + + response.EnsureSuccessStatusCode(); + + var responseObj = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + + 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); + + foreach (var request in result) + { + if (request.Content != null) + { + request.Content = Encoding.Default.GetString(Convert.FromBase64String(request.Content)); + } + } + + return result; + } + + public async Task WaitForRequestsAsync(string sessionId, TimeSpan timeout) + { + var requests = Array.Empty(); + + try + { + using var cts = new CancellationTokenSource(timeout); + + while (!cts.IsCancellationRequested) + { + requests = await GetRequestsAsync(sessionId, cts.Token); + + if (requests.Length > 0) + { + break; + } + + await Task.Delay(50, cts.Token); + } + } + catch (OperationCanceledException) + { + } + + return requests; + } + } +} diff --git a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/WebhookCatcherFixture.cs b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/WebhookCatcherFixture.cs new file mode 100644 index 000000000..0bada8f02 --- /dev/null +++ b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/WebhookCatcherFixture.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using TestSuite.Utils; + +namespace TestSuite.Fixtures +{ + public sealed class WebhookCatcherFixture + { + public WebhookCatcherClient Client { get; } + + public WebhookCatcherFixture() + { + Client = new WebhookCatcherClient( + TestHelpers.GetAndPrintValue("webhookcatcher:host:api", "localhost"), 1026, + TestHelpers.GetAndPrintValue("webhookcatcher:host:endpoint", "localhost"), 1026); + } + } +} diff --git a/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj b/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj index fee661026..9b88cb9fb 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj +++ b/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj @@ -16,7 +16,7 @@ - + diff --git a/backend/tools/TestSuite/TestSuite.Shared/Utils/TestHelpers.cs b/backend/tools/TestSuite/TestSuite.Shared/Utils/TestHelpers.cs index 28caecf75..bb631fa3a 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/Utils/TestHelpers.cs +++ b/backend/tools/TestSuite/TestSuite.Shared/Utils/TestHelpers.cs @@ -25,7 +25,7 @@ namespace TestSuite.Utils .Build(); } - public static string GetValue(string name, string fallback) + public static string GetAndPrintValue(string name, string fallback) { var value = Configuration[name]; diff --git a/frontend/src/app/shared/components/schema-category.component.html b/frontend/src/app/shared/components/schema-category.component.html index 137810997..0f914a115 100644 --- a/frontend/src/app/shared/components/schema-category.component.html +++ b/frontend/src/app/shared/components/schema-category.component.html @@ -48,7 +48,7 @@ -