Browse Source

Improve tests (#899)

* Improve tests

* Fixes for comments and more tests.

* Add missing files.

* Increase timeout.

* More fixes

* More tolerant tests.
pull/902/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
2a59d7c163
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .github/workflows/dev.yml
  2. 2
      .github/workflows/release.yml
  3. 6
      backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs
  4. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleOptions.cs
  5. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs
  6. 7
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs
  7. 20
      backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsStream.cs
  8. 15
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs
  9. 2
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs
  10. 10
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerProcessor.cs
  11. 2
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs
  12. 19
      backend/src/Squidex.Infrastructure/Commands/DefaultDomainObjectCache.cs
  13. 14
      backend/src/Squidex.Infrastructure/Commands/DomainObjectCacheOptions.cs
  14. 12
      backend/src/Squidex.Infrastructure/EventSourcing/Consume/BatchSubscription.cs
  15. 12
      backend/src/Squidex.Infrastructure/EventSourcing/Consume/ParseSubscription.cs
  16. 74
      backend/src/Squidex.Infrastructure/EventSourcing/RetrySubscription.cs
  17. 2
      backend/src/Squidex.Infrastructure/Tasks/LimitedConcurrencyLevelTaskScheduler.cs
  18. 13
      backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs
  19. 3
      backend/src/Squidex/Areas/Api/Controllers/Comments/Notifications/UserNotificationsController.cs
  20. 2
      backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs
  21. 3
      backend/src/Squidex/Config/Domain/CommandsServices.cs
  22. 10
      backend/src/Squidex/appsettings.json
  23. 23
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs
  24. 9
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs
  25. 5
      backend/tests/Squidex.Infrastructure.Tests/Commands/DefaultDomainObjectCacheTests.cs
  26. 60
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/RetrySubscriptionTests.cs
  27. 9
      backend/tests/docker-compose.yml
  28. 94
      backend/tools/TestSuite/TestSuite.ApiTests/BackupTests.cs
  29. 91
      backend/tools/TestSuite/TestSuite.ApiTests/CommentsTests.cs
  30. 6
      backend/tools/TestSuite/TestSuite.ApiTests/ContentCleanupTests.cs
  31. 12
      backend/tools/TestSuite/TestSuite.ApiTests/ContentScriptingTests.cs
  32. 12
      backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs
  33. 193
      backend/tools/TestSuite/TestSuite.ApiTests/RuleRunnerTests.cs
  34. 162
      backend/tools/TestSuite/TestSuite.ApiTests/RuleTests.cs
  35. 14
      backend/tools/TestSuite/TestSuite.ApiTests/SchemaTests.cs
  36. 2
      backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj
  37. 65
      backend/tools/TestSuite/TestSuite.Shared/ClientExtensions.cs
  38. 46
      backend/tools/TestSuite/TestSuite.Shared/ClientManagerWrapper.cs
  39. 2
      backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientFixture.cs
  40. 117
      backend/tools/TestSuite/TestSuite.Shared/Fixtures/WebhookCatcherClient.cs
  41. 23
      backend/tools/TestSuite/TestSuite.Shared/Fixtures/WebhookCatcherFixture.cs
  42. 2
      backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj
  43. 2
      backend/tools/TestSuite/TestSuite.Shared/Utils/TestHelpers.cs
  44. 2
      frontend/src/app/shared/components/schema-category.component.html

2
.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

2
.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

6
backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs

@ -17,7 +17,6 @@ namespace Squidex.Extensions.Actions.Notification
public sealed class NotificationActionHandler : RuleActionHandler<NotificationAction, CreateComment>
{
private const string Description = "Send a Notification";
private static readonly NamedId<DomainId> 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}");

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

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

7
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<DomainId> NoApp = NamedId.Of(DomainId.NewGuid(), "none");
public NamedId<DomainId> 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;
}
}
}

20
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<CommandResult> ExecuteAsync(IAggregateCommand command,
public virtual async Task<CommandResult> 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!;
}
}

15
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<RuleOptions> 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);
});

2
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)

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

2
backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs

@ -163,7 +163,7 @@ namespace Squidex.Infrastructure.EventSourcing
}
public void WakeUp()
{
{
}
}
}

19
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<DomainObjectCacheOptions> options)
{
this.cache = cache;
this.serializer = serializer;
this.distributedCache = distributedCache;
cacheOptions = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = options.Value.CacheDuration
};
}
public async Task<T> GetAsync<T>(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

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

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

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

74
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<T> : IEventSubscription, IEventSubscriber<T>
{
private readonly RetryWindow retryWindow = new RetryWindow(TimeSpan.FromMinutes(5), 5);
private readonly AsyncLock lockObject = new AsyncLock();
private readonly IEventSubscriber<T> eventSubscriber;
private readonly EventSubscriptionSource<T> 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<T>.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<T>.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();
}
}
}

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

13
backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs

@ -79,7 +79,7 @@ namespace Squidex.Areas.Api.Controllers.Comments
[ApiCosts(0)]
public async Task<IActionResult> 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<IActionResult> 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();

3
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<DomainId> 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
};

2
backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs

