mirror of https://github.com/Squidex/squidex.git
Browse Source
* Describe graphql. * Initial tests. * Fixes. * A lot of progress. * Set other tests. * YDotnet * Simpler completion. * Use locking. * Remove comment tests.pull/1038/head
committed by
GitHub
216 changed files with 3746 additions and 4073 deletions
@ -0,0 +1,266 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Text.RegularExpressions; |
|||
using Microsoft.Extensions.Logging; |
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Core.Comments; |
|||
using Squidex.Domain.Apps.Events.Comments; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
using Squidex.Infrastructure.Json; |
|||
using Squidex.Infrastructure.Reflection; |
|||
using Squidex.Shared.Users; |
|||
using YDotNet.Document.Cells; |
|||
using YDotNet.Document.Types.Events; |
|||
using YDotNet.Extensions; |
|||
using YDotNet.Server; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Collaboration; |
|||
|
|||
public sealed partial class CommentCollaborationHandler : IDocumentCallback, ICollaborationService |
|||
{ |
|||
private static readonly Regex MentionRegex = BuildMentionRegex(); |
|||
private readonly IJsonSerializer jsonSerializer; |
|||
private readonly IEventStore eventStore; |
|||
private readonly IEventFormatter eventFormatter; |
|||
private readonly IUserResolver userResolver; |
|||
private readonly IClock clock; |
|||
private readonly ILogger<CommentCollaborationHandler> log; |
|||
private IDocumentManager? currentManager; |
|||
|
|||
public Task LastTask { get; private set; } |
|||
|
|||
public CommentCollaborationHandler( |
|||
IJsonSerializer jsonSerializer, |
|||
IEventStore eventStore, |
|||
IEventFormatter eventFormatter, |
|||
IUserResolver userResolver, |
|||
IClock clock, |
|||
ILogger<CommentCollaborationHandler> log) |
|||
{ |
|||
this.jsonSerializer = jsonSerializer; |
|||
this.eventStore = eventStore; |
|||
this.eventFormatter = eventFormatter; |
|||
this.userResolver = userResolver; |
|||
this.clock = clock; |
|||
this.log = log; |
|||
} |
|||
|
|||
public ValueTask OnInitializedAsync(IDocumentManager manager) |
|||
{ |
|||
currentManager = manager; |
|||
return default; |
|||
} |
|||
|
|||
public Task NotifyAsync(string userId, string text, RefToken actor, Uri? url, bool skipHandlers, |
|||
CancellationToken ct = default) |
|||
{ |
|||
return CommentAsync(UserDocument(userId), text, actor, url, skipHandlers, ct); |
|||
} |
|||
|
|||
public Task CommentAsync(NamedId<DomainId> appId, DomainId resourceId, string text, RefToken actor, Uri? url, bool skipHandlers, |
|||
CancellationToken ct = default) |
|||
{ |
|||
return CommentAsync(ResourceDocument(appId, resourceId), text, actor, url, skipHandlers, ct); |
|||
} |
|||
|
|||
private async Task CommentAsync(string documentName, string text, RefToken actor, Uri? url, bool skipHandlers, |
|||
CancellationToken ct) |
|||
{ |
|||
if (currentManager == null) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var notificationsContext = new DocumentContext(documentName, 0); |
|||
|
|||
// Use the update method to ensure that only one thread has access to the doc.
|
|||
await currentManager.UpdateDocAsync(notificationsContext, doc => |
|||
{ |
|||
var stream = doc.Array("stream"); |
|||
|
|||
using (var transaction = doc.WriteTransaction()) |
|||
{ |
|||
var commentValue = new Comment(clock.GetCurrentInstant(), actor, text, url, skipHandlers); |
|||
var commentJson = jsonSerializer.Serialize(commentValue); |
|||
|
|||
stream.InsertRange(transaction, stream.Length, InputFactory.FromJson(commentJson)); |
|||
} |
|||
}, ct); |
|||
} |
|||
|
|||
public ValueTask OnDocumentLoadedAsync(DocumentLoadEvent @event) |
|||
{ |
|||
if (!IsResourceDocument(@event.Context.DocumentName, out var appId, out var resourceId)) |
|||
{ |
|||
return default; |
|||
} |
|||
|
|||
var stream = @event.Document.Array("stream"); |
|||
|
|||
stream.ObserveDeep(changes => |
|||
{ |
|||
var newComments = |
|||
changes |
|||
.Where(x => x.Tag == EventBranchTag.Array) |
|||
.Select(x => x.ArrayEvent) |
|||
.SelectMany(x => x.Delta).Where(x => x.Tag == EventChangeTag.Add) |
|||
.SelectMany(x => x.Values).Where(x => x.Tag == OutputTag.JsonObject) |
|||
.ToArray(); |
|||
|
|||
if (newComments.Length == 0) |
|||
{ |
|||
// Just store the last task for tests.
|
|||
LastTask = Task.CompletedTask; |
|||
return; |
|||
} |
|||
|
|||
LastTask = Task.Run(async () => |
|||
{ |
|||
try |
|||
{ |
|||
// Run in an extra task to prevent deadlocks with the outer transaction.
|
|||
await HandleAsync(@event, appId, resourceId, newComments); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
// We are in an extra task, so the exception would be probably swallowed.
|
|||
log.LogError(ex, "Failed to handle yjs event."); |
|||
throw; |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
return default; |
|||
} |
|||
|
|||
private async Task HandleAsync(DocumentLoadEvent @event, NamedId<DomainId> appId, DomainId resourceId, Output[] newComments) |
|||
{ |
|||
var comments = new List<Comment>(); |
|||
|
|||
// Use the update method to ensure that only one thread has access to the doc.
|
|||
await @event.Source.UpdateDocAsync(@event.Context, (doc) => |
|||
{ |
|||
using (var transaction = @event.Document.ReadTransaction()) |
|||
{ |
|||
foreach (var output in newComments) |
|||
{ |
|||
// Just use the json string for debuggability.
|
|||
var json = output.ToJson(transaction); |
|||
|
|||
var comment = jsonSerializer.Deserialize<Comment>(json); |
|||
|
|||
if (!comment.SkipHandlers) |
|||
{ |
|||
comments.Add(comment); |
|||
} |
|||
} |
|||
} |
|||
}); |
|||
|
|||
var streamName = $"comments-{DomainId.Combine(appId, resourceId)}"; |
|||
|
|||
foreach (var comment in comments) |
|||
{ |
|||
var commentEvent = await CreateEventAsync(comment, appId, resourceId); |
|||
|
|||
var eventBody = Envelope.Create<IEvent>(commentEvent); |
|||
var eventData = eventFormatter.ToEventData(eventBody, Guid.NewGuid()); |
|||
|
|||
await eventStore.AppendAsync(Guid.NewGuid(), streamName, EtagVersion.Any, new List<EventData> { eventData }); |
|||
|
|||
foreach (var mentionedUser in commentEvent.Mentions.OrEmpty()) |
|||
{ |
|||
await NotifyAsync(mentionedUser, comment.Text, RefToken.User(mentionedUser), comment.Url, true); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private async Task<CommentCreated> CreateEventAsync(Comment comment, NamedId<DomainId> appId, DomainId commentsId) |
|||
{ |
|||
var @event = new CommentCreated |
|||
{ |
|||
Actor = comment.User, |
|||
CommentId = DomainId.NewGuid(), |
|||
CommentsId = commentsId, |
|||
AppId = appId, |
|||
}; |
|||
|
|||
SimpleMapper.Map(comment, @event); |
|||
|
|||
await MentionUsersAsync(@event); |
|||
return @event; |
|||
} |
|||
|
|||
private async Task MentionUsersAsync(CommentCreated comment) |
|||
{ |
|||
if (string.IsNullOrWhiteSpace(comment.Text)) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var emails = MentionRegex.Matches(comment.Text).Select(x => x.Value[1..]).ToArray(); |
|||
|
|||
if (emails.Length == 0) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var mentions = new List<string>(); |
|||
|
|||
foreach (var email in emails) |
|||
{ |
|||
var user = await userResolver.FindByIdOrEmailAsync(email); |
|||
|
|||
if (user != null) |
|||
{ |
|||
mentions.Add(user.Id); |
|||
} |
|||
} |
|||
|
|||
if (mentions.Count > 0) |
|||
{ |
|||
comment.Mentions = mentions.ToArray(); |
|||
} |
|||
} |
|||
|
|||
public string UserDocument(string userId) |
|||
{ |
|||
return $"users/{userId}"; |
|||
} |
|||
|
|||
public string ResourceDocument(NamedId<DomainId> appId, DomainId resourceId) |
|||
{ |
|||
return $"apps/{appId}/{resourceId}"; |
|||
} |
|||
|
|||
private static bool IsResourceDocument(string name, out NamedId<DomainId> appId, out DomainId resourceId) |
|||
{ |
|||
resourceId = default; |
|||
|
|||
if (!name.StartsWith("apps", StringComparison.Ordinal)) |
|||
{ |
|||
appId = default!; |
|||
return false; |
|||
} |
|||
|
|||
var parts = name.Split('/'); |
|||
|
|||
if (parts.Length < 3 || !NamedId<DomainId>.TryParse(parts[1], DomainId.TryParse, out appId!)) |
|||
{ |
|||
appId = default!; |
|||
return false; |
|||
} |
|||
|
|||
resourceId = DomainId.Create(string.Join('/', parts.Skip(2))); |
|||
return true; |
|||
} |
|||
|
|||
[GeneratedRegex(@"@(?=.{1,64}@)[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*", RegexOptions.Compiled | RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 100)]
|
|||
private static partial Regex BuildMentionRegex(); |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Collaboration; |
|||
|
|||
public interface ICollaborationService |
|||
{ |
|||
Task NotifyAsync(string userId, string text, RefToken actor, Uri? url, bool skipHandlers, |
|||
CancellationToken ct = default); |
|||
|
|||
Task CommentAsync(NamedId<DomainId> appId, DomainId resourceId, string text, RefToken actor, Uri? url, bool skipHandlers, |
|||
CancellationToken ct = default); |
|||
|
|||
string UserDocument(string userId); |
|||
|
|||
string ResourceDocument(NamedId<DomainId> appId, DomainId resourceId); |
|||
} |
|||
@ -1,15 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments.Commands; |
|||
|
|||
public abstract class CommentTextCommand : CommentCommand |
|||
{ |
|||
public string Text { get; set; } |
|||
|
|||
public string[]? Mentions { get; set; } |
|||
} |
|||
@ -1,22 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments.Commands; |
|||
|
|||
public sealed class CreateComment : CommentTextCommand |
|||
{ |
|||
public bool IsMention { get; set; } |
|||
|
|||
public Uri? Url { get; set; } |
|||
|
|||
public CreateComment() |
|||
{ |
|||
CommentId = DomainId.NewGuid(); |
|||
} |
|||
} |
|||
@ -1,12 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments.Commands; |
|||
|
|||
public sealed class DeleteComment : CommentCommand |
|||
{ |
|||
} |
|||
@ -1,12 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments.Commands; |
|||
|
|||
public sealed class UpdateComment : CommentTextCommand |
|||
{ |
|||
} |
|||
@ -1,48 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Commands; |
|||
|
|||
#pragma warning disable MA0048 // File name must match type name
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments.Commands; |
|||
|
|||
public abstract class CommentCommand : CommentsCommand |
|||
{ |
|||
public DomainId CommentId { get; set; } |
|||
} |
|||
|
|||
public abstract class CommentsCommand : CommentsCommandBase |
|||
{ |
|||
public static readonly NamedId<DomainId> NoApp = NamedId.Of(DomainId.Empty, "none"); |
|||
|
|||
public DomainId CommentsId { get; set; } |
|||
|
|||
public override DomainId AggregateId |
|||
{ |
|||
get |
|||
{ |
|||
if (AppId.Id == default) |
|||
{ |
|||
return CommentsId; |
|||
} |
|||
else |
|||
{ |
|||
return DomainId.Combine(AppId, CommentsId); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// This command is needed as marker for middlewares.
|
|||
public abstract class CommentsCommandBase : SquidexCommand, IAppCommand, IAggregateCommand |
|||
{ |
|||
public NamedId<DomainId> AppId { get; set; } |
|||
|
|||
public abstract DomainId AggregateId { get; } |
|||
} |
|||
@ -1,32 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Entities.Comments.DomainObject; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Commands; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments; |
|||
|
|||
public sealed class CommentsLoader : ICommentsLoader |
|||
{ |
|||
private readonly IDomainObjectFactory domainObjectFactory; |
|||
|
|||
public CommentsLoader(IDomainObjectFactory domainObjectFactory) |
|||
{ |
|||
this.domainObjectFactory = domainObjectFactory; |
|||
} |
|||
|
|||
public async Task<CommentsResult> GetCommentsAsync(DomainId id, long version = EtagVersion.Any, |
|||
CancellationToken ct = default) |
|||
{ |
|||
var stream = domainObjectFactory.Create<CommentsStream>(id); |
|||
|
|||
await stream.LoadAsync(ct); |
|||
|
|||
return stream.GetComments(version); |
|||
} |
|||
} |
|||
@ -1,95 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.Comments; |
|||
using Squidex.Domain.Apps.Events.Comments; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments; |
|||
|
|||
public sealed class CommentsResult |
|||
{ |
|||
public List<Comment> CreatedComments { get; set; } = new List<Comment>(); |
|||
|
|||
public List<Comment> UpdatedComments { get; set; } = new List<Comment>(); |
|||
|
|||
public List<DomainId> DeletedComments { get; set; } = new List<DomainId>(); |
|||
|
|||
public long Version { get; set; } |
|||
|
|||
public static CommentsResult FromEvents(IEnumerable<Envelope<CommentsEvent>> 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.Exists(x => x.Id == id)) |
|||
{ |
|||
result.CreatedComments.RemoveAll(x => x.Id == id); |
|||
} |
|||
else if (result.UpdatedComments.Exists(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, |
|||
created.Url); |
|||
|
|||
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, |
|||
null); |
|||
|
|||
if (result.CreatedComments.Exists(x => x.Id == id)) |
|||
{ |
|||
result.CreatedComments.RemoveAll(x => x.Id == id); |
|||
result.CreatedComments.Add(comment); |
|||
} |
|||
else |
|||
{ |
|||
result.UpdatedComments.Add(comment); |
|||
} |
|||
|
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
} |
|||
@ -1,76 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Text.RegularExpressions; |
|||
using Squidex.Domain.Apps.Entities.Comments.Commands; |
|||
using Squidex.Infrastructure.Commands; |
|||
using Squidex.Shared.Users; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject; |
|||
|
|||
public sealed class CommentsCommandMiddleware : AggregateCommandMiddleware<CommentsCommandBase, CommentsStream> |
|||
{ |
|||
private static readonly Regex MentionRegex = new Regex(@"@(?=.{1,64}@)[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*", RegexOptions.Compiled | RegexOptions.ExplicitCapture, TimeSpan.FromMilliseconds(100)); |
|||
private readonly IUserResolver userResolver; |
|||
|
|||
public CommentsCommandMiddleware(IDomainObjectFactory domainObjectFactory, IUserResolver userResolver) |
|||
: base(domainObjectFactory) |
|||
{ |
|||
this.userResolver = userResolver; |
|||
} |
|||
|
|||
public override async Task HandleAsync(CommandContext context, NextDelegate next, |
|||
CancellationToken ct) |
|||
{ |
|||
if (context.Command is CommentsCommand commentsCommand) |
|||
{ |
|||
if (commentsCommand is CreateComment createComment && !IsMention(createComment)) |
|||
{ |
|||
await MentionUsersAsync(createComment); |
|||
} |
|||
} |
|||
|
|||
await base.HandleAsync(context, next, ct); |
|||
} |
|||
|
|||
private static bool IsMention(CreateComment createComment) |
|||
{ |
|||
return createComment.IsMention; |
|||
} |
|||
|
|||
private async Task MentionUsersAsync(CommentTextCommand command) |
|||
{ |
|||
if (string.IsNullOrWhiteSpace(command.Text)) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var emails = MentionRegex.Matches(command.Text).Select(x => x.Value[1..]).ToArray(); |
|||
|
|||
if (emails.Length == 0) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var mentions = new List<string>(); |
|||
|
|||
foreach (var email in emails) |
|||
{ |
|||
var user = await userResolver.FindByIdOrEmailAsync(email); |
|||
|
|||
if (user != null) |
|||
{ |
|||
mentions.Add(user.Id); |
|||
} |
|||
} |
|||
|
|||
if (mentions.Count > 0) |
|||
{ |
|||
command.Mentions = mentions.ToArray(); |
|||
} |
|||
} |
|||
} |
|||
@ -1,167 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Entities.Comments.Commands; |
|||
using Squidex.Domain.Apps.Entities.Comments.DomainObject.Guards; |
|||
using Squidex.Domain.Apps.Events.Comments; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Commands; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
using Squidex.Infrastructure.Reflection; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject; |
|||
|
|||
public class CommentsStream : IAggregate |
|||
{ |
|||
private readonly List<Envelope<CommentsEvent>> uncommittedEvents = new List<Envelope<CommentsEvent>>(); |
|||
private readonly List<Envelope<CommentsEvent>> events = new List<Envelope<CommentsEvent>>(); |
|||
private readonly DomainId key; |
|||
private readonly IEventFormatter eventFormatter; |
|||
private readonly IEventStore eventStore; |
|||
private readonly string streamName; |
|||
private long version = EtagVersion.Empty; |
|||
|
|||
private long Version => version; |
|||
|
|||
public CommentsStream( |
|||
DomainId key, |
|||
IEventFormatter eventFormatter, |
|||
IEventStore eventStore) |
|||
{ |
|||
this.key = key; |
|||
this.eventFormatter = eventFormatter; |
|||
this.eventStore = eventStore; |
|||
|
|||
streamName = $"comments-{key}"; |
|||
} |
|||
|
|||
public virtual async Task LoadAsync( |
|||
CancellationToken ct) |
|||
{ |
|||
var storedEvents = await eventStore.QueryStreamReverseAsync(streamName, 100, ct); |
|||
|
|||
foreach (var @event in storedEvents) |
|||
{ |
|||
var parsedEvent = eventFormatter.Parse(@event); |
|||
|
|||
version = @event.EventStreamNumber; |
|||
|
|||
events.Add(parsedEvent.To<CommentsEvent>()); |
|||
} |
|||
} |
|||
|
|||
public virtual async Task<CommandResult> ExecuteAsync(IAggregateCommand command, |
|||
CancellationToken ct) |
|||
{ |
|||
await LoadAsync(ct); |
|||
|
|||
switch (command) |
|||
{ |
|||
case CreateComment createComment: |
|||
return await Upsert(createComment, c => |
|||
{ |
|||
GuardComments.CanCreate(c); |
|||
|
|||
Create(c); |
|||
}, ct); |
|||
|
|||
case UpdateComment updateComment: |
|||
return await Upsert(updateComment, c => |
|||
{ |
|||
GuardComments.CanUpdate(c, key.ToString(), events); |
|||
|
|||
Update(c); |
|||
}, ct); |
|||
|
|||
case DeleteComment deleteComment: |
|||
return await Upsert(deleteComment, c => |
|||
{ |
|||
GuardComments.CanDelete(c, key.ToString(), events); |
|||
|
|||
Delete(c); |
|||
}, ct); |
|||
|
|||
default: |
|||
ThrowHelper.NotSupportedException(); |
|||
return null!; |
|||
} |
|||
} |
|||
|
|||
private async Task<CommandResult> Upsert<TCommand>(TCommand command, Action<TCommand> handler, |
|||
CancellationToken ct) where TCommand : CommentsCommand |
|||
{ |
|||
Guard.NotNull(command); |
|||
Guard.NotNull(handler); |
|||
|
|||
if (command.ExpectedVersion > EtagVersion.Any && command.ExpectedVersion != Version) |
|||
{ |
|||
throw new DomainObjectVersionException(key.ToString(), Version, command.ExpectedVersion); |
|||
} |
|||
|
|||
var previousVersion = version; |
|||
|
|||
try |
|||
{ |
|||
handler(command); |
|||
|
|||
if (uncommittedEvents.Count > 0) |
|||
{ |
|||
var commitId = Guid.NewGuid(); |
|||
|
|||
var eventData = uncommittedEvents.Select(x => eventFormatter.ToEventData(x, commitId)).ToList(); |
|||
|
|||
await eventStore.AppendAsync(commitId, streamName, previousVersion, eventData, ct); |
|||
} |
|||
|
|||
events.AddRange(uncommittedEvents); |
|||
|
|||
return CommandResult.Empty(key, Version, previousVersion); |
|||
} |
|||
catch |
|||
{ |
|||
version = previousVersion; |
|||
|
|||
throw; |
|||
} |
|||
finally |
|||
{ |
|||
uncommittedEvents.Clear(); |
|||
} |
|||
} |
|||
|
|||
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())); |
|||
} |
|||
|
|||
private void RaiseEvent(CommentsEvent @event) |
|||
{ |
|||
uncommittedEvents.Add(Envelope.Create(@event)); |
|||
|
|||
version++; |
|||
} |
|||
|
|||
public virtual List<Envelope<CommentsEvent>> GetUncommittedEvents() |
|||
{ |
|||
return uncommittedEvents; |
|||
} |
|||
|
|||
public virtual CommentsResult GetComments(long sinceVersion = EtagVersion.Any) |
|||
{ |
|||
return CommentsResult.FromEvents(events, Version, (int)sinceVersion); |
|||
} |
|||
} |
|||
@ -1,87 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Entities.Comments.Commands; |
|||
using Squidex.Domain.Apps.Events.Comments; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
using Squidex.Infrastructure.Translations; |
|||
using Squidex.Infrastructure.Validation; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject.Guards; |
|||
|
|||
public static class GuardComments |
|||
{ |
|||
public static void CanCreate(CreateComment command) |
|||
{ |
|||
Guard.NotNull(command); |
|||
|
|||
Validate.It(e => |
|||
{ |
|||
if (string.IsNullOrWhiteSpace(command.Text)) |
|||
{ |
|||
e(Not.Defined(nameof(command.Text)), nameof(command.Text)); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public static void CanUpdate(UpdateComment command, string commentsId, List<Envelope<CommentsEvent>> events) |
|||
{ |
|||
Guard.NotNull(command); |
|||
|
|||
var comment = FindComment(events, command.CommentId); |
|||
|
|||
if (!string.Equals(commentsId, command.Actor.Identifier, StringComparison.Ordinal) && !comment.Payload.Actor.Equals(command.Actor)) |
|||
{ |
|||
throw new DomainException(T.Get("comments.notUserComment")); |
|||
} |
|||
|
|||
Validate.It(e => |
|||
{ |
|||
if (string.IsNullOrWhiteSpace(command.Text)) |
|||
{ |
|||
e(Not.Defined(nameof(command.Text)), nameof(command.Text)); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public static void CanDelete(DeleteComment command, string commentsId, List<Envelope<CommentsEvent>> events) |
|||
{ |
|||
Guard.NotNull(command); |
|||
|
|||
var comment = FindComment(events, command.CommentId); |
|||
|
|||
if (!string.Equals(commentsId, command.Actor.Identifier, StringComparison.Ordinal) && !comment.Payload.Actor.Equals(command.Actor)) |
|||
{ |
|||
throw new DomainException(T.Get("comments.notUserComment")); |
|||
} |
|||
} |
|||
|
|||
private static Envelope<CommentCreated> FindComment(List<Envelope<CommentsEvent>> events, DomainId commentId) |
|||
{ |
|||
Envelope<CommentCreated>? result = null; |
|||
|
|||
foreach (var @event in events) |
|||
{ |
|||
if (@event.Payload is CommentCreated created && created.CommentId == commentId) |
|||
{ |
|||
result = @event.To<CommentCreated>(); |
|||
} |
|||
else if (@event.Payload is CommentDeleted deleted && deleted.CommentId == commentId) |
|||
{ |
|||
result = null; |
|||
} |
|||
} |
|||
|
|||
if (result == null) |
|||
{ |
|||
throw new DomainObjectNotFoundException(commentId.ToString()); |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
} |
|||
@ -1,16 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments; |
|||
|
|||
public interface ICommentsLoader |
|||
{ |
|||
Task<CommentsResult> GetCommentsAsync(DomainId id, long version = EtagVersion.Any, |
|||
CancellationToken ct = default); |
|||
} |
|||
@ -1,16 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments; |
|||
|
|||
public interface IWatchingService |
|||
{ |
|||
Task<string[]> GetWatchingUsersAsync(DomainId appId, string? resource, string userId, |
|||
CancellationToken ct = default); |
|||
} |
|||
@ -1,61 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using NodaTime; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.States; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments; |
|||
|
|||
public sealed class WatchingService : IWatchingService |
|||
{ |
|||
private readonly IPersistenceFactory<State> persistenceFactory; |
|||
|
|||
[CollectionName("Watches")] |
|||
public sealed class State |
|||
{ |
|||
private static readonly Duration Timeout = Duration.FromMinutes(1); |
|||
|
|||
public Dictionary<string, Instant> Users { get; set; } = new Dictionary<string, Instant>(); |
|||
|
|||
public (bool, string[]) Add(string watcherId, IClock clock) |
|||
{ |
|||
var now = clock.GetCurrentInstant(); |
|||
|
|||
foreach (var (userId, lastSeen) in Users.ToList()) |
|||
{ |
|||
var timeSinceLastSeen = now - lastSeen; |
|||
|
|||
if (timeSinceLastSeen > Timeout) |
|||
{ |
|||
Users.Remove(userId); |
|||
} |
|||
} |
|||
|
|||
Users[watcherId] = now; |
|||
|
|||
return (true, Users.Keys.ToArray()); |
|||
} |
|||
} |
|||
|
|||
public IClock Clock { get; set; } = SystemClock.Instance; |
|||
|
|||
public WatchingService(IPersistenceFactory<State> persistenceFactory) |
|||
{ |
|||
this.persistenceFactory = persistenceFactory; |
|||
} |
|||
|
|||
public async Task<string[]> GetWatchingUsersAsync(DomainId appId, string? resource, string userId, |
|||
CancellationToken ct = default) |
|||
{ |
|||
var state = new SimpleState<State>(persistenceFactory, GetType(), $"{appId}_{resource}"); |
|||
|
|||
await state.LoadAsync(ct); |
|||
|
|||
return await state.UpdateAsync(x => x.Add(userId, Clock), ct: ct); |
|||
} |
|||
} |
|||
@ -1,17 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Events.Comments; |
|||
|
|||
public abstract class CommentsEvent : AppEvent |
|||
{ |
|||
public DomainId CommentsId { get; set; } |
|||
|
|||
public DomainId CommentId { get; set; } |
|||
} |
|||
@ -1,56 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
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 |
|||
{ |
|||
/// <summary>
|
|||
/// The ID of the comment.
|
|||
/// </summary>
|
|||
public DomainId Id { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The time when the comment was created or updated last.
|
|||
/// </summary>
|
|||
public Instant Time { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The user who created or updated the comment.
|
|||
/// </summary>
|
|||
public RefToken User { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The text of the comment.
|
|||
/// </summary>
|
|||
public string Text { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The url where the comment is created.
|
|||
/// </summary>
|
|||
public Uri? Url { get; set; } |
|||
|
|||
public static CommentDto FromDomain(Comment comment) |
|||
{ |
|||
var result = SimpleMapper.Map(comment, new CommentDto()); |
|||
|
|||
return result; |
|||
} |
|||
|
|||
public static CommentDto FromDomain(CreateComment command) |
|||
{ |
|||
var time = SystemClock.Instance.GetCurrentInstant(); |
|||
|
|||
return SimpleMapper.Map(command, new CommentDto { Id = command.CommentId, User = command.Actor, Time = time }); |
|||
} |
|||
} |
|||
@ -1,47 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Entities.Comments; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Areas.Api.Controllers.Comments.Models; |
|||
|
|||
public sealed class CommentsDto |
|||
{ |
|||
/// <summary>
|
|||
/// The created comments including the updates.
|
|||
/// </summary>
|
|||
public CommentDto[]? CreatedComments { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The updates comments since the last version.
|
|||
/// </summary>
|
|||
public CommentDto[]? UpdatedComments { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The deleted comments since the last version.
|
|||
/// </summary>
|
|||
public List<DomainId>? DeletedComments { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The current version.
|
|||
/// </summary>
|
|||
public long Version { get; set; } |
|||
|
|||
public static CommentsDto FromDomain(CommentsResult comments) |
|||
{ |
|||
var result = new CommentsDto |
|||
{ |
|||
CreatedComments = comments.CreatedComments.Select(CommentDto.FromDomain).ToArray(), |
|||
UpdatedComments = comments.UpdatedComments.Select(CommentDto.FromDomain).ToArray(), |
|||
DeletedComments = comments.DeletedComments, |
|||
Version = comments.Version |
|||
}; |
|||
|
|||
return result; |
|||
} |
|||
} |
|||
@ -1,46 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Entities.Comments.Commands; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Reflection; |
|||
using Squidex.Infrastructure.Validation; |
|||
using Squidex.Web; |
|||
|
|||
namespace Squidex.Areas.Api.Controllers.Comments.Models; |
|||
|
|||
[OpenApiRequest] |
|||
public sealed class UpsertCommentDto |
|||
{ |
|||
/// <summary>
|
|||
/// The comment text.
|
|||
/// </summary>
|
|||
[LocalizedRequired] |
|||
public string Text { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The url where the comment is created.
|
|||
/// </summary>
|
|||
public Uri? Url { get; set; } |
|||
|
|||
public CreateComment ToCreateCommand(DomainId commentsId) |
|||
{ |
|||
return SimpleMapper.Map(this, new CreateComment |
|||
{ |
|||
CommentsId = commentsId |
|||
}); |
|||
} |
|||
|
|||
public UpdateComment ToUpdateComment(DomainId commentsId, DomainId commentId) |
|||
{ |
|||
return SimpleMapper.Map(this, new UpdateComment |
|||
{ |
|||
CommentsId = commentsId, |
|||
CommentId = commentId |
|||
}); |
|||
} |
|||
} |
|||
@ -1,99 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Globalization; |
|||
using Microsoft.AspNetCore.Mvc; |
|||
using Microsoft.Net.Http.Headers; |
|||
using Squidex.Areas.Api.Controllers.Comments.Models; |
|||
using Squidex.Domain.Apps.Entities.Comments; |
|||
using Squidex.Domain.Apps.Entities.Comments.Commands; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Commands; |
|||
using Squidex.Infrastructure.Security; |
|||
using Squidex.Infrastructure.Translations; |
|||
using Squidex.Web; |
|||
|
|||
namespace Squidex.Areas.Api.Controllers.Comments.Notifications; |
|||
|
|||
/// <summary>
|
|||
/// Update and query user notifications.
|
|||
/// </summary>
|
|||
[ApiExplorerSettings(GroupName = nameof(Notifications))] |
|||
public sealed class UserNotificationsController : ApiController |
|||
{ |
|||
private readonly ICommentsLoader commentsLoader; |
|||
|
|||
public UserNotificationsController(ICommandBus commandBus, ICommentsLoader commentsLoader) |
|||
: base(commandBus) |
|||
{ |
|||
this.commentsLoader = commentsLoader; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Get all notifications.
|
|||
/// </summary>
|
|||
/// <param name="userId">The user id.</param>
|
|||
/// <param name="version">The current version.</param>
|
|||
/// <remarks>
|
|||
/// When passing in a version you can retrieve all updates since then.
|
|||
/// </remarks>
|
|||
/// <response code="200">All comments returned.</response>.
|
|||
[HttpGet] |
|||
[Route("users/{userId}/notifications")] |
|||
[ProducesResponseType(typeof(CommentsDto), StatusCodes.Status200OK)] |
|||
[ApiPermission] |
|||
public async Task<IActionResult> GetNotifications(DomainId userId, [FromQuery] long version = EtagVersion.Any) |
|||
{ |
|||
CheckPermissions(userId); |
|||
|
|||
var result = await commentsLoader.GetCommentsAsync(userId, version, HttpContext.RequestAborted); |
|||
|
|||
var response = Deferred.Response(() => |
|||
{ |
|||
return CommentsDto.FromDomain(result); |
|||
}); |
|||
|
|||
Response.Headers[HeaderNames.ETag] = result.Version.ToString(CultureInfo.InvariantCulture); |
|||
|
|||
return Ok(response); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Delete a notification.
|
|||
/// </summary>
|
|||
/// <param name="userId">The user id.</param>
|
|||
/// <param name="commentId">The ID of the comment.</param>
|
|||
/// <response code="204">Comment deleted.</response>.
|
|||
/// <response code="404">Comment not found.</response>.
|
|||
[HttpDelete] |
|||
[Route("users/{userId}/notifications/{commentId}")] |
|||
[ProducesResponseType(StatusCodes.Status204NoContent)] |
|||
[ApiPermission] |
|||
public async Task<IActionResult> DeleteComment(DomainId userId, DomainId commentId) |
|||
{ |
|||
CheckPermissions(userId); |
|||
|
|||
var commmand = new DeleteComment |
|||
{ |
|||
AppId = CommentsCommand.NoApp, |
|||
CommentsId = userId, |
|||
CommentId = commentId |
|||
}; |
|||
|
|||
await CommandBus.PublishAsync(commmand, HttpContext.RequestAborted); |
|||
|
|||
return NoContent(); |
|||
} |
|||
|
|||
private void CheckPermissions(DomainId userId) |
|||
{ |
|||
if (!string.Equals(userId.ToString(), User.OpenIdSubject(), StringComparison.Ordinal)) |
|||
{ |
|||
throw new DomainForbiddenException(T.Get("comments.noPermissions")); |
|||
} |
|||
} |
|||
} |
|||
@ -1,22 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Entities.Comments; |
|||
|
|||
namespace Squidex.Config.Domain; |
|||
|
|||
public static class CommentsServices |
|||
{ |
|||
public static void AddSquidexComments(this IServiceCollection services) |
|||
{ |
|||
services.AddSingletonAs<CommentsLoader>() |
|||
.As<ICommentsLoader>(); |
|||
|
|||
services.AddSingletonAs<WatchingService>() |
|||
.As<IWatchingService>(); |
|||
} |
|||
} |
|||
@ -0,0 +1,335 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using LoremNET; |
|||
using Microsoft.Extensions.Logging; |
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Core.Comments; |
|||
using Squidex.Domain.Apps.Core.TestHelpers; |
|||
using Squidex.Domain.Apps.Entities.TestHelpers; |
|||
using Squidex.Domain.Apps.Events.Comments; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
using Squidex.Shared.Users; |
|||
using YDotNet.Document; |
|||
using YDotNet.Document.Cells; |
|||
using YDotNet.Extensions; |
|||
using YDotNet.Server; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Collaboration; |
|||
|
|||
public class CommentCollaborationHandlerTests : GivenContext |
|||
{ |
|||
private readonly SimpleDocumentManager documentManager = new SimpleDocumentManager(); |
|||
private readonly IClock clock = A.Fake<IClock>(); |
|||
private readonly IEventFormatter eventFormatter = A.Fake<IEventFormatter>(); |
|||
private readonly IEventStore eventStore = A.Fake<IEventStore>(); |
|||
private readonly IUserResolver userResolver = A.Fake<IUserResolver>(); |
|||
private readonly CommentCollaborationHandler sut; |
|||
|
|||
public CommentCollaborationHandlerTests() |
|||
{ |
|||
var now = SystemClock.Instance.GetCurrentInstant(); |
|||
|
|||
A.CallTo(() => clock.GetCurrentInstant()) |
|||
.Returns(now); |
|||
|
|||
A.CallTo(() => userResolver.FindByIdOrEmailAsync(A<string>._, default)) |
|||
.Returns(Task.FromResult<IUser?>(null)); |
|||
|
|||
var log = A.Fake<ILogger<CommentCollaborationHandler>>(); |
|||
|
|||
sut = new CommentCollaborationHandler(TestUtils.DefaultSerializer, eventStore, eventFormatter, userResolver, clock, log); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_provider_user_document_name() |
|||
{ |
|||
var name = sut.UserDocument("user42"); |
|||
|
|||
Assert.Equal("users/user42", name); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_provider_app_document_name() |
|||
{ |
|||
var name = sut.ResourceDocument(AppId, DomainId.Create("resource42")); |
|||
|
|||
Assert.Equal($"apps/{AppId}/resource42", name); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_create_comment() |
|||
{ |
|||
await sut.OnInitializedAsync(documentManager); |
|||
|
|||
var commentsId = DomainId.Create("resource42"); |
|||
|
|||
var document = new Doc(); |
|||
var docName = sut.ResourceDocument(AppId, commentsId); |
|||
|
|||
documentManager.Doc = document; |
|||
|
|||
Output? addedInput = null; |
|||
document.Array("stream").ObserveDeep(events => |
|||
{ |
|||
addedInput = events.Single().ArrayEvent.Delta.Single().Values.Single(); |
|||
}); |
|||
|
|||
await sut.CommentAsync(AppId, commentsId, "My Comment", User, null, true, default); |
|||
|
|||
var commentJson = addedInput!.ToJson(document); |
|||
var commentItem = TestUtils.DefaultSerializer.Deserialize<Comment>(commentJson); |
|||
|
|||
commentItem.Should().BeEquivalentTo( |
|||
new Comment(clock.GetCurrentInstant(), User, "My Comment", null, true)); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_create_notification() |
|||
{ |
|||
await sut.OnInitializedAsync(documentManager); |
|||
|
|||
var document = new Doc(); |
|||
var docName = sut.UserDocument(User.Identifier); |
|||
|
|||
documentManager.Doc = document; |
|||
|
|||
Output? addedInput = null; |
|||
document.Array("stream").ObserveDeep(events => |
|||
{ |
|||
addedInput = events.Single().ArrayEvent.Delta.Single().Values.Single(); |
|||
}); |
|||
|
|||
await sut.NotifyAsync(User.Identifier, "My Notification", User, null, true, default); |
|||
|
|||
var commentJson = addedInput!.ToJson(document); |
|||
var commentItem = TestUtils.DefaultSerializer.Deserialize<Comment>(commentJson); |
|||
|
|||
commentItem.Should().BeEquivalentTo( |
|||
new Comment(clock.GetCurrentInstant(), User, "My Notification", null, true)); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_publish_event_for_comment() |
|||
{ |
|||
var text = "My Comment"; |
|||
|
|||
var commentsId = DomainId.Create("resource42"); |
|||
var commentItem = new Comment(clock.GetCurrentInstant(), User, text); |
|||
|
|||
var storedEvent = await CreateCommentAsync(commentsId, commentItem); |
|||
|
|||
storedEvent?.Payload.Should().BeEquivalentTo( |
|||
new CommentCreated |
|||
{ |
|||
Actor = User, |
|||
AppId = AppId, |
|||
CommentId = default, |
|||
CommentsId = commentsId, |
|||
Text = commentItem.Text, |
|||
Mentions = null, |
|||
}, opts => opts.Excluding(x => x.CommentId)); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_enrich_comment_with_mentioned_users_if_users_not_found() |
|||
{ |
|||
var text = "Hi @mail1@squidex.io, @mail2@squidex.io and @notfound@squidex.io"; |
|||
|
|||
var commentsId = DomainId.Create("resource42"); |
|||
var commentItem = new Comment(clock.GetCurrentInstant(), User, text); |
|||
|
|||
var storedEvent = await CreateCommentAsync(commentsId, commentItem); |
|||
|
|||
storedEvent?.Payload.Should().BeEquivalentTo( |
|||
new CommentCreated |
|||
{ |
|||
Actor = User, |
|||
AppId = AppId, |
|||
CommentId = default, |
|||
CommentsId = commentsId, |
|||
Text = commentItem.Text, |
|||
Mentions = null, |
|||
}, opts => opts.Excluding(x => x.CommentId)); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_enrich_comment_with_mentioned_users() |
|||
{ |
|||
SetupUser("id1", "mail1@squidex.io"); |
|||
SetupUser("id2", "mail2@squidex.io"); |
|||
|
|||
var text = "Hi @mail1@squidex.io, @mail2@squidex.io and @notfound@squidex.io"; |
|||
|
|||
var commentsId = DomainId.Create("resource42"); |
|||
var commentItem = new Comment(clock.GetCurrentInstant(), User, text); |
|||
|
|||
var storedEvent = await CreateCommentAsync(commentsId, commentItem); |
|||
|
|||
storedEvent?.Payload.Should().BeEquivalentTo( |
|||
new CommentCreated |
|||
{ |
|||
Actor = User, |
|||
AppId = AppId, |
|||
CommentId = default, |
|||
CommentsId = commentsId, |
|||
Text = commentItem.Text, |
|||
Mentions = new string[] { "id1", "id2" } |
|||
}, opts => opts.Excluding(x => x.CommentId)); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_enrich_comment_with_mentioned_users_and_long_text() |
|||
{ |
|||
SetupUser("id1", "mail1@squidex.io"); |
|||
SetupUser("id2", "mail2@squidex.io"); |
|||
|
|||
var text = $"Hi @mail1@squidex.io, @mail2@squidex.io and @notfound@squidex.io {Lorem.Paragraph(200, 10)}"; |
|||
|
|||
var commentsId = DomainId.Create("resource42"); |
|||
var commentItem = new Comment(clock.GetCurrentInstant(), User, text); |
|||
|
|||
var storedEvent = await CreateCommentAsync(commentsId, commentItem); |
|||
|
|||
storedEvent?.Payload.Should().BeEquivalentTo( |
|||
new CommentCreated |
|||
{ |
|||
Actor = User, |
|||
AppId = AppId, |
|||
CommentId = default, |
|||
CommentsId = commentsId, |
|||
Text = commentItem.Text, |
|||
Mentions = new string[] { "id1", "id2" } |
|||
}, opts => opts.Excluding(x => x.CommentId)); |
|||
} |
|||
|
|||
private async Task<Envelope<IEvent>?> CreateCommentAsync(DomainId commentsId, Comment comment) |
|||
{ |
|||
var document = new Doc(); |
|||
var docName = sut.ResourceDocument(AppId, commentsId); |
|||
|
|||
documentManager.Doc = document; |
|||
|
|||
var stream = document.Array("stream"); |
|||
|
|||
await sut.OnDocumentLoadedAsync(new DocumentLoadEvent |
|||
{ |
|||
Context = new DocumentContext(docName, 0), |
|||
Document = document, |
|||
Source = documentManager, |
|||
}); |
|||
|
|||
var commentJson = TestUtils.DefaultSerializer.Serialize(comment); |
|||
|
|||
Envelope<IEvent>? storedEvent = null; |
|||
|
|||
A.CallTo(() => eventFormatter.ToEventData(A<Envelope<IEvent>>._, A<Guid>._, true)) |
|||
.Invokes(c => |
|||
{ |
|||
storedEvent = c.GetArgument<Envelope<IEvent>>(0); |
|||
}); |
|||
|
|||
await documentManager.UpdateDocAsync(null!, doc => |
|||
{ |
|||
using (var transaction = doc.WriteTransaction()) |
|||
{ |
|||
stream.InsertRange(transaction, 0, InputFactory.FromJson(commentJson)); |
|||
} |
|||
}, default); |
|||
|
|||
await sut.LastTask; |
|||
|
|||
var streamName = $"comments-{DomainId.Combine(AppId.Id, commentsId)}"; |
|||
|
|||
A.CallTo(() => eventStore.AppendAsync(A<Guid>._, streamName, EtagVersion.Any, A<ICollection<EventData>>._, A<CancellationToken>._)) |
|||
.MustHaveHappened(); |
|||
|
|||
return storedEvent; |
|||
} |
|||
|
|||
private void SetupUser(string id, string email) |
|||
{ |
|||
var user = UserMocks.User(id, email); |
|||
|
|||
A.CallTo(() => userResolver.FindByIdOrEmailAsync(email, default)) |
|||
.Returns(user); |
|||
} |
|||
|
|||
private sealed class SimpleDocumentManager : IDocumentManager |
|||
{ |
|||
private readonly SemaphoreSlim lockObject = new SemaphoreSlim(1); |
|||
|
|||
public Doc Doc { get; set; } |
|||
|
|||
public async ValueTask UpdateDocAsync(DocumentContext context, Action<Doc> action, |
|||
CancellationToken ct = default) |
|||
{ |
|||
await lockObject.WaitAsync(ct); |
|||
try |
|||
{ |
|||
action(Doc); |
|||
} |
|||
finally |
|||
{ |
|||
lockObject.Release(); |
|||
} |
|||
} |
|||
|
|||
public Task StartAsync(CancellationToken cancellationToken) |
|||
{ |
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
public Task StopAsync(CancellationToken cancellationToken) |
|||
{ |
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
public ValueTask CleanupAsync( |
|||
CancellationToken ct = default) |
|||
{ |
|||
return default; |
|||
} |
|||
|
|||
public ValueTask PingAsync(DocumentContext context, ulong clock, string? state = null, |
|||
CancellationToken ct = default) |
|||
{ |
|||
return default; |
|||
} |
|||
|
|||
public ValueTask DisconnectAsync(DocumentContext context, |
|||
CancellationToken ct = default) |
|||
{ |
|||
return default; |
|||
} |
|||
|
|||
public ValueTask<IReadOnlyDictionary<ulong, ConnectedUser>> GetAwarenessAsync(DocumentContext context, |
|||
CancellationToken ct = default) |
|||
{ |
|||
return default; |
|||
} |
|||
|
|||
public ValueTask<byte[]> GetStateVectorAsync(DocumentContext context, |
|||
CancellationToken ct = default) |
|||
{ |
|||
return default; |
|||
} |
|||
|
|||
public ValueTask<byte[]> GetUpdateAsync(DocumentContext context, byte[] stateVector, |
|||
CancellationToken ct = default) |
|||
{ |
|||
return default; |
|||
} |
|||
|
|||
public ValueTask<UpdateResult> ApplyUpdateAsync(DocumentContext context, byte[] stateDiff, |
|||
CancellationToken ct = default) |
|||
{ |
|||
return default; |
|||
} |
|||
} |
|||
} |
|||
@ -1,46 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Entities.Comments.DomainObject; |
|||
using Squidex.Domain.Apps.Entities.TestHelpers; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Commands; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments; |
|||
|
|||
public sealed class CommentsLoaderTests : GivenContext |
|||
{ |
|||
private readonly IDomainObjectFactory domainObjectFactory = A.Fake<IDomainObjectFactory>(); |
|||
private readonly CommentsLoader sut; |
|||
|
|||
public CommentsLoaderTests() |
|||
{ |
|||
sut = new CommentsLoader(domainObjectFactory); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_get_comments_from_domain_object() |
|||
{ |
|||
var commentsId = DomainId.NewGuid(); |
|||
var comments = new CommentsResult(); |
|||
|
|||
var domainObject = A.Fake<CommentsStream>(); |
|||
|
|||
A.CallTo(() => domainObjectFactory.Create<CommentsStream>(commentsId)) |
|||
.Returns(domainObject); |
|||
|
|||
A.CallTo(() => domainObject.GetComments(11)) |
|||
.Returns(comments); |
|||
|
|||
var actual = await sut.GetCommentsAsync(commentsId, 11, CancellationToken); |
|||
|
|||
Assert.Same(comments, actual); |
|||
|
|||
A.CallTo(() => domainObject.LoadAsync(CancellationToken)) |
|||
.MustHaveHappened(); |
|||
} |
|||
} |
|||
@ -1,175 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using LoremNET; |
|||
using Squidex.Domain.Apps.Core.TestHelpers; |
|||
using Squidex.Domain.Apps.Entities.Comments.Commands; |
|||
using Squidex.Domain.Apps.Entities.TestHelpers; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Commands; |
|||
using Squidex.Shared.Users; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject; |
|||
|
|||
public class CommentsCommandMiddlewareTests : GivenContext |
|||
{ |
|||
private readonly IDomainObjectFactory domainObjectFactory = A.Fake<IDomainObjectFactory>(); |
|||
private readonly IUserResolver userResolver = A.Fake<IUserResolver>(); |
|||
private readonly ICommandBus commandBus = A.Fake<ICommandBus>(); |
|||
private readonly DomainId commentsId = DomainId.NewGuid(); |
|||
private readonly DomainId commentId = DomainId.NewGuid(); |
|||
private readonly CommentsCommandMiddleware sut; |
|||
|
|||
public CommentsCommandMiddlewareTests() |
|||
{ |
|||
A.CallTo(() => userResolver.FindByIdOrEmailAsync(A<string>._, default)) |
|||
.Returns(Task.FromResult<IUser?>(null)); |
|||
|
|||
sut = new CommentsCommandMiddleware(domainObjectFactory, userResolver); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_invoke_domain_object_for_comments_command() |
|||
{ |
|||
var command = CreateCommentsCommand(new CreateComment()); |
|||
var context = CrateCommandContext(command); |
|||
|
|||
var domainObject = A.Fake<CommentsStream>(); |
|||
|
|||
A.CallTo(() => domainObject.ExecuteAsync(command, CancellationToken)) |
|||
.Returns(CommandResult.Empty(commentsId, 0, 0)); |
|||
|
|||
A.CallTo(() => domainObjectFactory.Create<CommentsStream>(commentsId)) |
|||
.Returns(domainObject); |
|||
|
|||
var isNextCalled = false; |
|||
|
|||
await sut.HandleAsync(context, (c, ct) => |
|||
{ |
|||
isNextCalled = true; |
|||
|
|||
return Task.CompletedTask; |
|||
}, CancellationToken); |
|||
|
|||
Assert.True(isNextCalled); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_enrich_with_mentioned_user_ids_if_found() |
|||
{ |
|||
SetupUser("id1", "mail1@squidex.io"); |
|||
SetupUser("id2", "mail2@squidex.io"); |
|||
|
|||
var command = CreateCommentsCommand(new CreateComment |
|||
{ |
|||
Text = "Hi @mail1@squidex.io, @mail2@squidex.io and @notfound@squidex.io", |
|||
IsMention = false |
|||
}); |
|||
|
|||
var context = CrateCommandContext(command); |
|||
|
|||
await sut.HandleAsync(context, CancellationToken); |
|||
|
|||
Assert.Equal(command.Mentions, new[] { "id1", "id2" }); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_enrich_with_mentioned_user_ids_if_found_and_long_text() |
|||
{ |
|||
SetupUser("id1", "mail1@squidex.io"); |
|||
SetupUser("id2", "mail2@squidex.io"); |
|||
|
|||
var command = CreateCommentsCommand(new CreateComment |
|||
{ |
|||
Text = $"Hi @mail1@squidex.io, @mail2@squidex.io and @notfound@squidex.io {Lorem.Paragraph(200, 10)}", |
|||
IsMention = false |
|||
}); |
|||
|
|||
var context = CrateCommandContext(command); |
|||
|
|||
await sut.HandleAsync(context, CancellationToken); |
|||
|
|||
Assert.Equal(command.Mentions, new[] { "id1", "id2" }); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_invoke_commands_for_mentioned_users() |
|||
{ |
|||
SetupUser("id1", "mail1@squidex.io"); |
|||
SetupUser("id2", "mail2@squidex.io"); |
|||
|
|||
var command = CreateCommentsCommand(new CreateComment |
|||
{ |
|||
Text = "Hi @mail1@squidex.io and @mail2@squidex.io", |
|||
IsMention = false |
|||
}); |
|||
|
|||
var context = CrateCommandContext(command); |
|||
|
|||
await sut.HandleAsync(context, CancellationToken); |
|||
|
|||
A.CallTo(() => commandBus.PublishAsync(A<ICommand>._, A<CancellationToken>._)) |
|||
.MustNotHaveHappened(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_enrich_with_mentioned_user_ids_if_invalid_mentioned_tags_used() |
|||
{ |
|||
var command = CreateCommentsCommand(new CreateComment |
|||
{ |
|||
Text = "Hi invalid@squidex.io", |
|||
IsMention = false |
|||
}); |
|||
|
|||
var context = CrateCommandContext(command); |
|||
|
|||
await sut.HandleAsync(context, CancellationToken); |
|||
|
|||
A.CallTo(() => userResolver.FindByIdOrEmailAsync(A<string>._, A<CancellationToken>._)) |
|||
.MustNotHaveHappened(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_enrich_with_mentioned_user_ids_for_notification() |
|||
{ |
|||
var command = CreateCommentsCommand(new CreateComment |
|||
{ |
|||
Text = "Hi @invalid@squidex.io", |
|||
IsMention = true |
|||
}); |
|||
|
|||
var context = CrateCommandContext(command); |
|||
|
|||
await sut.HandleAsync(context, CancellationToken); |
|||
|
|||
A.CallTo(() => userResolver.FindByIdOrEmailAsync(A<string>._, A<CancellationToken>._)) |
|||
.MustNotHaveHappened(); |
|||
} |
|||
|
|||
private CommandContext CrateCommandContext(ICommand command) |
|||
{ |
|||
return new CommandContext(command, commandBus); |
|||
} |
|||
|
|||
private void SetupUser(string id, string email) |
|||
{ |
|||
var user = UserMocks.User(id, email); |
|||
|
|||
A.CallTo(() => userResolver.FindByIdOrEmailAsync(email, default)) |
|||
.Returns(user); |
|||
} |
|||
|
|||
private T CreateCommentsCommand<T>(T command) where T : CommentCommand |
|||
{ |
|||
command.AppId = AppId; |
|||
command.CommentsId = commentsId; |
|||
command.CommentId = commentId; |
|||
command.Actor = User; |
|||
|
|||
return command; |
|||
} |
|||
} |
|||
@ -1,175 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Core.Comments; |
|||
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.Commands; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject; |
|||
|
|||
public class CommentsStreamTests |
|||
{ |
|||
private readonly IEventFormatter eventFormatter = A.Fake<IEventFormatter>(); |
|||
private readonly IEventStore eventStore = A.Fake<IEventStore>(); |
|||
private readonly DomainId commentsId = DomainId.NewGuid(); |
|||
private readonly DomainId commentId = DomainId.NewGuid(); |
|||
private readonly RefToken actor = RefToken.User("me"); |
|||
private readonly CommentsStream sut; |
|||
|
|||
public IEnumerable<Envelope<IEvent>> LastEvents { get; private set; } = Enumerable.Empty<Envelope<IEvent>>(); |
|||
|
|||
public CommentsStreamTests() |
|||
{ |
|||
A.CallTo(() => eventStore.AppendAsync(A<Guid>._, A<string>._, A<long>._, A<ICollection<EventData>>._, default)) |
|||
.Invokes(x => LastEvents = sut!.GetUncommittedEvents().Select(x => x.To<IEvent>()).ToList()); |
|||
|
|||
sut = new CommentsStream(commentsId, eventFormatter, eventStore); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Create_should_create_events() |
|||
{ |
|||
var command = new CreateComment { Text = "text1", Url = new Uri("http://uri") }; |
|||
|
|||
var actual = await sut.ExecuteAsync(CreateCommentsCommand(command), default); |
|||
|
|||
actual.ShouldBeEquivalent(CommandResult.Empty(commentsId, 0, EtagVersion.Empty)); |
|||
|
|||
sut.GetComments(0).Should().BeEquivalentTo(new CommentsResult |
|||
{ |
|||
Version = 0 |
|||
}); |
|||
|
|||
sut.GetComments(-1).Should().BeEquivalentTo(new CommentsResult |
|||
{ |
|||
CreatedComments = new List<Comment> |
|||
{ |
|||
new Comment(command.CommentId, GetTime(), command.Actor, "text1", command.Url) |
|||
}, |
|||
Version = 0 |
|||
}); |
|||
|
|||
LastEvents |
|||
.ShouldHaveSameEvents( |
|||
CreateCommentsEvent(new CommentCreated { Text = command.Text, Url = command.Url }) |
|||
); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Update_should_create_events() |
|||
{ |
|||
await ExecuteCreateAsync(); |
|||
|
|||
var updateCommand = new UpdateComment { Text = "text2" }; |
|||
|
|||
var actual = await sut.ExecuteAsync(CreateCommentsCommand(updateCommand), default); |
|||
|
|||
actual.ShouldBeEquivalent(CommandResult.Empty(commentsId, 1, 0)); |
|||
|
|||
sut.GetComments(-1).Should().BeEquivalentTo(new CommentsResult |
|||
{ |
|||
CreatedComments = new List<Comment> |
|||
{ |
|||
new Comment(commentId, GetTime(), updateCommand.Actor, "text2") |
|||
}, |
|||
Version = 1 |
|||
}); |
|||
|
|||
sut.GetComments(0).Should().BeEquivalentTo(new CommentsResult |
|||
{ |
|||
UpdatedComments = new List<Comment> |
|||
{ |
|||
new Comment(commentId, GetTime(), updateCommand.Actor, "text2") |
|||
}, |
|||
Version = 1 |
|||
}); |
|||
|
|||
LastEvents |
|||
.ShouldHaveSameEvents( |
|||
CreateCommentsEvent(new CommentUpdated { Text = updateCommand.Text }) |
|||
); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Delete_should_create_events() |
|||
{ |
|||
await ExecuteCreateAsync(); |
|||
await ExecuteUpdateAsync(); |
|||
|
|||
var deleteCommand = new DeleteComment(); |
|||
|
|||
var actual = await sut.ExecuteAsync(CreateCommentsCommand(deleteCommand), default); |
|||
|
|||
actual.ShouldBeEquivalent(CommandResult.Empty(commentsId, 2, 1)); |
|||
|
|||
sut.GetComments(-1).Should().BeEquivalentTo(new CommentsResult |
|||
{ |
|||
Version = 2 |
|||
}); |
|||
|
|||
sut.GetComments(0).Should().BeEquivalentTo(new CommentsResult |
|||
{ |
|||
DeletedComments = new List<DomainId> |
|||
{ |
|||
commentId |
|||
}, |
|||
Version = 2 |
|||
}); |
|||
|
|||
sut.GetComments(1).Should().BeEquivalentTo(new CommentsResult |
|||
{ |
|||
DeletedComments = new List<DomainId> |
|||
{ |
|||
commentId |
|||
}, |
|||
Version = 2 |
|||
}); |
|||
|
|||
LastEvents |
|||
.ShouldHaveSameEvents( |
|||
CreateCommentsEvent(new CommentDeleted()) |
|||
); |
|||
} |
|||
|
|||
private Task ExecuteCreateAsync() |
|||
{ |
|||
return sut.ExecuteAsync(CreateCommentsCommand(new CreateComment { Text = "text1" }), default); |
|||
} |
|||
|
|||
private Task ExecuteUpdateAsync() |
|||
{ |
|||
return sut.ExecuteAsync(CreateCommentsCommand(new UpdateComment { Text = "text2" }), default); |
|||
} |
|||
|
|||
private T CreateCommentsEvent<T>(T @event) where T : CommentsEvent |
|||
{ |
|||
@event.Actor = actor; |
|||
@event.CommentsId = commentsId; |
|||
@event.CommentId = commentId; |
|||
|
|||
return @event; |
|||
} |
|||
|
|||
private T CreateCommentsCommand<T>(T command) where T : CommentCommand |
|||
{ |
|||
command.Actor = actor; |
|||
command.CommentsId = commentsId; |
|||
command.CommentId = commentId; |
|||
|
|||
return command; |
|||
} |
|||
|
|||
private Instant GetTime() |
|||
{ |
|||
return LastEvents.ElementAt(0).Headers.Timestamp(); |
|||
} |
|||
} |
|||
@ -1,191 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.TestHelpers; |
|||
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 Squidex.Infrastructure.Validation; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject.Guards; |
|||
|
|||
public class GuardCommentsTests : IClassFixture<TranslationsFixture> |
|||
{ |
|||
private readonly string commentsId = DomainId.NewGuid().ToString(); |
|||
private readonly RefToken user1 = RefToken.User("1"); |
|||
private readonly RefToken user2 = RefToken.User("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 = DomainId.NewGuid(); |
|||
var command = new UpdateComment { CommentId = commentId, Actor = user1 }; |
|||
|
|||
var events = new List<Envelope<CommentsEvent>> |
|||
{ |
|||
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>() |
|||
}; |
|||
|
|||
ValidationAssert.Throws(() => GuardComments.CanUpdate(command, commentsId, events), |
|||
new ValidationError("Text is required.", "Text")); |
|||
} |
|||
|
|||
[Fact] |
|||
public void CanUpdate_should_throw_exception_if_comment_from_another_user() |
|||
{ |
|||
var commentId = DomainId.NewGuid(); |
|||
var command = new UpdateComment { CommentId = commentId, Actor = user2, Text = "text2" }; |
|||
|
|||
var events = new List<Envelope<CommentsEvent>> |
|||
{ |
|||
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>() |
|||
}; |
|||
|
|||
Assert.Throws<DomainException>(() => GuardComments.CanUpdate(command, commentsId, events)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void CanUpdate_should_throw_exception_if_comment_not_found() |
|||
{ |
|||
var commentId = DomainId.NewGuid(); |
|||
var command = new UpdateComment { CommentId = commentId, Actor = user1 }; |
|||
|
|||
var events = new List<Envelope<CommentsEvent>>(); |
|||
|
|||
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanUpdate(command, commentsId, events)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void CanUpdate_should_throw_exception_if_comment_deleted_found() |
|||
{ |
|||
var commentId = DomainId.NewGuid(); |
|||
var command = new UpdateComment { CommentId = commentId, Actor = user1 }; |
|||
|
|||
var events = new List<Envelope<CommentsEvent>> |
|||
{ |
|||
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>(), |
|||
Envelope.Create<CommentsEvent>(new CommentDeleted { CommentId = commentId }).To<CommentsEvent>() |
|||
}; |
|||
|
|||
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanUpdate(command, commentsId, events)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void CanUpdate_should_not_throw_exception_if_comment_is_own_notification() |
|||
{ |
|||
var commentId = DomainId.NewGuid(); |
|||
var command = new UpdateComment { CommentId = commentId, Actor = user1, Text = "text2" }; |
|||
|
|||
var events = new List<Envelope<CommentsEvent>> |
|||
{ |
|||
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>() |
|||
}; |
|||
|
|||
GuardComments.CanUpdate(command, user1.Identifier, events); |
|||
} |
|||
|
|||
[Fact] |
|||
public void CanUpdate_should_not_throw_exception_if_comment_from_same_user() |
|||
{ |
|||
var commentId = DomainId.NewGuid(); |
|||
var command = new UpdateComment { CommentId = commentId, Actor = user1, Text = "text2" }; |
|||
|
|||
var events = new List<Envelope<CommentsEvent>> |
|||
{ |
|||
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>() |
|||
}; |
|||
|
|||
GuardComments.CanUpdate(command, commentsId, events); |
|||
} |
|||
|
|||
[Fact] |
|||
public void CanDelete_should_throw_exception_if_comment_from_another_user() |
|||
{ |
|||
var commentId = DomainId.NewGuid(); |
|||
var command = new DeleteComment { CommentId = commentId, Actor = user2 }; |
|||
|
|||
var events = new List<Envelope<CommentsEvent>> |
|||
{ |
|||
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>() |
|||
}; |
|||
|
|||
Assert.Throws<DomainException>(() => GuardComments.CanDelete(command, commentsId, events)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void CanDelete_should_throw_exception_if_comment_not_found() |
|||
{ |
|||
var commentId = DomainId.NewGuid(); |
|||
var command = new DeleteComment { CommentId = commentId, Actor = user1 }; |
|||
|
|||
var events = new List<Envelope<CommentsEvent>>(); |
|||
|
|||
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanDelete(command, commentsId, events)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void CanDelete_should_throw_exception_if_comment_deleted() |
|||
{ |
|||
var commentId = DomainId.NewGuid(); |
|||
var command = new DeleteComment { CommentId = commentId, Actor = user1 }; |
|||
|
|||
var events = new List<Envelope<CommentsEvent>> |
|||
{ |
|||
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }), |
|||
Envelope.Create<CommentsEvent>(new CommentDeleted { CommentId = commentId }) |
|||
}; |
|||
|
|||
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanDelete(command, commentsId, events)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void CanDelete_should_not_throw_exception_if_comment_is_own_notification() |
|||
{ |
|||
var commentId = DomainId.NewGuid(); |
|||
var command = new DeleteComment { CommentId = commentId, Actor = user1 }; |
|||
|
|||
var events = new List<Envelope<CommentsEvent>> |
|||
{ |
|||
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>() |
|||
}; |
|||
|
|||
GuardComments.CanDelete(command, user1.Identifier, events); |
|||
} |
|||
|
|||
[Fact] |
|||
public void CanDelete_should_not_throw_exception_if_comment_from_same_user() |
|||
{ |
|||
var commentId = DomainId.NewGuid(); |
|||
var command = new DeleteComment { CommentId = commentId, Actor = user1 }; |
|||
|
|||
var events = new List<Envelope<CommentsEvent>> |
|||
{ |
|||
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>() |
|||
}; |
|||
|
|||
GuardComments.CanDelete(command, commentsId, events); |
|||
} |
|||
} |
|||
@ -1,82 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Entities.TestHelpers; |
|||
using Squidex.Infrastructure.TestHelpers; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments; |
|||
|
|||
public class WatchingServiceTests : GivenContext |
|||
{ |
|||
private readonly TestState<WatchingService.State> state1; |
|||
private readonly TestState<WatchingService.State> state2; |
|||
private readonly IClock clock = A.Fake<IClock>(); |
|||
private readonly string resource1 = "resource1"; |
|||
private readonly string resource2 = "resource2"; |
|||
private readonly WatchingService sut; |
|||
private Instant now = SystemClock.Instance.GetCurrentInstant(); |
|||
|
|||
public WatchingServiceTests() |
|||
{ |
|||
A.CallTo(() => clock.GetCurrentInstant()) |
|||
.ReturnsLazily(() => now); |
|||
|
|||
state1 = new TestState<WatchingService.State>($"{AppId.Id}_{resource1}"); |
|||
state2 = new TestState<WatchingService.State>($"{AppId.Id}_{resource2}", state1.PersistenceFactory); |
|||
|
|||
sut = new WatchingService(state1.PersistenceFactory) |
|||
{ |
|||
Clock = clock |
|||
}; |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_only_return_self_if_no_one_watching() |
|||
{ |
|||
var watching = await sut.GetWatchingUsersAsync(AppId.Id, resource1, "user1", CancellationToken); |
|||
|
|||
Assert.Equal(new[] { "user1" }, watching); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_return_users_watching_on_same_resource() |
|||
{ |
|||
await sut.GetWatchingUsersAsync(AppId.Id, resource1, "user1", CancellationToken); |
|||
await sut.GetWatchingUsersAsync(AppId.Id, resource2, "user2", CancellationToken); |
|||
|
|||
var watching1 = await sut.GetWatchingUsersAsync(AppId.Id, resource1, "user3", CancellationToken); |
|||
var watching2 = await sut.GetWatchingUsersAsync(AppId.Id, resource2, "user4", CancellationToken); |
|||
|
|||
Assert.Equal(new[] { "user1", "user3" }, watching1); |
|||
Assert.Equal(new[] { "user2", "user4" }, watching2); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_cleanup_old_users() |
|||
{ |
|||
await sut.GetWatchingUsersAsync(AppId.Id, resource1, "user1", CancellationToken); |
|||
await sut.GetWatchingUsersAsync(AppId.Id, resource2, "user2", CancellationToken); |
|||
|
|||
now = now.Plus(Duration.FromMinutes(2)); |
|||
|
|||
await sut.GetWatchingUsersAsync(AppId.Id, resource1, "user3", CancellationToken); |
|||
await sut.GetWatchingUsersAsync(AppId.Id, resource2, "user4", CancellationToken); |
|||
|
|||
var watching1 = await sut.GetWatchingUsersAsync(AppId.Id, resource1, "user5", CancellationToken); |
|||
var watching2 = await sut.GetWatchingUsersAsync(AppId.Id, resource2, "user6", CancellationToken); |
|||
|
|||
Assert.Equal(new[] { "user3", "user5" }, watching1); |
|||
Assert.Equal(new[] { "user4", "user6" }, watching2); |
|||
|
|||
A.CallTo(() => state1.Persistence.WriteSnapshotAsync(A<WatchingService.State>._, CancellationToken)) |
|||
.MustHaveHappened(); |
|||
|
|||
A.CallTo(() => state2.Persistence.WriteSnapshotAsync(A<WatchingService.State>._, CancellationToken)) |
|||
.MustHaveHappened(); |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue