Browse Source

Merge pull request #374 from Squidex/workflow-generalization

Workflow generalization
pull/375/head^2
Sebastian Stehle 7 years ago
committed by GitHub
parent
commit
511b5afa9b
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 42
      src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs
  2. 53
      src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs
  3. 46
      src/Squidex.Domain.Apps.Core.Model/Contents/Status2.cs
  4. 5
      src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs
  5. 37
      src/Squidex.Domain.Apps.Core.Model/Contents/StatusFlow.cs
  6. 5
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs
  7. 1
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs
  8. 8
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/StatusSerializer.cs
  9. 7
      src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs
  10. 21
      src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs
  11. 38
      src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs
  12. 13
      src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs
  13. 54
      src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs
  14. 4
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs
  15. 4
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs
  16. 35
      src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs
  17. 5
      src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs
  18. 10
      src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs
  19. 5
      src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs
  20. 2
      src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs
  21. 2
      src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs
  22. 4
      src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs
  23. 29
      src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  24. 2
      src/Squidex/Areas/Api/Controllers/Contents/Helper.cs
  25. 26
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  26. 36
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs
  27. 3
      src/Squidex/Config/Domain/EntitiesServices.cs
  28. 2
      src/Squidex/Config/Domain/SerializationServices.cs
  29. 34
      tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/StatusFlowTests.cs
  30. 84
      tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/StatusTests.cs
  31. 1
      tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj
  32. 2
      tests/Squidex.Domain.Apps.Core.Tests/TestUtils.cs
  33. 3
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs
  34. 39
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentGrainTests.cs
  35. 15
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs
  36. 122
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs
  37. 3
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs
  38. 120
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs
  39. 4
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/StatusSerializerTests.cs
  40. 12
      tools/Migrate_01/OldTriggers/ContentChangedTriggerSchema.cs

42
src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs

@ -0,0 +1,42 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Newtonsoft.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
using System;
using System.Collections.Generic;
namespace Squidex.Domain.Apps.Core.Contents.Json
{
public sealed class StatusConverter : JsonConverter, ISupportedTypes
{
public IEnumerable<Type> SupportedTypes
{
get { yield return typeof(Status); }
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
writer.WriteValue(value.ToString());
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
if (reader.TokenType != JsonToken.String)
{
throw new JsonException($"Expected String, but got {reader.TokenType}.");
}
return new Status(reader.Value.ToString());
}
public override bool CanConvert(Type objectType)
{
return objectType == typeof(Status);
}
}
}

53
src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs

@ -5,12 +5,57 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using System;
namespace Squidex.Domain.Apps.Core.Contents
{
public enum Status
public struct Status : IEquatable<Status>
{
Draft,
Archived,
Published
public static readonly Status Archived = new Status("Archived");
public static readonly Status Draft = new Status("Draft");
public static readonly Status Published = new Status("Published");
private readonly string name;
public string Name
{
get { return name ?? "Unknown"; }
}
public Status(string name)
{
this.name = name;
}
public override bool Equals(object obj)
{
return obj is Status status && Equals(status);
}
public bool Equals(Status other)
{
return string.Equals(name, other.name);
}
public override int GetHashCode()
{
return name?.GetHashCode() ?? 0;
}
public override string ToString()
{
return name;
}
public static bool operator ==(Status lhs, Status rhs)
{
return lhs.Equals(rhs);
}
public static bool operator !=(Status lhs, Status rhs)
{
return !lhs.Equals(rhs);
}
}
}

46
src/Squidex.Domain.Apps.Core.Model/Contents/Status2.cs

@ -1,46 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using System;
namespace Squidex.Domain.Apps.Core.Contents
{
public struct Status2 : IEquatable<Status2>
{
public static readonly Status2 Published = new Status2("Published");
public string Name { get; }
public Status2(string name)
{
Guard.NotNullOrEmpty(name, nameof(name));
Name = name;
}
public override bool Equals(object obj)
{
return obj is Status2 status && Equals(status);
}
public bool Equals(Status2 other)
{
return Name.Equals(other.Name);
}
public override int GetHashCode()
{
return base.GetHashCode();
}
public override string ToString()
{
return Name;
}
}
}

5
src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs

@ -1,7 +1,7 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
@ -9,9 +9,8 @@ namespace Squidex.Domain.Apps.Core.Contents
{
public enum StatusChange
{
Archived,
Change,
Published,
Restored,
Unpublished
}
}

37
src/Squidex.Domain.Apps.Core.Model/Contents/StatusFlow.cs

@ -1,37 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
namespace Squidex.Domain.Apps.Core.Contents
{
public static class StatusFlow
{
private static readonly Dictionary<Status, Status[]> Flow = new Dictionary<Status, Status[]>
{
[Status.Draft] = new[] { Status.Published, Status.Archived },
[Status.Archived] = new[] { Status.Draft },
[Status.Published] = new[] { Status.Draft, Status.Archived }
};
public static bool Exists(Status status)
{
return Flow.ContainsKey(status);
}
public static bool CanChange(Status status, Status toStatus)
{
return Flow.TryGetValue(status, out var state) && state.Contains(toStatus);
}
public static IEnumerable<Status> Next(Status status)
{
return Flow.TryGetValue(status, out var result) ? result : Enumerable.Empty<Status>();
}
}
}

5
src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs

@ -9,12 +9,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents
{
public enum EnrichedContentEventType
{
Archived,
Created,
Deleted,
Published,
Restored,
StatusChanged,
Updated,
Unpublished,
Updated
}
}

1
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs

@ -51,7 +51,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
[BsonRequired]
[BsonElement("ss")]
[BsonRepresentation(BsonType.String)]
public Status Status { get; set; }
[BsonIgnoreIfNull]

8
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/StatusSerializer.cs

@ -12,7 +12,7 @@ using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
public sealed class StatusSerializer : SerializerBase<Status2>
public sealed class StatusSerializer : SerializerBase<Status>
{
private static volatile int isRegistered;
@ -24,14 +24,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
}
}
public override Status2 Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
public override Status Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
{
var value = context.Reader.ReadString();
return new Status2(value);
return new Status(value);
}
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, Status2 value)
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, Status value)
{
context.Writer.WriteString(value.Name);
}

7
src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs

