Browse Source

Flow fixes. (#1219)

* Flow fixes.

* Fix tests

* Fix test

* Another attempt.

* Fix trigger URL

* Fix tests
pull/1221/head
Sebastian Stehle 1 year ago
committed by GitHub
parent
commit
cbd3d4eecb
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      backend/src/Migrations/OldTriggers/AssetChangedTrigger.cs
  2. 2
      backend/src/Migrations/OldTriggers/ContentChangedTrigger.cs
  3. 14
      backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj
  4. 12
      backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj
  5. 4
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedManualEvent.cs
  6. 14
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleTriggerVisitor.cs
  7. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/RuleTrigger.cs
  8. 4
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/AssetChangedTriggerV2.cs
  9. 4
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/CommentTrigger.cs
  10. 4
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs
  11. 4
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ManualTrigger.cs
  12. 4
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/SchemaChangedTrigger.cs
  13. 4
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/UsageTrigger.cs
  14. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj
  15. 69
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs
  16. 5
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EventScriptVars.cs
  17. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  18. 3
      backend/src/Squidex.Domain.Apps.Entities/Rules/Commands/TriggerRule.cs
  19. 54
      backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/GuardRule.cs
  20. 85
      backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/RuleTriggerValidator.cs
  21. 21
      backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs
  22. 26
      backend/src/Squidex.Domain.Apps.Entities/Rules/IRuleValidator.cs
  23. 1
      backend/src/Squidex.Domain.Apps.Entities/Rules/ManualTriggerHandler.cs
  24. 141
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleValidator.cs
  25. 3
      backend/src/Squidex.Domain.Apps.Events/Rules/RuleManuallyTriggered.cs
  26. 30
      backend/src/Squidex.Infrastructure/Queries/OData/ConstantWithTypeVisitor.cs
  27. 118
      backend/src/Squidex.Infrastructure/Queries/OData/FilterVisitor.cs
  28. 14
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  29. 23
      backend/src/Squidex.Infrastructure/Validation/Validate.cs
  30. 14
      backend/src/Squidex/Areas/Api/Config/OpenApi/DiscriminatorProcessor.cs
  31. 2
      backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs
  32. 34
      backend/src/Squidex/Areas/Api/Config/OpenApi/SchemaNameGenerator.cs
  33. 17
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs
  34. 8
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs
  35. 28
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/TriggerRuleDto.cs
  36. 66
      backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs
  37. 3
      backend/src/Squidex/Config/Domain/RuleServices.cs
  38. 2
      backend/src/Squidex/Config/Domain/SerializationServices.cs
  39. 26
      backend/src/Squidex/Squidex.csproj
  40. 4
      backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Rules/RuleTests.cs
  41. 51
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs
  42. 4
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs
  43. 29
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/GuardRuleTests.cs
  44. 26
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/ContentChangedTriggerTests.cs
  45. 24
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/UsageTriggerValidationTests.cs
  46. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/RuleDomainObjectTests.cs
  47. 106
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleValidatorTests.cs
  48. 260
      backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromODataTests.cs
  49. 22
      frontend/src/app/features/rules/pages/rule/rule-page.component.ts
  50. 4
      frontend/src/app/features/rules/pages/rule/step-dialog.component.html
  51. 30
      frontend/src/app/features/rules/pages/rule/step-dialog.component.ts
  52. 2
      frontend/src/app/features/rules/pages/rule/trigger-dialog.component.html
  53. 23
      frontend/src/app/features/rules/pages/rule/trigger-dialog.component.ts
  54. 4
      frontend/src/app/features/rules/pages/rules/rule.component.html
  55. 4
      frontend/src/app/features/rules/pages/rules/rule.component.ts
  56. 2
      frontend/src/app/features/rules/shared/rule-icon.component.html
  57. 4
      frontend/src/app/features/rules/shared/rule-icon.component.scss
  58. 28
      frontend/src/app/shared/services/rules.service.spec.ts
  59. 23
      frontend/src/app/shared/services/rules.service.ts
  60. 2
      tools/TestSuite/TestSuite.ApiTests/RuleEventsTests.cs
  61. 144
      tools/TestSuite/TestSuite.ApiTests/RuleRunnerTests.cs
  62. 24
      tools/TestSuite/TestSuite.ApiTests/RuleTests.cs
  63. 84
      tools/TestSuite/TestSuite.ApiTests/RuleValidationTests.cs
  64. 4
      tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj
  65. 1
      tools/TestSuite/TestSuite.ApiTests/Verify/RuleRunnerTests.Should_log_to_console_in_rules.verified.txt
  66. 3
      tools/TestSuite/TestSuite.ApiTests/Verify/RuleTests.Should_create_rule.verified.txt
  67. 3
      tools/TestSuite/TestSuite.ApiTests/Verify/RuleTests.Should_create_rule_with_action.verified.txt
  68. 3
      tools/TestSuite/TestSuite.ApiTests/Verify/RuleTests.Should_update_rule.verified.txt
  69. 6
      tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj
  70. 0
      tools/TestSuite/images/local-proxy/docker-compose.yml
  71. 0
      tools/TestSuite/images/mysql/init.sql
  72. 0
      tools/TestSuite/images/sql-server/Dockerfile

2
backend/src/Migrations/OldTriggers/AssetChangedTrigger.cs

@ -24,7 +24,7 @@ public sealed record AssetChangedTrigger : RuleTrigger, IMigrated<RuleTrigger>
public bool SendDelete { get; set; }
public override T Accept<T>(IRuleTriggerVisitor<T> visitor)
public override T Accept<T, TArgs>(IRuleTriggerVisitor<T, TArgs> visitor, TArgs args)
{
throw new NotSupportedException();
}

2
backend/src/Migrations/OldTriggers/ContentChangedTrigger.cs

@ -20,7 +20,7 @@ public sealed record ContentChangedTrigger : RuleTrigger, IMigrated<RuleTrigger>
public bool HandleAll { get; set; }
public override T Accept<T>(IRuleTriggerVisitor<T> visitor)
public override T Accept<T, TArgs>(IRuleTriggerVisitor<T, TArgs> visitor, TArgs args)
{
throw new NotSupportedException();
}

14
backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj

@ -36,17 +36,17 @@
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql.Json.Microsoft" Version="8.0.2" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql.NetTopologySuite" Version="8.0.2" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI.EntityFramework" Version="7.1.4" />
<PackageReference Include="Squidex.Assets.EntityFramework" Version="7.1.4" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="7.1.4" />
<PackageReference Include="Squidex.AI.EntityFramework" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.EntityFramework" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="7.3.0" />
<PackageReference Include="Squidex.EFCore.BulkExtensions.Core" Version="8.1.6" />
<PackageReference Include="Squidex.EFCore.BulkExtensions.MySql" Version="8.1.6" />
<PackageReference Include="Squidex.EFCore.BulkExtensions.PostgreSQL" Version="8.1.6" />
<PackageReference Include="Squidex.EFCore.BulkExtensions.SqlServer" Version="8.1.6" />
<PackageReference Include="Squidex.Events.EntityFramework" Version="7.1.4" />
<PackageReference Include="Squidex.Flows.EntityFramework" Version="7.1.4" />
<PackageReference Include="Squidex.Hosting" Version="7.1.4" />
<PackageReference Include="Squidex.Messaging.EntityFramework" Version="7.1.4" />
<PackageReference Include="Squidex.Events.EntityFramework" Version="7.3.0" />
<PackageReference Include="Squidex.Flows.EntityFramework" Version="7.3.0" />
<PackageReference Include="Squidex.Hosting" Version="7.3.0" />
<PackageReference Include="Squidex.Messaging.EntityFramework" Version="7.3.0" />
<PackageReference Include="Squidex.OpenIdDict.EntityFramework" Version="5.8.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

12
backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj

@ -25,12 +25,12 @@
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.30.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI.Mongo" Version="7.1.4" />
<PackageReference Include="Squidex.Assets.Mongo" Version="7.1.4" />
<PackageReference Include="Squidex.Events.Mongo" Version="7.1.4" />
<PackageReference Include="Squidex.Flows.Mongo" Version="7.1.4" />
<PackageReference Include="Squidex.Hosting" Version="7.1.4" />
<PackageReference Include="Squidex.Messaging.Mongo" Version="7.1.4" />
<PackageReference Include="Squidex.AI.Mongo" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.Mongo" Version="7.3.0" />
<PackageReference Include="Squidex.Events.Mongo" Version="7.3.0" />
<PackageReference Include="Squidex.Flows.Mongo" Version="7.3.0" />
<PackageReference Include="Squidex.Hosting" Version="7.3.0" />
<PackageReference Include="Squidex.Messaging.Mongo" Version="7.3.0" />
<PackageReference Include="Squidex.OpenIddict.MongoDb" Version="5.8.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

4
backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedManualEvent.cs

@ -5,10 +5,14 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
public sealed class EnrichedManualEvent : EnrichedUserEventBase
{
public JsonValue Value { get; set; }
public override long Partition
{
get => 0;

14
backend/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleTriggerVisitor.cs

@ -9,17 +9,17 @@ using Squidex.Domain.Apps.Core.Rules.Triggers;
namespace Squidex.Domain.Apps.Core.Rules;
public interface IRuleTriggerVisitor<out T>
public interface IRuleTriggerVisitor<out T, in TArgs>
{
T Visit(AssetChangedTriggerV2 trigger);
T Visit(AssetChangedTriggerV2 trigger, TArgs args);
T Visit(ContentChangedTriggerV2 trigger);
T Visit(ContentChangedTriggerV2 trigger, TArgs args);
T Visit(CommentTrigger trigger);
T Visit(CommentTrigger trigger, TArgs args);
T Visit(ManualTrigger trigger);
T Visit(ManualTrigger trigger, TArgs args);
T Visit(SchemaChangedTrigger trigger);
T Visit(SchemaChangedTrigger trigger, TArgs args);
T Visit(UsageTrigger trigger);
T Visit(UsageTrigger trigger, TArgs args);
}

2
backend/src/Squidex.Domain.Apps.Core.Model/Rules/RuleTrigger.cs

@ -9,5 +9,5 @@ namespace Squidex.Domain.Apps.Core.Rules;
public abstract record RuleTrigger
{
public abstract T Accept<T>(IRuleTriggerVisitor<T> visitor);
public abstract T Accept<T, TArgs>(IRuleTriggerVisitor<T, TArgs> visitor, TArgs args);
}

4
backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/AssetChangedTriggerV2.cs

@ -14,8 +14,8 @@ public sealed record AssetChangedTriggerV2 : RuleTrigger
{
public string Condition { get; init; }
public override T Accept<T>(IRuleTriggerVisitor<T> visitor)
public override T Accept<T, TArgs>(IRuleTriggerVisitor<T, TArgs> visitor, TArgs args)
{
return visitor.Visit(this);
return visitor.Visit(this, args);
}
}

4
backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/CommentTrigger.cs

@ -14,8 +14,8 @@ public sealed record CommentTrigger : RuleTrigger
{
public string Condition { get; init; }
public override T Accept<T>(IRuleTriggerVisitor<T> visitor)
public override T Accept<T, TArgs>(IRuleTriggerVisitor<T, TArgs> visitor, TArgs args)
{
return visitor.Visit(this);
return visitor.Visit(this, args);
}
}

4
backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs

@ -19,8 +19,8 @@ public sealed record ContentChangedTriggerV2 : RuleTrigger
public bool HandleAll { get; init; }
public override T Accept<T>(IRuleTriggerVisitor<T> visitor)
public override T Accept<T, TArgs>(IRuleTriggerVisitor<T, TArgs> visitor, TArgs args)
{
return visitor.Visit(this);
return visitor.Visit(this, args);
}
}

4
backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ManualTrigger.cs

@ -12,8 +12,8 @@ namespace Squidex.Domain.Apps.Core.Rules.Triggers;
[TypeName(nameof(ManualTrigger))]
public sealed record ManualTrigger : RuleTrigger
{
public override T Accept<T>(IRuleTriggerVisitor<T> visitor)
public override T Accept<T, TArgs>(IRuleTriggerVisitor<T, TArgs> visitor, TArgs args)
{
return visitor.Visit(this);
return visitor.Visit(this, args);
}
}

4
backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/SchemaChangedTrigger.cs

@ -14,8 +14,8 @@ public sealed record SchemaChangedTrigger : RuleTrigger
{
public string Condition { get; init; }
public override T Accept<T>(IRuleTriggerVisitor<T> visitor)
public override T Accept<T, TArgs>(IRuleTriggerVisitor<T, TArgs> visitor, TArgs args)
{
return visitor.Visit(this);
return visitor.Visit(this, args);
}
}

4
backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/UsageTrigger.cs

@ -16,8 +16,8 @@ public sealed record UsageTrigger : RuleTrigger
public int? NumDays { get; init; }
public override T Accept<T>(IRuleTriggerVisitor<T> visitor)
public override T Accept<T, TArgs>(IRuleTriggerVisitor<T, TArgs> visitor, TArgs args)
{
return visitor.Visit(this);
return visitor.Visit(this, args);
}
}

2
backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj

@ -20,7 +20,7 @@
<PackageReference Include="NetTopologySuite" Version="2.5.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Flows" Version="7.1.4" />
<PackageReference Include="Squidex.Flows" Version="7.3.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />

69
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs

@ -6,7 +6,6 @@
// ==========================================================================
using System.Security.Claims;
using NodaTime;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Core.Templates;
@ -44,7 +43,19 @@ public sealed class RuleEventFormatter(
return false;
}
return scriptEngine.Evaluate(new ScriptVars { ["@event"] = value }, expression);
if (value is not EnrichedEvent @event)
{
if (value is not FlowEventContext context)
{
return false;
}
@event = context.Event;
}
var vars = CreateScriptVars(value, @event);
return scriptEngine.Evaluate(vars, expression);
}
public async ValueTask<string?> RenderAsync<T>(string? expression, T value, ExpressionFallback fallback = default)
@ -64,13 +75,11 @@ public sealed class RuleEventFormatter(
switch (fallback)
{
case ExpressionFallback.Context:
// Provide this overload with object to serialize the derived type and not the static type.
return serializer.Serialize(value, true);
case ExpressionFallback.Event:
// Provide this overload with object to serialize the derived type and not the static type.
return serializer.Serialize(@event, true);
case ExpressionFallback.Envelope:
return ToEnvelope(@event.Name, @event, @event.Timestamp);
return serializer.Serialize(new { type = @event.Name, payload = @event, timestamp = @event.Timestamp }, true);
default:
return expression;
}
@ -78,26 +87,16 @@ public sealed class RuleEventFormatter(
if (TryGetTemplate(expression.Trim(), out var template))
{
var vars = new TemplateVars
{
["event"] = @event,
};
var input = CreateTemplateVars(value, @event);
var result = await templateEngine.RenderAsync(template, input);
return await templateEngine.RenderAsync(template, vars);
return result;
}
if (TryGetScript(expression.Trim(), out var script))
{
// Script vars are just wrappers over dictionaries for better performance.
var vars = new EventScriptVars
{
Event = @event,
AppId = @event.AppId.Id,
AppName = @event.AppId.Name,
User = Admin(),
};
var result = (await scriptEngine.ExecuteAsync(vars, script)).ToString();
var input = CreateScriptVars(value, @event);
var result = (await scriptEngine.ExecuteAsync(input, script)).ToString();
if (result == "undefined")
{
@ -110,12 +109,6 @@ public sealed class RuleEventFormatter(
return await simpleFormatter.Format(expression, @event, GlobalFallback);
}
private string ToEnvelope(string type, object payload, Instant timestamp)
{
// Provide this overload with object to serialize the derived type and not the static type.
return serializer.Serialize(new { type, payload, timestamp }, true);
}
private static ClaimsPrincipal Admin()
{
var claimsIdentity = new ClaimsIdentity();
@ -126,6 +119,30 @@ public sealed class RuleEventFormatter(
return claimsPrincipal;
}
private static EventScriptVars CreateScriptVars<T>(T value, EnrichedEvent @event)
{
return new EventScriptVars
{
AppId = @event.AppId.Id,
AppName = @event.AppId.Name,
Event = @event,
User = Admin(),
UserData = (value as FlowEventContext)?.UserData ?? [],
};
}
private static TemplateVars CreateTemplateVars<T>(T value, EnrichedEvent @event)
{
return new TemplateVars
{
["appId"] = @event.AppId.Id,
["appName"] = @event.AppId.Name,
["event"] = @event,
["user"] = new { displayName = "FlowUser" },
["userData"] = (value as FlowEventContext)?.UserData ?? [],
};
}
private static bool TryGetScript(string text, out string script)
{
const string ScriptSuffix = ")";

5
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EventScriptVars.cs

@ -32,4 +32,9 @@ public sealed class EventScriptVars : ScriptVars
{
set => SetInitial(value);
}
public Dictionary<string, string> UserData
{
set => SetInitial(value);
}
}

4
backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj

@ -29,8 +29,8 @@
<PackageReference Include="NJsonSchema" Version="11.0.2" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI" Version="7.1.4" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="7.1.4" />
<PackageReference Include="Squidex.AI" Version="7.3.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="7.3.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />

3
backend/src/Squidex.Domain.Apps.Entities/Rules/Commands/TriggerRule.cs

@ -5,8 +5,11 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Entities.Rules.Commands;
public sealed class TriggerRule : RuleCommand
{
public JsonValue Value { get; set; }
}

54
backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/GuardRule.cs

@ -5,19 +5,16 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Entities.Rules.Commands;
using Squidex.Flows;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Rules.DomainObject.Guards;
public static class GuardRule
{
public static Task CanCreate(CreateRule command, IAppProvider appProvider, IFlowManager<FlowEventContext> flowManager,
public static Task CanCreate(CreateRule command, IRuleValidator validator,
CancellationToken ct)
{
Guard.NotNull(command);
@ -30,9 +27,8 @@ public static class GuardRule
}
else
{
var errors = await RuleTriggerValidator.ValidateAsync(command.AppId.Id, command.Trigger, appProvider, ct);
errors.Foreach((x, _) => x.AddTo(e));
await validator.ValidateTriggerAsync(command.Trigger, command.AppId.Id,
Validate.WithPrefix(nameof(command.Trigger), e), ct);
}
if (command.Flow == null)
@ -41,12 +37,13 @@ public static class GuardRule
}
else
{
await flowManager.ValidateAsync(command.Flow, ConvertError(e), ct);
await validator.ValidateFlowAsync(command.Flow,
Validate.WithPrefix(nameof(command.Flow), e), ct);
}
});
}
public static Task CanUpdate(UpdateRule command, Rule rule, IAppProvider appProvider, IFlowManager<FlowEventContext> flowManager,
public static Task CanUpdate(UpdateRule command, Rule rule, IRuleValidator validator,
CancellationToken ct)
{
Guard.NotNull(command);
@ -55,45 +52,14 @@ public static class GuardRule
{
if (command.Trigger != null)
{
var errors = await RuleTriggerValidator.ValidateAsync(rule.AppId.Id, command.Trigger, appProvider, ct);
errors.Foreach((x, _) => x.AddTo(e));
await validator.ValidateTriggerAsync(command.Trigger, command.AppId.Id,
Validate.WithPrefix(nameof(command.Trigger), e), ct);
}
if (command.Flow != null)
{
await flowManager.ValidateAsync(command.Flow, ConvertError(e), ct);
}
});
}
private static AddError ConvertError(AddValidation addError)
{
return new AddError((path, type, message) =>
{
var actualPath = nameof(CreateRule.Flow);
if (!string.IsNullOrWhiteSpace(path))
{
actualPath = $"{actualPath}.{path}";
}
switch (type)
{
case ValidationErrorType.NoSteps:
addError(T.Get("rules.validation.noSteps"), actualPath);
break;
case ValidationErrorType.NoStartStep:
addError(T.Get("rules.validation.noStartStep"), actualPath);
break;
case ValidationErrorType.InvalidNextStepId:
addError(T.Get("rules.validation.invalidNextStepId"), actualPath);
break;
case ValidationErrorType.InvalidStepId:
addError(T.Get("rules.validation.invalidStepId"), actualPath);
break;
case ValidationErrorType.InvalidProperty when !string.IsNullOrWhiteSpace(message):
addError(message, actualPath);
break;
await validator.ValidateFlowAsync(command.Flow,
Validate.WithPrefix(nameof(command.Flow), e), ct);
}
});
}

85
backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/RuleTriggerValidator.cs

@ -1,85 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Rules.DomainObject.Guards;
public sealed class RuleTriggerValidator(DomainId appId, IAppProvider appProvider, CancellationToken ct)
: IRuleTriggerVisitor<Task<IEnumerable<ValidationError>>>
{
public static Task<IEnumerable<ValidationError>> ValidateAsync(DomainId appId, RuleTrigger trigger, IAppProvider appProvider,
CancellationToken ct)
{
Guard.NotNull(trigger);
Guard.NotNull(appProvider);
var visitor = new RuleTriggerValidator(appId, appProvider, ct);
return trigger.Accept(visitor);
}
public Task<IEnumerable<ValidationError>> Visit(CommentTrigger trigger)
{
return Task.FromResult(Enumerable.Empty<ValidationError>());
}
public Task<IEnumerable<ValidationError>> Visit(AssetChangedTriggerV2 trigger)
{
return Task.FromResult(Enumerable.Empty<ValidationError>());
}
public Task<IEnumerable<ValidationError>> Visit(ManualTrigger trigger)
{
return Task.FromResult(Enumerable.Empty<ValidationError>());
}
public Task<IEnumerable<ValidationError>> Visit(SchemaChangedTrigger trigger)
{
return Task.FromResult(Enumerable.Empty<ValidationError>());
}
public Task<IEnumerable<ValidationError>> Visit(UsageTrigger trigger)
{
var errors = new List<ValidationError>();
if (trigger.NumDays is < 1 or > 30)
{
errors.Add(new ValidationError(Not.Between(nameof(trigger.NumDays), 1, 30), nameof(trigger.NumDays)));
}
return Task.FromResult<IEnumerable<ValidationError>>(errors);
}
public async Task<IEnumerable<ValidationError>> Visit(ContentChangedTriggerV2 trigger)
{
var errors = new List<ValidationError>();
if (trigger.Schemas == null)
{
return errors;
}
foreach (var schema in trigger.Schemas)
{
if (schema.SchemaId == DomainId.Empty)
{
errors.Add(new ValidationError(Not.Defined("SchemaId"), nameof(trigger.Schemas)));
}
else if (await appProvider.GetSchemaAsync(appId, schema.SchemaId, false, ct) == null)
{
errors.Add(new ValidationError(T.Get("schemas.notFoundId", new { id = schema.SchemaId }), nameof(trigger.Schemas)));
}
}
return errors;
}
}

21
backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs

@ -7,13 +7,11 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Entities.Rules.Commands;
using Squidex.Domain.Apps.Entities.Rules.DomainObject.Guards;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Rules;
using Squidex.Flows;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
@ -64,7 +62,7 @@ public partial class RuleDomainObject(
case CreateRule createRule:
return ApplyReturnAsync(createRule, async (c, ct) =>
{
await GuardRule.CanCreate(c, AppProvider(), FlowManager(), ct);
await GuardRule.CanCreate(c, Validator(), ct);
Create(c);
@ -74,7 +72,7 @@ public partial class RuleDomainObject(
case UpdateRule updateRule:
return ApplyReturnAsync(updateRule, async (c, ct) =>
{
await GuardRule.CanUpdate(c, Snapshot, AppProvider(), FlowManager(), ct);
await GuardRule.CanUpdate(c, Snapshot, Validator(), ct);
Update(c);
@ -124,12 +122,7 @@ public partial class RuleDomainObject(
SimpleMapper.Map(command, @event);
SimpleMapper.Map(Snapshot, @event);
await RuleEnqueuer().EnqueueAsync(Snapshot.Id, Snapshot, Envelope.Create(@event));
}
private IRuleEnqueuer RuleEnqueuer()
{
return serviceProvider.GetRequiredService<IRuleEnqueuer>();
await Enqueuer().EnqueueAsync(Snapshot.Id, Snapshot, Envelope.Create(@event));
}
private void Create(CreateRule command)
@ -162,13 +155,13 @@ public partial class RuleDomainObject(
RaiseEvent(Envelope.Create(SimpleMapper.Map(command, @event)));
}
private IAppProvider AppProvider()
private IRuleEnqueuer Enqueuer()
{
return serviceProvider.GetRequiredService<IAppProvider>();
return serviceProvider.GetRequiredService<IRuleEnqueuer>();
}
private IFlowManager<FlowEventContext> FlowManager()
private IRuleValidator Validator()
{
return serviceProvider.GetRequiredService<IFlowManager<FlowEventContext>>();
return serviceProvider.GetRequiredService<IRuleValidator>();
}
}

26
backend/src/Squidex.Domain.Apps.Entities/Rules/IRuleValidator.cs

@ -0,0 +1,26 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Flows;
using Squidex.Flows.Internal;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Rules;
public interface IRuleValidator
{
Task ValidateTriggerAsync(RuleTrigger trigger, DomainId appId, AddValidation addError,
CancellationToken ct = default);
Task ValidateFlowAsync(FlowDefinition flow, AddValidation addError,
CancellationToken ct = default);
Task ValidateStepAsync(FlowStep step, AddValidation addError,
CancellationToken ct = default);
}

1
backend/src/Squidex.Domain.Apps.Entities/Rules/ManualTriggerHandler.cs

@ -34,7 +34,6 @@ public sealed class ManualTriggerHandler : IRuleTriggerHandler
SimpleMapper.Map((RuleManuallyTriggered)@event.Payload, result);
await Task.Yield();
yield return result;
}

141
backend/src/Squidex.Domain.Apps.Entities/Rules/RuleValidator.cs

@ -0,0 +1,141 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Flows;
using Squidex.Flows.Internal;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Rules;
public sealed class RuleValidator(IFlowManager<FlowEventContext> flowManager, IAppProvider appProvider) : IRuleValidator
{
public async Task ValidateTriggerAsync(RuleTrigger trigger, DomainId appId, AddValidation addError,
CancellationToken ct = default)
{
Guard.NotNull(trigger);
Guard.NotNull(addError);
var context = new TriggerValidationContext(appId, addError, appProvider, ct);
await trigger.Accept(RuleTriggerValidator.Instance, context);
}
public async Task ValidateFlowAsync(FlowDefinition flow, AddValidation addError,
CancellationToken ct = default)
{
Guard.NotNull(flow);
Guard.NotNull(addError);
await flowManager.ValidateAsync(flow, ConvertError(addError), ct);
}
public async Task ValidateStepAsync(FlowStep step, AddValidation addError,
CancellationToken ct = default)
{
Guard.NotNull(step);
Guard.NotNull(addError);
await flowManager.ValidateAsync(step, ConvertError(addError), ct);
}
private static AddError ConvertError(AddValidation addError)
{
return new AddError((path, type, message) =>
{
switch (type)
{
case ValidationErrorType.NoSteps:
addError(T.Get("rules.validation.noSteps"), path);
break;
case ValidationErrorType.NoStartStep:
addError(T.Get("rules.validation.noStartStep"), path);
break;
case ValidationErrorType.InvalidNextStepId:
addError(T.Get("rules.validation.invalidNextStepId"), path);
break;
case ValidationErrorType.InvalidStepId:
addError(T.Get("rules.validation.invalidStepId"), path);
break;
case ValidationErrorType.InvalidProperty when !string.IsNullOrWhiteSpace(message):
addError(message, path);
break;
}
});
}
private sealed class RuleTriggerValidator : IRuleTriggerVisitor<ValueTask<None>, TriggerValidationContext>
{
public static readonly RuleTriggerValidator Instance = new RuleTriggerValidator();
public ValueTask<None> Visit(CommentTrigger trigger, TriggerValidationContext args)
{
return default;
}
public ValueTask<None> Visit(AssetChangedTriggerV2 trigger, TriggerValidationContext args)
{
return default;
}
public ValueTask<None> Visit(ManualTrigger trigger, TriggerValidationContext args)
{
return default;
}
public ValueTask<None> Visit(SchemaChangedTrigger trigger, TriggerValidationContext args)
{
return default;
}
public ValueTask<None> Visit(UsageTrigger trigger, TriggerValidationContext args)
{
if (trigger.NumDays is < 1 or > 30)
{
args.AddError(Not.Between(nameof(trigger.NumDays), 1, 30), nameof(trigger.NumDays));
}
return default;
}
public async ValueTask<None> Visit(ContentChangedTriggerV2 trigger, TriggerValidationContext args)
{
if (trigger.Schemas == null)
{
return None.Value;
}
foreach (var schema in trigger.Schemas)
{
if (schema.SchemaId == DomainId.Empty)
{
args.AddError(Not.Defined("SchemaId"), nameof(trigger.Schemas));
}
else if (await args.AppProvider.GetSchemaAsync(args.AppId, schema.SchemaId, false, args.CancellationToken) == null)
{
args.AddError(T.Get("schemas.notFoundId", new { id = schema.SchemaId }), nameof(trigger.Schemas));
}
}
return None.Value;
}
}
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
#pragma warning disable RECS0082 // Parameter has the same name as a member and hides it
private record struct TriggerValidationContext(
DomainId AppId,
AddValidation AddError,
IAppProvider AppProvider,
CancellationToken CancellationToken);
#pragma warning restore RECS0082 // Parameter has the same name as a member and hides it
#pragma warning restore SA1313 // Parameter names should begin with lower-case letter
}

3
backend/src/Squidex.Domain.Apps.Events/Rules/RuleManuallyTriggered.cs

@ -5,8 +5,11 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Events.Rules;
public sealed class RuleManuallyTriggered : RuleEvent
{
public JsonValue Value { get; set; }
}

30
backend/src/Squidex.Infrastructure/Queries/OData/ConstantWithTypeVisitor.cs

@ -144,6 +144,36 @@ public sealed class ConstantWithTypeVisitor : QueryNodeVisitor<ClrValue>
return default!;
}
public override ClrValue Visit(SingleValueFunctionCallNode nodeIn)
{
var parameterNode = nodeIn.Parameters.ElementAtOrDefault(0);
if (parameterNode == null)
{
ThrowHelper.NotSupportedException();
return default!;
}
var value = Visit(parameterNode);
if (value.Value is not string text)
{
ThrowHelper.NotSupportedException();
return default!;
}
if (string.Equals(nodeIn.Name, "tolower", StringComparison.OrdinalIgnoreCase))
{
return text.ToLowerInvariant();
}
if (string.Equals(nodeIn.Name, "toupper", StringComparison.OrdinalIgnoreCase))
{
return text.ToUpperInvariant();
}
ThrowHelper.NotSupportedException();
return default!;
}
private static Instant ParseInstant(object value)
{
if (value is DateTimeOffset dateTimeOffset)

118
backend/src/Squidex.Infrastructure/Queries/OData/FilterVisitor.cs

@ -5,6 +5,8 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text.RegularExpressions;
using Google.Protobuf;
using Microsoft.OData.UriParser;
using Microsoft.Spatial;
@ -48,7 +50,12 @@ public sealed class FilterVisitor : QueryNodeVisitor<FilterNode<ClrValue>>
public override FilterNode<ClrValue> Visit(SingleValueFunctionCallNode nodeIn)
{
var fieldNode = nodeIn.Parameters.ElementAt(0);
var fieldNode = nodeIn.Parameters.ElementAtOrDefault(0);
if (fieldNode == null)
{
ThrowHelper.NotSupportedException();
return default!;
}
if (string.Equals(nodeIn.Name, "empty", StringComparison.OrdinalIgnoreCase))
{
@ -65,7 +72,12 @@ public sealed class FilterVisitor : QueryNodeVisitor<FilterNode<ClrValue>>
return ClrFilter.Exists(PropertyPathVisitor.Visit(fieldNode));
}
var valueNode = nodeIn.Parameters.ElementAt(1);
var valueNode = nodeIn.Parameters.ElementAtOrDefault(1);
if (valueNode == null)
{
ThrowHelper.NotSupportedException();
return default!;
}
if (string.Equals(nodeIn.Name, "matchs", StringComparison.OrdinalIgnoreCase))
{
@ -113,42 +125,33 @@ public sealed class FilterVisitor : QueryNodeVisitor<FilterNode<ClrValue>>
if (nodeIn.Left is SingleValueFunctionCallNode functionNode)
{
if (string.Equals(functionNode.Name, "geo.distance", StringComparison.OrdinalIgnoreCase) && nodeIn.OperatorKind == BinaryOperatorKind.LessThan)
if (string.Equals(functionNode.Name, "geo.distance", StringComparison.OrdinalIgnoreCase))
{
var valueDistance = (double)ConstantWithTypeVisitor.Visit(nodeIn.Right).Value!;
if (functionNode.Parameters.ElementAt(1) is not ConstantNode constantNode)
{
ThrowHelper.NotSupportedException();
return default!;
}
if (constantNode.Value is not GeographyPoint geographyPoint)
{
ThrowHelper.NotSupportedException();
return default!;
}
var property = PropertyPathVisitor.Visit(functionNode.Parameters.ElementAt(0));
return ParseGeoDistance(nodeIn, functionNode);
}
return ClrFilter.Lt(property, new FilterSphere(geographyPoint.Longitude, geographyPoint.Latitude, valueDistance));
if (string.Equals(functionNode.Name, "toupper", StringComparison.OrdinalIgnoreCase))
{
return ParseMatch(nodeIn, functionNode, c => !char.IsLetter(c) || char.IsUpper(c));
}
else
if (string.Equals(functionNode.Name, "tolower", StringComparison.OrdinalIgnoreCase))
{
var regexFilter = Visit(functionNode);
return ParseMatch(nodeIn, functionNode, c => !char.IsLetter(c) || char.IsLower(c));
}
var value = ConstantWithTypeVisitor.Visit(nodeIn.Right);
var innerFunction = Visit(functionNode);
if (value.ValueType == ClrValueType.Boolean && value.Value is bool booleanRight)
var value = ConstantWithTypeVisitor.Visit(nodeIn.Right);
if (value.ValueType == ClrValueType.Boolean && value.Value is bool booleanRight)
{
if ((nodeIn.OperatorKind == BinaryOperatorKind.Equal && !booleanRight) ||
(nodeIn.OperatorKind == BinaryOperatorKind.NotEqual && booleanRight))
{
if ((nodeIn.OperatorKind == BinaryOperatorKind.Equal && !booleanRight) ||
(nodeIn.OperatorKind == BinaryOperatorKind.NotEqual && booleanRight))
{
regexFilter = ClrFilter.Not(regexFilter);
}
return regexFilter;
innerFunction = ClrFilter.Not(innerFunction);
}
return innerFunction;
}
}
else
@ -199,4 +202,59 @@ public sealed class FilterVisitor : QueryNodeVisitor<FilterNode<ClrValue>>
ThrowHelper.NotSupportedException();
return default!;
}
private static FilterNode<ClrValue> ParseMatch(BinaryOperatorNode nodeIn, SingleValueFunctionCallNode functionNode, Func<char, bool> condition)
{
if (nodeIn.OperatorKind is not BinaryOperatorKind.Equal and not BinaryOperatorKind.NotEqual)
{
ThrowHelper.NotSupportedException();
return default!;
}
var value = ConstantWithTypeVisitor.Visit(nodeIn.Right);
if (value.Value is not string text || !text.All(condition))
{
ThrowHelper.NotSupportedException();
return default!;
}
FilterNode<ClrValue> filter =
ClrFilter.Matchs(
PropertyPathVisitor.Visit(functionNode.Parameters.ElementAt(0)),
$"/^{Regex.Escape(text)}$/i");
if (nodeIn.OperatorKind == BinaryOperatorKind.NotEqual)
{
filter = ClrFilter.Not(filter);
}
return filter;
}
private static FilterNode<ClrValue> ParseGeoDistance(BinaryOperatorNode nodeIn, SingleValueFunctionCallNode functionNode)
{
if (nodeIn.OperatorKind != BinaryOperatorKind.LessThan)
{
ThrowHelper.NotSupportedException();
return default!;
}
var valueDistance = (double)ConstantWithTypeVisitor.Visit(nodeIn.Right).Value!;
if (functionNode.Parameters.ElementAt(1) is not ConstantNode constantNode)
{
ThrowHelper.NotSupportedException();
return default!;
}
if (constantNode.Value is not GeographyPoint geographyPoint)
{
ThrowHelper.NotSupportedException();
return default!;
}
var property = PropertyPathVisitor.Visit(functionNode.Parameters.ElementAt(0));
return ClrFilter.Lt(property, new FilterSphere(geographyPoint.Longitude, geographyPoint.Latitude, valueDistance));
}
}

14
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -24,13 +24,13 @@
<PackageReference Include="NodaTime" Version="3.2.0" />
<PackageReference Include="OpenTelemetry.Api" Version="1.9.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets" Version="7.1.4" />
<PackageReference Include="Squidex.Caching" Version="7.1.4" />
<PackageReference Include="Squidex.Events" Version="7.1.4" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="7.1.4" />
<PackageReference Include="Squidex.Log" Version="7.1.4" />
<PackageReference Include="Squidex.Messaging" Version="7.1.4" />
<PackageReference Include="Squidex.Text" Version="7.1.4" />
<PackageReference Include="Squidex.Assets" Version="7.3.0" />
<PackageReference Include="Squidex.Caching" Version="7.3.0" />
<PackageReference Include="Squidex.Events" Version="7.3.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="7.3.0" />
<PackageReference Include="Squidex.Log" Version="7.3.0" />
<PackageReference Include="Squidex.Messaging" Version="7.3.0" />
<PackageReference Include="Squidex.Text" Version="7.3.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />

23
backend/src/Squidex.Infrastructure/Validation/Validate.cs

@ -46,6 +46,29 @@ public static class Validate
throw new ValidationException(errors);
}
}
public static AddValidation WithPrefix(string prefix, AddValidation inner)
{
return (message, propertyNames) =>
{
if (propertyNames.Length == 0)
{
inner(message, propertyNames);
}
var withPrefix = propertyNames.Select(path =>
{
if (!string.IsNullOrWhiteSpace(path))
{
return $"{prefix}.{path}";
}
return path;
}).ToArray();
inner(message, withPrefix);
};
}
}
public delegate void AddValidation(string message, params string[] propertyNames);

14
backend/src/Squidex/Areas/Api/Config/OpenApi/DiscriminatorProcessor.cs

@ -8,8 +8,9 @@
using GraphQL.Utilities;
using NJsonSchema;
using NJsonSchema.Generation;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Infrastructure.Reflection;
using System.Reflection;
using System.Diagnostics;
namespace Squidex.Areas.Api.Config.OpenApi;
@ -22,9 +23,12 @@ public sealed class DiscriminatorProcessor(TypeRegistry typeRegistry) : ISchemaP
return;
}
if (!typeRegistry.TryGetConfig(context.ContextualType.Type, out var config) ||
config.IsEmpty ||
config.DiscriminatorProperty == null)
if (!typeRegistry.TryGetConfig(context.ContextualType.Type, out var config))
{
return;
}
if (config.IsEmpty || config.DiscriminatorProperty == null)
{
return;
}
@ -37,8 +41,6 @@ public sealed class DiscriminatorProcessor(TypeRegistry typeRegistry) : ISchemaP
var schema = context.Schema;
var isObsolete = context.ContextualType.Type.GetCustomAttribute<ObsoleteAttribute>();
foreach (var (derivedType, typeName) in config.DerivedTypes().OrderBy(x => x.TypeName))
{
var derivedSchema = context.Generator.Generate(derivedType, context.Resolver);

2
backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs

@ -16,6 +16,7 @@ using NSwag.Generation.Processors.Contexts;
using Squidex.Areas.Api.Controllers.Rules.Models;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
@ -90,6 +91,7 @@ public static class OpenApiServices
services.AddOpenApiDocument((settings, services) =>
{
ConfigureSchemaSettings(settings.SchemaSettings, services.GetRequiredService<TypeRegistry>(), false);
settings.DocumentProcessors.Add(new AddAdditionalTypeProcessor<EnrichedEvent>());
settings.DocumentProcessors.Add(new AddAdditionalTypeProcessor<DynamicCreateRuleDto>());
settings.DocumentProcessors.Add(new AddAdditionalTypeProcessor<DynamicFlowExecutionStateDto>());
settings.DocumentProcessors.Add(new AddAdditionalTypeProcessor<DynamicRulesDto>());

34
backend/src/Squidex/Areas/Api/Config/OpenApi/SchemaNameGenerator.cs

@ -7,6 +7,7 @@
using NJsonSchema.Generation;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
@ -16,28 +17,31 @@ public sealed class SchemaNameGenerator : DefaultSchemaNameGenerator
{
public override string Generate(Type type)
{
if (type.BaseType == typeof(FlowStep))
{
return $"{type.TypeName(false, "FlowStep")}FlowStepDto";
}
#pragma warning disable CS0618 // Type or member is obsolete
var result =
GenerateTypeName<FlowStep>(type, "FlowStep") ??
GenerateTypeName<EnrichedEvent>(type, "Event") ??
GenerateTypeName<RuleAction>(type, "Action", "RuleAction") ??
base.Generate(type);
#pragma warning restore CS0618 // Type or member is obsolete
if (type == typeof(FlowStep))
{
return $"FlowStepDto";
}
return result;
}
#pragma warning disable CS0618 // Type or member is obsolete
if (type.BaseType == typeof(RuleAction))
private static string? GenerateTypeName<T>(Type type, string suffix, string? newSuffix = null)
{
newSuffix ??= suffix;
if (type == typeof(T))
{
return $"{type.TypeName(false, "Action")}RuleActionDto";
return $"{type.TypeName(false)}Dto";
}
if (type == typeof(RuleAction))
if (type.IsAssignableTo(typeof(T)))
{
return $"RuleActionDto";
return $"{type.TypeName(false, suffix)}{newSuffix}Dto";
}
#pragma warning restore CS0618 // Type or member is obsolete
return base.Generate(type);
return null;
}
}

17
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs

@ -8,10 +8,11 @@
using Squidex.Areas.Api.Controllers.Rules.Models.Triggers;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Infrastructure;
namespace Squidex.Areas.Api.Controllers.Rules.Models.Converters;
public sealed class RuleTriggerDtoFactory : IRuleTriggerVisitor<RuleTriggerDto>
public sealed class RuleTriggerDtoFactory : IRuleTriggerVisitor<RuleTriggerDto, None>
{
private static readonly RuleTriggerDtoFactory Instance = new RuleTriggerDtoFactory();
@ -21,35 +22,35 @@ public sealed class RuleTriggerDtoFactory : IRuleTriggerVisitor<RuleTriggerDto>
public static RuleTriggerDto Create(RuleTrigger properties)
{
return properties.Accept(Instance);
return properties.Accept(Instance, None.Value);
}
public RuleTriggerDto Visit(AssetChangedTriggerV2 trigger)
public RuleTriggerDto Visit(AssetChangedTriggerV2 trigger, None args)
{
return AssetChangedRuleTriggerDto.FromDomain(trigger);
}
public RuleTriggerDto Visit(CommentTrigger trigger)
public RuleTriggerDto Visit(CommentTrigger trigger, None args)
{
return CommentRuleTriggerDto.FromDomain(trigger);
}
public RuleTriggerDto Visit(ManualTrigger trigger)
public RuleTriggerDto Visit(ManualTrigger trigger, None args)
{
return ManualRuleTriggerDto.FromDomain(trigger);
}
public RuleTriggerDto Visit(SchemaChangedTrigger trigger)
public RuleTriggerDto Visit(SchemaChangedTrigger trigger, None args)
{
return SchemaChangedRuleTriggerDto.FromDomain(trigger);
}
public RuleTriggerDto Visit(UsageTrigger trigger)
public RuleTriggerDto Visit(UsageTrigger trigger, None args)
{
return UsageRuleTriggerDto.FromDomain(trigger);
}
public RuleTriggerDto Visit(ContentChangedTriggerV2 trigger)
public RuleTriggerDto Visit(ContentChangedTriggerV2 trigger, None args)
{
return ContentChangedRuleTriggerDto.FromDomain(trigger);
}

8
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs

@ -8,6 +8,7 @@
using NodaTime;
using Squidex.Areas.Api.Controllers.Rules.Models.Converters;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.Runner;
using Squidex.Infrastructure;
@ -143,8 +144,11 @@ public sealed class RuleDto : Resource
if (resources.CanRunRuleEvents)
{
AddPutLink("trigger",
resources.Url<RulesController>(x => nameof(x.TriggerRule), values));
if (rule.Trigger is ManualTrigger)
{
AddPutLink("trigger",
resources.Url<RulesController>(x => nameof(x.TriggerRule), values));
}
if (canRun && ruleRunnerService.CanRunRule(rule))
{

28
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/TriggerRuleDto.cs

@ -0,0 +1,28 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Rules.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Reflection;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Rules.Models;
[OpenApiRequest]
public class TriggerRuleDto
{
/// <summary>
/// The optional value to send to the flow.
/// </summary>
public JsonValue Value { get; set; }
public TriggerRule ToCommand(DomainId id)
{
return SimpleMapper.Map(this, new TriggerRule { RuleId = id });
}
}

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

@ -20,6 +20,7 @@ using Squidex.Flows;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.Validation;
using Squidex.Shared;
using Squidex.Web;
@ -36,6 +37,7 @@ public sealed class RulesController(
IFlowManager<FlowEventContext> flowManager,
IRuleQueryService ruleQuery,
IRuleRunnerService ruleRunnerService,
IRuleValidator ruleValidator,
EventJsonSchemaGenerator eventJsonSchemaGenerator)
: ApiController(commandBus)
{
@ -194,6 +196,7 @@ public sealed class RulesController(
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the rule to disable.</param>
/// <param name="request">The arguments for the rule flow.</param>
/// <response code="204">Rule triggered.</response>
/// <response code="404">Rule or app not found.</response>
[HttpPut]
@ -201,9 +204,9 @@ public sealed class RulesController(
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermissionOrAnonymous(PermissionIds.AppRulesEventsRun)]
[ApiCosts(1)]
public async Task<IActionResult> TriggerRule(string app, DomainId id)
public async Task<IActionResult> TriggerRule(string app, DomainId id, [FromBody] TriggerRuleDto request)
{
var command = new TriggerRule { RuleId = id };
var command = request.ToCommand(id);
await CommandBus.PublishAsync(command, HttpContext.RequestAborted);
@ -247,6 +250,54 @@ public sealed class RulesController(
return NoContent();
}
/// <summary>
/// Validates a rule trigger.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The rule trigger that needs to be validate.</param>
/// <response code="204">Rule trigger validated.</response>
/// <response code="400">Rule trigger not valid.</response>
/// <response code="404">Rule or app not found.</response>
[HttpPost]
[Route("apps/{app}/rules/validate/trigger/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermission]
[ApiCosts(0)]
public async Task<IActionResult> ValidateTrigger(string app, [FromBody] RuleTriggerDto request)
{
await Validate.It(async e =>
{
await ruleValidator.ValidateTriggerAsync(request.ToTrigger(), AppId, e,
HttpContext.RequestAborted);
});
return NoContent();
}
/// <summary>
/// Validates a rule step.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The rule step that needs to be validate.</param>
/// <response code="204">Rule step validated.</response>
/// <response code="400">Rule step not valid.</response>
/// <response code="404">Rule or app not found.</response>
[HttpPost]
[Route("apps/{app}/rules/validate/step/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermission]
[ApiCosts(0)]
public async Task<IActionResult> ValidateStep(string app, [FromBody] FlowStep request)
{
await Validate.It(async e =>
{
await ruleValidator.ValidateStepAsync(request, e,
HttpContext.RequestAborted);
});
return NoContent();
}
/// <summary>
/// Simulate a rule.
/// </summary>
@ -290,9 +341,8 @@ public sealed class RulesController(
return NotFound();
}
var simulation = await ruleRunnerService.SimulateAsync(rule, HttpContext.RequestAborted);
var response = SimulatedRuleEventsDto.FromDomain(simulation);
var result = await ruleRunnerService.SimulateAsync(rule, HttpContext.RequestAborted);
var response = SimulatedRuleEventsDto.FromDomain(result);
return Ok(response);
}
@ -334,7 +384,9 @@ public sealed class RulesController(
[ApiCosts(0)]
public async Task<IActionResult> GetEvents(string app, [FromQuery] DomainId? ruleId = null, [FromQuery] int skip = 0, [FromQuery] int take = 20)
{
var (states, total) = await flowManager.QueryInstancesByOwnerAsync(AppId.ToString(), ruleId?.ToString(), skip, take, HttpContext.RequestAborted);
var (states, total) =
await flowManager.QueryInstancesByOwnerAsync(AppId.ToString(), ruleId?.ToString(), skip, take,
HttpContext.RequestAborted);
var response = RuleEventsDto.FromDomain(ResultList.Create(total, states), Resources, ruleId);
@ -373,7 +425,7 @@ public sealed class RulesController(
[HttpDelete]
[Route("apps/{app}/rules/events/{id}/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermissionOrAnonymous(PermissionIds.AppRulesEventsDelete)]
[ApiPermission]
[ApiCosts(0)]
public async Task<IActionResult> DeleteEvent(string app, Guid id)
{

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

@ -75,6 +75,9 @@ public static class RuleServices
services.AddSingletonAs<RuleEnricher>()
.As<IRuleEnricher>();
services.AddSingletonAs<RuleValidator>()
.As<IRuleValidator>();
services.AddSingletonAs<RuleEnqueuer>()
.As<IRuleEnqueuer>().As<IEventConsumer>();

2
backend/src/Squidex/Config/Domain/SerializationServices.cs

@ -112,7 +112,7 @@ public static class SerializationServices
new AssemblyTypeProvider<RuleTriggerDto>("triggerType"));
services.AddSingleton<ITypeProvider>(
new AssemblyTypeProvider<EnrichedEvent>());
new AssemblyTypeProvider<EnrichedEvent>("$type"));
services.AddSingletonAs<FieldTypeProvider>()
.As<ITypeProvider>();

26
backend/src/Squidex/Squidex.csproj

@ -59,17 +59,17 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.9.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="5.4.1" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets.Azure" Version="7.1.4" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="7.1.4" />
<PackageReference Include="Squidex.Assets.FTP" Version="7.1.4" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="7.1.4" />
<PackageReference Include="Squidex.Assets.S3" Version="7.1.4" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="7.1.4" />
<PackageReference Include="Squidex.ClientLibrary" Version="20.1.0" />
<PackageReference Include="Squidex.Events.GetEventStore" Version="7.1.4" />
<PackageReference Include="Squidex.Hosting" Version="7.1.4" />
<PackageReference Include="Squidex.Messaging.All" Version="7.1.4" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="7.1.4" />
<PackageReference Include="Squidex.Assets.Azure" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.FTP" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.S3" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="7.3.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="21.5.0" />
<PackageReference Include="Squidex.Events.GetEventStore" Version="7.3.0" />
<PackageReference Include="Squidex.Hosting" Version="7.3.0" />
<PackageReference Include="Squidex.Messaging.All" Version="7.3.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="7.3.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="YDotNet" Version="0.4.3" />
<PackageReference Include="YDotNet.Native" Version="0.4.3" />
@ -83,11 +83,11 @@
</ItemGroup>
<ItemGroup Condition="'$(IncludeMagick)' == 'true'">
<PackageReference Include="Squidex.Assets.ImageMagick" Version="7.1.4" />
<PackageReference Include="Squidex.Assets.ImageMagick" Version="7.3.0" />
</ItemGroup>
<ItemGroup Condition="'$(IncludeKafka)' == 'true'">
<PackageReference Include="Squidex.Messaging.Kafka" Version="7.1.4" />
<PackageReference Include="Squidex.Messaging.Kafka" Version="7.3.0" />
</ItemGroup>
<PropertyGroup>

4
backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Rules/RuleTests.cs

@ -31,7 +31,7 @@ public class RuleTests
public sealed record OtherTrigger : RuleTrigger
{
public override T Accept<T>(IRuleTriggerVisitor<T> visitor)
public override T Accept<T, TArgs>(IRuleTriggerVisitor<T, TArgs> visitor, TArgs args)
{
throw new NotSupportedException();
}
@ -39,7 +39,7 @@ public class RuleTests
public sealed record MigratedTrigger : RuleTrigger, IMigrated<RuleTrigger>
{
public override T Accept<T>(IRuleTriggerVisitor<T> visitor)
public override T Accept<T, TArgs>(IRuleTriggerVisitor<T, TArgs> visitor, TArgs args)
{
throw new NotSupportedException();
}

51
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs

@ -280,6 +280,30 @@ public class RuleEventFormatterTests
Assert.Equal("Created", actual);
}
[Fact]
public void Should_evaluate_expression()
{
var @event = new EnrichedManualEvent { Value = 1, AppId = appId };
var actual1 = sut.Evaluate("event.value == 1", @event);
var actual2 = sut.Evaluate("event.value == 2", @event);
Assert.True(actual1);
Assert.False(actual2);
}
[Fact]
public void Should_evaluate_expression_with_context()
{
var context = new FlowEventContext { Event = new EnrichedManualEvent { Value = 1, AppId = appId } };
var actual1 = sut.Evaluate("event.value == 1", context);
var actual2 = sut.Evaluate("event.value == 2", context);
Assert.True(actual1);
Assert.False(actual2);
}
[Fact]
public async Task Should_return_json_string_if_array()
{
@ -293,16 +317,25 @@ public class RuleEventFormatterTests
.AddInvariant(JsonValue.Array("ref1", "ref2", "ref3"))),
};
var script = @"
Script(JSON.stringify(
{
'categories': event.data.categories.iv
}))";
var actual = await sut.RenderAsync(@"Script(JSON.stringify({ categories: event.data.categories.iv }))", @event);
var actual = await sut.RenderAsync(script, @event);
Assert.Equal("{'categories':['ref1','ref2','ref3']}",
actual?
.Replace(" ", string.Empty, StringComparison.Ordinal)
.Replace('"', '\''));
}
[Fact]
public async Task Should_add_to_context()
{
var context = new FlowEventContext
{
Event = new EnrichedManualEvent { Value = 1, AppId = appId },
UserData = [],
};
await sut.RenderAsync(@"Script(userData['myValue'] = '42')", context);
Assert.Equal("{'categories':['ref1','ref2','ref3']}", actual?
.Replace(" ", string.Empty, StringComparison.Ordinal)
.Replace('"', '\''));
Assert.Equal("42", context.UserData["myValue"]);
}
}

4
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs

@ -35,9 +35,9 @@ public class RuleServiceTests
public sealed record InvalidTrigger : RuleTrigger
{
public override T Accept<T>(IRuleTriggerVisitor<T> visitor)
public override T Accept<T, TArgs>(IRuleTriggerVisitor<T, TArgs> visitor, TArgs args)
{
return default!;
throw new NotSupportedException();
}
}

29
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/GuardRuleTests.cs

@ -8,6 +8,7 @@
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Rules.Commands;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Flows;
@ -19,9 +20,15 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject.Guards;
public class GuardRuleTests : GivenContext, IClassFixture<TranslationsFixture>
{
private readonly IFlowManager<FlowEventContext> flowManager = A.Fake<IFlowManager<FlowEventContext>>();
private readonly IRuleValidator validator;
public GuardRuleTests()
{
validator = new RuleValidator(flowManager, AppProvider);
}
[Fact]
public async Task CanCreate_should_throw_exception_if_trigger_null()
public async Task CanCreate_should_throw_exception_if_trigger_is_null()
{
var command = CreateCommand(new CreateRule
{
@ -32,12 +39,12 @@ public class GuardRuleTests : GivenContext, IClassFixture<TranslationsFixture>
Trigger = null!,
});
await ValidationAssert.ThrowsAsync(() => GuardRule.CanCreate(command, AppProvider, flowManager, CancellationToken),
await ValidationAssert.ThrowsAsync(() => GuardRule.CanCreate(command, validator, CancellationToken),
new ValidationError("Trigger is required.", "Trigger"));
}
[Fact]
public async Task CanCreate_should_throw_exception_if_flow_null()
public async Task CanCreate_should_throw_exception_if_flow_is_null()
{
var command = CreateCommand(new CreateRule
{
@ -48,7 +55,7 @@ public class GuardRuleTests : GivenContext, IClassFixture<TranslationsFixture>
Flow = null!,
});
await ValidationAssert.ThrowsAsync(() => GuardRule.CanCreate(command, AppProvider, flowManager, CancellationToken),
await ValidationAssert.ThrowsAsync(() => GuardRule.CanCreate(command, validator, CancellationToken),
new ValidationError("Flow is required.", "Flow"));
}
@ -67,7 +74,7 @@ public class GuardRuleTests : GivenContext, IClassFixture<TranslationsFixture>
},
});
await GuardRule.CanCreate(command, AppProvider, flowManager, CancellationToken);
await GuardRule.CanCreate(command, validator, CancellationToken);
}
[Fact]
@ -75,7 +82,7 @@ public class GuardRuleTests : GivenContext, IClassFixture<TranslationsFixture>
{
var command = new UpdateRule();
await GuardRule.CanUpdate(command, CreateRule(), AppProvider, flowManager, CancellationToken);
await GuardRule.CanUpdate(command, CreateRule(), validator, CancellationToken);
}
[Fact]
@ -83,13 +90,13 @@ public class GuardRuleTests : GivenContext, IClassFixture<TranslationsFixture>
{
var command = new UpdateRule { Name = "MyName" };
await GuardRule.CanUpdate(command, CreateRule(), AppProvider, flowManager, CancellationToken);
await GuardRule.CanUpdate(command, CreateRule(), validator, CancellationToken);
}
[Fact]
public async Task CanUpdate_should_not_throw_exception_if_trigger_flow_and_name_are_valid()
{
var command = new UpdateRule
var command = CreateCommand(new UpdateRule
{
Trigger = new ContentChangedTriggerV2
{
@ -100,12 +107,12 @@ public class GuardRuleTests : GivenContext, IClassFixture<TranslationsFixture>
InitialStepId = Guid.NewGuid(),
},
Name = "NewName",
};
});
await GuardRule.CanUpdate(command, CreateRule(), AppProvider, flowManager, CancellationToken);
await GuardRule.CanUpdate(command, CreateRule(), validator, CancellationToken);
}
private CreateRule CreateCommand(CreateRule command)
private T CreateCommand<T>(T command) where T : IAppCommand
{
command.AppId = AppId;

26
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/ContentChangedTriggerTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.TestHelpers;
@ -17,6 +18,13 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject.Guards.Triggers;
public class ContentChangedTriggerTests : GivenContext, IClassFixture<TranslationsFixture>
{
private readonly RuleValidator validator;
public ContentChangedTriggerTests()
{
validator = new RuleValidator(null!, AppProvider);
}
[Fact]
public async Task Should_add_error_if_schema_id_is_not_defined()
{
@ -25,7 +33,7 @@ public class ContentChangedTriggerTests : GivenContext, IClassFixture<Translatio
Schemas = ReadonlyList.Create(new SchemaCondition()),
};
var errors = await RuleTriggerValidator.ValidateAsync(AppId.Id, trigger, AppProvider, CancellationToken);
var errors = await ValidateAsync(trigger);
errors.Should().BeEquivalentTo(
[
@ -47,7 +55,7 @@ public class ContentChangedTriggerTests : GivenContext, IClassFixture<Translatio
Schemas = ReadonlyList.Create(new SchemaCondition { SchemaId = SchemaId.Id }),
};
var errors = await RuleTriggerValidator.ValidateAsync(AppId.Id, trigger, AppProvider, CancellationToken);
var errors = await ValidateAsync(trigger);
errors.Should().BeEquivalentTo(
[
@ -60,7 +68,7 @@ public class ContentChangedTriggerTests : GivenContext, IClassFixture<Translatio
{
var trigger = new ContentChangedTriggerV2();
var errors = await RuleTriggerValidator.ValidateAsync(AppId.Id, trigger, AppProvider, CancellationToken);
var errors = await ValidateAsync(trigger);
Assert.Empty(errors);
}
@ -73,7 +81,7 @@ public class ContentChangedTriggerTests : GivenContext, IClassFixture<Translatio
Schemas = [],
};
var errors = await RuleTriggerValidator.ValidateAsync(AppId.Id, trigger, AppProvider, CancellationToken);
var errors = await ValidateAsync(trigger);
Assert.Empty(errors);
}
@ -89,8 +97,16 @@ public class ContentChangedTriggerTests : GivenContext, IClassFixture<Translatio
Schemas = ReadonlyList.Create(new SchemaCondition { SchemaId = SchemaId.Id }),
};
var errors = await RuleTriggerValidator.ValidateAsync(AppId.Id, trigger, AppProvider, CancellationToken);
var errors = await ValidateAsync(trigger);
Assert.Empty(errors);
}
private async Task<List<ValidationError>> ValidateAsync(RuleTrigger trigger)
{
var errors = new List<ValidationError>();
await validator.ValidateTriggerAsync(trigger, AppId.Id, (m, p) => errors.Add(new ValidationError(m, p)), CancellationToken);
return errors;
}
}

24
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/UsageTriggerValidationTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.TestHelpers;
@ -14,12 +15,19 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject.Guards.Triggers;
public class UsageTriggerValidationTests : GivenContext, IClassFixture<TranslationsFixture>
{
private readonly IRuleValidator validator;
public UsageTriggerValidationTests()
{
validator = new RuleValidator(null!, AppProvider);
}
[Fact]
public async Task Should_add_error_if_num_days_less_than_1()
{
var trigger = new UsageTrigger { NumDays = 0 };
var errors = await RuleTriggerValidator.ValidateAsync(AppId.Id, trigger, AppProvider, CancellationToken);
var errors = await ValidateAsync(trigger);
errors.Should().BeEquivalentTo(
[
@ -32,7 +40,7 @@ public class UsageTriggerValidationTests : GivenContext, IClassFixture<Translati
{
var trigger = new UsageTrigger { NumDays = 32 };
var errors = await RuleTriggerValidator.ValidateAsync(AppId.Id, trigger, AppProvider, CancellationToken);
var errors = await ValidateAsync(trigger);
errors.Should().BeEquivalentTo(
[
@ -45,7 +53,7 @@ public class UsageTriggerValidationTests : GivenContext, IClassFixture<Translati
{
var trigger = new UsageTrigger { NumDays = 20 };
var errors = await RuleTriggerValidator.ValidateAsync(AppId.Id, trigger, AppProvider, CancellationToken);
var errors = await ValidateAsync(trigger);
Assert.Empty(errors);
}
@ -55,8 +63,16 @@ public class UsageTriggerValidationTests : GivenContext, IClassFixture<Translati
{
var trigger = new UsageTrigger();
var errors = await RuleTriggerValidator.ValidateAsync(AppId.Id, trigger, AppProvider, CancellationToken);
var errors = await ValidateAsync(trigger);
Assert.Empty(errors);
}
private async Task<List<ValidationError>> ValidateAsync(RuleTrigger trigger)
{
var errors = new List<ValidationError>();
await validator.ValidateTriggerAsync(trigger, AppId.Id, (m, p) => errors.Add(new ValidationError(m, p)), CancellationToken);
return errors;
}
}

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/RuleDomainObjectTests.cs

@ -7,13 +7,11 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Entities.Rules.Commands;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Domain.Apps.Events.Rules;
using Squidex.Flows;
using Squidex.Flows.Internal;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
@ -24,7 +22,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject;
public class RuleDomainObjectTests : HandlerTestBase<Rule>
{
private readonly IRuleEnqueuer ruleEnqueuer = A.Fake<IRuleEnqueuer>();
private readonly IFlowManager<FlowEventContext> flowManager = A.Fake<IFlowManager<FlowEventContext>>();
private readonly IRuleValidator ruleValidator = A.Fake<IRuleValidator>();
private readonly DomainId ruleId = DomainId.NewGuid();
private readonly RuleDomainObject sut;
@ -40,7 +38,7 @@ public class RuleDomainObjectTests : HandlerTestBase<Rule>
var serviceProvider =
new ServiceCollection()
.AddSingleton(AppProvider)
.AddSingleton(flowManager)
.AddSingleton(ruleValidator)
.AddSingleton(ruleEnqueuer)
.BuildServiceProvider();

106
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleValidatorTests.cs

@ -0,0 +1,106 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Flows;
using Squidex.Flows.Internal;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Rules;
public class RuleValidatorTests : GivenContext, IClassFixture<TranslationsFixture>
{
private readonly IFlowManager<FlowEventContext> flowManager = A.Fake<IFlowManager<FlowEventContext>>();
private readonly RuleValidator sut;
private sealed record TestFlowStep : FlowStep
{
public override ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
return default;
}
}
public RuleValidatorTests()
{
sut = new RuleValidator(flowManager, AppProvider);
}
[Fact]
public async Task Should_validate_trigger()
{
var trigger = new UsageTrigger { NumDays = 50 };
var errors = new List<ValidationError>();
await sut.ValidateTriggerAsync(
trigger,
AppId.Id,
(message, properties) => errors.Add(new ValidationError(message, properties)),
CancellationToken);
errors.Should().BeEquivalentTo(
[
new ValidationError("Num days must be between 1 and 30.", "NumDays"),
]);
}
[Fact]
public async Task Should_validate_step()
{
var step = new TestFlowStep();
A.CallTo(() => flowManager.ValidateAsync(step, A<AddError>._, CancellationToken))
.Invokes(x =>
{
x.GetArgument<AddError>(1)?.Invoke("Path", ValidationErrorType.InvalidProperty, "Invalid property.");
});
var errors = new List<ValidationError>();
await sut.ValidateStepAsync(
step,
(message, properties) => errors.Add(new ValidationError(message, properties)),
CancellationToken);
errors.Should().BeEquivalentTo(
[
new ValidationError("Invalid property.", "Path"),
]);
}
[Theory]
[InlineData(ValidationErrorType.NoSteps, "Flow has no step")]
[InlineData(ValidationErrorType.NoStartStep, "Flow has no start step")]
[InlineData(ValidationErrorType.InvalidNextStepId, "Invalid next step ID")]
[InlineData(ValidationErrorType.InvalidStepId, "Invalid step ID")]
public async Task Should_validate_flow(ValidationErrorType type, string expectedMessage)
{
var flow = new FlowDefinition();
A.CallTo(() => flowManager.ValidateAsync(flow, A<AddError>._, CancellationToken))
.Invokes(x =>
{
x.GetArgument<AddError>(1)?.Invoke("Path", type);
});
var errors = new List<ValidationError>();
await sut.ValidateFlowAsync(flow,
(message, properties) => errors.Add(new ValidationError(message, properties)),
CancellationToken);
errors.Should().BeEquivalentTo(
[
new ValidationError(expectedMessage, "Path"),
]);
}
}

260
backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromODataTests.cs

@ -79,8 +79,8 @@ public class QueryFromODataTests
[InlineData("properties/nested/datetime")]
public void Should_parse_filter_if_type_is_datetime(string field)
{
var i = _Q($"$filter={field} eq 1988-01-19T12:00:00Z");
var o = _C($"Filter: {field} == 1988-01-19T12:00:00Z");
var i = _o($"$filter={field} eq 1988-01-19T12:00:00Z");
var o = _q($"Filter: {field} == 1988-01-19T12:00:00Z");
Assert.Equal(o, i);
}
@ -92,8 +92,8 @@ public class QueryFromODataTests
[InlineData("properties/nested/datetime")]
public void Should_parse_filter_if_type_is_datetime_and_value_is_null(string field)
{
var i = _Q($"$filter={field} eq null");
var o = _C($"Filter: {field} == null");
var i = _o($"$filter={field} eq null");
var o = _q($"Filter: {field} == null");
Assert.Equal(o, i);
}
@ -101,8 +101,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_if_type_is_datetime_list()
{
var i = _Q("$filter=created in ('1988-01-19T12:00:00Z')");
var o = _C("Filter: created in [1988-01-19T12:00:00Z]");
var i = _o("$filter=created in ('1988-01-19T12:00:00Z')");
var o = _q("Filter: created in [1988-01-19T12:00:00Z]");
Assert.Equal(o, i);
}
@ -110,8 +110,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_if_type_is_datetime_and_and_value_is_date()
{
var i = _Q("$filter=created eq 1988-01-19");
var o = _C("Filter: created == 1988-01-19T00:00:00Z");
var i = _o("$filter=created eq 1988-01-19");
var o = _q("Filter: created == 1988-01-19T00:00:00Z");
Assert.Equal(o, i);
}
@ -123,8 +123,8 @@ public class QueryFromODataTests
[InlineData("properties/nested/guid")]
public void Should_parse_filter_if_type_is_guid(string field)
{
var i = _Q($"$filter={field} eq B5FE25E3-B262-4B17-91EF-B3772A6B62BB");
var o = _C($"Filter: {field} == b5fe25e3-b262-4b17-91ef-b3772a6b62bb");
var i = _o($"$filter={field} eq B5FE25E3-B262-4B17-91EF-B3772A6B62BB");
var o = _q($"Filter: {field} == b5fe25e3-b262-4b17-91ef-b3772a6b62bb");
Assert.Equal(o, i);
}
@ -136,8 +136,8 @@ public class QueryFromODataTests
[InlineData("properties/nested/guid")]
public void Should_parse_filter_if_type_is_guid_and_value_is_null(string field)
{
var i = _Q($"$filter={field} eq null");
var o = _C($"Filter: {field} == null");
var i = _o($"$filter={field} eq null");
var o = _q($"Filter: {field} == null");
Assert.Equal(o, i);
}
@ -145,8 +145,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_if_type_is_guid_list()
{
var i = _Q("$filter=id in ('B5FE25E3-B262-4B17-91EF-B3772A6B62BB')");
var o = _C("Filter: id in [b5fe25e3-b262-4b17-91ef-b3772a6b62bb]");
var i = _o("$filter=id in ('B5FE25E3-B262-4B17-91EF-B3772A6B62BB')");
var o = _q("Filter: id in [b5fe25e3-b262-4b17-91ef-b3772a6b62bb]");
Assert.Equal(o, i);
}
@ -154,8 +154,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_if_type_is_null()
{
var i = _Q("$filter=firstName eq null");
var o = _C("Filter: firstName == null");
var i = _o("$filter=firstName eq null");
var o = _q("Filter: firstName == null");
Assert.Equal(o, i);
}
@ -167,8 +167,8 @@ public class QueryFromODataTests
[InlineData("properties/nested/string")]
public void Should_parse_filter_if_type_is_string(string field)
{
var i = _Q($"$filter={field} eq 'Dagobert'");
var o = _C($"Filter: {field} == 'Dagobert'");
var i = _o($"$filter={field} eq 'Dagobert'");
var o = _q($"Filter: {field} == 'Dagobert'");
Assert.Equal(o, i);
}
@ -176,8 +176,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_if_type_is_string_list()
{
var i = _Q("$filter=firstName in ('Dagobert')");
var o = _C("Filter: firstName in ['Dagobert']");
var i = _o("$filter=firstName in ('Dagobert')");
var o = _q("Filter: firstName in ['Dagobert']");
Assert.Equal(o, i);
}
@ -189,8 +189,8 @@ public class QueryFromODataTests
[InlineData("properties/nested/boolean")]
public void Should_parse_filter_if_type_is_boolean(string field)
{
var i = _Q($"$filter={field} eq true");
var o = _C($"Filter: {field} == True");
var i = _o($"$filter={field} eq true");
var o = _q($"Filter: {field} == True");
Assert.Equal(o, i);
}
@ -202,8 +202,8 @@ public class QueryFromODataTests
[InlineData("properties/nested/boolean")]
public void Should_parse_filter_if_type_is_boolean_and_value_is_null(string field)
{
var i = _Q($"$filter={field} eq null");
var o = _C($"Filter: {field} == null");
var i = _o($"$filter={field} eq null");
var o = _q($"Filter: {field} == null");
Assert.Equal(o, i);
}
@ -211,8 +211,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_if_type_is_boolean_list()
{
var i = _Q("$filter=isComicFigure in (true)");
var o = _C("Filter: isComicFigure in [True]");
var i = _o("$filter=isComicFigure in (true)");
var o = _q("Filter: isComicFigure in [True]");
Assert.Equal(o, i);
}
@ -224,8 +224,8 @@ public class QueryFromODataTests
[InlineData("properties/nested/double")]
public void Should_parse_filter_if_type_is_double(string field)
{
var i = _Q($"$filter={field} eq 5634474356.1233");
var o = _C($"Filter: {field} == 5634474356.1233");
var i = _o($"$filter={field} eq 5634474356.1233");
var o = _q($"Filter: {field} == 5634474356.1233");
Assert.Equal(o, i);
}
@ -237,8 +237,8 @@ public class QueryFromODataTests
[InlineData("properties/nested/geo")]
public void Should_parse_filter_if_type_is_geograph(string field)
{
var i = _Q($"$filter=geo.distance({field}, geography'POINT(10 20)') lt 30.0");
var o = _C($"Filter: {field} < Radius(10, 20, 30)");
var i = _o($"$filter=geo.distance({field}, geography'POINT(10 20)') lt 30.0");
var o = _q($"Filter: {field} < Radius(10, 20, 30)");
Assert.Equal(o, i);
}
@ -246,8 +246,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_if_type_is_double_list()
{
var i = _Q("$filter=incomeMio in (5634474356.1233)");
var o = _C("Filter: incomeMio in [5634474356.1233]");
var i = _o("$filter=incomeMio in (5634474356.1233)");
var o = _q("Filter: incomeMio in [5634474356.1233]");
Assert.Equal(o, i);
}
@ -255,8 +255,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_negation()
{
var i = _Q("$filter=not endswith(lastName, 'Duck')");
var o = _C("Filter: !(endsWith(lastName, 'Duck'))");
var i = _o("$filter=not endswith(lastName, 'Duck')");
var o = _q("Filter: !(endsWith(lastName, 'Duck'))");
Assert.Equal(o, i);
}
@ -264,8 +264,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_startswith()
{
var i = _Q("$filter=startswith(lastName, 'Duck')");
var o = _C("Filter: startsWith(lastName, 'Duck')");
var i = _o("$filter=startswith(lastName, 'Duck')");
var o = _q("Filter: startsWith(lastName, 'Duck')");
Assert.Equal(o, i);
}
@ -273,8 +273,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_endswith()
{
var i = _Q("$filter=endswith(lastName, 'Duck')");
var o = _C("Filter: endsWith(lastName, 'Duck')");
var i = _o("$filter=endswith(lastName, 'Duck')");
var o = _q("Filter: endsWith(lastName, 'Duck')");
Assert.Equal(o, i);
}
@ -282,8 +282,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_matchs()
{
var i = _Q("$filter=matchs(lastName, 'Duck')");
var o = _C("Filter: matchs(lastName, 'Duck')");
var i = _o("$filter=matchs(lastName, 'Duck')");
var o = _q("Filter: matchs(lastName, 'Duck')");
Assert.Equal(o, i);
}
@ -291,8 +291,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_empty()
{
var i = _Q("$filter=empty(lastName)");
var o = _C("Filter: empty(lastName)");
var i = _o("$filter=empty(lastName)");
var o = _q("Filter: empty(lastName)");
Assert.Equal(o, i);
}
@ -300,8 +300,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_empty_to_true()
{
var i = _Q("$filter=empty(lastName) eq true");
var o = _C("Filter: empty(lastName)");
var i = _o("$filter=empty(lastName) eq true");
var o = _q("Filter: empty(lastName)");
Assert.Equal(o, i);
}
@ -309,8 +309,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_exists()
{
var i = _Q("$filter=exists(lastName)");
var o = _C("Filter: exists(lastName)");
var i = _o("$filter=exists(lastName)");
var o = _q("Filter: exists(lastName)");
Assert.Equal(o, i);
}
@ -318,8 +318,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_exists_to_true()
{
var i = _Q("$filter=exists(lastName) eq true");
var o = _C("Filter: exists(lastName)");
var i = _o("$filter=exists(lastName) eq true");
var o = _q("Filter: exists(lastName)");
Assert.Equal(o, i);
}
@ -327,8 +327,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_exists_to_false()
{
var i = _Q("$filter=exists(lastName) eq false");
var o = _C("Filter: !(exists(lastName))");
var i = _o("$filter=exists(lastName) eq false");
var o = _q("Filter: !(exists(lastName))");
Assert.Equal(o, i);
}
@ -336,8 +336,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_contains()
{
var i = _Q("$filter=contains(lastName, 'Duck')");
var o = _C("Filter: contains(lastName, 'Duck')");
var i = _o("$filter=contains(lastName, 'Duck')");
var o = _q("Filter: contains(lastName, 'Duck')");
Assert.Equal(o, i);
}
@ -345,8 +345,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_contains_to_true()
{
var i = _Q("$filter=contains(lastName, 'Duck') eq true");
var o = _C("Filter: contains(lastName, 'Duck')");
var i = _o("$filter=contains(lastName, 'Duck') eq true");
var o = _q("Filter: contains(lastName, 'Duck')");
Assert.Equal(o, i);
}
@ -354,8 +354,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_contains_to_false()
{
var i = _Q("$filter=contains(lastName, 'Duck') eq false");
var o = _C("Filter: !(contains(lastName, 'Duck'))");
var i = _o("$filter=contains(lastName, 'Duck') eq false");
var o = _q("Filter: !(contains(lastName, 'Duck'))");
Assert.Equal(o, i);
}
@ -363,8 +363,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_equals()
{
var i = _Q("$filter=age eq 1");
var o = _C("Filter: age == 1");
var i = _o("$filter=age eq 1");
var o = _q("Filter: age == 1");
Assert.Equal(o, i);
}
@ -372,8 +372,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_notequals()
{
var i = _Q("$filter=age ne 1");
var o = _C("Filter: age != 1");
var i = _o("$filter=age ne 1");
var o = _q("Filter: age != 1");
Assert.Equal(o, i);
}
@ -381,8 +381,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_lessthan()
{
var i = _Q("$filter=age lt 1");
var o = _C("Filter: age < 1");
var i = _o("$filter=age lt 1");
var o = _q("Filter: age < 1");
Assert.Equal(o, i);
}
@ -390,8 +390,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_lessthanorequal()
{
var i = _Q("$filter=age le 1");
var o = _C("Filter: age <= 1");
var i = _o("$filter=age le 1");
var o = _q("Filter: age <= 1");
Assert.Equal(o, i);
}
@ -399,8 +399,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_greaterthan()
{
var i = _Q("$filter=age gt 1");
var o = _C("Filter: age > 1");
var i = _o("$filter=age gt 1");
var o = _q("Filter: age > 1");
Assert.Equal(o, i);
}
@ -408,8 +408,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_greaterthanorequal()
{
var i = _Q("$filter=age ge 1");
var o = _C("Filter: age >= 1");
var i = _o("$filter=age ge 1");
var o = _q("Filter: age >= 1");
Assert.Equal(o, i);
}
@ -417,8 +417,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_conjunction_and_contains()
{
var i = _Q("$filter=contains(firstName, 'Sebastian') eq false and isComicFigure eq true");
var o = _C("Filter: (!(contains(firstName, 'Sebastian')) && isComicFigure == True)");
var i = _o("$filter=contains(firstName, 'Sebastian') eq false and isComicFigure eq true");
var o = _q("Filter: (!(contains(firstName, 'Sebastian')) && isComicFigure == True)");
Assert.Equal(o, i);
}
@ -426,8 +426,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_conjunction()
{
var i = _Q("$filter=age eq 1 and age eq 2");
var o = _C("Filter: (age == 1 && age == 2)");
var i = _o("$filter=age eq 1 and age eq 2");
var o = _q("Filter: (age == 1 && age == 2)");
Assert.Equal(o, i);
}
@ -435,17 +435,83 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_filter_with_disjunction()
{
var i = _Q("$filter=age eq 1 or age eq 2");
var o = _C("Filter: (age == 1 || age == 2)");
var i = _o("$filter=age eq 1 or age eq 2");
var o = _q("Filter: (age == 1 || age == 2)");
Assert.Equal(o, i);
}
[Fact]
public void Should_compare_constant_to_lowercase()
{
var i = _o("$filter=firstName eq tolower('DONALD')");
var o = _q("Filter: firstName == 'donald'");
Assert.Equal(o, i);
}
[Fact]
public void Should_compare_constant_to_uppercase()
{
var i = _o("$filter=firstName eq toupper('donald')");
var o = _q("Filter: firstName == 'DONALD'");
Assert.Equal(o, i);
}
[Fact]
public void Should_convert_lowercase_eq_comparison_to_regex()
{
var i = _o("$filter=tolower(firstName) eq tolower('DONALD.*42')");
var o = _q("Filter: matchs(firstName, '//^donald\\.\\*42$//i')");
Assert.Equal(o, i);
}
[Fact]
public void Should_convert_lowercase_ne_comparison_to_regex()
{
var i = _o("$filter=tolower(firstName) ne tolower('DONALD.*42')");
var o = _q("Filter: !(matchs(firstName, '//^donald\\.\\*42$//i'))");
Assert.Equal(o, i);
}
[Fact]
public void Should_convert_uppercase_eq_comparison_to_regex()
{
var i = _o("$filter=toupper(firstName) eq toupper('donald.*42')");
var o = _q("Filter: matchs(firstName, '//^DONALD\\.\\*42$//i')");
Assert.Equal(o, i);
}
[Fact]
public void Should_convert_uppercase_ne_comparison_to_regex()
{
var i = _o("$filter=toupper(firstName) ne toupper('donald.*42')");
var o = _q("Filter: !(matchs(firstName, '//^DONALD\\.\\*42$//i'))");
Assert.Equal(o, i);
}
[Fact]
public void Should_not_convert_lowercase_to_regex_if_right_side_is_mixed()
{
Assert.Throws<NotSupportedException>(() => _o("$filter=tolower(firstName) ne 'Donald.*42'"));
}
[Fact]
public void Should_not_convert_uppercase_to_regex_if_right_side_is_mixed()
{
Assert.Throws<NotSupportedException>(() => _o("$filter=toupper(firstName) ne 'Donald.*42'"));
}
[Fact]
public void Should_full_text()
{
var i = _Q("$search=Duck");
var o = _C("FullText: 'Duck'");
var i = _o("$search=Duck");
var o = _q("FullText: 'Duck'");
Assert.Equal(o, i);
}
@ -453,8 +519,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_collation1()
{
var i = _Q("collation=Collation");
var o = _C("Collation: 'Collation'");
var i = _o("collation=Collation");
var o = _q("Collation: 'Collation'");
Assert.Equal(o, i);
}
@ -462,8 +528,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_collation2()
{
var i = _Q("$collation=Collation");
var o = _C("Collation: 'Collation'");
var i = _o("$collation=Collation");
var o = _q("Collation: 'Collation'");
Assert.Equal(o, i);
}
@ -471,8 +537,8 @@ public class QueryFromODataTests
[Fact]
public void Should_text_and_multiple_terms()
{
var i = _Q("$search=Dagobert or Donald");
var o = _C("FullText: 'Dagobert or Donald'");
var i = _o("$search=Dagobert or Donald");
var o = _q("FullText: 'Dagobert or Donald'");
Assert.Equal(o, i);
}
@ -480,8 +546,8 @@ public class QueryFromODataTests
[Fact]
public void Should_full_text_numbers()
{
var i = _Q("$search=\"33k\"");
var o = _C("FullText: '33k'");
var i = _o("$search=\"33k\"");
var o = _q("FullText: '33k'");
Assert.Equal(o, i);
}
@ -489,8 +555,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_orderby()
{
var i = _Q("$orderby=age desc");
var o = _C("Sort: age Descending");
var i = _o("$orderby=age desc");
var o = _q("Sort: age Descending");
Assert.Equal(o, i);
}
@ -498,8 +564,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_orderby_with_multiple_field()
{
var i = _Q("$orderby=age, incomeMio desc");
var o = _C("Sort: age Ascending, incomeMio Descending");
var i = _o("$orderby=age, incomeMio desc");
var o = _q("Sort: age Ascending, incomeMio Descending");
Assert.Equal(o, i);
}
@ -507,8 +573,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_top()
{
var i = _Q("$top=3");
var o = _C("Take: 3");
var i = _o("$top=3");
var o = _q("Take: 3");
Assert.Equal(o, i);
}
@ -516,8 +582,8 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_skip()
{
var i = _Q("$skip=4");
var o = _C("Skip: 4");
var i = _o("$skip=4");
var o = _q("Skip: 4");
Assert.Equal(o, i);
}
@ -525,18 +591,18 @@ public class QueryFromODataTests
[Fact]
public void Should_parse_random()
{
var i = _Q("$random=4");
var o = _C("Random: 4");
var i = _o("$random=4");
var o = _q("Random: 4");
Assert.Equal(o, i);
}
private static string _C(string value)
private static string _q(string value)
{
return value.Replace('/', '.');
return value.Replace('/', '.').Replace("..", "/", StringComparison.OrdinalIgnoreCase);
}
private static string? _Q(string value)
private static string? _o(string value)
{
var parser = EdmModel.ParseQuery(value);

22
frontend/src/app/features/rules/pages/rule/rule-page.component.ts

@ -134,7 +134,7 @@ export class RulePageComponent implements OnInit {
public save() {
const { flow, isEditable, isEnabled, name, trigger } = this.editableRule.value;
if (!isEditable || !trigger || !flow || !flow.dto.steps || Object.entries(flow.dto.steps).length === 0) {
if (!isEditable || !trigger || !flow?.dto.steps || Object.entries(flow.dto.steps).length === 0) {
return;
}
@ -155,6 +155,9 @@ export class RulePageComponent implements OnInit {
this.rulesState.create(request)
.subscribe({
next: rule => {
this.router.navigate(['../', rule.id], { relativeTo: this.route, replaceUrl: true });
},
complete: () => {
this.dialogs.notifyInfo('i18n:rules.created');
},
@ -183,9 +186,14 @@ export class RulePageComponent implements OnInit {
}));
}
public startAddStep(target: FlowStepAdd) {
this.stepToUpsert = { target };
this.stepDialog.show();
public startUpdateTrigger(update: RuleTriggerDto) {
const metadata = this.availableTriggers[update.triggerType];
if (!metadata || !metadata.hasProperties) {
return;
}
this.triggerToEdit = update;
this.triggerDialog.show();
}
public startUpdateStep(update: FlowStepUpdate) {
@ -193,9 +201,9 @@ export class RulePageComponent implements OnInit {
this.stepDialog.show();
}
public startUpdateTrigger(update: RuleTriggerDto) {
this.triggerToEdit = update;
this.triggerDialog.show();
public startAddStep(target: FlowStepAdd) {
this.stepToUpsert = { target };
this.stepDialog.show();
}
public changeTrigger(trigger: RuleTriggerDto) {

4
frontend/src/app/features/rules/pages/rule/step-dialog.component.html

@ -20,7 +20,7 @@
<div class="col-9 offset-3">
<div class="form-check">
<input class="form-check-input" id="stepIgnore" [(ngModel)]="stepIgnore" [ngModelOptions]="{ standalone: true }" type="checkbox" />
<input class="form-check-input" id="stepIgnore" [(ngModel)]="stepIgnoreError" [ngModelOptions]="{ standalone: true }" type="checkbox" />
<label class="form-check-label" for="stepIgnore"> {{ "rules.stepIgnoreError" | sqxTranslate }} </label>
</div>
<sqx-form-hint> {{ "rules.stepIgnoreErrorHint" | sqxTranslate }} </sqx-form-hint>
@ -28,6 +28,8 @@
<hr />
<sqx-form-error [error]="currentStep.error | async" />
@for (property of currentStep.editableProperties; track property.name) {
<div class="form-group row">
<label class="col-3 col-form-label" [for]="property.name">

30
frontend/src/app/features/rules/pages/rule/step-dialog.component.ts

@ -5,10 +5,10 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { LowerCasePipe } from '@angular/common';
import { AsyncPipe, LowerCasePipe } from '@angular/common';
import { booleanAttribute, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { CodeEditorComponent, ControlErrorsComponent, DynamicFlowStepDefinitionDto, EntriesPipe, FormHintComponent, MarkdownDirective, ModalDialogComponent, RuleElementDto, ScriptCompletions, StepForm, TranslatePipe, TypedSimpleChanges } from '@app/shared';
import { AppsState, CodeEditorComponent, ControlErrorsComponent, DynamicFlowStepDefinitionDto, EntriesPipe, FormErrorComponent, FormHintComponent, MarkdownDirective, ModalDialogComponent, RuleElementDto, RulesService, ScriptCompletions, StepForm, TranslatePipe, TypedSimpleChanges } from '@app/shared';
import { BranchesInputComponent } from '../../shared/actions/branches-input.component';
import { FormattableInputComponent } from '../../shared/actions/formattable-input.component';
import { RuleElementComponent } from '../../shared/rule-element.component';
@ -19,11 +19,13 @@ import { RuleElementComponent } from '../../shared/rule-element.component';
styleUrls: ['./step-dialog.component.scss'],
templateUrl: './step-dialog.component.html',
imports: [
AsyncPipe,
BranchesInputComponent,
CodeEditorComponent,
ControlErrorsComponent,
EntriesPipe,
FormattableInputComponent,
FormErrorComponent,
FormHintComponent,
FormsModule,
LowerCasePipe,
@ -59,14 +61,19 @@ export class StepDialogComponent {
public currentStep?: StepForm;
public stepName?: string;
public stepIgnoreError = false;
public stepIgnore = false;
constructor(
public appsState: AppsState,
private rulesService: RulesService,
) {
}
public ngOnChanges(changes: TypedSimpleChanges<this>) {
if (changes.stepDefinition && this.stepDefinition) {
this.selectStep(this.stepDefinition.step.stepType, this.stepDefinition.step);
this.stepIgnore = this.stepDefinition.ignoreError || false;
this.stepIgnoreError = this.stepDefinition.ignoreError || false;
this.stepName = this.stepDefinition?.name;
}
}
@ -89,11 +96,20 @@ export class StepDialogComponent {
return;
}
const step = this.currentStep.submit();
if (!step) {
const values = this.currentStep.submit();
if (!values) {
return;
}
this.dialogSave.emit(new DynamicFlowStepDefinitionDto({ name: this.stepName, ignoreError: this.stepIgnore, step }));
this.rulesService.validateStep(this.appsState.appName, values)
.subscribe({
error: error =>{
this.currentStep?.submitFailed(error);
},
complete: () => {
this.dialogSave.emit(new DynamicFlowStepDefinitionDto({ name: this.stepName, ignoreError: this.stepIgnoreError, step: values }));
},
});
}
}

2
frontend/src/app/features/rules/pages/rule/trigger-dialog.component.html

@ -9,6 +9,8 @@
</ng-container>
<ng-container content>
@if (currentTrigger) {
<sqx-form-error [error]="currentTrigger.error | async" />
@switch (currentTrigger.triggerType) {
@case ("AssetChanged") {
<sqx-asset-changed-trigger [triggerForm]="currentTrigger" />

23
frontend/src/app/features/rules/pages/rule/trigger-dialog.component.ts

@ -8,7 +8,7 @@
import { AsyncPipe } from '@angular/common';
import { booleanAttribute, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { EntriesPipe, ManualRuleTriggerDto, ModalDialogComponent, RuleTriggerDto, RuleTriggerMetadataDto, SchemasState, TranslatePipe, TriggerForm } from '@app/shared';
import { EntriesPipe, FormErrorComponent, ManualRuleTriggerDto, ModalDialogComponent, RulesService, RuleTriggerDto, RuleTriggerMetadataDto, SchemasState, TranslatePipe, TriggerForm } from '@app/shared';
import { RuleElementComponent } from '../../shared/rule-element.component';
import { AssetChangedTriggerComponent } from '../../shared/triggers/asset-changed-trigger.component';
import { CommentTriggerComponent } from '../../shared/triggers/comment-trigger.component';
@ -27,6 +27,7 @@ import { UsageTriggerComponent } from '../../shared/triggers/usage-trigger.compo
CommentTriggerComponent,
ContentChangedTriggerComponent,
EntriesPipe,
FormErrorComponent,
ModalDialogComponent,
ReactiveFormsModule,
RuleElementComponent,
@ -55,6 +56,7 @@ export class TriggerDialogComponent implements OnInit {
constructor(
public readonly schemasState: SchemasState,
private readonly rulesService: RulesService,
) {
}
@ -65,8 +67,15 @@ export class TriggerDialogComponent implements OnInit {
}
public selectTrigger(type: string, values?: any) {
if (type === 'Manual') {
const metadata = this.availableTriggers[type];
if (!metadata) {
return;
}
if (!metadata.hasProperties) {
this.dialogSave.emit(new ManualRuleTriggerDto());
return;
}
if (this.currentTrigger?.triggerType !== type) {
@ -89,6 +98,14 @@ export class TriggerDialogComponent implements OnInit {
return;
}
this.dialogSave.emit(values);
this.rulesService.validateTrigger(this.schemasState.appName, values)
.subscribe({
error: error =>{
this.currentTrigger?.submitFailed(error);
},
complete: () => {
this.dialogSave.emit(values);
},
});
}
}

4
frontend/src/app/features/rules/pages/rules/rule.component.html

@ -111,7 +111,7 @@
</div>
<div class="col col-last text-end">
@if (isManual) {
@if (rule.canTrigger) {
<button
class="btn btn-outline-secondary btn-run"
confirmRememberKey="triggerRule"
@ -147,7 +147,7 @@
@if (rule.canReadLogs) {
<div class="col-auto">
<a [queryParams]="{ ruleId: rule.id }" routerLink="events"> {{ "common.logs" | sqxTranslate }} </a>
@if (!isManual) {
@if (!rule.canTrigger) {
<a class="ms-2" [queryParams]="{ ruleId: rule.id }" routerLink="simulator">
{{ "rules.simulator" | sqxTranslate }}
</a>

4
frontend/src/app/features/rules/pages/rules/rule.component.ts

@ -45,10 +45,6 @@ export class RuleComponent {
public dropdown = new ModalModel();
public get isManual() {
return this.rule.trigger.triggerType === 'Manual';
}
constructor(
private readonly rulesState: RulesState,
) {

2
frontend/src/app/features/rules/shared/rule-icon.component.html

@ -1,4 +1,4 @@
<span class="rounded-full icon {{ size }}">
<span class="rounded-full icon d-inline-flex {{ size }}">
@if (elementInfo.iconCode) {
<i class="icon icon-{{ elementInfo.iconCode }}"></i>
} @else {

4
frontend/src/app/features/rules/shared/rule-icon.component.scss

@ -1,6 +1,10 @@
@import 'mixins';
@import 'vars';
:host {
display: inline-flex
}
:host ::ng-deep {
svg {
fill: $color-white;

28
frontend/src/app/shared/services/rules.service.spec.ts

@ -435,6 +435,34 @@ describe('RulesService', () => {
req.flush({});
}));
it('should make post request to validate trigger',
inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => {
const dto = new ManualRuleTriggerDto();
rulesService.validateTrigger('my-app', dto).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/validate/trigger');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({});
}));
it('should make post request to validate step',
inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => {
const dto = {};
rulesService.validateStep('my-app', dto).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/validate/step');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({});
}));
it('should make get request to get completions',
inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => {
let completions: ScriptCompletions;

23
frontend/src/app/shared/services/rules.service.ts

@ -10,7 +10,7 @@ import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ApiUrlConfig, HTTP, pretifyError, Resource, ScriptCompletions, StringHelper, VersionOrTag } from '@app/framework';
import { DynamicCreateRuleDto, DynamicRuleDto, DynamicRulesDto, DynamicUpdateRuleDto, RuleElementDto, RuleEventsDto, SimulatedRuleEventsDto } from './../model';
import { DynamicCreateRuleDto, DynamicRuleDto, DynamicRulesDto, DynamicUpdateRuleDto, RuleElementDto, RuleEventsDto, RuleTriggerDto, SimulatedRuleEventsDto } from './../model';
export type RuleTriggerMetadataDto = Readonly<{
description: string;
@ -18,6 +18,7 @@ export type RuleTriggerMetadataDto = Readonly<{
iconColor?: string;
iconCode?: string | null;
iconImage?: string;
hasProperties?: boolean;
title?: string;
readMore?: string;
}>;
@ -28,6 +29,7 @@ export const ALL_TRIGGERS: Record<string, RuleTriggerMetadataDto> = {
display: 'Asset changed',
iconColor: '#3389ff',
iconCode: 'assets',
hasProperties: true,
title: 'Asset changed',
},
Comment: {
@ -35,6 +37,7 @@ export const ALL_TRIGGERS: Record<string, RuleTriggerMetadataDto> = {
display: 'User mentioned',
iconColor: '#3389ff',
iconCode: 'comments',
hasProperties: true,
title: 'User mentioned',
},
ContentChanged: {
@ -42,6 +45,7 @@ export const ALL_TRIGGERS: Record<string, RuleTriggerMetadataDto> = {
display: 'Content changed',
iconColor: '#3389ff',
iconCode: 'contents',
hasProperties: true,
title: 'Content changed',
},
Manual: {
@ -49,6 +53,7 @@ export const ALL_TRIGGERS: Record<string, RuleTriggerMetadataDto> = {
display: 'Manually triggered',
iconColor: '#3389ff',
iconCode: 'play-line',
hasProperties: false,
title: 'Manually triggered',
},
SchemaChanged: {
@ -56,6 +61,7 @@ export const ALL_TRIGGERS: Record<string, RuleTriggerMetadataDto> = {
display: 'Schema changed',
iconColor: '#3389ff',
iconCode: 'schemas',
hasProperties: true,
title: 'Schema changed',
},
Usage: {
@ -63,6 +69,7 @@ export const ALL_TRIGGERS: Record<string, RuleTriggerMetadataDto> = {
display: 'Usage exceeded',
iconColor: '#3389ff',
iconCode: 'dashboard',
hasProperties: true,
title: 'Usage',
},
};
@ -217,6 +224,20 @@ export class RulesService {
pretifyError('i18n:rules.ruleEvents.cancelFailed'));
}
public validateTrigger(appName: string, dto: RuleTriggerDto): Observable<DynamicRuleDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/validate/trigger`);
return this.http.post<any>(url, dto.toJSON()).pipe(
pretifyError('i18n:rules.ruleEvents.validationFailed'));
}
public validateStep(appName: string, dto: Record<string, any>): Observable<DynamicRuleDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/validate/step`);
return this.http.post<any>(url, dto).pipe(
pretifyError('i18n:rules.ruleEvents.validationFailed'));
}
public getCompletions(appName: string, actionType: string): Observable<ScriptCompletions> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/completion/${actionType}`);

2
tools/TestSuite/TestSuite.ApiTests/RuleEventsTests.cs

@ -115,7 +115,7 @@ public class RuleEventsTests(ClientFixture fixture) : IClassFixture<ClientFixtur
var rule = await app.Rules.PostRuleAsync(createRule);
await app.Rules.TriggerRuleAsync(rule.Id);
await app.Rules.TriggerRuleAsync(rule.Id, new TriggerRuleDto());
return (app, rule);
}

144
tools/TestSuite/TestSuite.ApiTests/RuleRunnerTests.cs

@ -20,7 +20,9 @@ public class RuleRunnerTests(ClientFixture fixture, WebhookCatcherFixture webhoo
private readonly string schemaName = $"schema-{Guid.NewGuid()}";
private readonly string schemaNameRef = $"schema-{Guid.NewGuid()}-ref";
private readonly string contentString = Guid.NewGuid().ToString();
private readonly Guid stepId = Guid.NewGuid();
private readonly Guid stepId1 = Guid.NewGuid();
private readonly Guid stepId2 = Guid.NewGuid();
private readonly Guid stepId3 = Guid.NewGuid();
private readonly WebhookCatcherClient webhookCatcher = webhookCatcher.Client;
public ClientFixture _ { get; } = fixture;
@ -41,10 +43,10 @@ public class RuleRunnerTests(ClientFixture fixture, WebhookCatcherFixture webhoo
{
Flow = new FlowDefinitionDto
{
InitialStepId = stepId,
InitialStepId = stepId1,
Steps = new Dictionary<string, FlowStepDefinitionDto>
{
[stepId.ToString()] = new FlowStepDefinitionDto
[stepId1.ToString()] = new FlowStepDefinitionDto
{
Step = new WebhookFlowStepDto
{
@ -122,10 +124,10 @@ public class RuleRunnerTests(ClientFixture fixture, WebhookCatcherFixture webhoo
{
Flow = new FlowDefinitionDto
{
InitialStepId = stepId,
InitialStepId = stepId1,
Steps = new Dictionary<string, FlowStepDefinitionDto>
{
[stepId.ToString()] = new FlowStepDefinitionDto
[stepId1.ToString()] = new FlowStepDefinitionDto
{
Step = new WebhookFlowStepDto
{
@ -207,10 +209,10 @@ public class RuleRunnerTests(ClientFixture fixture, WebhookCatcherFixture webhoo
{
Flow = new FlowDefinitionDto
{
InitialStepId = stepId,
InitialStepId = stepId1,
Steps = new Dictionary<string, FlowStepDefinitionDto>
{
[stepId.ToString()] = new FlowStepDefinitionDto
[stepId1.ToString()] = new FlowStepDefinitionDto
{
Step = new ScriptFlowStepDto
{
@ -263,10 +265,10 @@ public class RuleRunnerTests(ClientFixture fixture, WebhookCatcherFixture webhoo
{
Flow = new FlowDefinitionDto
{
InitialStepId = stepId,
InitialStepId = stepId1,
Steps = new Dictionary<string, FlowStepDefinitionDto>
{
[stepId.ToString()] = new FlowStepDefinitionDto
[stepId1.ToString()] = new FlowStepDefinitionDto
{
Step = new WebhookFlowStepDto
{
@ -316,10 +318,10 @@ public class RuleRunnerTests(ClientFixture fixture, WebhookCatcherFixture webhoo
{
Flow = new FlowDefinitionDto
{
InitialStepId = stepId,
InitialStepId = stepId1,
Steps = new Dictionary<string, FlowStepDefinitionDto>
{
[stepId.ToString()] = new FlowStepDefinitionDto
[stepId1.ToString()] = new FlowStepDefinitionDto
{
Step = new ScriptFlowStepDto
{
@ -365,10 +367,10 @@ public class RuleRunnerTests(ClientFixture fixture, WebhookCatcherFixture webhoo
{
Flow = new FlowDefinitionDto
{
InitialStepId = stepId,
InitialStepId = stepId1,
Steps = new Dictionary<string, FlowStepDefinitionDto>
{
[stepId.ToString()] = new FlowStepDefinitionDto
[stepId1.ToString()] = new FlowStepDefinitionDto
{
Step = new WebhookFlowStepDto
{
@ -418,10 +420,10 @@ public class RuleRunnerTests(ClientFixture fixture, WebhookCatcherFixture webhoo
{
Flow = new FlowDefinitionDto
{
InitialStepId = stepId,
InitialStepId = stepId1,
Steps = new Dictionary<string, FlowStepDefinitionDto>
{
[stepId.ToString()] = new FlowStepDefinitionDto
[stepId1.ToString()] = new FlowStepDefinitionDto
{
Step = new WebhookFlowStepDto
{
@ -439,7 +441,7 @@ public class RuleRunnerTests(ClientFixture fixture, WebhookCatcherFixture webhoo
// STEP 3: Trigger rule.
await app.Rules.TriggerRuleAsync(rule.Id);
await app.Rules.TriggerRuleAsync(rule.Id, new TriggerRuleDto());
// Get requests.
var request = await webhookCatcher.PollAsync(sessionId, x => x.IsPost());
@ -473,10 +475,10 @@ public class RuleRunnerTests(ClientFixture fixture, WebhookCatcherFixture webhoo
{
Flow = new FlowDefinitionDto
{
InitialStepId = stepId,
InitialStepId = stepId1,
Steps = new Dictionary<string, FlowStepDefinitionDto>
{
[stepId.ToString()] = new FlowStepDefinitionDto
[stepId1.ToString()] = new FlowStepDefinitionDto
{
Step = new WebhookFlowStepDto
{
@ -524,10 +526,10 @@ public class RuleRunnerTests(ClientFixture fixture, WebhookCatcherFixture webhoo
{
Flow = new FlowDefinitionDto
{
InitialStepId = stepId,
InitialStepId = stepId1,
Steps = new Dictionary<string, FlowStepDefinitionDto>
{
[stepId.ToString()] = new FlowStepDefinitionDto
[stepId1.ToString()] = new FlowStepDefinitionDto
{
Step = new ScriptFlowStepDto
{
@ -550,7 +552,7 @@ public class RuleRunnerTests(ClientFixture fixture, WebhookCatcherFixture webhoo
// STEP 2: Create test content.
await app.Rules.TriggerRuleAsync(rule.Id);
await app.Rules.TriggerRuleAsync(rule.Id, new TriggerRuleDto());
// STEP 3: Get events.
@ -559,6 +561,106 @@ public class RuleRunnerTests(ClientFixture fixture, WebhookCatcherFixture webhoo
await Verify(@event);
}
[Fact]
public async Task Should_run_rule_with_condition()
{
// STEP 0: Create app.
var (app, _) = await _.PostAppAsync();
// STEP 1: Create rule.
var createRule = new CreateRuleDto
{
Flow = new FlowDefinitionDto
{
InitialStepId = stepId1,
Steps = new Dictionary<string, FlowStepDefinitionDto>
{
[stepId1.ToString()] = new FlowStepDefinitionDto
{
Step = new IfFlowStepDto
{
Branches =
[
new IfFlowBranch
{
Condition = "event.value.testValue == 1",
NextStepId = stepId2,
},
new IfFlowBranch
{
Condition = "event.value.testValue == 2",
NextStepId = stepId3,
},
]
}
},
[stepId2.ToString()] = new FlowStepDefinitionDto
{
Step = new ScriptFlowStepDto
{
Script = @"
console.info('Hello from Branch1');
"
}
},
[stepId3.ToString()] = new FlowStepDefinitionDto
{
Step = new ScriptFlowStepDto
{
Script = @"
console.info('Hello from Branch2');
"
}
}
}
},
Trigger = new ManualRuleTriggerDto()
};
var rule = await app.Rules.PostRuleAsync(createRule);
// STEP 2: Trigger rules 1.
await app.Rules.TriggerRuleAsync(rule.Id, new TriggerRuleDto
{
Value = new Dictionary<string, int>
{
["testValue"] = 1
}
});
var @event1 = await app.Rules.PollEventAsync(rule.Id, x => x.FlowState.Status == FlowExecutionStatus.Completed);
var allLogs1 =
@event1?.FlowState.Steps.Values
.SelectMany(x => x.Attempts)
.SelectMany(x => x.Log) ?? [];
Assert.Contains(allLogs1, x => x.Message.Contains("Hello from Branch1", StringComparison.Ordinal));
// STEP 3: Trigger rules 2.
await app.Rules.TriggerRuleAsync(rule.Id, new TriggerRuleDto
{
Value = new Dictionary<string, int>
{
["testValue"] = 2
}
});
var @event2 = await app.Rules.PollEventAsync(rule.Id, x => x.FlowState.Status == FlowExecutionStatus.Completed && x.Id != event1!.Id);
var allLogs2 =
@event2?.FlowState.Steps.Values
.SelectMany(x => x.Attempts)
.SelectMany(x => x.Log) ?? [];
Assert.Contains(allLogs2, x => x.Message.Contains("Hello from Branch2", StringComparison.Ordinal));
}
private async Task CreateContentAsync(ISquidexClient app)
{
await TestEntity.CreateSchemaAsync(app.Schemas, schemaName);

24
tools/TestSuite/TestSuite.ApiTests/RuleTests.cs

@ -181,28 +181,4 @@ public class RuleTests(ClientFixture fixture) : IClassFixture<ClientFixture>
Assert.DoesNotContain(rules.Items, x => x.Id == rule.Id);
}
[Fact]
public async Task Should_get_steps()
{
var steps = await _.Client.Rules.GetStepsAsync();
Assert.NotEmpty(steps);
}
[Fact]
public async Task Should_get_event_schemas()
{
var schema = await _.Client.Rules.GetEventSchemaAsync("EnrichedContentEvent");
Assert.NotNull(schema);
}
[Fact]
public async Task Should_get_event_types()
{
var eventTypes = await _.Client.Rules.GetEventTypesAsync();
Assert.NotEmpty(eventTypes);
}
}

84
tools/TestSuite/TestSuite.ApiTests/RuleValidationTests.cs

@ -0,0 +1,84 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.ClientLibrary;
using TestSuite.Fixtures;
#pragma warning disable SA1300 // Element should begin with upper-case letter
namespace TestSuite.ApiTests;
public class RuleValidationTests(CreatedAppFixture fixture) : IClassFixture<CreatedAppFixture>
{
public CreatedAppFixture _ { get; } = fixture;
[Fact]
public async Task Should_get_steps()
{
var steps = await _.Client.Rules.GetStepsAsync();
Assert.NotEmpty(steps);
}
[Fact]
public async Task Should_get_event_schemas()
{
var schema = await _.Client.Rules.GetEventSchemaAsync("EnrichedContentEvent");
Assert.NotNull(schema);
}
[Fact]
public async Task Should_get_event_types()
{
var eventTypes = await _.Client.Rules.GetEventTypesAsync();
Assert.NotEmpty(eventTypes);
}
[Fact]
public async Task Should_validate_valid_trigger()
{
var trigger = new UsageRuleTriggerDto { Limit = 500, NumDays = 5 };
await _.Client.Rules.ValidateRuleAsync(trigger);
}
[Fact]
public async Task Should_validate_invalid_trigger()
{
var trigger = new UsageRuleTriggerDto { Limit = 500, NumDays = 50 };
var ex = await Assert.ThrowsAnyAsync<SquidexException>(async () =>
{
await _.Client.Rules.ValidateRuleAsync(trigger);
});
Assert.Equal(400, ex.StatusCode);
}
[Fact]
public async Task Should_validate_valid_step()
{
var trigger = new WebhookFlowStepDto { Url = new Uri("https://squidex.io") };
await _.Client.Rules.ValidateStepAsync(trigger);
}
[Fact]
public async Task Should_validate_invalid_step()
{
var trigger = new WebhookFlowStepDto();
var ex = await Assert.ThrowsAnyAsync<SquidexException>(async () =>
{
await _.Client.Rules.ValidateStepAsync(trigger);
});
Assert.Equal(400, ex.StatusCode);
}
}

4
tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj

@ -16,8 +16,8 @@
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NSwag.Core" Version="14.1.0" />
<PackageReference Include="Squidex.Assets" Version="7.1.4" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="7.1.4" />
<PackageReference Include="Squidex.Assets" Version="7.2.0" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="7.2.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="Verify.Xunit" Version="28.3.1" />
<PackageReference Include="xunit" Version="2.9.2" />

1
tools/TestSuite/TestSuite.ApiTests/Verify/RuleRunnerTests.Should_log_to_console_in_rules.verified.txt

@ -27,6 +27,7 @@
name: [],
partition: [],
timestamp: [],
value: [],
version: []
}
},

3
tools/TestSuite/TestSuite.ApiTests/Verify/RuleTests.Should_create_rule.verified.txt

@ -31,9 +31,6 @@
run/snapshots: {
Method: PUT
},
trigger: {
Method: PUT
},
update: {
Method: PUT
}

3
tools/TestSuite/TestSuite.ApiTests/Verify/RuleTests.Should_create_rule_with_action.verified.txt

@ -31,9 +31,6 @@
run/snapshots: {
Method: PUT
},
trigger: {
Method: PUT
},
update: {
Method: PUT
}

3
tools/TestSuite/TestSuite.ApiTests/Verify/RuleTests.Should_update_rule.verified.txt

@ -33,9 +33,6 @@
run/snapshots: {
Method: PUT
},
trigger: {
Method: PUT
},
update: {
Method: PUT
}

6
tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj

@ -17,9 +17,9 @@
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets" Version="7.1.4" />
<PackageReference Include="Squidex.ClientLibrary" Version="21.4.0" />
<PackageReference Include="Squidex.ClientLibrary.ServiceExtensions" Version="21.4.0" />
<PackageReference Include="Squidex.Assets" Version="7.2.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="21.5.0" />
<PackageReference Include="Squidex.ClientLibrary.ServiceExtensions" Version="21.5.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="Verify" Version="28.3.1" />
<PackageReference Include="xunit" Version="2.9.2" />

0
tools/TestSuite/local-proxy/docker-compose.yml → tools/TestSuite/images/local-proxy/docker-compose.yml

0
tools/TestSuite/mysql/init.sql → tools/TestSuite/images/mysql/init.sql

0
tools/TestSuite/sql-server/Dockerfile → tools/TestSuite/images/sql-server/Dockerfile

Loading…
Cancel
Save