From 762728dd1d673f4c0dfe747cf67e8cd54ac28c03 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sat, 20 Oct 2018 19:27:08 +0200 Subject: [PATCH] Started with API end service. --- .../Comments/Comment.cs | 32 ++++ .../Comments/Commands/CommentsCommand.cs | 25 +++ .../Comments/Commands/CreateComment.cs | 18 ++ .../Comments/Commands/DeleteComment.cs | 18 ++ .../Comments/Commands/UpdateComment.cs | 18 ++ .../Comments/CommentsGrain.cs | 127 ++++++++++++++ .../Comments/CommentsResult.cs | 96 ++++++++++ .../Comments/Guards/GuardComments.cs | 88 ++++++++++ .../Comments/ICommentGrain.cs | 18 ++ .../Comments/State/CommentsState.cs | 13 ++ .../Contents/ContentEntity.cs | 4 +- .../Comments/CommentCreated.cs | 20 +++ .../Comments/CommentDeleted.cs | 18 ++ .../Comments/CommentUpdated.cs | 20 +++ .../Comments/CommentsEvent.cs | 16 ++ .../Commands/DomainObjectGrainBase.cs | 51 ++++-- .../Controllers/Assets/AssetsController.cs | 4 +- .../Assets/Models/AssetReplacedDto.cs | 2 +- .../{AssetUpdateDto.cs => UpdateAssetDto.cs} | 2 +- .../Comments/CommentsController.cs | 128 ++++++++++++++ .../Controllers/Comments/Models/CommentDto.cs | 53 ++++++ .../Comments/Models/CommentsDto.cs | 48 +++++ .../Comments/Models/UpsertCommentDto.cs | 33 ++++ src/Squidex/Config/Domain/EntitiesServices.cs | 5 + src/Squidex/app/shared/module.ts | 2 + .../app/shared/services/assets.service.ts | 2 +- .../shared/services/comments.service.spec.ts | 133 ++++++++++++++ .../app/shared/services/comments.service.ts | 125 +++++++++++++ .../Comments/CommentsGrainTests.cs | 162 +++++++++++++++++ .../Comments/Guards/GuardCommentsTests.cs | 164 ++++++++++++++++++ 30 files changed, 1426 insertions(+), 19 deletions(-) create mode 100644 src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Comments/Commands/CreateComment.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Comments/Commands/UpdateComment.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Comments/CommentsResult.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Comments/Guards/GuardComments.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Comments/ICommentGrain.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Comments/State/CommentsState.cs create mode 100644 src/Squidex.Domain.Apps.Events/Comments/CommentCreated.cs create mode 100644 src/Squidex.Domain.Apps.Events/Comments/CommentDeleted.cs create mode 100644 src/Squidex.Domain.Apps.Events/Comments/CommentUpdated.cs create mode 100644 src/Squidex.Domain.Apps.Events/Comments/CommentsEvent.cs rename src/Squidex/Areas/Api/Controllers/Assets/Models/{AssetUpdateDto.cs => UpdateAssetDto.cs} (96%) create mode 100644 src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs create mode 100644 src/Squidex/Areas/Api/Controllers/Comments/Models/CommentDto.cs create mode 100644 src/Squidex/Areas/Api/Controllers/Comments/Models/CommentsDto.cs create mode 100644 src/Squidex/Areas/Api/Controllers/Comments/Models/UpsertCommentDto.cs create mode 100644 src/Squidex/app/shared/services/comments.service.spec.ts create mode 100644 src/Squidex/app/shared/services/comments.service.ts create mode 100644 tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsGrainTests.cs create mode 100644 tests/Squidex.Domain.Apps.Entities.Tests/Comments/Guards/GuardCommentsTests.cs diff --git a/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs b/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs new file mode 100644 index 000000000..c7ba45f1b --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using NodaTime; +using Squidex.Infrastructure; +using System; + +namespace Squidex.Domain.Apps.Core.Comments +{ + public sealed class Comment + { + public Guid Id { get; } + + public Instant Time { get; } + + public RefToken User { get; } + + public string Text { get; } + + public Comment(Guid id, Instant time, RefToken user, string text) + { + Id = id; + Time = time; + Text = text; + User = user; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs b/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs new file mode 100644 index 000000000..c40935da8 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Comments.Commands +{ + public abstract class CommentsCommand : SquidexCommand, IAggregateCommand, IAppCommand + { + public Guid CommentsId { get; set; } + + public NamedId AppId { get; set; } + + Guid IAggregateCommand.AggregateId + { + get { return CommentsId; } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Comments/Commands/CreateComment.cs b/src/Squidex.Domain.Apps.Entities/Comments/Commands/CreateComment.cs new file mode 100644 index 000000000..f47e41bea --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Comments/Commands/CreateComment.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Entities.Comments.Commands +{ + public sealed class CreateComment : CommentsCommand + { + public Guid CommentId { get; } = Guid.NewGuid(); + + public string Text { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs b/src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs new file mode 100644 index 000000000..c7824fef0 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Entities.Comments.Commands +{ + public sealed class DeleteComment : CommentsCommand + { + public Guid CommentId { get; set; } + + public string Text { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Comments/Commands/UpdateComment.cs b/src/Squidex.Domain.Apps.Entities/Comments/Commands/UpdateComment.cs new file mode 100644 index 000000000..ca34b5995 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Comments/Commands/UpdateComment.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Entities.Comments.Commands +{ + public sealed class UpdateComment : CommentsCommand + { + public Guid CommentId { get; set; } + + public string Text { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs b/src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs new file mode 100644 index 000000000..db937b10d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs @@ -0,0 +1,127 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Comments.Commands; +using Squidex.Domain.Apps.Entities.Comments.Guards; +using Squidex.Domain.Apps.Entities.Comments.State; +using Squidex.Domain.Apps.Events.Comments; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Comments +{ + public sealed class CommentsGrain : DomainObjectGrainBase, ICommentGrain + { + private readonly IStore store; + private readonly List> events = new List>(); + private CommentsState snapshot = new CommentsState { Version = EtagVersion.Empty }; + private IPersistence persistence; + + public override CommentsState Snapshot + { + get { return snapshot; } + } + + public CommentsGrain(IStore store, ISemanticLog log) + : base(log) + { + Guard.NotNull(store, nameof(store)); + + this.store = store; + } + + protected override void ApplyEvent(Envelope @event) + { + snapshot = new CommentsState { Version = snapshot.Version + 1 }; + + events.Add(@event.To()); + } + + protected override void RestorePreviousSnapshot(CommentsState previousSnapshot, long previousVersion) + { + snapshot = previousSnapshot; + } + + protected override Task ReadAsync(Type type, Guid id) + { + persistence = store.WithEventSourcing(GetType(), id, ApplyEvent); + + return persistence.ReadAsync(); + } + + protected override async Task WriteAsync(Envelope[] events, long previousVersion) + { + if (events.Length > 0) + { + await persistence.WriteEventsAsync(events); + await persistence.WriteSnapshotAsync(Snapshot); + } + } + + protected override Task ExecuteAsync(IAggregateCommand command) + { + switch (command) + { + case CreateComment createComment: + return UpsertAsync(createComment, c => + { + GuardComments.CanCreate(c); + + Create(c); + + return EntityCreatedResult.Create(createComment.CommentId, Version); + }); + + case UpdateComment updateComment: + return UpsertAsync(updateComment, c => + { + GuardComments.CanUpdate(events, c); + + Update(c); + }); + + case DeleteComment deleteComment: + return UpsertAsync(deleteComment, c => + { + GuardComments.CanDelete(events, c); + + Delete(c); + }); + + default: + throw new NotSupportedException(); + } + } + + public void Create(CreateComment command) + { + RaiseEvent(SimpleMapper.Map(command, new CommentCreated())); + } + + public void Update(UpdateComment command) + { + RaiseEvent(SimpleMapper.Map(command, new CommentUpdated())); + } + + public void Delete(DeleteComment command) + { + RaiseEvent(SimpleMapper.Map(command, new CommentDeleted())); + } + + public Task GetCommentsAsync(long version = EtagVersion.Any) + { + return Task.FromResult(CommentsResult.FromEvents(events, Version, (int)version)); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Comments/CommentsResult.cs b/src/Squidex.Domain.Apps.Entities/Comments/CommentsResult.cs new file mode 100644 index 000000000..6e8b4d6f7 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Comments/CommentsResult.cs @@ -0,0 +1,96 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using Squidex.Domain.Apps.Core.Comments; +using Squidex.Domain.Apps.Events.Comments; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Entities.Comments +{ + public sealed class CommentsResult + { + public List CreatedComments { get; set; } = new List(); + + public List UpdatedComments { get; set; } = new List(); + + public List DeletedComments { get; set; } = new List(); + + public long Version { get; set; } + + public static CommentsResult FromEvents(IEnumerable> events, long currentVersion, int lastVersion) + { + var result = new CommentsResult { Version = currentVersion }; + + foreach (var @event in events.Skip(lastVersion < 0 ? 0 : lastVersion + 1)) + { + switch (@event.Payload) + { + case CommentDeleted deleted: + { + var id = deleted.CommentId; + + if (result.CreatedComments.Any(x => x.Id == id)) + { + result.CreatedComments.RemoveAll(x => x.Id == id); + } + else if (result.UpdatedComments.Any(x => x.Id == id)) + { + result.UpdatedComments.RemoveAll(x => x.Id == id); + result.DeletedComments.Add(id); + } + else + { + result.DeletedComments.Add(id); + } + + break; + } + + case CommentCreated created: + { + var comment = new Comment( + created.CommentId, + @event.Headers.Timestamp(), + @event.Payload.Actor, + created.Text); + + result.CreatedComments.Add(comment); + break; + } + + case CommentUpdated updated: + { + var id = updated.CommentId; + + var comment = new Comment( + id, + @event.Headers.Timestamp(), + @event.Payload.Actor, + updated.Text); + + if (result.CreatedComments.Any(x => x.Id == id)) + { + result.CreatedComments.RemoveAll(x => x.Id == id); + result.CreatedComments.Add(comment); + } + else + { + result.UpdatedComments.Add(comment); + } + + break; + } + } + } + + return result; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Comments/Guards/GuardComments.cs b/src/Squidex.Domain.Apps.Entities/Comments/Guards/GuardComments.cs new file mode 100644 index 000000000..90ba851ce --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Comments/Guards/GuardComments.cs @@ -0,0 +1,88 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using Squidex.Domain.Apps.Entities.Comments.Commands; +using Squidex.Domain.Apps.Events.Comments; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Entities.Comments.Guards +{ + public static class GuardComments + { + public static void CanCreate(CreateComment command) + { + Guard.NotNull(command, nameof(command)); + + Validate.It(() => "Cannot create comment.", e => + { + if (string.IsNullOrWhiteSpace(command.Text)) + { + e("Text is required.", nameof(command.Text)); + } + }); + } + + public static void CanUpdate(List> events, UpdateComment command) + { + Guard.NotNull(command, nameof(command)); + + var comment = FindComment(events, command.CommentId); + + if (!comment.Payload.Actor.Equals(command.Actor)) + { + throw new DomainException("Comment is created by another actor."); + } + + Validate.It(() => "Cannot update comment.", e => + { + if (string.IsNullOrWhiteSpace(command.Text)) + { + e("Text is required.", nameof(command.Text)); + } + }); + } + + public static void CanDelete(List> events, DeleteComment command) + { + Guard.NotNull(command, nameof(command)); + + var comment = FindComment(events, command.CommentId); + + if (!comment.Payload.Actor.Equals(command.Actor)) + { + throw new DomainException("Comment is created by another actor."); + } + } + + private static Envelope FindComment(List> events, Guid commentId) + { + Envelope result = null; + + foreach (var @event in events) + { + if (@event.Payload is CommentCreated created && created.CommentId == commentId) + { + result = @event.To(); + } + else if (@event.Payload is CommentDeleted deleted && deleted.CommentId == commentId) + { + result = null; + } + } + + if (result == null) + { + throw new DomainObjectNotFoundException(commentId.ToString(), "Comments", typeof(CommentsGrain)); + } + + return result; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Comments/ICommentGrain.cs b/src/Squidex.Domain.Apps.Entities/Comments/ICommentGrain.cs new file mode 100644 index 000000000..d3cd6979a --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Comments/ICommentGrain.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Comments +{ + public interface ICommentGrain : IDomainObjectGrain + { + Task GetCommentsAsync(long version = EtagVersion.Any); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Comments/State/CommentsState.cs b/src/Squidex.Domain.Apps.Entities/Comments/State/CommentsState.cs new file mode 100644 index 000000000..c5e8184ac --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Comments/State/CommentsState.cs @@ -0,0 +1,13 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Comments.State +{ + public sealed class CommentsState : DomainObjectState + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs index a32b203db..9c5a5f3f7 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs @@ -28,8 +28,6 @@ namespace Squidex.Domain.Apps.Entities.Contents public Instant LastModified { get; set; } - public Status Status { get; set; } - public ScheduleJob ScheduleJob { get; set; } public RefToken CreatedBy { get; set; } @@ -40,6 +38,8 @@ namespace Squidex.Domain.Apps.Entities.Contents public NamedContentData DataDraft { get; set; } + public Status Status { get; set; } + public bool IsPending { get; set; } public static ContentEntity Create(CreateContent command, EntityCreatedResult result) diff --git a/src/Squidex.Domain.Apps.Events/Comments/CommentCreated.cs b/src/Squidex.Domain.Apps.Events/Comments/CommentCreated.cs new file mode 100644 index 000000000..cf65df94c --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Comments/CommentCreated.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Comments +{ + [EventType(nameof(CommentCreated))] + public sealed class CommentCreated : CommentsEvent + { + public Guid CommentId { get; set; } + + public string Text { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Comments/CommentDeleted.cs b/src/Squidex.Domain.Apps.Events/Comments/CommentDeleted.cs new file mode 100644 index 000000000..0d60a2ce6 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Comments/CommentDeleted.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Comments +{ + [EventType(nameof(CommentCreated))] + public sealed class CommentDeleted : CommentsEvent + { + public Guid CommentId { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Comments/CommentUpdated.cs b/src/Squidex.Domain.Apps.Events/Comments/CommentUpdated.cs new file mode 100644 index 000000000..bd066335b --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Comments/CommentUpdated.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Comments +{ + [EventType(nameof(CommentUpdated))] + public sealed class CommentUpdated : CommentsEvent + { + public Guid CommentId { get; set; } + + public string Text { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Comments/CommentsEvent.cs b/src/Squidex.Domain.Apps.Events/Comments/CommentsEvent.cs new file mode 100644 index 000000000..637c8ef7a --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Comments/CommentsEvent.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Events.Comments +{ + public abstract class CommentsEvent : AppEvent + { + public Guid CommentsId { get; set; } + } +} diff --git a/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs b/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs index a6b542645..75b018bf4 100644 --- a/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs +++ b/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs @@ -21,6 +21,13 @@ namespace Squidex.Infrastructure.Commands private readonly ISemanticLog log; private Guid id; + private enum Mode + { + Create, + Update, + Upsert + } + public Guid Id { get { return id; } @@ -81,45 +88,65 @@ namespace Squidex.Infrastructure.Commands protected Task CreateReturnAsync(TCommand command, Func> handler) where TCommand : class, IAggregateCommand { - return InvokeAsync(command, handler, false); + return InvokeAsync(command, handler, Mode.Create); } protected Task CreateReturnAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand { - return InvokeAsync(command, handler?.ToAsync(), false); + return InvokeAsync(command, handler?.ToAsync(), Mode.Create); } protected Task CreateAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand { - return InvokeAsync(command, handler.ToDefault(), false); + return InvokeAsync(command, handler.ToDefault(), Mode.Create); } protected Task CreateAsync(TCommand command, Action handler) where TCommand : class, IAggregateCommand { - return InvokeAsync(command, handler?.ToDefault()?.ToAsync(), false); + return InvokeAsync(command, handler?.ToDefault()?.ToAsync(), Mode.Create); } protected Task UpdateReturnAsync(TCommand command, Func> handler) where TCommand : class, IAggregateCommand { - return InvokeAsync(command, handler, true); + return InvokeAsync(command, handler, Mode.Update); } protected Task UpdateAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand { - return InvokeAsync(command, handler?.ToAsync(), true); + return InvokeAsync(command, handler?.ToAsync(), Mode.Update); } protected Task UpdateAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand { - return InvokeAsync(command, handler?.ToDefault(), true); + return InvokeAsync(command, handler?.ToDefault(), Mode.Update); } protected Task UpdateAsync(TCommand command, Action handler) where TCommand : class, IAggregateCommand { - return InvokeAsync(command, handler?.ToDefault()?.ToAsync(), true); + return InvokeAsync(command, handler?.ToDefault()?.ToAsync(), Mode.Update); + } + + protected Task UpsertReturnAsync(TCommand command, Func> handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler, Mode.Upsert); + } + + protected Task UpsertAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler?.ToAsync(), Mode.Upsert); + } + + protected Task UpsertAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler?.ToDefault(), Mode.Upsert); + } + + protected Task UpsertAsync(TCommand command, Action handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler?.ToDefault()?.ToAsync(), Mode.Upsert); } - private async Task InvokeAsync(TCommand command, Func> handler, bool isUpdate) where TCommand : class, IAggregateCommand + private async Task InvokeAsync(TCommand command, Func> handler, Mode mode) where TCommand : class, IAggregateCommand { Guard.NotNull(command, nameof(command)); @@ -128,7 +155,7 @@ namespace Squidex.Infrastructure.Commands throw new DomainObjectVersionException(id.ToString(), GetType(), Version, command.ExpectedVersion); } - if (isUpdate && Version < 0) + if (mode == Mode.Update && Version < 0) { try { @@ -141,7 +168,7 @@ namespace Squidex.Infrastructure.Commands throw new DomainObjectNotFoundException(id.ToString(), GetType()); } - if (!isUpdate && Version >= 0) + if (mode == Mode.Create && Version >= 0) { throw new DomainException("Object has already been created."); } @@ -158,7 +185,7 @@ namespace Squidex.Infrastructure.Commands if (result == null) { - if (isUpdate) + if (Version > 0) { result = new EntitySavedResult(Version); } diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index 8008cb779..b11f10b56 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -215,7 +215,7 @@ namespace Squidex.Areas.Api.Controllers.Assets var context = await CommandBus.PublishAsync(command); var result = context.Result(); - var response = AssetReplacedDto.Create(command, result); + var response = AssetReplacedDto.FromCommand(command, result); return StatusCode(201, response); } @@ -236,7 +236,7 @@ namespace Squidex.Areas.Api.Controllers.Assets [Route("apps/{app}/assets/{id}/")] [ProducesResponseType(typeof(ErrorDto), 400)] [ApiCosts(1)] - public async Task PutAsset(string app, Guid id, [FromBody] AssetUpdateDto request) + public async Task PutAsset(string app, Guid id, [FromBody] UpdateAssetDto request) { await CommandBus.PublishAsync(request.ToCommand(id)); diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetReplacedDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetReplacedDto.cs index 73c151a18..cb6fcb801 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetReplacedDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetReplacedDto.cs @@ -49,7 +49,7 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models /// public long Version { get; set; } - public static AssetReplacedDto Create(UpdateAsset command, AssetSavedResult result) + public static AssetReplacedDto FromCommand(UpdateAsset command, AssetSavedResult result) { var response = new AssetReplacedDto { diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetUpdateDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/UpdateAssetDto.cs similarity index 96% rename from src/Squidex/Areas/Api/Controllers/Assets/Models/AssetUpdateDto.cs rename to src/Squidex/Areas/Api/Controllers/Assets/Models/UpdateAssetDto.cs index 61b4e956b..a8401e483 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetUpdateDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/Models/UpdateAssetDto.cs @@ -12,7 +12,7 @@ using Squidex.Domain.Apps.Entities.Assets.Commands; namespace Squidex.Areas.Api.Controllers.Assets.Models { - public sealed class AssetUpdateDto + public sealed class UpdateAssetDto { /// /// The new name of the asset. diff --git a/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs b/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs new file mode 100644 index 000000000..455a96f48 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs @@ -0,0 +1,128 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Orleans; +using Squidex.Areas.Api.Controllers.Comments.Models; +using Squidex.Domain.Apps.Entities.Comments; +using Squidex.Domain.Apps.Entities.Comments.Commands; +using Squidex.Infrastructure.Commands; +using Squidex.Pipeline; + +namespace Squidex.Areas.Api.Controllers.Comments +{ + /// + /// Manages comments for any kind of resource. + /// + [ApiExceptionFilter] + [ApiExplorerSettings(GroupName = nameof(Languages))] + public sealed class CommentsController : ApiController + { + private readonly IGrainFactory grainFactory; + + public CommentsController(ICommandBus commandBus, IGrainFactory grainFactory) + : base(commandBus) + { + this.grainFactory = grainFactory; + } + + /// + /// Get all comments. + /// + /// The id of the comments. + /// + /// 200 => All comments returned. + /// + [HttpGet] + [Route("comments/{commentsId}")] + [ProducesResponseType(typeof(CommentsDto), 200)] + [ApiCosts(0)] + public async Task GetComments(Guid commentsId) + { + if (!int.TryParse(Request.Headers["X-Since"], out var version)) + { + version = -1; + } + + var result = await grainFactory.GetGrain(commentsId).GetCommentsAsync(version); + var response = CommentsDto.FromResult(result); + + Response.Headers["ETag"] = response.Version.ToString(); + + return Ok(response); + } + + /// + /// Create a new comment. + /// + /// The id of the comments. + /// The comment object that needs to created. + /// + /// 201 => Comment created. + /// 400 => Comment is not valid. + /// + [HttpPost] + [Route("comments/{commentdsId}")] + [ProducesResponseType(typeof(EntityCreatedDto), 201)] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(0)] + public async Task PostComment(Guid commentsId, [FromBody] UpsertCommentDto request) + { + var command = request.ToCreateCommand(commentsId); + var context = await CommandBus.PublishAsync(command); + + var response = CommentDto.FromCommand(command); + + return CreatedAtAction(nameof(GetComments), new { commentsId }, response); + } + + /// + /// Updates the comment. + /// + /// The id of the comments. + /// The id of the comment. + /// The comment object that needs to updated. + /// + /// 204 => Comment updated. + /// 400 => Comment text not valid. + /// 404 => Comment not found. + /// + [MustBeAppReader] + [HttpPut] + [Route("comments/{commentdsId}/{commentId}")] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(0)] + public async Task PutComment(Guid commentsId, Guid commentId, [FromBody] UpsertCommentDto request) + { + await CommandBus.PublishAsync(request.ToUpdateComment(commentsId, commentId)); + + return NoContent(); + } + + /// + /// Deletes the comment. + /// + /// The id of the comments. + /// The id of the comment. + /// + /// 204 => Comment deleted. + /// 404 => Comment not found. + /// + [HttpDelete] + [Route("comments/{commentdsId}/{commentId}")] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(0)] + public async Task DeleteComment(Guid commentsId, Guid commentId) + { + await CommandBus.PublishAsync(new DeleteComment { CommentsId = commentsId, CommentId = commentId }); + + return NoContent(); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Comments/Models/CommentDto.cs b/src/Squidex/Areas/Api/Controllers/Comments/Models/CommentDto.cs new file mode 100644 index 000000000..5e582f02b --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Comments/Models/CommentDto.cs @@ -0,0 +1,53 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; +using NodaTime; +using Squidex.Domain.Apps.Core.Comments; +using Squidex.Domain.Apps.Entities.Comments.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Comments.Models +{ + public sealed class CommentDto + { + /// + /// The id of the comment. + /// + public Guid Id { get; set; } + + /// + /// The time when the comment was created or updated last. + /// + [Required] + public Instant Time { get; set; } + + /// + /// The user who created or updated the comment. + /// + [Required] + public RefToken User { get; set; } + + /// + /// The text of the comment. + /// + [Required] + public string Text { get; set; } + + public static CommentDto FromComment(Comment comment) + { + return SimpleMapper.Map(comment, new CommentDto()); + } + + public static CommentDto FromCommand(CreateComment command) + { + return SimpleMapper.Map(command, new CommentDto { User = command.Actor, Time = SystemClock.Instance.GetCurrentInstant() }); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Comments/Models/CommentsDto.cs b/src/Squidex/Areas/Api/Controllers/Comments/Models/CommentsDto.cs new file mode 100644 index 000000000..dceed44de --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Comments/Models/CommentsDto.cs @@ -0,0 +1,48 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using Squidex.Domain.Apps.Entities.Comments; + +namespace Squidex.Areas.Api.Controllers.Comments.Models +{ + public sealed class CommentsDto + { + /// + /// The created comments including the updates. + /// + public List CreatedComments { get; set; } + + /// + /// The updates comments since the last version. + /// + public List UpdatedComments { get; set; } + + /// + /// The deleted comments since the last version. + /// + public List DeletedComments { get; set; } + + /// + /// The current version. + /// + public long Version { get; set; } + + public static CommentsDto FromResult(CommentsResult result) + { + return new CommentsDto + { + CreatedComments = result.CreatedComments.Select(CommentDto.FromComment).ToList(), + UpdatedComments = result.UpdatedComments.Select(CommentDto.FromComment).ToList(), + DeletedComments = result.DeletedComments, + Version = result.Version + }; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Comments/Models/UpsertCommentDto.cs b/src/Squidex/Areas/Api/Controllers/Comments/Models/UpsertCommentDto.cs new file mode 100644 index 000000000..d52667ae6 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Comments/Models/UpsertCommentDto.cs @@ -0,0 +1,33 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Entities.Comments.Commands; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Comments.Models +{ + public sealed class UpsertCommentDto + { + /// + /// The comment text. + /// + [Required] + public string Text { get; set; } + + public CreateComment ToCreateCommand(Guid commentsId) + { + return SimpleMapper.Map(this, new CreateComment { CommentsId = commentsId }); + } + + public UpdateComment ToUpdateComment(Guid commentsId, Guid commentId) + { + return SimpleMapper.Map(this, new UpdateComment { CommentsId = commentsId, CommentId = commentId }); + } + } +} diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index b3f72de7c..eae7bdb28 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/src/Squidex/Config/Domain/EntitiesServices.cs @@ -24,6 +24,8 @@ using Squidex.Domain.Apps.Entities.Apps.Templates; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Entities.Comments; +using Squidex.Domain.Apps.Entities.Comments.Commands; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.Edm; @@ -160,6 +162,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs>() .As(); + services.AddSingletonAs>() + .As(); + services.AddSingletonAs>() .As(); diff --git a/src/Squidex/app/shared/module.ts b/src/Squidex/app/shared/module.ts index 3bcdc192d..40e78c6ff 100644 --- a/src/Squidex/app/shared/module.ts +++ b/src/Squidex/app/shared/module.ts @@ -34,6 +34,7 @@ import { BackupsService, BackupsState, ClientsState, + CommentsService, ContentMustExistGuard, ContentsService, ContentsState, @@ -163,6 +164,7 @@ export class SqxSharedModule { BackupsService, BackupsState, ClientsState, + CommentsService, ContentMustExistGuard, ContentsService, ContentsState, diff --git a/src/Squidex/app/shared/services/assets.service.ts b/src/Squidex/app/shared/services/assets.service.ts index e2436b4f7..680d905b2 100644 --- a/src/Squidex/app/shared/services/assets.service.ts +++ b/src/Squidex/app/shared/services/assets.service.ts @@ -237,7 +237,7 @@ export class AssetsService { throw 'Invalid'; } }), - tap(dto => { + tap(() => { this.analytics.trackEvent('Asset', 'Uploaded', appName); }), pretifyError('Failed to upload asset. Please reload.')); diff --git a/src/Squidex/app/shared/services/comments.service.spec.ts b/src/Squidex/app/shared/services/comments.service.spec.ts new file mode 100644 index 000000000..ec73049b7 --- /dev/null +++ b/src/Squidex/app/shared/services/comments.service.spec.ts @@ -0,0 +1,133 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { inject, TestBed } from '@angular/core/testing'; + +import { + ApiUrlConfig, + CommentDto, + CommentsDto, + CommentsService, + DateTime, + UpsertCommentDto, + Version +} from './../'; + +describe('CommentsService', () => { + const user = 'me'; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule + ], + providers: [ + CommentsService, + { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') } + ] + }); + }); + + afterEach(inject([HttpTestingController], (httpMock: HttpTestingController) => { + httpMock.verify(); + })); + + it('should make get request to get comments', + inject([CommentsService, HttpTestingController], (commentsService: CommentsService, httpMock: HttpTestingController) => { + + let comments: CommentsDto; + + commentsService.getComments('my-comments', new Version('123')).subscribe(result => { + comments = result; + }); + + const req = httpMock.expectOne('http://service/p/api/comments/my-comments'); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('X-Since')).toBe('123'); + + req.flush({ + createdComments: [{ + id: '123', + text: 'text1', + time: '2016-10-12T10:10', + user: user + }], + updatedComments: [{ + id: '456', + text: 'text2', + time: '2017-11-12T12:12', + user: user + }], + deletedComments: ['789'], + version: '9' + }); + + expect(comments!).toEqual( + new CommentsDto( + [ + new CommentDto('123', DateTime.parseISO_UTC('2016-12-12T10:10'), 'text1', user) + ], [ + new CommentDto('456', DateTime.parseISO_UTC('2017-11-12T12:12'), 'text2', user) + ], [ + '789' + ], + new Version('9')) + ); + })); + + it('should make post request to create comment', + inject([CommentsService, HttpTestingController], (commentsService: CommentsService, httpMock: HttpTestingController) => { + + let comment: CommentDto; + + commentsService.postComment('my-comments', new UpsertCommentDto('text1')).subscribe(result => { + comment = result; + }); + + const req = httpMock.expectOne('http://service/p/api/comments/my-comments'); + + expect(req.request.method).toEqual('POST'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({ + id: '456', + text: 'text2', + time: '2017-11-12T12:12', + user: user + }); + + expect(comment!).toEqual(new CommentDto('123', DateTime.parseISO_UTC('2016-12-12T10:10'), 'text1', user)); + })); + + it('should make put request to replace comment content', + inject([CommentsService, HttpTestingController], (commentsService: CommentsService, httpMock: HttpTestingController) => { + + commentsService.putComment('my-comments', '123', new UpsertCommentDto('text1')).subscribe(); + + const req = httpMock.expectOne('http://service/p/api/comments/my-comments/123'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); + })); + + it('should make delete request to delete comment', + inject([CommentsService, HttpTestingController], (commentsService: CommentsService, httpMock: HttpTestingController) => { + + commentsService.deleteComment('my-comments', '123').subscribe(); + + const req = httpMock.expectOne('http://service/p/api/comments/my-comments/123'); + + expect(req.request.method).toEqual('DELETE'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); + })); +}); \ No newline at end of file diff --git a/src/Squidex/app/shared/services/comments.service.ts b/src/Squidex/app/shared/services/comments.service.ts new file mode 100644 index 000000000..e7cd3915a --- /dev/null +++ b/src/Squidex/app/shared/services/comments.service.ts @@ -0,0 +1,125 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { + ApiUrlConfig, + DateTime, + Model, + pretifyError, + Version +} from '@app/framework'; + +export class CommentsDto extends Model { + constructor( + public readonly createdComments: CommentDto[], + public readonly updatedComments: CommentDto[], + public readonly deletedComments: string[], + public readonly version: Version + ) { + super(); + } +} + +export class CommentDto extends Model { + constructor( + public readonly id: string, + public readonly time: DateTime, + public readonly text: string, + public readonly user: string + ) { + super(); + } +} + +export class UpsertCommentDto { + constructor( + public readonly text: string + ) { + } +} + +@Injectable() +export class CommentsService { + constructor( + private readonly http: HttpClient, + private readonly apiUrl: ApiUrlConfig + ) { + } + + public getComments(commentsId: string, version: Version): Observable { + const url = this.apiUrl.buildUrl(`api/comments/${commentsId}`); + + return this.http.get(url, { headers: { 'Since': version.value } }).pipe( + map(response => { + const body: any = response; + + return new CommentsDto( + body.createdComments.map((item: any) => { + return new CommentDto( + item.id, + DateTime.parseISO_UTC(item.time), + item.text, + item.user); + }), + body.updatedComments.map((item: any) => { + return new CommentDto( + item.id, + DateTime.parseISO_UTC(item.time), + item.text, + item.user); + }), + body.deletedComments, + new Version(body.version) + ); + }), + pretifyError('Failed to load comments.')); + } + + public postComment(commentsId: string, dto: UpsertCommentDto): Observable { + const url = this.apiUrl.buildUrl(`api/comments/${commentsId}`); + + return this.http.post(url, dto).pipe( + map(response => { + const body: any = response; + + return new CommentDto( + body.id, + DateTime.parseISO_UTC(body.time), + body.text, + body.user); + }), + pretifyError('Failed to create comment.')); + } + + public putComment(commentsId: string, commentId: string, dto: UpsertCommentDto): Observable { + const url = this.apiUrl.buildUrl(`api/comments/${commentsId}/${commentId}`); + + return this.http.put(url, dto).pipe( + map(response => { + const body: any = response; + + return new CommentDto( + body.id, + DateTime.parseISO_UTC(body.time), + body.text, + body.user); + }), + pretifyError('Failed to update comment.')); + } + + public deleteComment(commentsId: string, commentId: string): Observable { + const url = this.apiUrl.buildUrl(`api/comments/${commentsId}/${commentId}`); + + return this.http.delete(url).pipe( + pretifyError('Failed to delete comment.')); + } +} \ No newline at end of file diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsGrainTests.cs new file mode 100644 index 000000000..18fe8aa70 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsGrainTests.cs @@ -0,0 +1,162 @@ +// ========================================================================== +// 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 FakeItEasy; +using FluentAssertions; +using NodaTime; +using Squidex.Domain.Apps.Core.Comments; +using Squidex.Domain.Apps.Entities.Comments.Commands; +using Squidex.Domain.Apps.Entities.Comments.State; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Domain.Apps.Events.Comments; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Comments +{ + public class CommentsGrainTests : HandlerTestBase + { + private readonly IAppProvider appProvider = A.Fake(); + private readonly Guid commentsId = Guid.NewGuid(); + private readonly CommentsGrain sut; + + protected override Guid Id + { + get { return commentsId; } + } + + public CommentsGrainTests() + { + sut = new CommentsGrain(Store, A.Dummy()); + sut.OnActivateAsync(Id).Wait(); + } + + [Fact] + public async Task Create_should_create_events() + { + var command = new CreateComment { Text = "text1" }; + + var result = await sut.ExecuteAsync(CreateCommentsCommand(command)); + + result.ShouldBeEquivalent(EntityCreatedResult.Create(command.CommentId, 0)); + + sut.GetCommentsAsync(0).Result.Should().BeEquivalentTo(new CommentsResult { Version = 0 }); + sut.GetCommentsAsync(-1).Result.Should().BeEquivalentTo(new CommentsResult + { + CreatedComments = new List + { + new Comment(command.CommentId, LastEvents.ElementAt(0).Headers.Timestamp(), command.Actor, "text1") + }, + Version = 0 + }); + + LastEvents + .ShouldHaveSameEvents( + CreateCommentsEvent(new CommentCreated { CommentId = command.CommentId, Text = command.Text }) + ); + } + + [Fact] + public async Task Update_should_create_events_and_update_state() + { + var createCommand = new CreateComment { Text = "text1" }; + var updateCommand = new UpdateComment { Text = "text2", CommentId = createCommand.CommentId }; + + await sut.ExecuteAsync(CreateCommentsCommand(createCommand)); + + var result = await sut.ExecuteAsync(CreateCommentsCommand(updateCommand)); + + result.ShouldBeEquivalent(new EntitySavedResult(1)); + + sut.GetCommentsAsync(-1).Result.Should().BeEquivalentTo(new CommentsResult + { + CreatedComments = new List + { + new Comment(createCommand.CommentId, LastEvents.ElementAt(0).Headers.Timestamp(), createCommand.Actor, "text2") + }, + Version = 1 + }); + + sut.GetCommentsAsync(0).Result.Should().BeEquivalentTo(new CommentsResult + { + UpdatedComments = new List + { + new Comment(createCommand.CommentId, LastEvents.ElementAt(0).Headers.Timestamp(), createCommand.Actor, "text2") + }, + Version = 1 + }); + + LastEvents + .ShouldHaveSameEvents( + CreateCommentsEvent(new CommentUpdated { CommentId = createCommand.CommentId, Text = updateCommand.Text }) + ); + } + + [Fact] + public async Task Delete_should_create_events_and_update_state() + { + var createCommand = new CreateComment { Text = "text1" }; + var updateCommand = new UpdateComment { Text = "text2", CommentId = createCommand.CommentId }; + var deleteCommand = new DeleteComment { CommentId = createCommand.CommentId }; + + await sut.ExecuteAsync(CreateCommentsCommand(createCommand)); + await sut.ExecuteAsync(CreateCommentsCommand(updateCommand)); + + var result = await sut.ExecuteAsync(CreateCommentsCommand(deleteCommand)); + + result.ShouldBeEquivalent(new EntitySavedResult(2)); + + sut.GetCommentsAsync(-1).Result.Should().BeEquivalentTo(new CommentsResult { Version = 2 }); + sut.GetCommentsAsync(0).Result.Should().BeEquivalentTo(new CommentsResult + { + DeletedComments = new List + { + deleteCommand.CommentId + }, + Version = 2 + }); + sut.GetCommentsAsync(1).Result.Should().BeEquivalentTo(new CommentsResult + { + DeletedComments = new List + { + deleteCommand.CommentId + }, + Version = 2 + }); + + LastEvents + .ShouldHaveSameEvents( + CreateCommentsEvent(new CommentDeleted { CommentId = createCommand.CommentId }) + ); + } + + private Task ExecuteDeleteAsync() + { + return sut.ExecuteAsync(CreateCommentsCommand(new DeleteComment())); + } + + protected T CreateCommentsEvent(T @event) where T : CommentsEvent + { + @event.CommentsId = commentsId; + + return CreateEvent(@event); + } + + protected T CreateCommentsCommand(T command) where T : CommentsCommand + { + command.CommentsId = commentsId; + + return CreateCommand(command); + } + } +} \ No newline at end of file diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Comments/Guards/GuardCommentsTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Comments/Guards/GuardCommentsTests.cs new file mode 100644 index 000000000..6248c89e2 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Comments/Guards/GuardCommentsTests.cs @@ -0,0 +1,164 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using Squidex.Domain.Apps.Entities.Comments.Commands; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Domain.Apps.Events.Comments; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Comments.Guards +{ + public class GuardCommentsTests + { + private readonly RefToken user1 = new RefToken(RefTokenType.Subject, "1"); + private readonly RefToken user2 = new RefToken(RefTokenType.Subject, "2"); + + [Fact] + public void CanCreate_should_throw_exception_if_text_not_defined() + { + var command = new CreateComment(); + + ValidationAssert.Throws(() => GuardComments.CanCreate(command), + new ValidationError("Text is required.", "Text")); + } + + [Fact] + public void CanCreate_should_not_throw_exception_if_text_defined() + { + var command = new CreateComment { Text = "text" }; + + GuardComments.CanCreate(command); + } + + [Fact] + public void CanUpdate_should_throw_exception_if_text_not_defined() + { + var commentId = Guid.NewGuid(); + var command = new UpdateComment { CommentId = commentId, Actor = user1 }; + + var events = new List> + { + Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To() + }; + + ValidationAssert.Throws(() => GuardComments.CanUpdate(events, command), + new ValidationError("Text is required.", "Text")); + } + + [Fact] + public void CanUpdate_should_throw_exception_if_comment_from_another_user() + { + var commentId = Guid.NewGuid(); + var command = new UpdateComment { CommentId = commentId, Actor = user2, Text = "text2" }; + + var events = new List> + { + Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To() + }; + + Assert.Throws(() => GuardComments.CanUpdate(events, command)); + } + + [Fact] + public void CanUpdate_should_throw_exception_if_comment_not_found() + { + var commentId = Guid.NewGuid(); + var command = new UpdateComment { CommentId = commentId, Actor = user1 }; + + var events = new List>(); + + Assert.Throws(() => GuardComments.CanUpdate(events, command)); + } + + [Fact] + public void CanUpdate_should_throw_exception_if_comment_deleted_found() + { + var commentId = Guid.NewGuid(); + var command = new UpdateComment { CommentId = commentId, Actor = user1 }; + + var events = new List> + { + Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To(), + Envelope.Create(new CommentDeleted { CommentId = commentId }).To(), + }; + + Assert.Throws(() => GuardComments.CanUpdate(events, command)); + } + + [Fact] + public void CanUpdate_should_not_throw_exception_if_comment_from_same_user() + { + var commentId = Guid.NewGuid(); + var command = new UpdateComment { CommentId = commentId, Actor = user1, Text = "text2" }; + + var events = new List> + { + Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To() + }; + + GuardComments.CanUpdate(events, command); + } + + [Fact] + public void CanDelete_should_throw_exception_if_comment_from_another_user() + { + var commentId = Guid.NewGuid(); + var command = new DeleteComment { CommentId = commentId, Actor = user2 }; + + var events = new List> + { + Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To() + }; + + Assert.Throws(() => GuardComments.CanDelete(events, command)); + } + + [Fact] + public void CanDelete_should_throw_exception_if_comment_not_found() + { + var commentId = Guid.NewGuid(); + var command = new DeleteComment { CommentId = commentId, Actor = user1 }; + + var events = new List>(); + + Assert.Throws(() => GuardComments.CanDelete(events, command)); + } + + [Fact] + public void CanDelete_should_throw_exception_if_comment_deleted() + { + var commentId = Guid.NewGuid(); + var command = new DeleteComment { CommentId = commentId, Actor = user1 }; + + var events = new List> + { + Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To(), + Envelope.Create(new CommentDeleted { CommentId = commentId }).To(), + }; + + Assert.Throws(() => GuardComments.CanDelete(events, command)); + } + + [Fact] + public void CanDelete_should_not_throw_exception_if_comment_from_same_user() + { + var commentId = Guid.NewGuid(); + var command = new DeleteComment { CommentId = commentId, Actor = user1 }; + + var events = new List> + { + Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To() + }; + + GuardComments.CanDelete(events, command); + } + } +}