@ -69,11 +69,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
case StatusChange.Unpublished:
result.Type = EnrichedContentEventType.Unpublished;
break;
case StatusChange.Archived:
result.Type = EnrichedContentEventType.Archived;
break;
case StatusChange.Restored:
result.Type = EnrichedContentEventType.Restored;
default:
result.Type = EnrichedContentEventType.StatusChanged;
break;
}

21
src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs

@ -8,9 +8,7 @@
using System;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Contents
{
@ -41,24 +39,5 @@ namespace Squidex.Domain.Apps.Entities.Contents
public Status Status { get; set; }
public bool IsPending { get; set; }
public static ContentEntity Create(CreateContent command, EntityCreatedResult<NamedContentData> result)
{
var now = SystemClock.Instance.GetCurrentInstant();
var response = new ContentEntity
{
Id = command.ContentId,
Data = result.IdOrValue,
Version = result.Version,
Created = now,
CreatedBy = command.Actor,
LastModified = now,
LastModifiedBy = command.Actor,
Status = command.Publish ? Status.Published : Status.Draft
};
return response;
}
}
}

38
src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs

@ -32,6 +32,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
private readonly IAssetRepository assetRepository;
private readonly IContentRepository contentRepository;
private readonly IScriptEngine scriptEngine;
private readonly IContentWorkflow contentWorkflow;
public ContentGrain(
IStore<Guid> store,
@ -39,17 +40,20 @@ namespace Squidex.Domain.Apps.Entities.Contents
IAppProvider appProvider,
IAssetRepository assetRepository,
IScriptEngine scriptEngine,
IContentWorkflow contentWorkflow,
IContentRepository contentRepository)
: base(store, log)
{
Guard.NotNull(appProvider, nameof(appProvider));
Guard.NotNull(scriptEngine, nameof(scriptEngine));
Guard.NotNull(assetRepository, nameof(assetRepository));
Guard.NotNull(contentWorkflow, nameof(contentWorkflow));
Guard.NotNull(contentRepository, nameof(contentRepository));
this.appProvider = appProvider;
this.scriptEngine = scriptEngine;
this.assetRepository = assetRepository;
this.contentWorkflow = contentWorkflow;
this.contentRepository = contentRepository;
}
@ -79,25 +83,27 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ctx.ExecuteScriptAsync(s => s.Change, "Published", c, c.Data);
}
Create(c);
var status = await contentWorkflow.GetInitialStatusAsync(ctx.Schema);
Create(c, status);
return Snapshot;
});
case UpdateContent updateContent:
return UpdateReturnAsync(updateContent, c =>
return UpdateReturnAsync(updateContent, async c =>
{
GuardContent.CanUpdate(c);
await GuardContent.CanUpdate(Snapshot, contentWorkflow, c);
return UpdateAsync(c, x => c.Data, false);
return await UpdateAsync(c, x => c.Data, false);
});
case PatchContent patchContent:
return UpdateReturnAsync(patchContent, c =>
return UpdateReturnAsync(patchContent, async c =>
{
GuardContent.CanPatch(c);
await GuardContent.CanPatch(Snapshot, contentWorkflow, c);
return UpdateAsync(c, c.Data.MergeInto, true);
return await UpdateAsync(c, c.Data.MergeInto, true);
});
case ChangeContentStatus changeContentStatus:
@ -107,7 +113,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, Snapshot.Id, () => "Failed to change content.");
GuardContent.CanChangeContentStatus(ctx.Schema, Snapshot.IsPending, Snapshot.Status, c);
await GuardContent.CanChangeStatus(ctx.Schema, Snapshot, contentWorkflow, c);
if (c.DueTime.HasValue)
{
@ -127,17 +133,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
reason = StatusChange.Published;
}
else if (c.Status == Status.Archived)
{
reason = StatusChange.Archived;
}
else if (Snapshot.Status == Status.Published)
{
reason = StatusChange.Unpublished;
}
else
{
reason = StatusChange.Restored;
reason = StatusChange.Change;
}
await ctx.ExecuteScriptAsync(s => s.Change, reason, c, Snapshot.Data);
@ -227,13 +229,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
return Snapshot;
}
public void Create(CreateContent command)
public void Create(CreateContent command, Status status)
{
RaiseEvent(SimpleMapper.Map(command, new ContentCreated()));
RaiseEvent(SimpleMapper.Map(command, new ContentCreated { Status = status }));
if (command.Publish)
{
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Status = Status.Published, Change = StatusChange.Published }));
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Status = Status.Published }));
}
}
@ -272,9 +274,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
RaiseEvent(SimpleMapper.Map(command, new ContentStatusScheduled { DueTime = command.DueTime.Value }));
}
public void ChangeStatus(ChangeContentStatus command, StatusChange reason)
public void ChangeStatus(ChangeContentStatus command, StatusChange change)
{
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Change = reason }));
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Change = change }));
}
private void RaiseEvent(SchemaEvent @event)

13
src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs

@ -73,16 +73,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
this.scriptEngine = scriptEngine;
}
public Task ThrowIfSchemaNotExistsAsync(QueryContext context, string schemaIdOrName)
{
return GetSchemaAsync(context, schemaIdOrName);
}
public async Task<IContentEntity> FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = -1)
{
Guard.NotNull(context, nameof(context));
var schema = await GetSchemaAsync(context, schemaIdOrName);
var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName);
CheckPermission(context.User, schema);
@ -110,7 +105,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
Guard.NotNull(context, nameof(context));
var schema = await GetSchemaAsync(context, schemaIdOrName);
var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName);
CheckPermission(context.User, schema);
@ -136,7 +131,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
public async Task<IList<IContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids)
public async Task<IReadOnlyList<IContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids)
{
Guard.NotNull(context, nameof(context));
@ -295,7 +290,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
public async Task<ISchemaEntity> GetSchemaAsync(QueryContext context, string schemaIdOrName)
public async Task<ISchemaEntity> GetSchemaOrThrowAsync(QueryContext context, string schemaIdOrName)
{
ISchemaEntity schema = null;

54
src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs

@ -0,0 +1,54 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class DefaultContentWorkflow : IContentWorkflow
{
private static readonly Status[] All = { Status.Archived, Status.Draft, Status.Published };
private static readonly Dictionary<Status, Status[]> Flow = new Dictionary<Status, Status[]>
{
[Status.Draft] = new[] { Status.Archived, Status.Published },
[Status.Archived] = new[] { Status.Draft },
[Status.Published] = new[] { Status.Draft, Status.Archived }
};
public Task<Status> GetInitialStatusAsync(ISchemaEntity schema)
{
return Task.FromResult(Status.Draft);
}
public Task<bool> CanMoveToAsync(IContentEntity content, Status next)
{
return Task.FromResult(Flow.TryGetValue(content.Status, out var state) && state.Contains(next));
}
public Task<bool> CanUpdateAsync(IContentEntity content)
{
return Task.FromResult(content.Status != Status.Archived);
}
public Task<Status[]> GetNextsAsync(IContentEntity content)
{
return Task.FromResult(Flow.TryGetValue(content.Status, out var result) ? result : Array.Empty<Status>());
}
public Task<Status[]> GetAllAsync(ISchemaEntity schema)
{
return Task.FromResult(All);
}
}
}

4
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs

@ -28,8 +28,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
public static readonly IGraphType Float = new FloatGraphType();
public static readonly IGraphType Status = new EnumerationGraphType<Status>();
public static readonly IGraphType String = new StringGraphType();
public static readonly IGraphType Boolean = new BooleanGraphType();
@ -46,8 +44,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
public static readonly IGraphType NonNullBoolean = new NonNullGraphType(Boolean);
public static readonly IGraphType NonNullStatusType = new NonNullGraphType(Status);
public static readonly IGraphType NoopDate = new NoopGraphType(Date);
public static readonly IGraphType NoopJson = new NoopGraphType(Json);

4
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs

@ -73,8 +73,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
AddField(new FieldType
{
Name = "status",
ResolvedType = AllTypes.NonNullStatusType,
Resolver = Resolve(x => x.Status),
ResolvedType = AllTypes.NonNullString,
Resolver = Resolve(x => x.Status.Name.ToUpperInvariant()),
Description = $"The the status of the {schemaName} content."
});

35
src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands;
@ -30,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
}
}
public static void CanUpdate(UpdateContent command)
public static async Task CanUpdate(IContentEntity content, IContentWorkflow contentWorkflow, UpdateContent command)
{
Guard.NotNull(command, nameof(command));
@ -38,9 +39,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
{
ValidateData(command, e);
});
await ValidateCanUpdate(content, contentWorkflow);
}
public static void CanPatch(PatchContent command)
public static async Task CanPatch(IContentEntity content, IContentWorkflow contentWorkflow, PatchContent command)
{
Guard.NotNull(command, nameof(command));
@ -48,6 +51,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
{
ValidateData(command, e);
});
await ValidateCanUpdate(content, contentWorkflow);
}
public static void CanDiscardChanges(bool isPending, DiscardChanges command)
@ -60,33 +65,29 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
}
}
public static void CanChangeContentStatus(ISchemaEntity schema, bool isPending, Status status, ChangeContentStatus command)
public static Task CanChangeStatus(ISchemaEntity schema, IContentEntity content, IContentWorkflow contentWorkflow, ChangeContentStatus command)
{
Guard.NotNull(command, nameof(command));
if (schema.SchemaDef.IsSingleton && command.Status != Status.Published)
{
throw new DomainException("Singleton content archived or unpublished.");
throw new DomainException("Singleton content cannot be changed.");
}
Validate.It(() => "Cannot change status.", e =>
return Validate.It(() => "Cannot change status.", async e =>
{
if (!StatusFlow.Exists(command.Status))
if (!await contentWorkflow.CanMoveToAsync(content, command.Status))
{
e(Not.Valid("Status"), nameof(command.Status));
}
else if (!StatusFlow.CanChange(status, command.Status))
{
if (status == command.Status && status == Status.Published)
if (content.Status == command.Status && content.Status == Status.Published)
{
if (!isPending)
if (!content.IsPending)
{
e("Content has no changes to publish.", nameof(command.Status));
}
}
else
{
e($"Cannot change status from {status} to {command.Status}.", nameof(command.Status));
e($"Cannot change status from {content.Status} to {command.Status}.", nameof(command.Status));
}
}
@ -114,5 +115,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
e(Not.Defined("Data"), nameof(command.Data));
}
}
private static async Task ValidateCanUpdate(IContentEntity content, IContentWorkflow contentWorkflow)
{
if (!await contentWorkflow.CanUpdateAsync(content))
{
throw new DomainException($"The workflow does not allow updates at status {content.Status}");
}
}
}
}

5
src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs

@ -8,6 +8,7 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents
@ -16,12 +17,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
int DefaultPageSizeGraphQl { get; }
Task<IList<IContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids);
Task<IReadOnlyList<IContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids);
Task<IResultList<IContentEntity>> QueryAsync(QueryContext context, string schemaIdOrName, Q query);
Task<IContentEntity> FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = EtagVersion.Any);
Task ThrowIfSchemaNotExistsAsync(QueryContext context, string schemaIdOrName);
Task<ISchemaEntity> GetSchemaOrThrowAsync(QueryContext context, string schemaIdOrName);
}
}

10
src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs

@ -13,12 +13,14 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
public interface IContentWorkflow
{
Task<Status2> GetInitialStatusAsync(ISchemaEntity schema);
Task<Status> GetInitialStatusAsync(ISchemaEntity schema);
Task<bool> IsValidNextStatus(IContentEntity content, Status2 next);
Task<bool> CanMoveToAsync(IContentEntity content, Status next);
Task<Status2[]> GetNextsAsync(IContentEntity content);
Task<bool> CanUpdateAsync(IContentEntity content);
Task<Status2[]> GetAllAsync(ISchemaEntity schema);
Task<Status[]> GetNextsAsync(IContentEntity content);
Task<Status[]> GetAllAsync(ISchemaEntity schema);
}
}

5
src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs

@ -50,6 +50,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
SimpleMapper.Map(@event, this);
UpdateData(null, @event.Data, false);
if (Status == default)
{
Status = Status.Draft;
}
}
protected void On(ContentChangesPublished @event)

2
src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs

@ -13,6 +13,8 @@ namespace Squidex.Domain.Apps.Events.Contents
[EventType(nameof(ContentCreated))]
public sealed class ContentCreated : ContentEvent
{
public Status Status { get; set; }
public NamedContentData Data { get; set; }
}
}

2
src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs

@ -13,7 +13,7 @@ namespace Squidex.Domain.Apps.Events.Contents
[EventType(nameof(ContentStatusChanged))]
public sealed class ContentStatusChanged : ContentEvent
{
public StatusChange? Change { get; set; }
public StatusChange Change { get; set; }
public Status Status { get; set; }
}

