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: | environment: |
CONFIG__WAIT=60 CONFIG__WAIT=60
CONFIG__SERVER__URL=http://localhost:8080 CONFIG__SERVER__URL=http://localhost:8080
WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher
default_network: host default_network: host
options: --name test1 options: --name test1
volumes: ${{ github.workspace }}:/src volumes: ${{ github.workspace }}:/src
@ -123,6 +124,7 @@ jobs:
environment: | environment: |
CONFIG__WAIT=60 CONFIG__WAIT=60
CONFIG__SERVER__URL=http://localhost:8081/squidex CONFIG__SERVER__URL=http://localhost:8081/squidex
WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher
default_network: host default_network: host
options: --name test2 options: --name test2
volumes: ${{ github.workspace }}:/src volumes: ${{ github.workspace }}:/src

2
.github/workflows/release.yml

@ -94,6 +94,7 @@ jobs:
environment: | environment: |
CONFIG__WAIT=60 CONFIG__WAIT=60
CONFIG__SERVER__URL=http://localhost:8080 CONFIG__SERVER__URL=http://localhost:8080
WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher
default_network: host default_network: host
options: --name test1 options: --name test1
volumes: ${{ github.workspace }}:/src volumes: ${{ github.workspace }}:/src
@ -106,6 +107,7 @@ jobs:
environment: | environment: |
CONFIG__WAIT=60 CONFIG__WAIT=60
CONFIG__SERVER__URL=http://localhost:8081/squidex CONFIG__SERVER__URL=http://localhost:8081/squidex
WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher
default_network: host default_network: host
options: --name test2 options: --name test2
volumes: ${{ github.workspace }}:/src 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> public sealed class NotificationActionHandler : RuleActionHandler<NotificationAction, CreateComment>
{ {
private const string Description = "Send a Notification"; private const string Description = "Send a Notification";
private static readonly NamedId<DomainId> NoApp = NamedId.Of(DomainId.Empty, "none");
private readonly ICommandBus commandBus; private readonly ICommandBus commandBus;
private readonly IUserResolver userResolver; private readonly IUserResolver userResolver;
@ -49,9 +48,11 @@ namespace Squidex.Extensions.Actions.Notification
var ruleJob = new CreateComment var ruleJob = new CreateComment
{ {
AppId = CommentsCommand.NoApp,
Actor = actor, Actor = actor,
CommentId = DomainId.NewGuid(), CommentId = DomainId.NewGuid(),
CommentsId = DomainId.Create(user.Id), CommentsId = DomainId.Create(user.Id),
FromRule = true,
Text = await FormatAsync(action.Text, @event) Text = await FormatAsync(action.Text, @event)
}; };
@ -81,9 +82,6 @@ namespace Squidex.Extensions.Actions.Notification
return Result.Ignored(); return Result.Ignored();
} }
command.AppId = NoApp;
command.FromRule = true;
await commandBus.PublishAsync(command, ct); await commandBus.PublishAsync(command, ct);
return Result.Success($"Notified: {command.Text}"); 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 sealed class RuleOptions
{ {
public int ExecutionTimeoutInSeconds { get; set; } = 3; 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; var rule = context.Rule;
if (!rule.IsEnabled) if (!rule.IsEnabled && !context.IncludeSkipped)
{ {
yield break; 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 abstract class CommentsCommand : SquidexCommand, IAppCommand, IAggregateCommand
{ {
public static readonly NamedId<DomainId> NoApp = NamedId.Of(DomainId.NewGuid(), "none");
public NamedId<DomainId> AppId { get; set; } public NamedId<DomainId> AppId { get; set; }
public DomainId CommentsId { get; set; } public DomainId CommentsId { get; set; }
public DomainId CommentId { 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.EventSourcing;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
#pragma warning disable MA0022 // Return Task.FromResult instead of returning null
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
{ {
public class CommentsStream : IAggregate public class CommentsStream : IAggregate
@ -24,8 +22,8 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
private readonly DomainId key; private readonly DomainId key;
private readonly IEventFormatter eventFormatter; private readonly IEventFormatter eventFormatter;
private readonly IEventStore eventStore; private readonly IEventStore eventStore;
private readonly string streamName;
private long version = EtagVersion.Empty; private long version = EtagVersion.Empty;
private string streamName;
private long Version => version; private long Version => version;
@ -37,13 +35,13 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
this.key = key; this.key = key;
this.eventFormatter = eventFormatter; this.eventFormatter = eventFormatter;
this.eventStore = eventStore; this.eventStore = eventStore;
streamName = $"comments-{key}";
} }
public virtual async Task LoadAsync( public virtual async Task LoadAsync(
CancellationToken ct) CancellationToken ct)
{ {
streamName = $"comments-{key}";
var storedEvents = await eventStore.QueryReverseAsync(streamName, 100, ct); var storedEvents = await eventStore.QueryReverseAsync(streamName, 100, ct);
foreach (var @event in storedEvents) 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) CancellationToken ct)
{ {
await LoadAsync(ct);
switch (command) switch (command)
{ {
case CreateComment createComment: case CreateComment createComment:
return Upsert(createComment, c => return await Upsert(createComment, c =>
{ {
GuardComments.CanCreate(c); GuardComments.CanCreate(c);
@ -70,7 +70,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
}, ct); }, ct);
case UpdateComment updateComment: case UpdateComment updateComment:
return Upsert(updateComment, c => return await Upsert(updateComment, c =>
{ {
GuardComments.CanUpdate(c, key.ToString(), events); GuardComments.CanUpdate(c, key.ToString(), events);
@ -78,7 +78,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
}, ct); }, ct);
case DeleteComment deleteComment: case DeleteComment deleteComment:
return Upsert(deleteComment, c => return await Upsert(deleteComment, c =>
{ {
GuardComments.CanDelete(c, key.ToString(), events); GuardComments.CanDelete(c, key.ToString(), events);
@ -87,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
default: default:
ThrowHelper.NotSupportedException(); 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.Caching.Memory;
using Microsoft.Extensions.Options;
using Squidex.Caching; using Squidex.Caching;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
@ -18,25 +19,27 @@ namespace Squidex.Domain.Apps.Entities.Rules
{ {
public sealed class RuleEnqueuer : IEventConsumer, IRuleEnqueuer public sealed class RuleEnqueuer : IEventConsumer, IRuleEnqueuer
{ {
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(10);
private readonly IMemoryCache cache; private readonly IMemoryCache cache;
private readonly IRuleEventRepository ruleEventRepository; private readonly IRuleEventRepository ruleEventRepository;
private readonly IRuleService ruleService; private readonly IRuleService ruleService;
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
private readonly ILocalCache localCache; private readonly ILocalCache localCache;
private readonly TimeSpan cacheDuration;
public string Name public string Name
{ {
get => GetType().Name; get => GetType().Name;
} }
public RuleEnqueuer(IAppProvider appProvider, IMemoryCache cache, ILocalCache localCache, public RuleEnqueuer(IMemoryCache cache, ILocalCache localCache,
IAppProvider appProvider,
IRuleEventRepository ruleEventRepository, IRuleEventRepository ruleEventRepository,
IRuleService ruleService) IRuleService ruleService,
IOptions<RuleOptions> options)
{ {
this.appProvider = appProvider; this.appProvider = appProvider;
this.cache = cache; this.cache = cache;
this.cacheDuration = options.Value.RuleCacheDuration;
this.ruleEventRepository = ruleEventRepository; this.ruleEventRepository = ruleEventRepository;
this.ruleService = ruleService; this.ruleService = ruleService;
this.localCache = localCache; this.localCache = localCache;
@ -57,6 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
await foreach (var job in jobs) 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) if (job.Job != null && job.SkipReason == SkipReason.None)
{ {
await ruleEventRepository.EnqueueAsync(job.Job, job.EnrichmentError); await ruleEventRepository.EnqueueAsync(job.Job, job.EnrichmentError);
@ -89,9 +93,10 @@ namespace Squidex.Domain.Apps.Entities.Rules
{ {
var cacheKey = $"{typeof(RuleEnqueuer)}_Rules_{appId}"; 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 => return cache.GetOrCreateAsync(cacheKey, entry =>
{ {
entry.AbsoluteExpirationRelativeToNow = CacheDuration; entry.AbsoluteExpirationRelativeToNow = cacheDuration;
return appProvider.GetRulesAsync(appId); 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); 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) 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); await state.LoadAsync(ct);
if (!state.Value.RunFromSnapshots) if (!state.Value.RunFromSnapshots && state.Value.RuleId != default)
{ {
TaskHelper.Forget(RunAsync(state.Value.RuleId, false, default)); TaskHelper.Forget(RunAsync(state.Value.RuleId, false, default));
} }
@ -178,12 +178,14 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
using (localCache.StartContext()) using (localCache.StartContext())
{ {
// Also run disabled rules, because we want to enable rules to be only used with manual trigger.
run.Context = new RuleContext run.Context = new RuleContext
{ {
AppId = rule.AppId, AppId = rule.AppId,
Rule = rule.RuleDef, Rule = rule.RuleDef,
RuleId = rule.Id, RuleId = rule.Id,
IncludeStale = true IncludeStale = true,
IncludeSkipped = true
}; };
if (run.Job.RunFromSnapshots && ruleService.CanCreateSnapshotEvents(run.Context)) 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)) 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); 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)) 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); 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() public void WakeUp()
{ {
} }
} }
} }

19
backend/src/Squidex.Infrastructure/Commands/DefaultDomainObjectCache.cs

@ -7,6 +7,7 @@
using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.ObjectPool; using Squidex.Infrastructure.ObjectPool;
@ -14,20 +15,22 @@ namespace Squidex.Infrastructure.Commands
{ {
public sealed class DefaultDomainObjectCache : IDomainObjectCache public sealed class DefaultDomainObjectCache : IDomainObjectCache
{ {
private static readonly DistributedCacheEntryOptions CacheOptions = new DistributedCacheEntryOptions private readonly DistributedCacheEntryOptions cacheOptions;
{
SlidingExpiration = TimeSpan.FromMinutes(10)
};
private readonly IMemoryCache cache; private readonly IMemoryCache cache;
private readonly IJsonSerializer serializer; private readonly IJsonSerializer serializer;
private readonly IDistributedCache distributedCache; 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.cache = cache;
this.serializer = serializer; this.serializer = serializer;
this.distributedCache = distributedCache; this.distributedCache = distributedCache;
cacheOptions = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = options.Value.CacheDuration
};
} }
public async Task<T> GetAsync<T>(DomainId id, long version, public async Task<T> GetAsync<T>(DomainId id, long version,
@ -67,7 +70,7 @@ namespace Squidex.Infrastructure.Commands
{ {
var cacheKey = CacheKey(id, version); var cacheKey = CacheKey(id, version);
cache.Set(cacheKey, snapshot, CacheOptions.SlidingExpiration!.Value); cache.Set(cacheKey, snapshot, cacheOptions.AbsoluteExpirationRelativeToNow!.Value);
try try
{ {
@ -75,7 +78,7 @@ namespace Squidex.Infrastructure.Commands
{ {
serializer.Serialize(snapshot, stream, true); serializer.Serialize(snapshot, stream, true);
await distributedCache.SetAsync(cacheKey, stream.ToArray(), CacheOptions, ct); await distributedCache.SetAsync(cacheKey, stream.ToArray(), cacheOptions, ct);
} }
} }
catch 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. // Forward the exception from one task only, but bypass the batch.
await taskQueue.Writer.WriteAsync(exception, completed.Token); await taskQueue.Writer.WriteAsync(exception, completed.Token);
} }
catch (OperationCanceledException)
{
// These exception are acceptable and happens when an exception has been thrown before.
}
catch (ChannelClosedException) 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); await batchQueue.Writer.WriteAsync(@event, completed.Token);
} }
catch (OperationCanceledException)
{
// These exception are acceptable and happens when an exception has been thrown before.
}
catch (ChannelClosedException) 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. // Forward the exception from one task only.
await deserializeQueue.Writer.WriteAsync(exception, completed.Token); await deserializeQueue.Writer.WriteAsync(exception, completed.Token);
} }
catch (OperationCanceledException)
{
// These exception are acceptable and happens when an exception has been thrown before.
}
catch (ChannelClosedException) 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); await deserializeQueue.Writer.WriteAsync(@event, completed.Token);
} }
catch (OperationCanceledException)
{
// These exception are acceptable and happens when an exception has been thrown before.
}
catch (ChannelClosedException) 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Infrastructure.Tasks;
namespace Squidex.Infrastructure.EventSourcing namespace Squidex.Infrastructure.EventSourcing
{ {
public sealed class RetrySubscription<T> : IEventSubscription, IEventSubscriber<T> public sealed class RetrySubscription<T> : IEventSubscription, IEventSubscriber<T>
{ {
private readonly RetryWindow retryWindow = new RetryWindow(TimeSpan.FromMinutes(5), 5); private readonly RetryWindow retryWindow = new RetryWindow(TimeSpan.FromMinutes(5), 5);
private readonly AsyncLock lockObject = new AsyncLock();
private readonly IEventSubscriber<T> eventSubscriber; private readonly IEventSubscriber<T> eventSubscriber;
private readonly EventSubscriptionSource<T> eventSource; private readonly EventSubscriptionSource<T> eventSource;
private SubscriptionHolder? currentSubscription; private SubscriptionHolder? currentSubscription;
public int ReconnectWaitMs { get; set; } = 5000; 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. // Holds all information for a current subscription. Therefore we only have to maintain one reference.
private sealed class SubscriptionHolder : IDisposable private sealed class SubscriptionHolder : IDisposable
{ {
@ -53,33 +52,34 @@ namespace Squidex.Infrastructure.EventSourcing
public void Dispose() public void Dispose()
{ {
using (lockObject.Enter()) Unsubscribe();
{
Unsubscribe();
}
lockObject.Dispose();
} }
private void Subscribe() 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() private void Unsubscribe()
{ {
if (currentSubscription == null) lock (retryWindow)
{ {
return; if (currentSubscription == null)
} {
return;
}
currentSubscription.Dispose(); currentSubscription.Dispose();
currentSubscription = null; currentSubscription = null;
}
} }
public void WakeUp() public void WakeUp()
@ -94,16 +94,12 @@ namespace Squidex.Infrastructure.EventSourcing
async ValueTask IEventSubscriber<T>.OnNextAsync(IEventSubscription subscription, T @event) 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. if (!ReferenceEquals(subscription, currentSubscription?.Subscription))
using (await lockObject.EnterAsync(default))
{ {
if (!ReferenceEquals(subscription, currentSubscription?.Subscription)) return;
{
return;
}
await eventSubscriber.OnNextAsync(this, @event);
} }
await eventSubscriber.OnNextAsync(this, @event);
} }
async ValueTask IEventSubscriber<T>.OnErrorAsync(IEventSubscription subscription, Exception exception) async ValueTask IEventSubscriber<T>.OnErrorAsync(IEventSubscription subscription, Exception exception)
@ -113,21 +109,17 @@ namespace Squidex.Infrastructure.EventSourcing
return; 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()) if (!retryWindow.CanRetryAfterFailure())
{ {
await eventSubscriber.OnErrorAsync(this, exception); await eventSubscriber.OnErrorAsync(this, exception);
return; return;
}
} }
try try
@ -139,11 +131,7 @@ namespace Squidex.Infrastructure.EventSourcing
return; return;
} }
using (await lockObject.EnterAsync(default)) Subscribe();
{
// Subscribing is not an atomar operation, therefore the lock.
Subscribe();
}
} }
} }
} }

2
backend/src/Squidex.Infrastructure/Tasks/LimitedConcurrencyLevelTaskScheduler.cs

@ -19,7 +19,7 @@ namespace Squidex.Infrastructure.Tasks
public LimitedConcurrencyLevelTaskScheduler(int maxDegreeOfParallelism) public LimitedConcurrencyLevelTaskScheduler(int maxDegreeOfParallelism)
{ {
Guard.GreaterEquals(maxDegreeOfParallelism, 1, nameof(maxDegreeOfParallelism)); Guard.GreaterEquals(maxDegreeOfParallelism, 1);
this.maxDegreeOfParallelism = maxDegreeOfParallelism; 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)] [ApiCosts(0)]
public async Task<IActionResult> GetComments(string app, DomainId commentsId, [FromQuery] long version = EtagVersion.Any) 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(() => var response = Deferred.Response(() =>
{ {
@ -159,13 +159,22 @@ namespace Squidex.Areas.Api.Controllers.Comments
[ApiCosts(0)] [ApiCosts(0)]
public async Task<IActionResult> DeleteComment(string app, DomainId commentsId, DomainId commentId) 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); await CommandBus.PublishAsync(command, HttpContext.RequestAborted);
return NoContent(); return NoContent();
} }
private DomainId Id(DomainId commentsId)
{
return DomainId.Combine(App.Id, commentsId);
}
private string UserId() private string UserId()
{ {
var subject = User.OpenIdSubject(); 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))] [ApiExplorerSettings(GroupName = nameof(Notifications))]
public sealed class UserNotificationsController : ApiController public sealed class UserNotificationsController : ApiController
{ {
private static readonly NamedId<DomainId> NoApp = NamedId.Of(DomainId.Empty, "none");
private readonly ICommentsLoader commentsLoader; private readonly ICommentsLoader commentsLoader;
public UserNotificationsController(ICommandBus commandBus, ICommentsLoader commentsLoader) public UserNotificationsController(ICommandBus commandBus, ICommentsLoader commentsLoader)
@ -83,7 +82,7 @@ namespace Squidex.Areas.Api.Controllers.Comments.Notifications
var commmand = new DeleteComment var commmand = new DeleteComment
{ {
AppId = NoApp, AppId = CommentsCommand.NoApp,
CommentsId = userId, CommentsId = userId,
CommentId = commentId 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 namespace Squidex.Areas.Api.Controllers.Rules
{ {
/// <summary> /// <summary>
/// Manages and retrieves information about schemas. /// Manages and retrieves information about rules.
/// </summary> /// </summary>
[ApiExplorerSettings(GroupName = nameof(Rules))] [ApiExplorerSettings(GroupName = nameof(Rules))]
public sealed class RulesController : ApiController 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, services.Configure<RestrictAppsOptions>(config,
"usage"); "usage");
services.Configure<DomainObjectCacheOptions>(config,
"caching:domainObjects");
services.AddSingletonAs<InMemoryCommandBus>() services.AddSingletonAs<InMemoryCommandBus>()
.As<ICommandBus>(); .As<ICommandBus>();

10
backend/src/Squidex/appsettings.json

@ -72,6 +72,11 @@
"replicated": { "replicated": {
// Set to true to enable a replicated cache for app, schemas and rules. Increases performance but reduces consistency. // Set to true to enable a replicated cache for app, schemas and rules. Increases performance but reduces consistency.
"enable": true "enable": true
},
"domainObjects": {
// The cache duration for domain objects.
"cacheDuration": "00:10:00"
} }
}, },
@ -94,7 +99,10 @@
"rules": { "rules": {
// The timeout to execute rule actions. // The timeout to execute rule actions.
"executionTimeoutInSeconds": 10 "executionTimeoutInSeconds": 10,
// The cache duration for rules.
"rulesCacheDuration": "00:00:10"
}, },
"ui": { "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(); .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] [Fact]
public async Task Should_create_jobs_from_snapshots() 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() public RuleEnqueuerTests()
{ {
sut = new RuleEnqueuer( var options = Options.Create(new RuleOptions());
sut = new RuleEnqueuer(cache, localCache,
appProvider, appProvider,
cache,
localCache,
ruleEventRepository, ruleEventRepository,
ruleService); ruleService,
options);
} }
[Fact] [Fact]

5
backend/tests/Squidex.Infrastructure.Tests/Commands/DefaultDomainObjectCacheTests.cs

@ -8,6 +8,7 @@
using FakeItEasy; using FakeItEasy;
using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json;
using Xunit; using Xunit;
@ -27,7 +28,9 @@ namespace Squidex.Infrastructure.Commands
{ {
ct = cts.Token; ct = cts.Token;
sut = new DefaultDomainObjectCache(cache, serializer, distributedCache); var options = Options.Create(new DomainObjectCacheOptions());
sut = new DefaultDomainObjectCache(cache, serializer, distributedCache, options);
} }
[Fact] [Fact]

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

@ -24,7 +24,6 @@ namespace Squidex.Infrastructure.EventSourcing
.Returns(eventSubscription); .Returns(eventSubscription);
sut = new RetrySubscription<StoredEvent>(eventSubscriber, s => eventStore.CreateSubscription(s)) { ReconnectWaitMs = 50 }; sut = new RetrySubscription<StoredEvent>(eventSubscriber, s => eventStore.CreateSubscription(s)) { ReconnectWaitMs = 50 };
sutSubscriber = sut; sutSubscriber = sut;
} }
@ -40,7 +39,9 @@ namespace Squidex.Infrastructure.EventSourcing
[Fact] [Fact]
public async Task Should_reopen_subscription_once_if_exception_is_retrieved() 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); await Task.Delay(1000);
@ -61,12 +62,7 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
var ex = new InvalidOperationException(); var ex = new InvalidOperationException();
await OnErrorAsync(eventSubscription, ex); await OnErrorAsync(eventSubscription, ex, times: 6);
await OnErrorAsync(eventSubscription, ex);
await OnErrorAsync(eventSubscription, ex);
await OnErrorAsync(eventSubscription, ex);
await OnErrorAsync(eventSubscription, ex);
await OnErrorAsync(eventSubscription, ex);
sut.Dispose(); sut.Dispose();
@ -74,12 +70,25 @@ namespace Squidex.Infrastructure.EventSourcing
.MustHaveHappened(); .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] [Fact]
public async Task Should_not_forward_error_if_exception_is_raised_after_unsubscribe() public async Task Should_not_forward_error_if_exception_is_raised_after_unsubscribe()
{ {
var ex = new InvalidOperationException(); var ex = new InvalidOperationException();
await OnErrorAsync(eventSubscription, ex); await OnErrorAsync(eventSubscription, ex, times: 1);
sut.Dispose(); sut.Dispose();
@ -113,9 +122,38 @@ namespace Squidex.Infrastructure.EventSourcing
.MustNotHaveHappened(); .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) private ValueTask OnNextAsync(IEventSubscription subscriber, StoredEvent ev)

9
backend/tests/docker-compose.yml

@ -18,6 +18,7 @@ services:
- IDENTITY__ADMINCLIENTID=root - IDENTITY__ADMINCLIENTID=root
- IDENTITY__ADMINCLIENTSECRET=xeLd6jFxqbXJrfmNLlO2j1apagGGGSyZJhFnIuHp4I0= - IDENTITY__ADMINCLIENTSECRET=xeLd6jFxqbXJrfmNLlO2j1apagGGGSyZJhFnIuHp4I0=
- IDENTITY__MULTIPLEDOMAINS=true - IDENTITY__MULTIPLEDOMAINS=true
- RULES__RULESCACHEDURATION=00:00:00
- SCRIPTING__TIMEOUTEXECUTION=00:00:10 - SCRIPTING__TIMEOUTEXECUTION=00:00:10
- SCRIPTING__TIMEOUTSCRIPT=00:00:10 - SCRIPTING__TIMEOUTSCRIPT=00:00:10
- STORE__MONGODB__CONFIGURATION=mongodb://mongo - STORE__MONGODB__CONFIGURATION=mongodb://mongo
@ -63,6 +64,14 @@ services:
depends_on: depends_on:
- mongo - mongo
webhookcatcher:
image: tarampampam/webhook-tester
command: serve --port 1026
ports:
- "1026:1026"
networks:
- internal
squidex_proxy1: squidex_proxy1:
image: squidex/caddy-proxy image: squidex/caddy-proxy
ports: ports:

94
backend/tools/TestSuite/TestSuite.ApiTests/BackupTests.cs

@ -7,6 +7,7 @@
using Squidex.ClientLibrary.Management; using Squidex.ClientLibrary.Management;
using TestSuite.Fixtures; using TestSuite.Fixtures;
using TestSuite.Model;
using Xunit; using Xunit;
#pragma warning disable SA1300 // Element should begin with upper-case letter #pragma warning disable SA1300 // Element should begin with upper-case letter
@ -17,6 +18,9 @@ namespace TestSuite.ApiTests
[Trait("Category", "NotAutomated")] [Trait("Category", "NotAutomated")]
public class BackupTests : IClassFixture<ClientFixture> public class BackupTests : IClassFixture<ClientFixture>
{ {
private readonly string appName = Guid.NewGuid().ToString();
private readonly string schemaName = $"schema-{Guid.NewGuid()}";
public ClientFixture _ { get; } public ClientFixture _ { get; }
public BackupTests(ClientFixture fixture) public BackupTests(ClientFixture fixture)
@ -27,9 +31,6 @@ namespace TestSuite.ApiTests
[Fact] [Fact]
public async Task Should_backup_and_restore_app() public async Task Should_backup_and_restore_app()
{ {
var timeout = TimeSpan.FromMinutes(2);
var appName = Guid.NewGuid().ToString();
var appNameRestore = $"{appName}-restore"; var appNameRestore = $"{appName}-restore";
// STEP 1: Create app // STEP 1: Create app
@ -38,68 +39,57 @@ namespace TestSuite.ApiTests
await _.Apps.PostAppAsync(createRequest); await _.Apps.PostAppAsync(createRequest);
// STEP 2: Create backup // STEP 2: Prepare app.
await _.Backups.PostBackupAsync(appName); await PrepareAppAsync(appName);
BackupJobDto backup = null;
try // STEP 3: Create backup
{ await _.Backups.PostBackupAsync(appNameRestore);
using (var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)))
{
while (true)
{
cts.Token.ThrowIfCancellationRequested();
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; // STEP 3: Restore backup
} var uri = new Uri(new Uri(_.ServerUrl, UriKind.Absolute), backup._links["download"].Href);
}
}
}
catch (OperationCanceledException)
{
Assert.True(false, $"Could not retrieve backup within {timeout}.");
}
var restore = await _.Backups.WaitForRestoreAsync(uri, TimeSpan.FromMinutes(2));
// STEP 3: Restore backup Assert.Equal(JobStatus.Completed, restore?.Status);
var uri = new Uri($"{_.ServerUrl}{backup._links["download"].Href}"); }
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 await contents.CreateAsync(new TestEntityData { Number = 1 });
{
using (var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)))
{ // Upload a test asset
while (true) var fileInfo = new FileInfo("Assets/logo-squared.png");
{
cts.Token.ThrowIfCancellationRequested(); await using (var stream = fileInfo.OpenRead())
await Task.Delay(1000);
var job = await _.Backups.GetRestoreJobAsync();
if (job != null && job.Url == uri && job.Status == JobStatus.Completed)
{
break;
}
}
}
}
catch (OperationCanceledException)
{ {
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> public class ContentCleanupTests : IClassFixture<CreatedAppFixture>
{ {
private readonly string schemaName = $"schema-{Guid.NewGuid()}";
public CreatedAppFixture _ { get; } public CreatedAppFixture _ { get; }
public ContentCleanupTests(CreatedAppFixture fixture) public ContentCleanupTests(CreatedAppFixture fixture)
@ -27,8 +29,6 @@ namespace TestSuite.ApiTests
[Fact] [Fact]
public async Task Should_cleanup_old_data_from_update_response() public async Task Should_cleanup_old_data_from_update_response()
{ {
var schemaName = $"schema-{Guid.NewGuid()}";
// STEP 1: Create a schema. // STEP 1: Create a schema.
var schema = await TestEntity.CreateSchemaAsync(_.Schemas, _.AppName, schemaName); var schema = await TestEntity.CreateSchemaAsync(_.Schemas, _.AppName, schemaName);
@ -60,8 +60,6 @@ namespace TestSuite.ApiTests
[Fact] [Fact]
public async Task Should_cleanup_old_references() public async Task Should_cleanup_old_references()
{ {
var schemaName = $"schema-{Guid.NewGuid()}";
// STEP 1: Create a schema. // STEP 1: Create a schema.
await TestEntityWithReferences.CreateSchemaAsync(_.Schemas, _.AppName, schemaName); 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> public class ContentScriptingTests : IClassFixture<CreatedAppFixture>
{ {
private readonly string schemaName = $"schema-{Guid.NewGuid()}";
public CreatedAppFixture _ { get; } public CreatedAppFixture _ { get; }
public ContentScriptingTests(CreatedAppFixture fixture) public ContentScriptingTests(CreatedAppFixture fixture)
@ -28,8 +30,6 @@ namespace TestSuite.ApiTests
[Fact] [Fact]
public async Task Should_create_content_with_scripting() public async Task Should_create_content_with_scripting()
{ {
var schemaName = $"schema-{Guid.NewGuid()}";
var scripts = new SchemaScriptsDto var scripts = new SchemaScriptsDto
{ {
Create = @$" Create = @$"
@ -52,8 +52,6 @@ namespace TestSuite.ApiTests
[Fact] [Fact]
public async Task Should_query_content_with_scripting() public async Task Should_query_content_with_scripting()
{ {
var schemaName = $"schema-{Guid.NewGuid()}";
var scripts = new SchemaScriptsDto var scripts = new SchemaScriptsDto
{ {
Query = @$" Query = @$"
@ -76,8 +74,6 @@ namespace TestSuite.ApiTests
[Fact] [Fact]
public async Task Should_query_content_with_scripting_and_pre_query() public async Task Should_query_content_with_scripting_and_pre_query()
{ {
var schemaName = $"schema-{Guid.NewGuid()}";
var scripts = new SchemaScriptsDto var scripts = new SchemaScriptsDto
{ {
QueryPre = @$" QueryPre = @$"
@ -102,8 +98,6 @@ namespace TestSuite.ApiTests
[Fact] [Fact]
public async Task Should_create_bulk_content_with_scripting() public async Task Should_create_bulk_content_with_scripting()
{ {
var schemaName = $"schema-{Guid.NewGuid()}";
// STEP 1: Create a schema. // STEP 1: Create a schema.
var scripts = new SchemaScriptsDto var scripts = new SchemaScriptsDto
{ {
@ -152,8 +146,6 @@ namespace TestSuite.ApiTests
[Fact] [Fact]
public async Task Should_create_bulk_content_with_scripting_but_disabled() public async Task Should_create_bulk_content_with_scripting_but_disabled()
{ {
var schemaName = $"schema-{Guid.NewGuid()}";
// STEP 1: Create a schema. // STEP 1: Create a schema.
var scripts = new SchemaScriptsDto 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 }); 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; return;
} }
}); });
@ -440,11 +440,11 @@ namespace TestSuite.ApiTests
{ {
await _.Contents.UpsertAsync(content.Id, new TestEntityData { Number = i }); 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; 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> public class SchemaTests : IClassFixture<CreatedAppFixture>
{ {
private readonly string schemaName = $"schema-{Guid.NewGuid()}";
public CreatedAppFixture _ { get; } public CreatedAppFixture _ { get; }
public SchemaTests(CreatedAppFixture fixture) public SchemaTests(CreatedAppFixture fixture)
@ -27,8 +29,6 @@ namespace TestSuite.ApiTests
[Fact] [Fact]
public async Task Should_create_schema() public async Task Should_create_schema()
{ {
var schemaName = $"schema-{Guid.NewGuid()}";
// STEP 1: Create schema // STEP 1: Create schema
var createRequest = new CreateSchemaDto { Name = schemaName }; var createRequest = new CreateSchemaDto { Name = schemaName };
@ -48,8 +48,6 @@ namespace TestSuite.ApiTests
[Fact] [Fact]
public async Task Should_not_allow_creation_if_name_used() public async Task Should_not_allow_creation_if_name_used()
{ {
var schemaName = $"schema-{Guid.NewGuid()}";
// STEP 1: Create schema // STEP 1: Create schema
var createRequest = new CreateSchemaDto { Name = schemaName }; var createRequest = new CreateSchemaDto { Name = schemaName };
@ -68,8 +66,6 @@ namespace TestSuite.ApiTests
[Fact] [Fact]
public async Task Should_create_singleton_schema() public async Task Should_create_singleton_schema()
{ {
var schemaName = $"schema-{Guid.NewGuid()}";
// STEP 1: Create schema // STEP 1: Create schema
var createRequest = new CreateSchemaDto var createRequest = new CreateSchemaDto
{ {
@ -102,8 +98,6 @@ namespace TestSuite.ApiTests
[Fact] [Fact]
public async Task Should_create_schema_with_checkboxes() public async Task Should_create_schema_with_checkboxes()
{ {
var schemaName = $"schema-{Guid.NewGuid()}";
// STEP 1: Create schema // STEP 1: Create schema
var createRequest = new CreateSchemaDto var createRequest = new CreateSchemaDto
{ {
@ -141,8 +135,6 @@ namespace TestSuite.ApiTests
[Fact] [Fact]
public async Task Should_delete_Schema() public async Task Should_delete_Schema()
{ {
var schemaName = $"schema-{Guid.NewGuid()}";
// STEP 1: Create schema // STEP 1: Create schema
var createRequest = new CreateSchemaDto { Name = schemaName }; var createRequest = new CreateSchemaDto { Name = schemaName };
@ -164,8 +156,6 @@ namespace TestSuite.ApiTests
[Fact] [Fact]
public async Task Should_recreate_after_deleted() public async Task Should_recreate_after_deleted()
{ {
var schemaName = $"schema-{Guid.NewGuid()}";
// STEP 1: Create schema // STEP 1: Create schema
var createRequest = new CreateSchemaDto { Name = schemaName }; var createRequest = new CreateSchemaDto { Name = schemaName };

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

@ -21,7 +21,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
<PackageReference Include="NSwag.Core" Version="13.16.1" /> <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="Squidex.Assets" Version="3.6.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.4.1" /> <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<IAppsClient> apps;
private readonly Lazy<IAssetsClient> assets; private readonly Lazy<IAssetsClient> assets;
private readonly Lazy<ICommentsClient> comments;
private readonly Lazy<IBackupsClient> backups; private readonly Lazy<IBackupsClient> backups;
private readonly Lazy<ILanguagesClient> languages; private readonly Lazy<ILanguagesClient> languages;
private readonly Lazy<IPingClient> ping; private readonly Lazy<IPingClient> ping;
@ -41,6 +42,11 @@ namespace TestSuite
get => backups.Value; get => backups.Value;
} }
public ICommentsClient Comments
{
get => comments.Value;
}
public ILanguagesClient Languages public ILanguagesClient Languages
{ {
get => languages.Value; get => languages.Value;
@ -68,10 +74,10 @@ namespace TestSuite
public ClientManagerWrapper() public ClientManagerWrapper()
{ {
var appName = TestHelpers.GetValue("config:app:name", "integration-tests"); var appName = TestHelpers.GetAndPrintValue("config:app:name", "integration-tests");
var clientId = TestHelpers.GetValue("config:client:id", "root"); var clientId = TestHelpers.GetAndPrintValue("config:client:id", "root");
var clientSecret = TestHelpers.GetValue("config:client:secret", "xeLd6jFxqbXJrfmNLlO2j1apagGGGSyZJhFnIuHp4I0="); var clientSecret = TestHelpers.GetAndPrintValue("config:client:secret", "xeLd6jFxqbXJrfmNLlO2j1apagGGGSyZJhFnIuHp4I0=");
var serverUrl = TestHelpers.GetValue("config:server:url", "https://localhost:5001"); var serverUrl = TestHelpers.GetAndPrintValue("config:server:url", "https://localhost:5001");
ClientManager = new SquidexClientManager(new SquidexOptions ClientManager = new SquidexClientManager(new SquidexOptions
{ {
@ -99,6 +105,11 @@ namespace TestSuite
return ClientManager.CreateBackupsClient(); return ClientManager.CreateBackupsClient();
}); });
comments = new Lazy<ICommentsClient>(() =>
{
return ClientManager.CreateCommentsClient();
});
languages = new Lazy<ILanguagesClient>(() => languages = new Lazy<ILanguagesClient>(() =>
{ {
return ClientManager.CreateLanguagesClient(); return ClientManager.CreateLanguagesClient();
@ -134,23 +145,28 @@ namespace TestSuite
Console.WriteLine("Waiting {0} seconds to access server", waitSeconds); Console.WriteLine("Waiting {0} seconds to access server", waitSeconds);
var pingClient = ClientManager.CreatePingClient(); var pingClient = ClientManager.CreatePingClient();
try
using (var cts = new CancellationTokenSource(waitSeconds * 1000))
{ {
while (!cts.IsCancellationRequested) using (var cts = new CancellationTokenSource(waitSeconds * 1000))
{ {
try while (!cts.IsCancellationRequested)
{
await pingClient.GetPingAsync(cts.Token);
break;
}
catch
{ {
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."); 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 IBackupsClient Backups => Squidex.Backups;
public ICommentsClient Comments => Squidex.Comments;
public ILanguagesClient Languages => Squidex.Languages; public ILanguagesClient Languages => Squidex.Languages;
public IPingClient Ping => Squidex.Ping; 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.EnvironmentVariables" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <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="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />
</ItemGroup> </ItemGroup>

2
backend/tools/TestSuite/TestSuite.Shared/Utils/TestHelpers.cs

@ -25,7 +25,7 @@ namespace TestSuite.Utils
.Build(); .Build();
} }
public static string GetValue(string name, string fallback) public static string GetAndPrintValue(string name, string fallback)
{ {
var value = Configuration[name]; var value = Configuration[name];

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

@ -48,7 +48,7 @@
</ng-container> </ng-container>
<ng-template #simpleMode> <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 <a class="nav-link truncate drag-none" [routerLink]="schemaRoute(schema)" routerLinkActive="active" sqxStopDrag
title="{{schema.displayName}}" title="{{schema.displayName}}"
titlePosition="top-left"> titlePosition="top-left">

Loading…
Cancel
Save