@ -27,7 +27,7 @@ using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Rules
{
/// <summary>
/// Manages and retrieves information about schemas.
/// Manages and retrieves information about rules.
/// </summary>
[ApiExplorerSettings(GroupName = nameof(Rules))]
public sealed class RulesController : ApiController

3
backend/src/Squidex/Config/Domain/CommandsServices.cs

@ -37,6 +37,9 @@ namespace Squidex.Config.Domain
services.Configure<RestrictAppsOptions>(config,
"usage");
services.Configure<DomainObjectCacheOptions>(config,
"caching:domainObjects");
services.AddSingletonAs<InMemoryCommandBus>()
.As<ICommandBus>();

10
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": {

23
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<EnrichedEvent>._, context))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.CreateSnapshotEventsAsync(context, default))
.Returns(new List<EnrichedEvent>
{
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()
{

9
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]

5
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]

60
backend/tests/Squidex.Infrastructure.Tests/EventSourcing/RetrySubscriptionTests.cs

@ -24,7 +24,6 @@ namespace Squidex.Infrastructure.EventSourcing
.Returns(eventSubscription);
sut = new RetrySubscription<StoredEvent>(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<IEventSubscription>._, A<Exception>._))
.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<IEventSubscription>._, A<StoredEvent>._))
.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)

9
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:

94
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<ClientFixture>
{
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<TestEntity, TestEntityData>(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);
}
}
}

91
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<CreatedAppFixture>
{
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);
}
}
}

6
backend/tools/TestSuite/TestSuite.ApiTests/ContentCleanupTests.cs

@ -17,6 +17,8 @@ namespace TestSuite.ApiTests
{
public class ContentCleanupTests : IClassFixture<CreatedAppFixture>
{
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);

12
backend/tools/TestSuite/TestSuite.ApiTests/ContentScriptingTests.cs

@ -18,6 +18,8 @@ namespace TestSuite.ApiTests
{
public class ContentScriptingTests : IClassFixture<CreatedAppFixture>
{
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
{

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

193
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<ClientFixture>, IClassFixture<WebhookCatcherFixture>
{
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<TestEntity, TestEntityData>(appName, schemaName);
await contents.CreateAsync(new TestEntityData { String = contentString });
}
private async Task CreateAppAsync()
{
var createRequest = new CreateAppDto
{
Name = appName
};
await _.Apps.PostAppAsync(createRequest);
}
}
}

162
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<ClientFixture>
{
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<WebhookRuleActionDto>(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);
}
}
}

14
backend/tools/TestSuite/TestSuite.ApiTests/SchemaTests.cs

@ -17,6 +17,8 @@ namespace TestSuite.ApiTests
{
public class SchemaTests : IClassFixture<CreatedAppFixture>
{
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 };

2
backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj

@ -21,7 +21,7 @@
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
<PackageReference Include="NSwag.Core" Version="13.16.1" />
<PackageReference Include="PuppeteerSharp" Version="7.0.0" />
<PackageReference Include="PuppeteerSharp" Version="7.1.0" />
<PackageReference Include="Squidex.Assets" Version="3.6.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.4.1" />

65
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<BackupJobDto> 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<RestoreJobDto> 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;
}
}
}

46
backend/tools/TestSuite/TestSuite.Shared/ClientManagerWrapper.cs

@ -17,6 +17,7 @@ namespace TestSuite
{
private readonly Lazy<IAppsClient> apps;
private readonly Lazy<IAssetsClient> assets;
private readonly Lazy<ICommentsClient> comments;
private readonly Lazy<IBackupsClient> backups;
private readonly Lazy<ILanguagesClient> languages;
private readonly Lazy<IPingClient> 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<ICommentsClient>(() =>
{
return ClientManager.CreateCommentsClient();
});
languages = new Lazy<ILanguagesClient>(() =>
{
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.");
}

2
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;

117
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<WebhookSession>(cancellationToken: ct);
return ($"http://{EndpointHost}:{EndpointPort}/{responseObj.Uuid}", responseObj.Uuid);
}
public async Task<WebhookRequest[]> GetRequestsAsync(string sessionId,
CancellationToken ct = default)
{
var result = await httpClient.GetFromJsonAsync<WebhookRequest[]>($"/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<WebhookRequest[]> WaitForRequestsAsync(string sessionId, TimeSpan timeout)
{
var requests = Array.Empty<WebhookRequest>();
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;
}
}
}

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

2
backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj

@ -16,7 +16,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.ClientLibrary" Version="8.23.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="8.24.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.4.1" />
</ItemGroup>

2
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];

2
frontend/src/app/shared/components/schema-category.component.html

@ -48,7 +48,7 @@
</ng-container>
<ng-template #simpleMode>
<li *ngFor="let schema of schemas; trackBy: trackBySchema" class="nav-item truncate" [style.height]="getItemHeight()">
<li *ngFor="let schema of schemas; trackBy: trackBySchema" class="nav-item truncate">
<a class="nav-link truncate drag-none" [routerLink]="schemaRoute(schema)" routerLinkActive="active" sqxStopDrag
title="{{schema.displayName}}"
titlePosition="top-left">

Loading…
Cancel
Save