4
src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs

@ -14,6 +14,7 @@ using NSwag.SwaggerGeneration;
using NSwag.SwaggerGeneration.Processors;
using Squidex.Areas.Api.Controllers.Contents.Generator;
using Squidex.Areas.Api.Controllers.Rules.Models;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
namespace Squidex.Areas.Api.Config.Swagger
@ -76,7 +77,8 @@ namespace Squidex.Areas.Api.Config.Swagger
}),
new PrimitiveTypeMapper(typeof(Language), s => s.Type = JsonObjectType.String),
new PrimitiveTypeMapper(typeof(RefToken), s => s.Type = JsonObjectType.String)
new PrimitiveTypeMapper(typeof(RefToken), s => s.Type = JsonObjectType.String),
new PrimitiveTypeMapper(typeof(Status), s => s.Type = JsonObjectType.String),
};
}
}

29
src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -16,6 +16,7 @@ using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Contents.GraphQL;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Shared;
using Squidex.Web;
@ -26,15 +27,18 @@ namespace Squidex.Areas.Api.Controllers.Contents
{
private readonly IOptions<MyContentsControllerOptions> controllerOptions;
private readonly IContentQueryService contentQuery;
private readonly IContentWorkflow contentWorkflow;
private readonly IGraphQLService graphQl;
public ContentsController(ICommandBus commandBus,
IContentQueryService contentQuery,
IContentWorkflow contentWorkflow,
IGraphQLService graphQl,
IOptions<MyContentsControllerOptions> controllerOptions)
: base(commandBus)
{
this.contentQuery = contentQuery;
this.contentWorkflow = contentWorkflow;
this.controllerOptions = controllerOptions;
this.graphQl = graphQl;
@ -123,8 +127,9 @@ namespace Squidex.Areas.Api.Controllers.Contents
{
var context = Context();
var contents = await contentQuery.QueryAsync(context, Q.Empty.WithIds(ids).Ids);
var contentsList = ResultList.Create<IContentEntity>(contents.Count, contents);
var response = ContentsDto.FromContents(contents.Count, contents, context, this, app, null);
var response = await ContentsDto.FromContentsAsync(contentsList, context, this, null, contentWorkflow);
if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys)
{
@ -159,7 +164,9 @@ namespace Squidex.Areas.Api.Controllers.Contents
var context = Context();
var contents = await contentQuery.QueryAsync(context, name, Q.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString()));
var response = ContentsDto.FromContents(contents.Total, contents, context, this, app, name);
var schema = await contentQuery.GetSchemaOrThrowAsync(context, name);
var response = await ContentsDto.FromContentsAsync(contents, context, this, schema, contentWorkflow);
if (ShouldProvideSurrogateKeys(response))
{
@ -194,7 +201,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var context = Context();
var content = await contentQuery.FindContentAsync(context, name, id);
var response = ContentDto.FromContent(content, context, this, app, name);
var response = await ContentDto.FromContentAsync(context, content, contentWorkflow, this);
if (controllerOptions.Value.EnableSurrogateKeys)
{
@ -230,7 +237,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var context = Context();
var content = await contentQuery.FindContentAsync(context, name, id, version);
var response = ContentDto.FromContent(content, context, this, app, name);
var response = await ContentDto.FromContentAsync(context, content, contentWorkflow, this);
if (controllerOptions.Value.EnableSurrogateKeys)
{
@ -264,7 +271,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> PostContent(string app, string name, [FromBody] NamedContentData request, [FromQuery] bool publish = false)
{
await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
if (publish && !this.HasPermission(Helper.StatusPermission(app, name, Status.Published)))
{
@ -301,7 +308,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> PutContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false)
{
await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
var command = new UpdateContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft };
@ -333,7 +340,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false)
{
await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
var command = new PatchContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft };
@ -364,7 +371,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> PutContentStatus(string app, string name, Guid id, ChangeStatusDto request)
{
await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
if (!this.HasPermission(Helper.StatusPermission(app, name, Status.Published)))
{
@ -399,7 +406,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> DiscardDraft(string app, string name, Guid id)
{
await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
var command = new DiscardChanges { ContentId = id };
@ -427,7 +434,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> DeleteContent(string app, string name, Guid id)
{
await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
var command = new DeleteContent { ContentId = id };
@ -441,7 +448,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var context = await CommandBus.PublishAsync(command);
var result = context.Result<IContentEntity>();
var response = ContentDto.FromContent(result, null, this, app, schema);
var response = await ContentDto.FromContentAsync(null, result, contentWorkflow, this);
return response;
}

2
src/Squidex/Areas/Api/Controllers/Contents/Helper.cs

@ -15,7 +15,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
{
public static Permission StatusPermission(string app, string schema, Status status)
{
var id = Permissions.AppContentsStatus.Replace("{status}", status.ToString());
var id = Permissions.AppContentsStatus.Replace("{status}", status.Name);
return Permissions.ForApp(id, app, schema);
}

26
src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs

@ -7,6 +7,7 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ConvertContent;
@ -79,7 +80,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
/// </summary>
public long Version { get; set; }
public static ContentDto FromContent(IContentEntity content, QueryContext context, ApiController controller, string app, string schema)
public static ValueTask<ContentDto> FromContentAsync(
QueryContext context,
IContentEntity content,
IContentWorkflow contentWorkflow,
ApiController controller)
{
var response = SimpleMapper.Map(content, new ContentDto());
@ -99,10 +104,14 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
response.ScheduleJob = SimpleMapper.Map(content.ScheduleJob, new ScheduleJobDto());
}
return response.CreateLinks(controller, app, schema);
return response.CreateLinksAsync(content, controller, content.AppId.Name, content.SchemaId.Name, contentWorkflow);
}
private ContentDto CreateLinks(ApiController controller, string app, string schema)
private async ValueTask<ContentDto> CreateLinksAsync(IContentEntity content,
ApiController controller,
string app,
string schema,
IContentWorkflow contentWorkflow)
{
var values = new { app, name = schema, id = Id };
@ -130,11 +139,14 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
if (controller.HasPermission(Permissions.AppContentsUpdate, app, schema))
{
AddPutLink("update", controller.Url<ContentsController>(x => nameof(x.PutContent), values));
if (await contentWorkflow.CanUpdateAsync(content))
{
AddPutLink("update", controller.Url<ContentsController>(x => nameof(x.PutContent), values));
}
if (Status == Status.Published)
{
AddPutLink("draft/propose", controller.Url<ContentsController>(x => nameof(x.PutContent), values) + "?asDraft=true");
AddPutLink("draft/propose", controller.Url((ContentsController x) => nameof(x.PutContent), values) + "?asDraft=true");
}
AddPatchLink("patch", controller.Url<ContentsController>(x => nameof(x.PatchContent), values));
@ -145,7 +157,9 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
AddDeleteLink("delete", controller.Url<ContentsController>(x => nameof(x.DeleteContent), values));
}
foreach (var next in StatusFlow.Next(Status))
var nextStatuses = await contentWorkflow.GetNextsAsync(content);
foreach (var next in nextStatuses)
{
if (controller.HasPermission(Helper.StatusPermission(app, schema, next)))
{

36
src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs

@ -5,12 +5,13 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Shared;
using Squidex.Web;
@ -34,7 +35,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
/// The possible statuses.
/// </summary>
[Required]
public string[] Statuses { get; set; }
public Status[] Statuses { get; set; }
public string ToEtag()
{
@ -46,20 +47,37 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
return Items.ToSurrogateKeys();
}
public static ContentsDto FromContents(long total, IEnumerable<IContentEntity> contents, QueryContext context,
public static async Task<ContentsDto> FromContentsAsync(IResultList<IContentEntity> contents, QueryContext context,
ApiController controller,
string app,
string schema)
ISchemaEntity schema,
IContentWorkflow contentWorkflow)
{
var result = new ContentsDto
{
Total = total,
Items = contents.Select(x => ContentDto.FromContent(x, context, controller, app, schema)).ToArray(),
Total = contents.Total,
Items = new ContentDto[contents.Count]
};
result.Statuses = new string[] { "Archived", "Draft", "Published" };
await Task.WhenAll(
result.AssignContentsAsync(contentWorkflow, contents, context, controller),
result.AssignStatusesAsync(contentWorkflow, schema));
return result.CreateLinks(controller, app, schema);
return result.CreateLinks(controller, schema.AppId.Name, schema.SchemaDef.Name);
}
private async Task AssignStatusesAsync(IContentWorkflow contentWorkflow, ISchemaEntity schema)
{
var allStatuses = await contentWorkflow.GetAllAsync(schema);
Statuses = allStatuses.ToArray();
}
private async Task AssignContentsAsync(IContentWorkflow contentWorkflow, IResultList<IContentEntity> contents, QueryContext context, ApiController controller)
{
for (var i = 0; i < Items.Length; i++)
{
Items[i] = await ContentDto.FromContentAsync(context, contents[i], contentWorkflow, controller);
}
}
private ContentsDto CreateLinks(ApiController controller, string app, string schema)

3
src/Squidex/Config/Domain/EntitiesServices.cs

@ -115,6 +115,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<SchemaHistoryEventsCreator>()
.As<IHistoryEventsCreator>();
services.AddSingletonAs<DefaultContentWorkflow>()
.AsOptional<IContentWorkflow>();
services.AddSingletonAs<RolePermissionsProvider>()
.AsSelf();

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

@ -11,6 +11,7 @@ using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Apps.Json;
using Squidex.Domain.Apps.Core.Contents.Json;
using Squidex.Domain.Apps.Core.Rules.Json;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.Schemas.Json;
@ -44,6 +45,7 @@ namespace Squidex.Config.Domain
new RolesConverter(),
new RuleConverter(),
new SchemaConverter(),
new StatusConverter(),
new StringEnumConverter());
settings.NullValueHandling = NullValueHandling.Ignore;

34
tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/StatusFlowTests.cs

@ -1,34 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
using Xunit;
namespace Squidex.Domain.Apps.Core.Model.Contents
{
public class StatusFlowTests
{
[Fact]
public void Should_make_tests()
{
Assert.True(StatusFlow.Exists(Status.Draft));
Assert.True(StatusFlow.Exists(Status.Archived));
Assert.True(StatusFlow.Exists(Status.Published));
Assert.True(StatusFlow.CanChange(Status.Draft, Status.Archived));
Assert.True(StatusFlow.CanChange(Status.Draft, Status.Published));
Assert.True(StatusFlow.CanChange(Status.Published, Status.Draft));
Assert.True(StatusFlow.CanChange(Status.Published, Status.Archived));
Assert.True(StatusFlow.CanChange(Status.Archived, Status.Draft));
Assert.False(StatusFlow.Exists((Status)int.MaxValue));
Assert.False(StatusFlow.CanChange(Status.Archived, Status.Published));
}
}
}

84
tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/StatusTests.cs

@ -0,0 +1,84 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
using Xunit;
namespace Squidex.Domain.Apps.Core.Model.Contents
{
public class StatusTests
{
[Fact]
public void Should_initialize_status_from_string()
{
var result = new Status("Custom");
Assert.Equal("Custom", result.Name);
Assert.Equal("Custom", result.ToString());
}
[Fact]
public void Should_provide_draft_status()
{
var result = Status.Draft;
Assert.Equal("Draft", result.Name);
Assert.Equal("Draft", result.ToString());
}
[Fact]
public void Should_provide_archived_status()
{
var result = Status.Archived;
Assert.Equal("Archived", result.Name);
Assert.Equal("Archived", result.ToString());
}
[Fact]
public void Should_provide_published_status()
{
var result = Status.Published;
Assert.Equal("Published", result.Name);
Assert.Equal("Published", result.ToString());
}
[Fact]
public void Should_make_correct_equal_comparisons()
{
var status_1_a = Status.Draft;
var status_1_b = Status.Draft;
var status2_a = Status.Published;
Assert.Equal(status_1_a, status_1_b);
Assert.Equal(status_1_a.GetHashCode(), status_1_b.GetHashCode());
Assert.True(status_1_a.Equals((object)status_1_b));
Assert.NotEqual(status_1_a, status2_a);
Assert.NotEqual(status_1_a.GetHashCode(), status2_a.GetHashCode());
Assert.False(status_1_a.Equals((object)status2_a));
Assert.True(status_1_a == status_1_b);
Assert.True(status_1_a != status2_a);
Assert.False(status_1_a != status_1_b);
Assert.False(status_1_a == status2_a);
}
[Fact]
public void Should_serialize_and_deserialize()
{
var status = Status.Draft;
var serialized = status.SerializeAndDeserialize();
Assert.Equal(status, serialized);
}
}
}

1
tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj

@ -9,6 +9,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Squidex.Domain.Apps.Core.Model\Squidex.Domain.Apps.Core.Model.csproj" />
<ProjectReference Include="..\..\src\Squidex.Domain.Apps.Core.Operations\Squidex.Domain.Apps.Core.Operations.csproj" />
<ProjectReference Include="..\..\src\Squidex.Domain.Apps.Entities\Squidex.Domain.Apps.Entities.csproj" />
<ProjectReference Include="..\..\src\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>

2
tests/Squidex.Domain.Apps.Core.Tests/TestUtils.cs

@ -11,6 +11,7 @@ using System.Reflection;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Squidex.Domain.Apps.Core.Apps.Json;
using Squidex.Domain.Apps.Core.Contents.Json;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Json;
using Squidex.Domain.Apps.Core.Schemas;
@ -56,6 +57,7 @@ namespace Squidex.Domain.Apps.Core
new RolesConverter(),
new RuleConverter(),
new SchemaConverter(),
new StatusConverter(),
new StringEnumConverter()),
TypeNameHandling = typeNameHandling

3
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs

@ -54,9 +54,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
new object[] { new ContentCreated(), EnrichedContentEventType.Created },
new object[] { new ContentUpdated(), EnrichedContentEventType.Updated },
new object[] { new ContentDeleted(), EnrichedContentEventType.Deleted },
new object[] { new ContentStatusChanged { Change = StatusChange.Archived }, EnrichedContentEventType.Archived },
new object[] { new ContentStatusChanged { Change = StatusChange.Change }, EnrichedContentEventType.StatusChanged },
new object[] { new ContentStatusChanged { Change = StatusChange.Published }, EnrichedContentEventType.Published },
new object[] { new ContentStatusChanged { Change = StatusChange.Restored }, EnrichedContentEventType.Restored },
new object[] { new ContentStatusChanged { Change = StatusChange.Unpublished }, EnrichedContentEventType.Unpublished }
};

39
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentGrainTests.cs

@ -33,6 +33,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
private readonly ISchemaEntity schema = A.Fake<ISchemaEntity>();
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
private readonly IContentRepository contentRepository = A.Dummy<IContentRepository>();
private readonly IContentWorkflow contentWorkflow = A.Fake<IContentWorkflow>();
private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly IAppEntity app = A.Fake<IAppEntity>();
private readonly LanguagesConfig languagesConfig = LanguagesConfig.Build(Language.DE);
@ -100,9 +102,15 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, A<string>.Ignored))
.ReturnsLazily(x => x.GetArgument<ScriptContext>(0).Data);
A.CallTo(() => contentWorkflow.CanUpdateAsync(A<IContentEntity>.Ignored))
.Returns(true);
A.CallTo(() => contentWorkflow.CanMoveToAsync(A<IContentEntity>.Ignored, A<Status>.Ignored))
.Returns(true);
patched = patch.MergeInto(data);
sut = new ContentGrain(Store, A.Dummy<ISemanticLog>(), appProvider, A.Dummy<IAssetRepository>(), scriptEngine, A.Dummy<IContentRepository>());
sut = new ContentGrain(Store, A.Dummy<ISemanticLog>(), appProvider, A.Dummy<IAssetRepository>(), scriptEngine, contentWorkflow, contentRepository);
sut.ActivateAsync(Id).Wait();
}
@ -135,6 +143,26 @@ namespace Squidex.Domain.Apps.Entities.Contents
.MustNotHaveHappened();
}
[Fact]
public async Task Create_should_create_events_and_update_state_with_custom_initial_status()
{
var command = new CreateContent { Data = data };
A.CallTo(() => contentWorkflow.GetInitialStatusAsync(schema))
.Returns(Status.Archived);
var result = await sut.ExecuteAsync(CreateContentCommand(command));
result.ShouldBeEquivalent(sut.Snapshot);
Assert.Equal(Status.Archived, sut.Snapshot.Status);
LastEvents
.ShouldHaveSameEvents(
CreateContentEvent(new ContentCreated { Data = data, Status = Status.Archived })
);
}
[Fact]
public async Task Create_should_also_publish()
{
@ -147,7 +175,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
LastEvents
.ShouldHaveSameEvents(
CreateContentEvent(new ContentCreated { Data = data }),
CreateContentEvent(new ContentStatusChanged { Status = Status.Published, Change = StatusChange.Published })
CreateContentEvent(new ContentStatusChanged { Status = Status.Published })
);
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<create-script>"))
@ -337,7 +365,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
LastEvents
.ShouldHaveSameEvents(
CreateContentEvent(new ContentStatusChanged { Status = Status.Archived, Change = StatusChange.Archived })
CreateContentEvent(new ContentStatusChanged { Status = Status.Archived })
);
A.CallTo(() => scriptEngine.Execute(A<ScriptContext>.Ignored, "<change-script>"))
@ -383,7 +411,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
LastEvents
.ShouldHaveSameEvents(
CreateContentEvent(new ContentStatusChanged { Status = Status.Draft, Change = StatusChange.Restored })
CreateContentEvent(new ContentStatusChanged { Status = Status.Draft })
);
A.CallTo(() => scriptEngine.Execute(A<ScriptContext>.Ignored, "<change-script>"))
@ -448,6 +476,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
var command = new ChangeContentStatus { Status = Status.Draft, JobId = sut.Snapshot.ScheduleJob.Id };
A.CallTo(() => contentWorkflow.CanMoveToAsync(sut.Snapshot, command.Status))
.Returns(false);
var result = await sut.ExecuteAsync(CreateContentCommand(command));
result.ShouldBeEquivalent(sut.Snapshot);

15
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs

@ -103,7 +103,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
SetupSchemaFound();
var result = await sut.GetSchemaAsync(context, schemaId.Name);
var result = await sut.GetSchemaOrThrowAsync(context, schemaId.Name);
Assert.Equal(schema, result);
}
@ -113,7 +113,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
SetupSchemaFound();
var result = await sut.GetSchemaAsync(context, schemaId.Name);
var result = await sut.GetSchemaOrThrowAsync(context, schemaId.Name);
Assert.Equal(schema, result);
}
@ -125,7 +125,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
var ctx = context;
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.GetSchemaAsync(ctx, schemaId.Name));
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.GetSchemaOrThrowAsync(ctx, schemaId.Name));
}
[Fact]
@ -135,7 +135,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
var ctx = context;
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.ThrowIfSchemaNotExistsAsync(ctx, schemaId.Name));
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.GetSchemaOrThrowAsync(ctx, schemaId.Name));
}
[Fact]
@ -520,7 +520,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
.Returns((ISchemaEntity)null);
}
private IContentEntity CreateContent(Guid id, Status status = Status.Published)
private IContentEntity CreateContent(Guid id)
{
return CreateContent(id, Status.Published);
}
private IContentEntity CreateContent(Guid id, Status status)
{
var content = A.Fake<IContentEntity>();

122
tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultContentWorkflowTests.cs

@ -0,0 +1,122 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Core.Contents;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents
{
public class DefaultContentWorkflowTests
{
private readonly DefaultContentWorkflow sut = new DefaultContentWorkflow();
[Fact]
public async Task Should_draft_as_initial_status()
{
var result = await sut.GetInitialStatusAsync(null);
Assert.Equal(Status.Draft, result);
}
[Fact]
public async Task Should_check_is_valid_next()
{
var entity = CreateContent(Status.Published);
var result = await sut.CanMoveToAsync(entity, Status.Draft);
Assert.True(result);
}
[Fact]
public async Task Should_be_able_to_update_published()
{
var entity = CreateContent(Status.Published);
var result = await sut.CanUpdateAsync(entity);
Assert.True(result);
}
[Fact]
public async Task Should_be_able_to_update_draft()
{
var entity = CreateContent(Status.Published);
var result = await sut.CanUpdateAsync(entity);
Assert.True(result);
}
[Fact]
public async Task Should_not_be_able_to_update_archived()
{
var entity = CreateContent(Status.Archived);
var result = await sut.CanUpdateAsync(entity);
Assert.False(result);
}
[Fact]
public async Task Should_get_next_statuses_for_draft()
{
var content = CreateContent(Status.Draft);
var expected = new[] { Status.Archived, Status.Published };
var result = await sut.GetNextsAsync(content);
Assert.Equal(expected, result);
}
[Fact]
public async Task Should_get_next_statuses_for_archived()
{
var content = CreateContent(Status.Archived);
var expected = new[] { Status.Draft };
var result = await sut.GetNextsAsync(content);
Assert.Equal(expected, result);
}
[Fact]
public async Task Should_get_next_statuses_for_published()
{
var content = CreateContent(Status.Published);
var expected = new[] { Status.Draft, Status.Archived };
var result = await sut.GetNextsAsync(content);
Assert.Equal(expected, result);
}
[Fact]
public async Task Should_return_all_statuses()
{
var expected = new[] { Status.Archived, Status.Draft, Status.Published };
var result = await sut.GetAllAsync(null);
Assert.Equal(expected, result);
}
private IContentEntity CreateContent(Status status)
{
var content = A.Fake<IContentEntity>();
A.CallTo(() => content.Status).Returns(status);
return content;
}
}
}

3
tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs

@ -156,7 +156,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
LastModified = now,
LastModifiedBy = new RefToken(RefTokenType.Subject, "user2"),
Data = data,
DataDraft = dataDraft
DataDraft = dataDraft,
Status = Status.Draft
};
return content;

