From 6c28b464378c17f2ba29d06bebc5a274ef518340 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Wed, 26 Jun 2019 20:21:23 +0200 Subject: [PATCH] Workflow flow. --- .../Apps/Role.cs | 1 + .../Contents/Workflow.cs | 2 + .../Contents/WorkflowStep.cs | 4 +- .../Contents/Workflows.cs | 6 + .../Apps/AppGrain.cs | 15 ++ .../Apps/Commands/ConfigureWorkflow.cs | 16 +++ .../Apps/Guards/GuardAppWorkflows.cs | 71 ++++++++++ .../Apps/State/AppState.cs | 5 + .../Apps/AppWorkflowConfigured.cs | 18 +++ src/Squidex.Shared/Permissions.cs | 6 + .../Apps/AppWorkflowsController.cs | 86 +++++++++++ .../Apps/Models/UpsertWorkflowDto.cs | 45 ++++++ .../Controllers/Apps/Models/WorkflowDto.cs | 61 ++++++++ .../Apps/Models/WorkflowResponseDto.cs | 32 +++++ .../Apps/Models/WorkflowStepDto.cs | 32 +++++ .../Apps/Models/WorkflowTransitionDto.cs | 22 +++ .../Model/Contents/WorkflowTests.cs | 6 +- .../Apps/AppGrainTests.cs | 32 ++++- .../Apps/Guards/GuardAppWorkflowTests.cs | 134 ++++++++++++++++++ 19 files changed, 583 insertions(+), 11 deletions(-) create mode 100644 src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureWorkflow.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs create mode 100644 src/Squidex.Domain.Apps.Events/Apps/AppWorkflowConfigured.cs create mode 100644 src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs create mode 100644 src/Squidex/Areas/Api/Controllers/Apps/Models/UpsertWorkflowDto.cs create mode 100644 src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs create mode 100644 src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowResponseDto.cs create mode 100644 src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs create mode 100644 src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs create mode 100644 tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppWorkflowTests.cs diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs index a8ae9e43f..7f5bd195e 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs @@ -90,6 +90,7 @@ namespace Squidex.Domain.Apps.Core.Apps P.ForApp(P.AppCommon, app), P.ForApp(P.AppContents, app), P.ForApp(P.AppPatterns, app), + P.ForApp(P.AppWorkflows, app), P.ForApp(P.AppRules, app), P.ForApp(P.AppSchemas, app)); } diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs index 5b31999a7..2093a76c0 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs @@ -12,6 +12,8 @@ namespace Squidex.Domain.Apps.Core.Contents { public sealed class Workflow { + public static readonly IReadOnlyDictionary EmptySteps = new Dictionary(); + public static readonly Workflow Default = new Workflow( new Dictionary { diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs index 553f42c76..d4c6848fc 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs @@ -12,13 +12,15 @@ namespace Squidex.Domain.Apps.Core.Contents { public sealed class WorkflowStep { + public static readonly Dictionary EmptyTransitions = new Dictionary(); + public IReadOnlyDictionary Transitions { get; } public string Color { get; } public bool NoUpdate { get; } - public WorkflowStep(IReadOnlyDictionary transitions, string color, bool noUpdate = false) + public WorkflowStep(IReadOnlyDictionary transitions, string color = null, bool noUpdate = false) { Guard.NotNull(transitions, nameof(transitions)); diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs index 1a461c67d..d027b8d32 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.Contracts; +using System.Linq; using Squidex.Infrastructure; using Squidex.Infrastructure.Collections; @@ -33,5 +34,10 @@ namespace Squidex.Domain.Apps.Core.Contents return new Workflows(With(Guid.Empty, workflow)); } + + public Workflow GetFirst() + { + return Values.FirstOrDefault() ?? Workflow.Default; + } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs index eceb7bfa5..e17c17290 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs @@ -119,6 +119,16 @@ namespace Squidex.Domain.Apps.Entities.Apps return Snapshot; }); + case ConfigureWorkflow configureWorkflow: + return UpdateReturn(configureWorkflow, c => + { + GuardAppWorkflows.CanConfigure(c); + + ConfigureWorkflow(c); + + return Snapshot; + }); + case AddLanguage addLanguage: return UpdateReturn(addLanguage, c => { @@ -319,6 +329,11 @@ namespace Squidex.Domain.Apps.Entities.Apps RaiseEvent(SimpleMapper.Map(command, new AppClientRevoked())); } + public void ConfigureWorkflow(ConfigureWorkflow command) + { + RaiseEvent(SimpleMapper.Map(command, new AppWorkflowConfigured())); + } + public void AddLanguage(AddLanguage command) { RaiseEvent(SimpleMapper.Map(command, new AppLanguageAdded())); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureWorkflow.cs new file mode 100644 index 000000000..efa2b503b --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureWorkflow.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Contents; + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class ConfigureWorkflow : AppCommand + { + public Workflow Workflow { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs new file mode 100644 index 000000000..70f987498 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs @@ -0,0 +1,71 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Apps.Guards +{ + public static class GuardAppWorkflows + { + public static void CanConfigure(ConfigureWorkflow command) + { + Guard.NotNull(command, nameof(command)); + + Validate.It(() => "Cannot configure workflow.", e => + { + if (command.Workflow == null) + { + e(Not.Defined("Workflow"), nameof(command.Workflow)); + return; + } + + var workflow = command.Workflow; + + if (!workflow.Steps.ContainsKey(workflow.Initial)) + { + e(Not.Defined("Initial step"), $"{nameof(command.Workflow)}.{nameof(workflow.Initial)}"); + } + + var stepsPrefix = $"{nameof(command.Workflow)}.{nameof(workflow.Steps)}"; + + if (!workflow.Steps.ContainsKey(Status.Published)) + { + e("Workflow must have a published step.", stepsPrefix); + } + + foreach (var step in workflow.Steps) + { + var stepPrefix = $"{stepsPrefix}.{step.Key}"; + + if (step.Value == null) + { + e(Not.Defined("Step"), stepPrefix); + } + else + { + foreach (var transition in step.Value.Transitions) + { + var transitionPrefix = $"{stepPrefix}.{nameof(step.Value.Transitions)}.{transition.Key}"; + + if (!workflow.Steps.ContainsKey(transition.Key)) + { + e("Transition has an invalid target.", transitionPrefix); + } + + if (transition.Value == null) + { + e(Not.Defined("Transition"), transitionPrefix); + } + } + } + } + }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs index f9d9b3519..ac71df0bc 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs @@ -96,6 +96,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.State Clients = Clients.Revoke(@event.Id); } + protected void On(AppWorkflowConfigured @event) + { + Workflows = Workflows.Set(@event.Workflow); + } + protected void On(AppPatternAdded @event) { Patterns = Patterns.Add(@event.PatternId, @event.Name, @event.Pattern, @event.Message); diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowConfigured.cs b/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowConfigured.cs new file mode 100644 index 000000000..65166ae97 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowConfigured.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Apps +{ + [EventType(nameof(AppWorkflowConfigured))] + public sealed class AppWorkflowConfigured : AppEvent + { + public Workflow Workflow { get; set; } + } +} diff --git a/src/Squidex.Shared/Permissions.cs b/src/Squidex.Shared/Permissions.cs index 7118a0604..62329248e 100644 --- a/src/Squidex.Shared/Permissions.cs +++ b/src/Squidex.Shared/Permissions.cs @@ -81,6 +81,12 @@ namespace Squidex.Shared public const string AppPatternsUpdate = "squidex.apps.{app}.patterns.update"; public const string AppPatternsDelete = "squidex.apps.{app}.patterns.delete"; + public const string AppWorkflows = "squidex.apps.{app}.workflows"; + public const string AppWorkflowsRead = "squidex.apps.{app}.workflows.read"; + public const string AppWorkflowsCreate = "squidex.apps.{app}.workflows.create"; + public const string AppWorkflowsUpdate = "squidex.apps.{app}.workflows.update"; + public const string AppWorkflowsDelete = "squidex.apps.{app}.workflows.delete"; + public const string AppBackups = "squidex.apps.{app}.backups"; public const string AppBackupsRead = "squidex.apps.{app}.backups.read"; public const string AppBackupsCreate = "squidex.apps.{app}.backups.create"; diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs new file mode 100644 index 000000000..be489f20e --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs @@ -0,0 +1,86 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; +using Squidex.Areas.Api.Controllers.Apps.Models; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Infrastructure.Commands; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Apps +{ + /// + /// Manages and configures apps. + /// + [ApiExplorerSettings(GroupName = nameof(Apps))] + public sealed class AppWorkflowsController : ApiController + { + public AppWorkflowsController(ICommandBus commandBus) + : base(commandBus) + { + } + + /// + /// Get app workflow. + /// + /// The name of the app. + /// + /// 200 => App workflows returned. + /// 404 => App not found. + /// + [HttpGet] + [Route("apps/{app}/workflow/")] + [ProducesResponseType(typeof(WorkflowResponseDto), 200)] + [ApiPermission(Permissions.AppWorkflowsRead)] + [ApiCosts(0)] + public IActionResult GetWorkflow(string app) + { + var response = WorkflowResponseDto.FromApp(App, this); + + Response.Headers[HeaderNames.ETag] = App.Version.ToString(); + + return Ok(response); + } + + /// + /// Configure workflow of the app. + /// + /// The name of the app. + /// The new workflow. + /// + /// 200 => Workflow configured. + /// 400 => Workflow is not valid. + /// 404 => App not found. + /// + [HttpPut] + [Route("apps/{app}/workflow/")] + [ProducesResponseType(typeof(WorkflowResponseDto), 200)] + [ApiPermission(Permissions.AppWorkflowsUpdate)] + [ApiCosts(1)] + public async Task PutWorkflow(string app, [FromBody] UpsertWorkflowDto request) + { + var command = request.ToCommand(); + + var response = await InvokeCommandAsync(command); + + return Ok(response); + } + + private async Task InvokeCommandAsync(ICommand command) + { + var context = await CommandBus.PublishAsync(command); + + var result = context.Result(); + var response = WorkflowResponseDto.FromApp(result, this); + + return response; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpsertWorkflowDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpsertWorkflowDto.cs new file mode 100644 index 000000000..d9772dd52 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpsertWorkflowDto.cs @@ -0,0 +1,45 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Apps.Commands; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class UpsertWorkflowDto + { + /// + /// The workflow steps. + /// + [Required] + public Dictionary Steps { get; set; } + + /// + /// The initial step. + /// + public Status Initial { get; set; } + + public ConfigureWorkflow ToCommand() + { + var workflow = new Workflow( + Steps?.ToDictionary( + x => x.Key, + x => new WorkflowStep( + x.Value?.Transitions.ToDictionary( + y => x.Key, + y => new WorkflowTransition(y.Value.Expression, y.Value.Role)) ?? WorkflowStep.EmptyTransitions, + x.Value.Color, + x.Value.NoUpdate)) ?? Workflow.EmptySteps, + Initial); + + return new ConfigureWorkflow { Workflow = workflow }; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs new file mode 100644 index 000000000..3a6a3fecc --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs @@ -0,0 +1,61 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure.Reflection; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class WorkflowDto : Resource + { + /// + /// The workflow steps. + /// + [Required] + public Dictionary Steps { get; set; } + + /// + /// The initial step. + /// + public Status Initial { get; set; } + + public static WorkflowDto FromWorkflow(Workflow workflow, ApiController controller, string app) + { + var result = new WorkflowDto + { + Steps = workflow.Steps.ToDictionary( + x => x.Key, + x => SimpleMapper.Map(x.Value, new WorkflowStepDto + { + Transitions = x.Value.Transitions.ToDictionary( + y => y.Key, + y => new WorkflowTransitionDto { Expression = y.Value.Expression, Role = y.Value.Role }) + })), + Initial = workflow.Initial + }; + + return result.CreateLinks(controller, app); + } + + private WorkflowDto CreateLinks(ApiController controller, string app) + { + var values = new { app }; + + if (controller.HasPermission(Permissions.AppWorkflowsUpdate, app)) + { + AddPutLink("update", controller.Url(x => nameof(x.PutWorkflow), values)); + } + + return this; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowResponseDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowResponseDto.cs new file mode 100644 index 000000000..3186a7893 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowResponseDto.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class WorkflowResponseDto : Resource + { + /// + /// The workflow. + /// + [Required] + public WorkflowDto Workflow { get; set; } + + public static WorkflowResponseDto FromApp(IAppEntity app, ApiController controller) + { + var result = new WorkflowResponseDto + { + Workflow = WorkflowDto.FromWorkflow(app.Workflows.GetFirst(), controller, app.Name) + }; + + return result; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs new file mode 100644 index 000000000..9012d406d --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Contents; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class WorkflowStepDto + { + /// + /// The transitions. + /// + [Required] + public Dictionary Transitions { get; set; } + + /// + /// The optional color. + /// + public string Color { get; set; } + + /// + /// Indicates if updates should not be allowed. + /// + public bool NoUpdate { get; set; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs new file mode 100644 index 000000000..94235a60d --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class WorkflowTransitionDto + { + /// + /// The optional expression. + /// + public string Expression { get; set; } + + /// + /// The optional restricted role. + /// + public string Role { get; set; } + } +} diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs index 2ccdf1c3f..d696f9aef 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs @@ -27,12 +27,10 @@ namespace Squidex.Domain.Apps.Core.Model.Contents StatusColors.Draft), [Status.Archived] = new WorkflowStep( - new Dictionary(), - StatusColors.Archived, true), + WorkflowStep.EmptyTransitions), [Status.Published] = new WorkflowStep( - new Dictionary(), - StatusColors.Archived) + WorkflowStep.EmptyTransitions) }, Status.Draft); [Fact] diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs index c2a0c2f51..ac892f78a 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using FakeItEasy; using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Domain.Apps.Entities.Apps.Services.Implementations; @@ -256,6 +257,27 @@ namespace Squidex.Domain.Apps.Entities.Apps ); } + [Fact] + public async Task UpdateClient_should_create_events_and_update_state() + { + var command = new UpdateClient { Id = clientId, Name = clientNewName, Role = Role.Developer }; + + await ExecuteCreateAsync(); + await ExecuteAttachClientAsync(); + + var result = await sut.ExecuteAsync(CreateCommand(command)); + + result.ShouldBeEquivalent(sut.Snapshot); + + Assert.Equal(clientNewName, sut.Snapshot.Clients[clientId].Name); + + LastEvents + .ShouldHaveSameEvents( + CreateEvent(new AppClientRenamed { Id = clientId, Name = clientNewName }), + CreateEvent(new AppClientUpdated { Id = clientId, Role = Role.Developer }) + ); + } + [Fact] public async Task RevokeClient_should_create_events_and_update_state() { @@ -277,23 +299,21 @@ namespace Squidex.Domain.Apps.Entities.Apps } [Fact] - public async Task UpdateClient_should_create_events_and_update_state() + public async Task ConfigureWorkflow_should_create_events_and_update_state() { - var command = new UpdateClient { Id = clientId, Name = clientNewName, Role = Role.Developer }; + var command = new ConfigureWorkflow { Workflow = Workflow.Default }; await ExecuteCreateAsync(); - await ExecuteAttachClientAsync(); var result = await sut.ExecuteAsync(CreateCommand(command)); result.ShouldBeEquivalent(sut.Snapshot); - Assert.Equal(clientNewName, sut.Snapshot.Clients[clientId].Name); + Assert.NotEmpty(sut.Snapshot.Workflows); LastEvents .ShouldHaveSameEvents( - CreateEvent(new AppClientRenamed { Id = clientId, Name = clientNewName }), - CreateEvent(new AppClientUpdated { Id = clientId, Role = Role.Developer }) + CreateEvent(new AppWorkflowConfigured { Workflow = Workflow.Default }) ); } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppWorkflowTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppWorkflowTests.cs new file mode 100644 index 000000000..68d8d72af --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppWorkflowTests.cs @@ -0,0 +1,134 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Apps.Guards +{ + public class GuardAppWorkflowTests + { + [Fact] + public void CanConfigure_should_throw_exception_if_workflow_is_not_defined() + { + var command = new ConfigureWorkflow(); + + ValidationAssert.Throws(() => GuardAppWorkflows.CanConfigure(command), + new ValidationError("Workflow is required.", "Workflow")); + } + + [Fact] + public void CanConfigure_should_throw_exception_if_workflow_has_no_initial_step() + { + var command = new ConfigureWorkflow + { + Workflow = new Workflow( + new Dictionary + { + [Status.Published] = new WorkflowStep(WorkflowStep.EmptyTransitions) + }, + default) + }; + + ValidationAssert.Throws(() => GuardAppWorkflows.CanConfigure(command), + new ValidationError("Initial step is required.", "Workflow.Initial")); + } + + [Fact] + public void CanConfigure_should_throw_exception_if_workflow_does_not_have_published_state() + { + var command = new ConfigureWorkflow + { + Workflow = new Workflow( + new Dictionary + { + [Status.Draft] = new WorkflowStep(WorkflowStep.EmptyTransitions) + }, + Status.Draft) + }; + + ValidationAssert.Throws(() => GuardAppWorkflows.CanConfigure(command), + new ValidationError("Workflow must have a published step.", "Workflow.Steps")); + } + + [Fact] + public void CanConfigure_should_throw_exception_if_workflow_step_is_not_defined() + { + var command = new ConfigureWorkflow + { + Workflow = new Workflow( + new Dictionary + { + [Status.Published] = null + }, + Status.Published) + }; + + ValidationAssert.Throws(() => GuardAppWorkflows.CanConfigure(command), + new ValidationError("Step is required.", "Workflow.Steps.Published")); + } + + [Fact] + public void CanConfigure_should_throw_exception_if_workflow_transition_is_invalid() + { + var command = new ConfigureWorkflow + { + Workflow = new Workflow( + new Dictionary + { + [Status.Published] = + new WorkflowStep( + new Dictionary + { + [Status.Draft] = new WorkflowTransition() + }) + }, + Status.Published) + }; + + ValidationAssert.Throws(() => GuardAppWorkflows.CanConfigure(command), + new ValidationError("Transition has an invalid target.", "Workflow.Steps.Published.Transitions.Draft")); + } + + [Fact] + public void CanConfigure_should_throw_exception_if_workflow_transition_is_not_defined() + { + var command = new ConfigureWorkflow + { + Workflow = new Workflow( + new Dictionary + { + [Status.Draft] = + new WorkflowStep( + WorkflowStep.EmptyTransitions), + [Status.Published] = + new WorkflowStep( + new Dictionary + { + [Status.Draft] = null + }) + }, + Status.Published) + }; + + ValidationAssert.Throws(() => GuardAppWorkflows.CanConfigure(command), + new ValidationError("Transition is required.", "Workflow.Steps.Published.Transitions.Draft")); + } + + [Fact] + public void CanConfigure_should_not_throw_exception_if_workflow_is_valid() + { + var command = new ConfigureWorkflow { Workflow = Workflow.Default }; + + GuardAppWorkflows.CanConfigure(command); + } + } +}