diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs index 000314ccb..755e30a1d 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs @@ -61,17 +61,17 @@ namespace Squidex.Domain.Apps.Entities.Contents case CreateContent createContent: return CreateReturnAsync(createContent, async c => { - GuardContent.CanCreate(c); + var ctx = await CreateContext(c.AppId.Id, c.SchemaId.Id, () => "Failed to create content."); - var operationContext = await CreateContext(c.AppId.Id, c.SchemaId.Id, () => "Failed to create content."); + GuardContent.CanCreate(ctx.Schema, c); - await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptCreate, "Create", c, c.Data, null); - await operationContext.EnrichAsync(c.Data); - await operationContext.ValidateAsync(c.Data); + await ctx.ExecuteScriptAndTransformAsync(x => x.ScriptCreate, "Create", c, c.Data, null); + await ctx.EnrichAsync(c.Data); + await ctx.ValidateAsync(c.Data); if (c.Publish) { - await operationContext.ExecuteScriptAsync(x => x.ScriptChange, "Published", c, c.Data, null); + await ctx.ExecuteScriptAsync(x => x.ScriptChange, "Published", c, c.Data, null); } Create(c); @@ -100,7 +100,9 @@ namespace Squidex.Domain.Apps.Entities.Contents { try { - GuardContent.CanChangeContentStatus(Snapshot.IsPending, Snapshot.Status, c); + var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to change content."); + + GuardContent.CanChangeContentStatus(ctx.Schema, Snapshot.IsPending, Snapshot.Status, c); if (c.DueTime.HasValue) { @@ -114,8 +116,6 @@ namespace Squidex.Domain.Apps.Entities.Contents } else { - var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to change content."); - await ctx.ExecuteScriptAsync(x => x.ScriptChange, c.Status, c, Snapshot.Data); ChangeStatus(c); @@ -138,11 +138,11 @@ namespace Squidex.Domain.Apps.Entities.Contents case DeleteContent deleteContent: return UpdateAsync(deleteContent, async c => { - GuardContent.CanDelete(c); + var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to delete content."); - var operationContext = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to delete content."); + GuardContent.CanDelete(ctx.Schema, c); - await operationContext.ExecuteScriptAsync(x => x.ScriptDelete, "Delete", c, Snapshot.Data); + await ctx.ExecuteScriptAsync(x => x.ScriptDelete, "Delete", c, Snapshot.Data); Delete(c); }); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs index 5b1bc9474..97e729321 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs @@ -31,6 +31,11 @@ namespace Squidex.Domain.Apps.Entities.Contents private IAppEntity appEntity; private Func message; + public ISchemaEntity Schema + { + get { return schemaEntity; } + } + public static async Task CreateAsync( Guid appId, Guid schemaId, diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs index 8f5e4d1b8..8c02f6d88 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs @@ -5,16 +5,18 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.Guards { public static class GuardContent { - public static void CanCreate(CreateContent command) + public static void CanCreate(ISchemaEntity schema, CreateContent command) { Guard.NotNull(command, nameof(command)); @@ -22,6 +24,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards { ValidateData(command, e); }); + + if (schema.IsSingleton && command.ContentId != Guid.Empty) + { + throw new DomainException("Singleton content cannot be created."); + } } public static void CanUpdate(UpdateContent command) @@ -54,10 +61,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards } } - public static void CanChangeContentStatus(bool isPending, Status status, ChangeContentStatus command) + public static void CanChangeContentStatus(ISchemaEntity schema, bool isPending, Status status, ChangeContentStatus command) { Guard.NotNull(command, nameof(command)); + if (schema.IsSingleton && command.Status != Status.Published) + { + throw new DomainException("Singleton content archived or unpublished."); + } + Validate.It(() => "Cannot change status.", e => { if (!StatusFlow.Exists(command.Status)) @@ -86,9 +98,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards }); } - public static void CanDelete(DeleteContent command) + public static void CanDelete(ISchemaEntity schema, DeleteContent command) { Guard.NotNull(command, nameof(command)); + + if (schema.IsSingleton) + { + throw new DomainException("Singleton content cannot be deleted."); + } } private static void ValidateData(ContentDataCommand command, AddValidation e) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/SingletonCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Contents/SingletonCommandMiddleware.cs new file mode 100644 index 000000000..2f1547416 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/SingletonCommandMiddleware.cs @@ -0,0 +1,36 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.Schemas.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class SingletonCommandMiddleware : ICommandMiddleware + { + public async Task HandleAsync(CommandContext context, Func next) + { + await next(); + + if (context.IsCompleted && + context.Command is CreateSchema createSchema && + createSchema.Singleton) + { + var schemaId = new NamedId(createSchema.SchemaId, createSchema.Name); + + var command = SimpleMapper.Map(createSchema, new CreateContent { ContentId = Guid.Empty, SchemaId = schemaId, Publish = true }); + + await context.CommandBus.PublishAsync(command); + } + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs index f3a1ed76b..56357383f 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs @@ -5,10 +5,13 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using FakeItEasy; using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.Guards; +using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; using Xunit; @@ -17,6 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard { public class GuardContentTests { + private readonly ISchemaEntity schema = A.Fake(); private readonly Instant dueTimeInPast = SystemClock.Instance.GetCurrentInstant().Minus(Duration.FromHours(1)); [Fact] @@ -24,16 +28,36 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard { var command = new CreateContent(); - ValidationAssert.Throws(() => GuardContent.CanCreate(command), + ValidationAssert.Throws(() => GuardContent.CanCreate(schema, command), new ValidationError("Data is required.", "Data")); } + [Fact] + public void CanCreate_should_throw_exception_if_singleton() + { + A.CallTo(() => schema.IsSingleton).Returns(true); + + var command = new CreateContent { Data = new NamedContentData() }; + + Assert.Throws(() => GuardContent.CanCreate(schema, command)); + } + + [Fact] + public void CanCreate_should_not_throw_exception_if_singleton_and_id_empty() + { + A.CallTo(() => schema.IsSingleton).Returns(true); + + var command = new CreateContent { Data = new NamedContentData(), ContentId = Guid.Empty }; + + GuardContent.CanCreate(schema, command); + } + [Fact] public void CanCreate_should_not_throw_exception_if_data_is_not_null() { var command = new CreateContent { Data = new NamedContentData() }; - GuardContent.CanCreate(command); + GuardContent.CanCreate(schema, command); } [Fact] @@ -75,7 +99,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard { var command = new ChangeContentStatus { Status = (Status)10 }; - ValidationAssert.Throws(() => GuardContent.CanChangeContentStatus(false, Status.Archived, command), + ValidationAssert.Throws(() => GuardContent.CanChangeContentStatus(schema, false, Status.Archived, command), new ValidationError("Status is not valid.", "Status")); } @@ -84,7 +108,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard { var command = new ChangeContentStatus { Status = Status.Published }; - ValidationAssert.Throws(() => GuardContent.CanChangeContentStatus(false, Status.Archived, command), + ValidationAssert.Throws(() => GuardContent.CanChangeContentStatus(schema, false, Status.Archived, command), new ValidationError("Cannot change status from Archived to Published.", "Status")); } @@ -93,7 +117,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard { var command = new ChangeContentStatus { Status = Status.Published, DueTime = dueTimeInPast }; - ValidationAssert.Throws(() => GuardContent.CanChangeContentStatus(false, Status.Draft, command), + ValidationAssert.Throws(() => GuardContent.CanChangeContentStatus(schema, false, Status.Draft, command), new ValidationError("Due time must be in the future.", "DueTime")); } @@ -102,16 +126,28 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard { var command = new ChangeContentStatus { Status = Status.Published }; - ValidationAssert.Throws(() => GuardContent.CanChangeContentStatus(false, Status.Published, command), + ValidationAssert.Throws(() => GuardContent.CanChangeContentStatus(schema, false, Status.Published, command), new ValidationError("Content has no changes to publish.", "Status")); } + [Fact] + public void CanChangeContentStatus_should_throw_exception_if_singleton() + { + A.CallTo(() => schema.IsSingleton).Returns(true); + + var command = new ChangeContentStatus { Status = Status.Draft }; + + Assert.Throws(() => GuardContent.CanChangeContentStatus(schema, false, Status.Published, command)); + } + [Fact] public void CanChangeContentStatus_should_not_throw_exception_if_publishing_with_pending_changes() { + A.CallTo(() => schema.IsSingleton).Returns(true); + var command = new ChangeContentStatus { Status = Status.Published }; - GuardContent.CanChangeContentStatus(true, Status.Published, command); + GuardContent.CanChangeContentStatus(schema, true, Status.Published, command); } [Fact] @@ -119,7 +155,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard { var command = new ChangeContentStatus { Status = Status.Published }; - GuardContent.CanChangeContentStatus(false, Status.Draft, command); + GuardContent.CanChangeContentStatus(schema, false, Status.Draft, command); } [Fact] @@ -139,11 +175,21 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard } [Fact] - public void CanPatch_should_not_throw_exception() + public void CanDelete_should_throw_exception_if_singleton() + { + A.CallTo(() => schema.IsSingleton).Returns(true); + + var command = new DeleteContent(); + + Assert.Throws(() => GuardContent.CanDelete(schema, command)); + } + + [Fact] + public void CanDelete_should_not_throw_exception() { var command = new DeleteContent(); - GuardContent.CanDelete(command); + GuardContent.CanDelete(schema, command); } } } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/SingletonCommandMiddlewareTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/SingletonCommandMiddlewareTests.cs new file mode 100644 index 000000000..27587abd7 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/SingletonCommandMiddlewareTests.cs @@ -0,0 +1,60 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.Schemas.Commands; +using Squidex.Infrastructure.Commands; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class SingletonCommandMiddlewareTests + { + private readonly ICommandBus commandBus = A.Fake(); + private readonly SingletonCommandMiddleware sut = new SingletonCommandMiddleware(); + + [Fact] + public async Task Should_create_content_when_singleton_schema_is_created() + { + var context = + new CommandContext(new CreateSchema { Singleton = true, Name = "my-schema" }, commandBus) + .Complete(); + + await sut.HandleAsync(context); + + A.CallTo(() => commandBus.PublishAsync(A.That.Matches(x => x is CreateContent))) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_not_create_content_when_non_singleton_schema_is_created() + { + var context = + new CommandContext(new CreateSchema { Singleton = false }, commandBus) + .Complete(); + + await sut.HandleAsync(context); + + A.CallTo(() => commandBus.PublishAsync(A.Ignored)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_create_content_when_singleton_schema_not_created() + { + var context = + new CommandContext(new CreateSchema { Singleton = true }, commandBus); + + await sut.HandleAsync(context); + + A.CallTo(() => commandBus.PublishAsync(A.Ignored)) + .MustNotHaveHappened(); + } + } +}