120
tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using FakeItEasy;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
@ -21,6 +22,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
public class GuardContentTests
{
private readonly ISchemaEntity schema = A.Fake<ISchemaEntity>();
private readonly IContentWorkflow contentWorkflow = A.Fake<IContentWorkflow>();
private readonly Instant dueTimeInPast = SystemClock.Instance.GetCurrentInstant().Minus(Duration.FromHours(1));
[Fact]
@ -65,119 +67,155 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
}
[Fact]
public void CanUpdate_should_throw_exception_if_data_is_null()
public async Task CanUpdate_should_throw_exception_if_data_is_null()
{
SetupSingleton(false);
SetupCanUpdate(true);
var content = CreateContent(Status.Draft, false);
var command = new UpdateContent();
ValidationAssert.Throws(() => GuardContent.CanUpdate(command),
await ValidationAssert.ThrowsAsync(() => GuardContent.CanUpdate(content, contentWorkflow, command),
new ValidationError("Data is required.", "Data"));
}
[Fact]
public void CanUpdate_should_not_throw_exception_if_data_is_not_null()
public async Task CanUpdate_should_throw_exception_if_workflow_blocks_it()
{
SetupSingleton(false);
SetupCanUpdate(false);
var content = CreateContent(Status.Draft, false);
var command = new UpdateContent { Data = new NamedContentData() };
GuardContent.CanUpdate(command);
await Assert.ThrowsAsync<DomainException>(() => GuardContent.CanUpdate(content, contentWorkflow, command));
}
[Fact]
public void CanPatch_should_throw_exception_if_data_is_null()
public async Task CanUpdate_should_not_throw_exception_if_data_is_not_null()
{
SetupSingleton(false);
SetupCanUpdate(true);
var content = CreateContent(Status.Draft, false);
var command = new UpdateContent { Data = new NamedContentData() };
await GuardContent.CanUpdate(content, contentWorkflow, command);
}
[Fact]
public async Task CanPatch_should_throw_exception_if_data_is_null()
{
SetupSingleton(false);
SetupCanUpdate(true);
var content = CreateContent(Status.Draft, false);
var command = new PatchContent();
ValidationAssert.Throws(() => GuardContent.CanPatch(command),
await ValidationAssert.ThrowsAsync(() => GuardContent.CanPatch(content, contentWorkflow, command),
new ValidationError("Data is required.", "Data"));
}
[Fact]
public void CanPatch_should_not_throw_exception_if_data_is_not_null()
public async Task CanPatch_should_throw_exception_if_workflow_blocks_it()
{
SetupSingleton(false);
SetupCanUpdate(false);
var content = CreateContent(Status.Draft, false);
var command = new PatchContent { Data = new NamedContentData() };
GuardContent.CanPatch(command);
await Assert.ThrowsAsync<DomainException>(() => GuardContent.CanPatch(content, contentWorkflow, command));
}
[Fact]
public void CanChangeContentStatus_should_throw_exception_if_status_not_valid()
public async Task CanPatch_should_not_throw_exception_if_data_is_not_null()
{
SetupSingleton(false);
SetupCanUpdate(true);
var command = new ChangeContentStatus { Status = (Status)10 };
var content = CreateContent(Status.Draft, false);
var command = new PatchContent { Data = new NamedContentData() };
ValidationAssert.Throws(() => GuardContent.CanChangeContentStatus(schema, false, Status.Archived, command),
new ValidationError("Status is not a valid value.", "Status"));
await GuardContent.CanPatch(content, contentWorkflow, command);
}
[Fact]
public void CanChangeContentStatus_should_throw_exception_if_status_flow_not_valid()
public async Task CanChangeStatus_should_throw_exception_if_publishing_without_pending_changes()
{
SetupSingleton(false);
var content = CreateContent(Status.Published, false);
var command = new ChangeContentStatus { Status = Status.Published };
ValidationAssert.Throws(() => GuardContent.CanChangeContentStatus(schema, false, Status.Archived, command),
new ValidationError("Cannot change status from Archived to Published.", "Status"));
await ValidationAssert.ThrowsAsync(() => GuardContent.CanChangeStatus(schema, content, contentWorkflow, command),
new ValidationError("Content has no changes to publish.", "Status"));
}
[Fact]
public void CanChangeContentStatus_should_throw_exception_if_due_date_in_past()
public async Task CanChangeStatus_should_throw_exception_if_singleton()
{
SetupSingleton(false);
SetupSingleton(true);
var command = new ChangeContentStatus { Status = Status.Published, DueTime = dueTimeInPast };
var content = CreateContent(Status.Published, false);
var command = new ChangeContentStatus { Status = Status.Draft };
ValidationAssert.Throws(() => GuardContent.CanChangeContentStatus(schema, false, Status.Draft, command),
new ValidationError("Due time must be in the future.", "DueTime"));
await Assert.ThrowsAsync<DomainException>(() => GuardContent.CanChangeStatus(schema, content, contentWorkflow, command));
}
[Fact]
public void CanChangeContentStatus_should_throw_exception_if_publishing_without_pending_changes()
public async Task CanChangeStatus_should_not_throw_exception_if_publishing_with_pending_changes()
{
SetupSingleton(false);
SetupSingleton(true);
var content = CreateContent(Status.Published, true);
var command = new ChangeContentStatus { Status = Status.Published };
ValidationAssert.Throws(() => GuardContent.CanChangeContentStatus(schema, false, Status.Published, command),
new ValidationError("Content has no changes to publish.", "Status"));
await GuardContent.CanChangeStatus(schema, content, contentWorkflow, command);
}
[Fact]
public void CanChangeContentStatus_should_throw_exception_if_singleton()
public async Task CanChangeStatus_should_throw_exception_if_due_date_in_past()
{
SetupSingleton(true);
SetupSingleton(false);
var command = new ChangeContentStatus { Status = Status.Draft };
var content = CreateContent(Status.Draft, false);
var command = new ChangeContentStatus { Status = Status.Published, DueTime = dueTimeInPast };
A.CallTo(() => contentWorkflow.CanMoveToAsync(content, command.Status))
.Returns(true);
Assert.Throws<DomainException>(() => GuardContent.CanChangeContentStatus(schema, false, Status.Published, command));
await ValidationAssert.ThrowsAsync(() => GuardContent.CanChangeStatus(schema, content, contentWorkflow, command),
new ValidationError("Due time must be in the future.", "DueTime"));
}
[Fact]
public void CanChangeContentStatus_should_not_throw_exception_if_publishing_with_pending_changes()
public async Task CanChangeStatus_should_throw_exception_if_status_flow_not_valid()
{
SetupSingleton(true);
SetupSingleton(false);
var content = CreateContent(Status.Draft, false);
var command = new ChangeContentStatus { Status = Status.Published };
GuardContent.CanChangeContentStatus(schema, true, Status.Published, command);
A.CallTo(() => contentWorkflow.CanMoveToAsync(content, command.Status))
.Returns(false);
await ValidationAssert.ThrowsAsync(() => GuardContent.CanChangeStatus(schema, content, contentWorkflow, command),
new ValidationError("Cannot change status from Draft to Published.", "Status"));
}
[Fact]
public void CanChangeContentStatus_should_not_throw_exception_if_status_flow_valid()
public async Task CanChangeStatus_should_not_throw_exception_if_status_flow_valid()
{
SetupSingleton(false);
var content = CreateContent(Status.Draft, false);
var command = new ChangeContentStatus { Status = Status.Published };
GuardContent.CanChangeContentStatus(schema, false, Status.Draft, command);
A.CallTo(() => contentWorkflow.CanMoveToAsync(content, command.Status))
.Returns(true);
await GuardContent.CanChangeStatus(schema, content, contentWorkflow, command);
}
[Fact]
@ -218,10 +256,26 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
GuardContent.CanDelete(schema, command);
}
private void SetupCanUpdate(bool canUpdate)
{
A.CallTo(() => contentWorkflow.CanUpdateAsync(A<IContentEntity>.Ignored))
.Returns(canUpdate);
}
private void SetupSingleton(bool isSingleton)
{
A.CallTo(() => schema.SchemaDef)
.Returns(new Schema("schema", isSingleton: isSingleton));
}
private IContentEntity CreateContent(Status status, bool isPending)
{
var content = A.Fake<IContentEntity>();
A.CallTo(() => content.Status).Returns(status);
A.CallTo(() => content.IsPending).Returns(isPending);
return content;
}
}
}

4
tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/StatusSerializerTests.cs

@ -18,7 +18,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb
{
private sealed class TestObject
{
public Status2 Status { get; set; }
public Status Status { get; set; }
}
[Fact]
@ -28,7 +28,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb
var source = new TestObject
{
Status = new Status2("Published")
Status = Status.Published
};
var document = new BsonDocument();

12
tools/Migrate_01/OldTriggers/ContentChangedTriggerSchema.cs

@ -50,19 +50,9 @@ namespace Migrate_01.OldTriggers
conditions.Add($"event.type == '{EnrichedContentEventType.Published}'");
}
if (SendUnpublish)
{
conditions.Add($"event.type == '{EnrichedContentEventType.Unpublished}'");
}
if (SendArchived)
{
conditions.Add($"event.type == '{EnrichedContentEventType.Archived}'");
}
if (SendRestore)
{
conditions.Add($"event.type == '{EnrichedContentEventType.Restored}'");
conditions.Add($"event.status == 'Archived'");
}
if (SendDelete)

Loading…
Cancel
Save