From 22be04a77d0935a59711a3a0083010f7d659ad8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=BD=D0=BE=D0=B9=20=D0=B8=D1=88?= =?UTF-8?q?=D0=B0=D0=BA?= <69521267+undrcrxwn@users.noreply.github.com> Date: Mon, 7 Oct 2024 20:05:46 +0300 Subject: [PATCH 1/4] feat: add reactions and implement Unit of Work pattern --- .../Services/CommentsService.cs | 58 ++- .../Services/DiscussionsService.cs | 14 +- ...ntRepository.cs => ICommentsRepository.cs} | 6 +- .../Abstractions/IDiscussionsRepository.cs | 2 +- .../Abstractions/IReactionsRepository.cs | 8 + .../Abstractions/IUnitOfWork.cs | 11 + .../Abstractions/IUnitOfWorkFactory.cs | 6 + .../DTOs/ReactionCounter.cs | 7 + .../Entities/Comment.cs | 3 + .../Entities/Discussion.cs | 3 + .../ConfigureServices.cs | 8 +- .../Services/CommentsRepository.cs | 375 ++++++++---------- .../Services/DiscussionsRepository.cs | 216 +++++----- .../Services/ReactionsRepository.cs | 71 ++++ .../Services/UnitOfWork.cs | 20 + .../Services/UnitOfWorkFactory.cs | 13 + .../Tests/CommentsRepositoryTests.cs | 86 ++-- .../Tests/DiscussionsRepositoryTests.cs | 15 +- .../Tests/ReactionsRepositoryTests.cs | 35 ++ 19 files changed, 567 insertions(+), 390 deletions(-) rename src/CrowdParlay.Social.Domain/Abstractions/{ICommentRepository.cs => ICommentsRepository.cs} (65%) create mode 100644 src/CrowdParlay.Social.Domain/Abstractions/IReactionsRepository.cs create mode 100644 src/CrowdParlay.Social.Domain/Abstractions/IUnitOfWork.cs create mode 100644 src/CrowdParlay.Social.Domain/Abstractions/IUnitOfWorkFactory.cs create mode 100644 src/CrowdParlay.Social.Domain/DTOs/ReactionCounter.cs create mode 100644 src/CrowdParlay.Social.Infrastructure.Persistence/Services/ReactionsRepository.cs create mode 100644 src/CrowdParlay.Social.Infrastructure.Persistence/Services/UnitOfWork.cs create mode 100644 src/CrowdParlay.Social.Infrastructure.Persistence/Services/UnitOfWorkFactory.cs create mode 100644 tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsRepositoryTests.cs diff --git a/src/CrowdParlay.Social.Application/Services/CommentsService.cs b/src/CrowdParlay.Social.Application/Services/CommentsService.cs index da870be..8741278 100644 --- a/src/CrowdParlay.Social.Application/Services/CommentsService.cs +++ b/src/CrowdParlay.Social.Application/Services/CommentsService.cs @@ -7,17 +7,21 @@ namespace CrowdParlay.Social.Application.Services; -public class CommentsService(ICommentRepository commentRepository, IUsersService usersService) : ICommentsService +public class CommentsService( + IUnitOfWorkFactory unitOfWorkFactory, + ICommentsRepository commentsRepository, + IUsersService usersService) + : ICommentsService { public async Task GetByIdAsync(Guid id) { - var comment = await commentRepository.GetByIdAsync(id); + var comment = await commentsRepository.GetByIdAsync(id); return await EnrichAsync(comment); } public async Task> SearchAsync(Guid? discussionId, Guid? authorId, int offset, int count) { - var page = await commentRepository.SearchAsync(discussionId, authorId, offset, count); + var page = await commentsRepository.SearchAsync(discussionId, authorId, offset, count); return new Page { TotalCount = page.TotalCount, @@ -27,17 +31,21 @@ public async Task> SearchAsync(Guid? discussionId, Guid? author public async Task CreateAsync(Guid authorId, Guid discussionId, string content) { - var comment = await commentRepository.CreateAsync(authorId, discussionId, content); - var result = await EnrichAsync(comment); + Comment comment; + await using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) + { + var commentId = await unitOfWork.CommentsRepository.CreateAsync(authorId, discussionId, content); + comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId); + } // TODO: notify clients via SignalR - return result; + return await EnrichAsync(comment); } public async Task> GetRepliesToCommentAsync(Guid parentCommentId, int offset, int count) { - var page = await commentRepository.GetRepliesToCommentAsync(parentCommentId, offset, count); + var page = await commentsRepository.GetRepliesToCommentAsync(parentCommentId, offset, count); return new Page { TotalCount = page.TotalCount, @@ -47,11 +55,43 @@ public async Task> GetRepliesToCommentAsync(Guid parentCommentI public async Task ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content) { - var comment = await commentRepository.ReplyToCommentAsync(authorId, parentCommentId, content); + Comment comment; + await using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) + { + var commentId = await unitOfWork.CommentsRepository.ReplyToCommentAsync(authorId, parentCommentId, content); + comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId); + } + + return await EnrichAsync(comment); + } + + public async Task AddReactionAsync(Guid authorId, Guid commentId, string reaction) + { + Comment comment; + await using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) + { + await unitOfWork.ReactionsRepository.AddAsync(authorId, commentId, reaction); + comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId); + await unitOfWork.CommitAsync(); + } + + return await EnrichAsync(comment); + } + + public async Task RemoveReactionAsync(Guid authorId, Guid commentId, string reaction) + { + Comment comment; + await using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) + { + await unitOfWork.ReactionsRepository.RemoveAsync(authorId, commentId, reaction); + comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId); + await unitOfWork.CommitAsync(); + } + return await EnrichAsync(comment); } - public async Task DeleteAsync(Guid id) => await commentRepository.DeleteAsync(id); + public async Task DeleteAsync(Guid id) => await commentsRepository.DeleteAsync(id); private async Task EnrichAsync(Comment comment) { diff --git a/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs b/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs index 564294b..c0785c9 100644 --- a/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs +++ b/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs @@ -7,7 +7,11 @@ namespace CrowdParlay.Social.Application.Services; -public class DiscussionsService(IDiscussionsRepository discussionsRepository, IUsersService usersService) : IDiscussionsService +public class DiscussionsService( + IUnitOfWorkFactory unitOfWorkFactory, + IDiscussionsRepository discussionsRepository, + IUsersService usersService) + : IDiscussionsService { public async Task GetByIdAsync(Guid id) { @@ -37,7 +41,13 @@ public async Task> GetByAuthorAsync(Guid authorId, int offse public async Task CreateAsync(Guid authorId, string title, string description) { - var discussion = await discussionsRepository.CreateAsync(authorId, title, description); + Discussion discussion; + await using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) + { + var discussionId = await unitOfWork.DiscussionsRepository.CreateAsync(authorId, title, description); + discussion = await unitOfWork.DiscussionsRepository.GetByIdAsync(discussionId); + } + return await EnrichAsync(discussion); } diff --git a/src/CrowdParlay.Social.Domain/Abstractions/ICommentRepository.cs b/src/CrowdParlay.Social.Domain/Abstractions/ICommentsRepository.cs similarity index 65% rename from src/CrowdParlay.Social.Domain/Abstractions/ICommentRepository.cs rename to src/CrowdParlay.Social.Domain/Abstractions/ICommentsRepository.cs index 6483a6b..96b2d5c 100644 --- a/src/CrowdParlay.Social.Domain/Abstractions/ICommentRepository.cs +++ b/src/CrowdParlay.Social.Domain/Abstractions/ICommentsRepository.cs @@ -3,12 +3,12 @@ namespace CrowdParlay.Social.Domain.Abstractions; -public interface ICommentRepository +public interface ICommentsRepository { public Task GetByIdAsync(Guid id); public Task> SearchAsync(Guid? discussionId, Guid? authorId, int offset, int count); - public Task CreateAsync(Guid authorId, Guid discussionId, string content); + public Task CreateAsync(Guid authorId, Guid discussionId, string content); public Task> GetRepliesToCommentAsync(Guid parentCommentId, int offset, int count); - public Task ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content); + public Task ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content); public Task DeleteAsync(Guid id); } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/Abstractions/IDiscussionsRepository.cs b/src/CrowdParlay.Social.Domain/Abstractions/IDiscussionsRepository.cs index ec83a75..6944432 100644 --- a/src/CrowdParlay.Social.Domain/Abstractions/IDiscussionsRepository.cs +++ b/src/CrowdParlay.Social.Domain/Abstractions/IDiscussionsRepository.cs @@ -8,5 +8,5 @@ public interface IDiscussionsRepository public Task GetByIdAsync(Guid id); public Task> GetAllAsync(int offset, int count); public Task> GetByAuthorAsync(Guid authorId, int offset, int count); - public Task CreateAsync(Guid authorId, string title, string description); + public Task CreateAsync(Guid authorId, string title, string description); } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/Abstractions/IReactionsRepository.cs b/src/CrowdParlay.Social.Domain/Abstractions/IReactionsRepository.cs new file mode 100644 index 0000000..37d5866 --- /dev/null +++ b/src/CrowdParlay.Social.Domain/Abstractions/IReactionsRepository.cs @@ -0,0 +1,8 @@ +namespace CrowdParlay.Social.Domain.Abstractions; + +public interface IReactionsRepository +{ + public Task AddAsync(Guid authorId, Guid subjectId, string reaction); + public Task RemoveAsync(Guid authorId, Guid subjectId, string reaction); + public Task> GetAllAsync(Guid authorId, Guid subjectId); +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/Abstractions/IUnitOfWork.cs b/src/CrowdParlay.Social.Domain/Abstractions/IUnitOfWork.cs new file mode 100644 index 0000000..44b524c --- /dev/null +++ b/src/CrowdParlay.Social.Domain/Abstractions/IUnitOfWork.cs @@ -0,0 +1,11 @@ +namespace CrowdParlay.Social.Domain.Abstractions; + +public interface IUnitOfWork : IAsyncDisposable +{ + public IDiscussionsRepository DiscussionsRepository { get; } + public ICommentsRepository CommentsRepository { get; } + public IReactionsRepository ReactionsRepository { get; } + + Task CommitAsync(); + Task RollbackAsync(); +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/Abstractions/IUnitOfWorkFactory.cs b/src/CrowdParlay.Social.Domain/Abstractions/IUnitOfWorkFactory.cs new file mode 100644 index 0000000..5cbdd0a --- /dev/null +++ b/src/CrowdParlay.Social.Domain/Abstractions/IUnitOfWorkFactory.cs @@ -0,0 +1,6 @@ +namespace CrowdParlay.Social.Domain.Abstractions; + +public interface IUnitOfWorkFactory +{ + public Task CreateAsync(); +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/DTOs/ReactionCounter.cs b/src/CrowdParlay.Social.Domain/DTOs/ReactionCounter.cs new file mode 100644 index 0000000..6680132 --- /dev/null +++ b/src/CrowdParlay.Social.Domain/DTOs/ReactionCounter.cs @@ -0,0 +1,7 @@ +namespace CrowdParlay.Social.Domain.DTOs; + +public class ReactionCounter +{ + public required string Reaction { get; set; } + public required int Count { get; set; } +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/Entities/Comment.cs b/src/CrowdParlay.Social.Domain/Entities/Comment.cs index aeee8cc..bf298a1 100644 --- a/src/CrowdParlay.Social.Domain/Entities/Comment.cs +++ b/src/CrowdParlay.Social.Domain/Entities/Comment.cs @@ -1,3 +1,5 @@ +using CrowdParlay.Social.Domain.DTOs; + namespace CrowdParlay.Social.Domain.Entities; public class Comment @@ -8,4 +10,5 @@ public class Comment public required DateTimeOffset CreatedAt { get; set; } public required int ReplyCount { get; set; } public required ISet FirstRepliesAuthorIds { get; set; } + public required ISet Reactions { get; set; } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/Entities/Discussion.cs b/src/CrowdParlay.Social.Domain/Entities/Discussion.cs index 1f2d879..82a2a87 100644 --- a/src/CrowdParlay.Social.Domain/Entities/Discussion.cs +++ b/src/CrowdParlay.Social.Domain/Entities/Discussion.cs @@ -1,3 +1,5 @@ +using CrowdParlay.Social.Domain.DTOs; + namespace CrowdParlay.Social.Domain.Entities; public class Discussion @@ -7,4 +9,5 @@ public class Discussion public required string Description { get; set; } public required Guid AuthorId { get; set; } public required DateTimeOffset CreatedAt { get; set; } + public required ISet Reactions { get; set; } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs index 1ec8fcc..43ba148 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs @@ -10,8 +10,12 @@ public static class ConfigurePersistenceExtensions { public static IServiceCollection AddPersistence(this IServiceCollection services, IConfiguration configuration) => services .AddNeo4j(configuration) - .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(provider => provider.GetRequiredService().AsyncSession()) + .AddScoped(provider => provider.GetRequiredService()) + .AddScoped(); // ReSharper disable once InconsistentNaming private static IServiceCollection AddNeo4j(this IServiceCollection services, IConfiguration configuration) diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs index 7ca813c..0941e2a 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs @@ -7,40 +7,36 @@ namespace CrowdParlay.Social.Infrastructure.Persistence.Services; -public class CommentsRepository(IDriver driver) : ICommentRepository +public class CommentsRepository(IAsyncQueryRunner runner) : ICommentsRepository { public async Task GetByIdAsync(Guid id) { - await using var session = driver.AsyncSession(); - return await session.ExecuteReadAsync(async runner => - { - var data = await runner.RunAsync( - """ - MATCH (comment:Comment { Id: $id })-[:AUTHORED_BY]->(author:Author) - OPTIONAL MATCH (replyAuthor:Author)<-[:AUTHORED_BY]-(reply:Comment)-[:REPLIES_TO]->(comment) - - WITH comment, author, COUNT(reply) AS replyCount, - CASE WHEN COUNT(reply) > 0 - THEN COLLECT(DISTINCT replyAuthor.Id)[0..3] - ELSE [] END AS firstRepliesAuthorIds - - RETURN { - Id: comment.Id, - Content: comment.Content, - AuthorId: author.Id, - CreatedAt: comment.CreatedAt, - ReplyCount: replyCount, - FirstRepliesAuthorIds: firstRepliesAuthorIds - } - """, - new { id = id.ToString() }); + var data = await runner.RunAsync( + """ + MATCH (comment:Comment { Id: $id })-[:AUTHORED_BY]->(author:Author) + OPTIONAL MATCH (replyAuthor:Author)<-[:AUTHORED_BY]-(reply:Comment)-[:REPLIES_TO]->(comment) + + WITH comment, author, COUNT(reply) AS replyCount, + CASE WHEN COUNT(reply) > 0 + THEN COLLECT(DISTINCT replyAuthor.Id)[0..3] + ELSE [] END AS firstRepliesAuthorIds + + RETURN { + Id: comment.Id, + Content: comment.Content, + AuthorId: author.Id, + CreatedAt: comment.CreatedAt, + ReplyCount: replyCount, + FirstRepliesAuthorIds: firstRepliesAuthorIds + } + """, + new { id = id.ToString() }); - if (await data.PeekAsync() is null) - throw new NotFoundException(); + if (await data.PeekAsync() is null) + throw new NotFoundException(); - var record = await data.SingleAsync(); - return record[0].Adapt(); - }); + var record = await data.SingleAsync(); + return record[0].Adapt(); } public async Task> SearchAsync(Guid? discussionId, Guid? authorId, int offset, int count) @@ -52,210 +48,175 @@ public async Task> SearchAsync(Guid? discussionId, Guid? authorId, if (discussionId is not null) matchSelector += "-[:REPLIES_TO]->(discussion:Discussion { Id: $discussionId })"; - await using var session = driver.AsyncSession(); - return await session.ExecuteReadAsync(async runner => - { - var data = await runner.RunAsync( - matchSelector + - """ - OPTIONAL MATCH (deepReplyAuthor:Author)<-[:AUTHORED_BY]-(deepReply:Comment)-[:REPLIES_TO*]->(comment) + var data = await runner.RunAsync( + matchSelector + + """ + OPTIONAL MATCH (deepReplyAuthor:Author)<-[:AUTHORED_BY]-(deepReply:Comment)-[:REPLIES_TO*]->(comment) - WITH author, comment, deepReplyAuthor, deepReply - ORDER BY comment.CreatedAt, deepReply.CreatedAt DESC + WITH author, comment, deepReplyAuthor, deepReply + ORDER BY comment.CreatedAt, deepReply.CreatedAt DESC - WITH author, comment, { - DeepReplyCount: COUNT(deepReply), - FirstDeepRepliesAuthorIds: CASE WHEN COUNT(deepReply) > 0 - THEN COLLECT(DISTINCT deepReplyAuthor.Id)[0..3] - ELSE [] END - } AS deepRepliesData + WITH author, comment, { + DeepReplyCount: COUNT(deepReply), + FirstDeepRepliesAuthorIds: CASE WHEN COUNT(deepReply) > 0 + THEN COLLECT(DISTINCT deepReplyAuthor.Id)[0..3] + ELSE [] END + } AS deepRepliesData - RETURN { - TotalCount: COUNT(comment), - Items: COLLECT({ - Id: comment.Id, - Content: comment.Content, - AuthorId: author.Id, - CreatedAt: comment.CreatedAt, - ReplyCount: deepRepliesData.DeepReplyCount, - FirstRepliesAuthorIds: deepRepliesData.FirstDeepRepliesAuthorIds - })[$offset..$offset + $count] - } - """, - new - { - discussionId = discussionId.ToString(), - authorId = authorId.ToString(), - offset, - count - }); + RETURN { + TotalCount: COUNT(comment), + Items: COLLECT({ + Id: comment.Id, + Content: comment.Content, + AuthorId: author.Id, + CreatedAt: comment.CreatedAt, + ReplyCount: deepRepliesData.DeepReplyCount, + FirstRepliesAuthorIds: deepRepliesData.FirstDeepRepliesAuthorIds + })[$offset..$offset + $count] + } + """, + new + { + discussionId = discussionId.ToString(), + authorId = authorId.ToString(), + offset, + count + }); - if (await data.PeekAsync() is null) + if (await data.PeekAsync() is null) + { + return new Page { - return new Page - { - TotalCount = 0, - Items = Enumerable.Empty() - }; - } + TotalCount = 0, + Items = Enumerable.Empty() + }; + } - var record = await data.SingleAsync(); - return record[0].Adapt>(); - }); + var record = await data.SingleAsync(); + return record[0].Adapt>(); } - public async Task CreateAsync(Guid authorId, Guid discussionId, string content) + public async Task CreateAsync(Guid authorId, Guid discussionId, string content) { - await using var session = driver.AsyncSession(); - return await session.ExecuteWriteAsync(async runner => - { - var data = await runner.RunAsync( - """ - MATCH (discussion:Discussion { Id: $discussionId }) - MERGE (author:Author { Id: $authorId }) - - CREATE (comment:Comment { - Id: randomUUID(), - Content: $content, - CreatedAt: datetime() - }) - CREATE (discussion)<-[:REPLIES_TO]-(comment)-[:AUTHORED_BY]->(author) - - RETURN { - Id: comment.Id, - Content: comment.Content, - AuthorId: author.Id, - CreatedAt: datetime(), - ReplyCount: 0, - FirstRepliesAuthorIds: [] - } - """, - new - { - authorId = authorId.ToString(), - discussionId = discussionId.ToString(), - content - }); + var data = await runner.RunAsync( + """ + MATCH (discussion:Discussion { Id: $discussionId }) + MERGE (author:Author { Id: $authorId }) + CREATE (comment:Comment { + Id: randomUUID(), + Content: $content, + CreatedAt: datetime() + }) + CREATE (discussion)<-[:REPLIES_TO]-(comment)-[:AUTHORED_BY]->(author) + RETURN comment.Id + """, + new + { + authorId = authorId.ToString(), + discussionId = discussionId.ToString(), + content + }); - if (await data.PeekAsync() is null) - throw new NotFoundException(); + if (await data.PeekAsync() is null) + throw new NotFoundException(); - var record = await data.SingleAsync(); - return record[0].Adapt(); - }); + var record = await data.SingleAsync(); + return record[0].Adapt(); } public async Task> GetRepliesToCommentAsync(Guid parentCommentId, int offset, int count) { - await using var session = driver.AsyncSession(); - return await session.ExecuteReadAsync(async runner => - { - var data = await runner.RunAsync( - """ - MATCH (author:Author)<-[:AUTHORED_BY]-(comment:Comment)-[:REPLIES_TO]->(parent:Comment { Id: $parentCommentId }) - - WITH author, comment, COUNT(comment) AS totalCount - ORDER BY comment.CreatedAt - SKIP $offset - LIMIT $count - - OPTIONAL MATCH (replyAuthor:Author)<-[:AUTHORED_BY]-(reply:Comment)-[:REPLIES_TO]->(comment) - - WITH totalCount, author, comment, COUNT(reply) AS replyCount, - CASE WHEN COUNT(reply) > 0 - THEN COLLECT(DISTINCT replyAuthor.Id)[0..3] - ELSE [] END AS firstRepliesAuthorIds - - WITH totalCount, - COLLECT({ - Id: comment.Id, - Content: comment.Content, - AuthorId: author.Id, - CreatedAt: datetime(comment.CreatedAt), - ReplyCount: replyCount, - FirstRepliesAuthorIds: firstRepliesAuthorIds - }) AS comments - - RETURN { - TotalCount: totalCount, - Items: comments - } - """, - new - { - parentCommentId = parentCommentId.ToString(), - offset, - count - }); + var data = await runner.RunAsync( + """ + MATCH (author:Author)<-[:AUTHORED_BY]-(comment:Comment)-[:REPLIES_TO]->(parent:Comment { Id: $parentCommentId }) + + WITH author, comment, COUNT(comment) AS totalCount + ORDER BY comment.CreatedAt + SKIP $offset + LIMIT $count + + OPTIONAL MATCH (replyAuthor:Author)<-[:AUTHORED_BY]-(reply:Comment)-[:REPLIES_TO]->(comment) + + WITH totalCount, author, comment, COUNT(reply) AS replyCount, + CASE WHEN COUNT(reply) > 0 + THEN COLLECT(DISTINCT replyAuthor.Id)[0..3] + ELSE [] END AS firstRepliesAuthorIds + + WITH totalCount, + COLLECT({ + Id: comment.Id, + Content: comment.Content, + AuthorId: author.Id, + CreatedAt: datetime(comment.CreatedAt), + ReplyCount: replyCount, + FirstRepliesAuthorIds: firstRepliesAuthorIds + }) AS comments + + RETURN { + TotalCount: totalCount, + Items: comments + } + """, + new + { + parentCommentId = parentCommentId.ToString(), + offset, + count + }); - if (await data.PeekAsync() is null) + if (await data.PeekAsync() is null) + { + return new Page { - return new Page - { - TotalCount = 0, - Items = Enumerable.Empty() - }; - } + TotalCount = 0, + Items = Enumerable.Empty() + }; + } - var record = await data.SingleAsync(); - return record[0].Adapt>(); - }); + var record = await data.SingleAsync(); + return record[0].Adapt>(); } - public async Task ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content) + public async Task ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content) { - await using var session = driver.AsyncSession(); - return await session.ExecuteWriteAsync(async runner => - { - var data = await runner.RunAsync( - """ - MATCH (parent:Comment {Id: $parentCommentId}) - MERGE (replyAuthor:Author { Id: $authorId }) - - CREATE (reply:Comment { - Id: randomUUID(), - Content: $content, - CreatedAt: datetime() - }) - CREATE (parent)<-[:REPLIES_TO]-(reply)-[:AUTHORED_BY]->(replyAuthor) - - RETURN { - Id: reply.Id, - Content: reply.Content, - AuthorId: replyAuthor.Id, - CreatedAt: reply.CreatedAt, - ReplyCount: 0, - FirstRepliesAuthorIds: [] - } - """, - new - { - parentCommentId = parentCommentId.ToString(), - authorId = authorId.ToString(), - content - }); + var cursor = await runner.RunAsync( + """ + MATCH (parent:Comment {Id: $parentCommentId}) + MERGE (replyAuthor:Author { Id: $authorId }) + CREATE (reply:Comment { + Id: randomUUID(), + Content: $content, + CreatedAt: datetime() + }) + CREATE (parent)<-[:REPLIES_TO]-(reply)-[:AUTHORED_BY]->(replyAuthor) + RETURN reply.Id + """, + new + { + parentCommentId = parentCommentId.ToString(), + authorId = authorId.ToString(), + content + }); + + if (await cursor.PeekAsync() is null) + throw new NotFoundException(); - var record = await data.SingleAsync(); - return record[0].Adapt(); - }); + var record = await cursor.SingleAsync(); + return record[0].Adapt(); } public async Task DeleteAsync(Guid id) { - await using var session = driver.AsyncSession(); - var notFount = await session.ExecuteWriteAsync(async runner => - { - var data = await runner.RunAsync( - """ - OPTIONAL MATCH (comment:Comment { Id: $id }) - DETACH DELETE comment - RETURN COUNT(comment) = 0 - """, - new { id = id.ToString() }); - - var record = await data.SingleAsync(); - return record[0].As(); - }); + var data = await runner.RunAsync( + """ + OPTIONAL MATCH (comment:Comment { Id: $id }) + DETACH DELETE comment + RETURN COUNT(comment) = 0 + """, + new { id = id.ToString() }); + + var record = await data.SingleAsync(); + var notFount = record[0].As(); if (notFount) throw new NotFoundException(); diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs index b1eb4e1..f0286c9 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs @@ -7,147 +7,125 @@ namespace CrowdParlay.Social.Infrastructure.Persistence.Services; -public class DiscussionsRepository(IDriver driver) : IDiscussionsRepository +public class DiscussionsRepository(IAsyncQueryRunner runner) : IDiscussionsRepository { public async Task GetByIdAsync(Guid id) { - await using var session = driver.AsyncSession(); - return await session.ExecuteReadAsync(async runner => - { - var data = await runner.RunAsync( - """ - MATCH (discussion:Discussion { Id: $id })-[:AUTHORED_BY]->(author:Author) - RETURN { - Id: discussion.Id, - Title: discussion.Title, - Description: discussion.Description, - AuthorId: author.Id, - CreatedAt: discussion.CreatedAt - } - """, - new { id = id.ToString() }); + var data = await runner.RunAsync( + """ + MATCH (discussion:Discussion { Id: $id })-[:AUTHORED_BY]->(author:Author) + RETURN { + Id: discussion.Id, + Title: discussion.Title, + Description: discussion.Description, + AuthorId: author.Id, + CreatedAt: discussion.CreatedAt + } + """, + new { id = id.ToString() }); - if (await data.PeekAsync() is null) - throw new NotFoundException(); + if (await data.PeekAsync() is null) + throw new NotFoundException(); - var record = await data.SingleAsync(); - return record[0].Adapt(); - }); + var record = await data.SingleAsync(); + return record[0].Adapt(); } public async Task> GetAllAsync(int offset, int count) { - await using var session = driver.AsyncSession(); - return await session.ExecuteReadAsync(async runner => - { - var data = await runner.RunAsync( - """ - MATCH (discussion:Discussion)-[:AUTHORED_BY]->(author:Author) - WITH discussion, author ORDER BY discussion.CreatedAt DESC - RETURN { - TotalCount: COUNT(discussion), - Items: COLLECT({ - Id: discussion.Id, - Title: discussion.Title, - Description: discussion.Description, - AuthorId: author.Id, - CreatedAt: discussion.CreatedAt - })[$offset..$offset + $count] - } - """, - new - { - offset, - count - }); - - if (await data.PeekAsync() is null) - { - return new Page - { - TotalCount = 0, - Items = Enumerable.Empty() - }; + var data = await runner.RunAsync( + """ + MATCH (discussion:Discussion)-[:AUTHORED_BY]->(author:Author) + WITH discussion, author ORDER BY discussion.CreatedAt DESC + RETURN { + TotalCount: COUNT(discussion), + Items: COLLECT({ + Id: discussion.Id, + Title: discussion.Title, + Description: discussion.Description, + AuthorId: author.Id, + CreatedAt: discussion.CreatedAt + })[$offset..$offset + $count] } + """, + new + { + offset, + count + }); - var record = await data.SingleAsync(); - return record[0].Adapt>(); - }); - } - - public async Task> GetByAuthorAsync(Guid authorId, int offset, int count) - { - await using var session = driver.AsyncSession(); - return await session.ExecuteReadAsync(async runner => + if (await data.PeekAsync() is null) { - var data = await runner.RunAsync( - """ - MATCH (discussion:Discussion)-[:AUTHORED_BY]->(author:Author { Id: $authorId }) - WITH discussion, author ORDER BY discussion.CreatedAt DESC - RETURN { - TotalCount: COUNT(discussion), - Items: COLLECT({ - Id: discussion.Id, - Title: discussion.Title, - Description: discussion.Description, - AuthorId: author.Id, - CreatedAt: discussion.CreatedAt - })[$offset..$offset + $count] - } - """, - new - { - authorId = authorId.ToString(), - offset, - count - }); - - if (await data.PeekAsync() is null) + return new Page { - return new Page - { - TotalCount = 0, - Items = Enumerable.Empty() - }; - } + TotalCount = 0, + Items = Enumerable.Empty() + }; + } - var record = await data.SingleAsync(); - return record[0].Adapt>(); - }); + var record = await data.SingleAsync(); + return record[0].Adapt>(); } - public async Task CreateAsync(Guid authorId, string title, string description) + public async Task> GetByAuthorAsync(Guid authorId, int offset, int count) { - await using var session = driver.AsyncSession(); - return await session.ExecuteWriteAsync(async runner => - { - var data = await runner.RunAsync( - """ - MERGE (author:Author { Id: $authorId }) - CREATE (discussion:Discussion { - Id: randomUUID(), - Title: $title, - Description: $description, - CreatedAt: datetime() - }) - CREATE (discussion)-[:AUTHORED_BY]->(author) - RETURN { + var data = await runner.RunAsync( + """ + MATCH (discussion:Discussion)-[:AUTHORED_BY]->(author:Author { Id: $authorId }) + WITH discussion, author ORDER BY discussion.CreatedAt DESC + RETURN { + TotalCount: COUNT(discussion), + Items: COLLECT({ Id: discussion.Id, Title: discussion.Title, Description: discussion.Description, AuthorId: author.Id, CreatedAt: discussion.CreatedAt - } - """, - new - { - authorId = authorId.ToString(), - title, - description - }); + })[$offset..$offset + $count] + } + """, + new + { + authorId = authorId.ToString(), + offset, + count + }); + + if (await data.PeekAsync() is null) + { + return new Page + { + TotalCount = 0, + Items = Enumerable.Empty() + }; + } + + var record = await data.SingleAsync(); + return record[0].Adapt>(); + } + + public async Task CreateAsync(Guid authorId, string title, string description) + { + var data = await runner.RunAsync( + """ + MERGE (author:Author { Id: $authorId }) + CREATE (discussion:Discussion { + Id: randomUUID(), + Title: $title, + Description: $description, + CreatedAt: datetime() + }) + CREATE (discussion)-[:AUTHORED_BY]->(author) + RETURN discussion.Id + """, + new + { + authorId = authorId.ToString(), + title, + description + }); - var record = await data.SingleAsync(); - return record[0].Adapt(); - }); + var record = await data.SingleAsync(); + return record[0].Adapt(); } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/ReactionsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/ReactionsRepository.cs new file mode 100644 index 0000000..51b018e --- /dev/null +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/ReactionsRepository.cs @@ -0,0 +1,71 @@ +using CrowdParlay.Social.Application.Exceptions; +using CrowdParlay.Social.Domain.Abstractions; +using Mapster; +using Neo4j.Driver; +using Neo4j.Driver.Preview.Mapping; + +namespace CrowdParlay.Social.Infrastructure.Persistence.Services; + +public class ReactionsRepository(IAsyncQueryRunner runner) : IReactionsRepository +{ + public async Task AddAsync(Guid authorId, Guid subjectId, string reaction) + { + var data = await runner.RunAsync( + """ + OPTIONAL MATCH (subject { Id: $subjectId }) + WHERE (subject:Comment OR subject:Discussion) + MERGE (author:Author { Id: $authorId })-[reaction:REACTED_TO { Reaction: $reaction }]->(subject) + RETURN COUNT(reaction) = 0 + """, + new + { + authorId = authorId.ToString(), + subjectId = subjectId.ToString(), + reaction + }); + + var record = await data.SingleAsync(); + var notFount = record[0].As(); + + if (notFount) + throw new NotFoundException(); + } + + public async Task RemoveAsync(Guid authorId, Guid subjectId, string reaction) + { + var data = await runner.RunAsync( + """ + OPTIONAL MATCH (author:Author { Id: $authorId })-[reaction:REACTED_TO { Reaction: $reaction }]->(subject { Id: $subjectId }) + WHERE (subject:Comment OR subject:Discussion) + DELETE reaction + RETURN COUNT(reaction) = 0 + """, + new + { + authorId = authorId.ToString(), + subjectId = subjectId.ToString(), + reaction + }); + + var record = await data.SingleAsync(); + var notFount = record[0].As(); + + if (notFount) + throw new NotFoundException(); + } + + public async Task> GetAllAsync(Guid authorId, Guid subjectId) + { + var data = await runner.RunAsync( + """ + MATCH (author:Author)-[reaction:REACTED_TO]->(subject { Id: $subjectId }) + WHERE (subject:Comment OR subject:Discussion) + RETURN reaction.Reaction + """, + new { subjectId = subjectId.ToString() }); + + return await data + .Select(record => record[0].As()) + .ToHashSetAsync(); + } +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/UnitOfWork.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/UnitOfWork.cs new file mode 100644 index 0000000..68aa11f --- /dev/null +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/UnitOfWork.cs @@ -0,0 +1,20 @@ +using CrowdParlay.Social.Domain.Abstractions; +using Neo4j.Driver; + +namespace CrowdParlay.Social.Infrastructure.Persistence.Services; + +public class UnitOfWork(IAsyncTransaction transaction) : IUnitOfWork +{ + private readonly Lazy _discussionsRepository = new(() => new(transaction)); + public IDiscussionsRepository DiscussionsRepository => _discussionsRepository.Value; + + private readonly Lazy _commentsRepository = new(() => new(transaction)); + public ICommentsRepository CommentsRepository => _commentsRepository.Value; + + private readonly Lazy _reactionsRepository = new(() => new(transaction)); + public IReactionsRepository ReactionsRepository => _reactionsRepository.Value; + + public async Task CommitAsync() => await transaction.CommitAsync(); + public async Task RollbackAsync() => await transaction.RollbackAsync(); + public async ValueTask DisposeAsync() => await transaction.DisposeAsync(); +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/UnitOfWorkFactory.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/UnitOfWorkFactory.cs new file mode 100644 index 0000000..6901bca --- /dev/null +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/UnitOfWorkFactory.cs @@ -0,0 +1,13 @@ +using CrowdParlay.Social.Domain.Abstractions; +using Neo4j.Driver; + +namespace CrowdParlay.Social.Infrastructure.Persistence.Services; + +public class UnitOfWorkFactory(IAsyncSession session) : IUnitOfWorkFactory +{ + public async Task CreateAsync() + { + var transaction = await session.BeginTransactionAsync(); + return new UnitOfWork(transaction); + } +} \ No newline at end of file diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs index a321197..0bf1f5a 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs @@ -3,7 +3,7 @@ using CrowdParlay.Social.Domain.Abstractions; using CrowdParlay.Social.Domain.DTOs; -using Mapster; +using CrowdParlay.Social.Domain.Entities; namespace CrowdParlay.Social.IntegrationTests.Tests; @@ -18,16 +18,17 @@ public async Task CreateComment() await using var scope = _services.CreateAsyncScope(); var discussions = scope.ServiceProvider.GetRequiredService(); - var comments = scope.ServiceProvider.GetRequiredService(); + var comments = scope.ServiceProvider.GetRequiredService(); var authorId = Guid.NewGuid(); - var discussion = await discussions.CreateAsync( + var discussionId = await discussions.CreateAsync( authorId: authorId, title: "Discussion", description: "Test discussion."); // Act - var comment = await comments.CreateAsync(authorId, discussion.Id, "Comment content"); + var commentId = await comments.CreateAsync(authorId, discussionId, "Comment content"); + var comment = await comments.GetByIdAsync(commentId); // Assert comment.AuthorId.Should().Be(authorId); @@ -42,19 +43,19 @@ public async Task SearchComments() { /* ┌───────────────────────┬───────────────────────┐ - │ COMMENT │ AUTHOR ID │ + │ COMMENT │ AUTHOR │ ├───────────────────────┼───────────────────────┤ - │ comment1 │ authorId1 │ - │ • comment11 │ authorId1 │ - │ • comment111 │ authorId1 │ - │ • comment112 │ authorId2 │ - │ • comment12 │ authorId1 │ - │ • comment121 │ authorId4 │ - │ • comment13 │ authorId3 │ - │ • comment14 │ authorId4 │ - │ comment2 │ authorId1 │ - │ • comment21 │ authorId3 │ - │ comment3 │ authorId4 │ + │ comment 1 │ author 1 │ + │ • comment 11 │ author 1 │ + │ • comment 111 │ author 1 │ + │ • comment 112 │ author 2 │ + │ • comment 12 │ author 1 │ + │ • comment 121 │ author 4 │ + │ • comment 13 │ author 3 │ + │ • comment 14 │ author 4 │ + │ comment 2 │ author 1 │ + │ • comment 21 │ author 3 │ + │ comment 3 │ author 4 │ └───────────────────────┴───────────────────────┘ */ @@ -62,39 +63,39 @@ public async Task SearchComments() await using var scope = _services.CreateAsyncScope(); var discussions = scope.ServiceProvider.GetRequiredService(); - var comments = scope.ServiceProvider.GetRequiredService(); + var comments = scope.ServiceProvider.GetRequiredService(); var authorId1 = Guid.NewGuid(); var authorId2 = Guid.NewGuid(); var authorId3 = Guid.NewGuid(); var authorId4 = Guid.NewGuid(); - var discussion = await discussions.CreateAsync( + var discussionId = await discussions.CreateAsync( authorId: authorId1, title: "Discussion", description: "Test discussion."); - var comment1 = await comments.CreateAsync(authorId1, discussion.Id, "Comment 1"); - var comment2 = await comments.CreateAsync(authorId1, discussion.Id, "Comment 2"); - var comment3 = await comments.CreateAsync(authorId4, discussion.Id, "Comment 3"); + var commentId1 = await comments.CreateAsync(authorId1, discussionId, "Comment 1"); + var commentId2 = await comments.CreateAsync(authorId1, discussionId, "Comment 2"); + var commentId3 = await comments.CreateAsync(authorId4, discussionId, "Comment 3"); - var comment11 = await comments.ReplyToCommentAsync(authorId1, comment1.Id, "Comment 11"); - var comment12 = await comments.ReplyToCommentAsync(authorId1, comment1.Id, "Comment 12"); - var comment13 = await comments.ReplyToCommentAsync(authorId3, comment1.Id, "Comment 13"); - var comment14 = await comments.ReplyToCommentAsync(authorId4, comment1.Id, "Comment 14"); - var comment21 = await comments.ReplyToCommentAsync(authorId3, comment2.Id, "Comment 21"); + var commentId11 = await comments.ReplyToCommentAsync(authorId1, commentId1, "Comment 11"); + var commentId12 = await comments.ReplyToCommentAsync(authorId1, commentId1, "Comment 12"); + var commentId13 = await comments.ReplyToCommentAsync(authorId3, commentId1, "Comment 13"); + var commentId14 = await comments.ReplyToCommentAsync(authorId4, commentId1, "Comment 14"); + var commentId21 = await comments.ReplyToCommentAsync(authorId3, commentId2, "Comment 21"); - var comment111 = await comments.ReplyToCommentAsync(authorId1, comment1.Id, "Comment 111"); - var comment112 = await comments.ReplyToCommentAsync(authorId2, comment1.Id, "Comment 112"); - var comment121 = await comments.ReplyToCommentAsync(authorId4, comment1.Id, "Comment 121"); + var commentId111 = await comments.ReplyToCommentAsync(authorId1, commentId1, "Comment 111"); + var commentId112 = await comments.ReplyToCommentAsync(authorId2, commentId1, "Comment 112"); + var commentId121 = await comments.ReplyToCommentAsync(authorId4, commentId1, "Comment 121"); - comment1 = await comments.GetByIdAsync(comment1.Id); - comment2 = await comments.GetByIdAsync(comment2.Id); - comment3 = await comments.GetByIdAsync(comment3.Id); + var comment1 = await comments.GetByIdAsync(commentId1); + var comment2 = await comments.GetByIdAsync(commentId2); + var comment3 = await comments.GetByIdAsync(commentId3); // Act var page = await comments.SearchAsync( - discussionId: discussion.Id, + discussionId: discussionId, authorId: null, offset: 0, count: 2); @@ -111,7 +112,7 @@ public async Task GetComment_WithUnknownId_ThrowsNotFoundException() { // Arrange await using var scope = _services.CreateAsyncScope(); - var comments = scope.ServiceProvider.GetRequiredService(); + var comments = scope.ServiceProvider.GetRequiredService(); // Act Func getComment = async () => await comments.GetByIdAsync(Guid.NewGuid()); @@ -126,21 +127,22 @@ public async Task GetRepliesToComment() // Arrange await using var scope = _services.CreateAsyncScope(); var discussions = scope.ServiceProvider.GetRequiredService(); - var comments = scope.ServiceProvider.GetRequiredService(); + var comments = scope.ServiceProvider.GetRequiredService(); var authorId = Guid.NewGuid(); - var discussion = await discussions.CreateAsync(authorId, "Discussion", "Test discussion."); - var comment = await comments.CreateAsync(authorId, discussion.Id, "Comment content"); - var reply = await comments.ReplyToCommentAsync(authorId, comment.Id, "Reply content"); + var discussionId = await discussions.CreateAsync(authorId, "Discussion", "Test discussion."); + var commentId = await comments.CreateAsync(authorId, discussionId, "Comment content"); + var replyId = await comments.ReplyToCommentAsync(authorId, commentId, "Reply content"); + var reply = await comments.GetByIdAsync(replyId); // Act - var page = await comments.GetRepliesToCommentAsync(comment.Id, offset: 0, count: 1); + var page = await comments.GetRepliesToCommentAsync(commentId, offset: 0, count: 1); // Assert - page.Should().BeEquivalentTo(new Page + page.Should().BeEquivalentTo(new Page { TotalCount = 1, - Items = [reply.Adapt()] + Items = [reply] }); } @@ -149,7 +151,7 @@ public async Task CreateComment_WithUnknownAuthorAndDiscussion_ThrowsNotFoundExc { // Arrange await using var scope = _services.CreateAsyncScope(); - var comments = scope.ServiceProvider.GetRequiredService(); + var comments = scope.ServiceProvider.GetRequiredService(); // Act Func createComment = async () => diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsRepositoryTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsRepositoryTests.cs index cf37ba7..8104297 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsRepositoryTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsRepositoryTests.cs @@ -1,5 +1,4 @@ using CrowdParlay.Social.Domain.Abstractions; -using CrowdParlay.Social.Domain.Entities; namespace CrowdParlay.Social.IntegrationTests.Tests; @@ -14,18 +13,21 @@ public async Task GetAllDiscussions() await using var scope = _services.CreateAsyncScope(); var discussions = scope.ServiceProvider.GetRequiredService(); - Discussion[] expected = + Guid[] expectedDiscussionIds = [ await discussions.CreateAsync(Guid.NewGuid(), "Discussion 1", "bla bla bla"), await discussions.CreateAsync(Guid.NewGuid(), "Discussion 2", "numa numa e"), await discussions.CreateAsync(Guid.NewGuid(), "Discussion 3", "bara bara bara") ]; + var expectedDiscussions = expectedDiscussionIds.Select(discussionId => + discussions.GetByIdAsync(discussionId).Result).ToArray(); + // Act var response = await discussions.GetAllAsync(0, 2); // Assert - response.Items.Should().BeEquivalentTo(expected.TakeLast(2).Reverse()); + response.Items.Should().BeEquivalentTo(expectedDiscussions.TakeLast(2).Reverse()); response.TotalCount.Should().BeGreaterOrEqualTo(3); } @@ -37,17 +39,20 @@ public async Task GetDiscussionsByAuthor() var discussions = scope.ServiceProvider.GetRequiredService(); var authorId = Guid.NewGuid(); - Discussion[] expected = + Guid[] expectedDiscussionIds = [ await discussions.CreateAsync(authorId, "Discussion 1", "bla bla bla"), await discussions.CreateAsync(authorId, "Discussion 2", "numa numa e") ]; + var expectedDiscussions = expectedDiscussionIds.Select(discussionId => + discussions.GetByIdAsync(discussionId).Result).ToArray(); + // Act var response = await discussions.GetByAuthorAsync(authorId, 0, 10); // Assert - response.Items.Should().BeEquivalentTo(expected.Reverse()); + response.Items.Should().BeEquivalentTo(expectedDiscussions.Reverse()); } [Fact(DisplayName = "Get discussions by author of no discussions")] diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsRepositoryTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsRepositoryTests.cs new file mode 100644 index 0000000..3f4b1f9 --- /dev/null +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsRepositoryTests.cs @@ -0,0 +1,35 @@ +using CrowdParlay.Social.Domain.Abstractions; + +namespace CrowdParlay.Social.IntegrationTests.Tests; + +public class ReactionsRepositoryTests(WebApplicationContext context) : IClassFixture +{ + private readonly IServiceProvider _services = context.Services; + + [Fact(DisplayName = "Add reactions")] + public async Task AddReaction_MultipleTimes_AddsReaction() + { + // Arrange + await using var scope = _services.CreateAsyncScope(); + var discussionsRepository = scope.ServiceProvider.GetRequiredService(); + var reactionsRepository = scope.ServiceProvider.GetRequiredService(); + + var authorId = Guid.NewGuid(); + var discussionId = await discussionsRepository.CreateAsync(Guid.NewGuid(), "Title", "Description"); + + const string thumbUp = "\ud83d\udc4d"; + const string thumbDown = "\ud83d\udc4e"; + + // Act + for (var i = 0; i < 4; i++) + await reactionsRepository.AddAsync(authorId, discussionId, thumbUp); + + for (var i = 0; i < 4; i++) + await reactionsRepository.AddAsync(authorId, discussionId, thumbDown); + + var reactions = await reactionsRepository.GetAllAsync(authorId, discussionId); + + // Assert + reactions.Should().BeEquivalentTo([thumbUp, thumbDown]); + } +} \ No newline at end of file From d16e18afa369d4869e00b8784068c0ca23937fd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=BD=D0=BE=D0=B9=20=D0=B8=D1=88?= =?UTF-8?q?=D0=B0=D0=BA?= <69521267+undrcrxwn@users.noreply.github.com> Date: Fri, 11 Oct 2024 19:46:32 +0300 Subject: [PATCH 2/4] tests(authentication): add JWT Bearer authentication test --- .../Tests/AuthenticationTests.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/CrowdParlay.Social.IntegrationTests/Tests/AuthenticationTests.cs diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthenticationTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthenticationTests.cs new file mode 100644 index 0000000..f9f8cd2 --- /dev/null +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthenticationTests.cs @@ -0,0 +1,26 @@ +using System.Net; +using System.Net.Http.Json; +using CrowdParlay.Social.Api.v1.DTOs; + +namespace CrowdParlay.Social.IntegrationTests.Tests; + +public class AuthenticationTests(WebApplicationContext context) : IClassFixture +{ + private readonly HttpClient _client = context.Server.CreateClient(); + + [Fact(DisplayName = "Create a discussion providing access JWT as Bearer token")] + public async Task CreateDiscussionWithAccessToken() + { + // Arrange + var userId = Guid.NewGuid(); + var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/discussions"); + request.Content = JsonContent.Create(new DiscussionRequest("Short title", "Long description.")); + request.Headers.Add("Authorization", "Bearer " + Authorization.ProduceAccessToken(userId)); + + // Act + var response = await _client.SendAsync(request); + + // Assert + response.Should().HaveStatusCode(HttpStatusCode.Created); + } +} \ No newline at end of file From 39f95ebb41525c55969912eaa0b7baf8af97a7fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=BD=D0=BE=D0=B9=20=D0=B8=D1=88?= =?UTF-8?q?=D0=B0=D0=BA?= <69521267+undrcrxwn@users.noreply.github.com> Date: Fri, 11 Oct 2024 19:48:37 +0300 Subject: [PATCH 3/4] fix(authentication): disable inbound claims mapping to make JWT Bearer scheme parse user ID correctly --- src/CrowdParlay.Social.Api/AuthenticationConstants.cs | 2 +- .../Extensions/ClaimsPrincipalExtensions.cs | 2 +- .../Extensions/ConfigureAuthenticationExtensions.cs | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/CrowdParlay.Social.Api/AuthenticationConstants.cs b/src/CrowdParlay.Social.Api/AuthenticationConstants.cs index 430da4b..8772f13 100644 --- a/src/CrowdParlay.Social.Api/AuthenticationConstants.cs +++ b/src/CrowdParlay.Social.Api/AuthenticationConstants.cs @@ -3,5 +3,5 @@ namespace CrowdParlay.Social.Api; public static class AuthenticationConstants { public const string CookieAuthenticationUserIdClaim = "user_id"; - public const string BearerAuthenticationUserIdClaim = "sub"; + public const string JwtBearerAuthenticationUserIdClaim = "sub"; } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Api/Extensions/ClaimsPrincipalExtensions.cs b/src/CrowdParlay.Social.Api/Extensions/ClaimsPrincipalExtensions.cs index 897dba9..c329c3f 100644 --- a/src/CrowdParlay.Social.Api/Extensions/ClaimsPrincipalExtensions.cs +++ b/src/CrowdParlay.Social.Api/Extensions/ClaimsPrincipalExtensions.cs @@ -8,7 +8,7 @@ public static class ClaimsPrincipalExtensions { var userIdClaim = principal.Claims.FirstOrDefault(claim => claim.Type is AuthenticationConstants.CookieAuthenticationUserIdClaim - or AuthenticationConstants.BearerAuthenticationUserIdClaim); + or AuthenticationConstants.JwtBearerAuthenticationUserIdClaim); return Guid.TryParse(userIdClaim?.Value, out var value) ? value : null; } diff --git a/src/CrowdParlay.Social.Api/Extensions/ConfigureAuthenticationExtensions.cs b/src/CrowdParlay.Social.Api/Extensions/ConfigureAuthenticationExtensions.cs index d34fddf..3803302 100644 --- a/src/CrowdParlay.Social.Api/Extensions/ConfigureAuthenticationExtensions.cs +++ b/src/CrowdParlay.Social.Api/Extensions/ConfigureAuthenticationExtensions.cs @@ -19,6 +19,7 @@ public static IServiceCollection ConfigureAuthentication(this IServiceCollection builder.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => { + options.MapInboundClaims = false; options.RequireHttpsMetadata = false; options.TokenValidationParameters.ValidateAudience = false; options.TokenValidationParameters.ValidateIssuer = false; From 157cc211b860771443a8f229814cb136d07130da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D0=BF=D0=BD=D0=BE=D0=B9=20=D0=B8=D1=88?= =?UTF-8?q?=D0=B0=D0=BA?= <69521267+undrcrxwn@users.noreply.github.com> Date: Mon, 14 Oct 2024 20:29:06 +0300 Subject: [PATCH 4/4] feat: add reactions --- .../Extensions/ClaimsPrincipalExtensions.cs | 4 + .../GlobalSerializerOptions.cs | 4 +- .../v1/Controllers/CommentsController.cs | 53 ++++++- .../v1/Controllers/DiscussionsController.cs | 44 ++++-- .../v1/Controllers/LookupController.cs | 13 ++ .../Abstractions/ICommentsService.cs | 9 +- .../Abstractions/IDiscussionsService.cs | 8 +- .../DTOs/CommentDto.cs | 2 + .../DTOs/DiscussionDto.cs | 2 + .../Services/CommentsService.cs | 33 ++-- .../Services/DiscussionsService.cs | 51 +++++-- .../Abstractions/ICommentsRepository.cs | 7 +- .../Abstractions/IDiscussionsRepository.cs | 5 +- .../Abstractions/IReactionsRepository.cs | 8 +- .../CrowdParlay.Social.Domain.csproj | 4 + .../DTOs/ReactionCounter.cs | 7 - .../Entities/Comment.cs | 5 +- .../Entities/Discussion.cs | 5 +- .../ReactionJsonConverter.cs | 20 +++ .../ReactionMapsterAdapterConfigurator.cs | 16 ++ .../ValueObjects/Reaction.cs | 61 ++++++++ .../ConfigureServices.cs | 3 + .../Services/CommentsRepository.cs | 141 ++++++++---------- .../Services/DiscussionsRepository.cs | 82 +++++----- .../Services/ReactionsRepository.cs | 43 +++--- ...CrowdParlay.Social.IntegrationTests.csproj | 3 +- .../Fixtures/WebApplicationContext.cs | 3 +- .../Tests/AuthenticationTests.cs | 2 +- .../Tests/CommentsRepositoryTests.cs | 20 +-- .../Tests/DiscussionsRepositoryTests.cs | 33 +--- .../Tests/HttpContractsTests.cs | 2 +- .../Tests/ReactionsRepositoryTests.cs | 7 +- .../Tests/SignalRTests.cs | 6 +- .../CrowdParlay.Social.UnitTests.csproj | 2 +- 34 files changed, 439 insertions(+), 269 deletions(-) create mode 100644 src/CrowdParlay.Social.Api/v1/Controllers/LookupController.cs delete mode 100644 src/CrowdParlay.Social.Domain/DTOs/ReactionCounter.cs create mode 100644 src/CrowdParlay.Social.Domain/ReactionJsonConverter.cs create mode 100644 src/CrowdParlay.Social.Domain/ReactionMapsterAdapterConfigurator.cs create mode 100644 src/CrowdParlay.Social.Domain/ValueObjects/Reaction.cs diff --git a/src/CrowdParlay.Social.Api/Extensions/ClaimsPrincipalExtensions.cs b/src/CrowdParlay.Social.Api/Extensions/ClaimsPrincipalExtensions.cs index c329c3f..f132f16 100644 --- a/src/CrowdParlay.Social.Api/Extensions/ClaimsPrincipalExtensions.cs +++ b/src/CrowdParlay.Social.Api/Extensions/ClaimsPrincipalExtensions.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using CrowdParlay.Social.Application.Exceptions; namespace CrowdParlay.Social.Api.Extensions; @@ -12,4 +13,7 @@ is AuthenticationConstants.CookieAuthenticationUserIdClaim return Guid.TryParse(userIdClaim?.Value, out var value) ? value : null; } + + public static Guid GetRequiredUserId(this ClaimsPrincipal principal) => + principal.GetUserId() ?? throw new ForbiddenException(); } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Api/GlobalSerializerOptions.cs b/src/CrowdParlay.Social.Api/GlobalSerializerOptions.cs index 807cf2e..369877d 100644 --- a/src/CrowdParlay.Social.Api/GlobalSerializerOptions.cs +++ b/src/CrowdParlay.Social.Api/GlobalSerializerOptions.cs @@ -7,7 +7,7 @@ public static class GlobalSerializerOptions { public static readonly JsonSerializerOptions SnakeCase = new() { - PropertyNamingPolicy = new SnakeCaseJsonNamingPolicy(), - DictionaryKeyPolicy = new SnakeCaseJsonNamingPolicy() + PropertyNamingPolicy = new SnakeCaseJsonNamingPolicy(), + DictionaryKeyPolicy = new SnakeCaseJsonNamingPolicy() }; } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs b/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs index 5d35aa5..a619925 100644 --- a/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs +++ b/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs @@ -7,6 +7,7 @@ using CrowdParlay.Social.Application.DTOs; using CrowdParlay.Social.Application.Exceptions; using CrowdParlay.Social.Domain.DTOs; +using CrowdParlay.Social.Domain.ValueObjects; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -26,7 +27,7 @@ public class CommentsController(ICommentsService comments, IHubContext GetCommentById([FromRoute] Guid commentId) => - await comments.GetByIdAsync(commentId); + await comments.GetByIdAsync(commentId, User.GetUserId()); /// /// Get comments by filters. @@ -41,7 +42,7 @@ public async Task> SearchComments( [FromQuery] Guid? authorId, [FromQuery, BindRequired] int offset, [FromQuery, BindRequired] int count) => - await comments.SearchAsync(discussionId, authorId, offset, count); + await comments.SearchAsync(discussionId, authorId, User.GetUserId(), offset, count); /// /// Creates a top-level comment in discussion. @@ -80,7 +81,7 @@ public async Task> GetRepliesToComment( [FromRoute] Guid parentCommentId, [FromQuery, BindRequired] int offset, [FromQuery, BindRequired] int count) => - await comments.GetRepliesToCommentAsync(parentCommentId, offset, count); + await comments.GetRepliesToCommentAsync(parentCommentId, User.GetUserId(), offset, count); /// /// Creates a reply to the comment with the specified ID. @@ -94,11 +95,49 @@ public async Task> GetRepliesToComment( [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.NotFound)] public async Task> ReplyToComment([FromRoute] Guid parentCommentId, [FromBody] ReplyRequest request) { - var authorId = - User.GetUserId() - ?? throw new ForbiddenException(); + var response = await comments.ReplyToCommentAsync(User.GetRequiredUserId(), parentCommentId, request.Content); + return CreatedAtAction(nameof(GetCommentById), new { commentId = response.Id }, response); + } - var response = await comments.ReplyToCommentAsync(authorId, parentCommentId, request.Content); + /// + /// React to the comment. + /// + [HttpPost("{commentId:guid}/replies"), Authorize] + [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(CommentDto), (int)HttpStatusCode.Created)] + [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.InternalServerError)] + [ProducesResponseType(typeof(ValidationProblemDetails), (int)HttpStatusCode.BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.Forbidden)] + [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.NotFound)] + public async Task> ReplyToComment([FromRoute] Guid commentId, [FromBody] string reaction) + { + var response = await comments.AddReactionAsync(User.GetRequiredUserId(), commentId, new Reaction(reaction)); return CreatedAtAction(nameof(GetCommentById), new { commentId = response.Id }, response); } + + /// + /// Add a reaction to a comment + /// + [HttpPost("{commentId:guid}/reactions"), Authorize] + [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(CommentDto), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.InternalServerError)] + [ProducesResponseType(typeof(ValidationProblemDetails), (int)HttpStatusCode.BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.Forbidden)] + [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.NotFound)] + public async Task AddReaction([FromRoute] Guid commentId, [FromBody] string reaction) => + await comments.AddReactionAsync(User.GetRequiredUserId(), commentId, reaction); + + /// + /// Remove a reaction from a comment + /// + [HttpDelete("{commentId:guid}/reactions"), Authorize] + [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(CommentDto), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.InternalServerError)] + [ProducesResponseType(typeof(ValidationProblemDetails), (int)HttpStatusCode.BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.Forbidden)] + [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.NotFound)] + public async Task RemoveReaction([FromRoute] Guid commentId, [FromBody] string reaction) => + await comments.AddReactionAsync(User.GetRequiredUserId(), commentId, reaction); } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs b/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs index 186033b..f924ab4 100644 --- a/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs +++ b/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs @@ -4,7 +4,6 @@ using CrowdParlay.Social.Api.v1.DTOs; using CrowdParlay.Social.Application.Abstractions; using CrowdParlay.Social.Application.DTOs; -using CrowdParlay.Social.Application.Exceptions; using CrowdParlay.Social.Domain.DTOs; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,7 +12,7 @@ namespace CrowdParlay.Social.Api.v1.Controllers; [ApiController, ApiRoute("[controller]")] -public class DiscussionsController(IDiscussionsService discussionsService) : ControllerBase +public class DiscussionsController(IDiscussionsService discussions) : ControllerBase { /// /// Returns discussion with the specified ID. @@ -23,7 +22,7 @@ public class DiscussionsController(IDiscussionsService discussionsService) : Con [ProducesResponseType(typeof(DiscussionDto), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.NotFound)] public async Task GetDiscussionById([FromRoute] Guid discussionId) => - await discussionsService.GetByIdAsync(discussionId); + await discussions.GetByIdAsync(discussionId, User.GetUserId()); /// /// Returns all discussions created by author with the specified ID. @@ -31,12 +30,11 @@ public async Task GetDiscussionById([FromRoute] Guid discussionId [HttpGet] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(typeof(Page), (int)HttpStatusCode.OK)] - public async Task> GetDiscussions( + public async Task> SearchDiscussions( [FromQuery] Guid? authorId, [FromQuery, BindRequired] int offset, - [FromQuery, BindRequired] int count) => authorId is null - ? await discussionsService.GetAllAsync(offset, count) - : await discussionsService.GetByAuthorAsync(authorId.Value, offset, count); + [FromQuery, BindRequired] int count) => + await discussions.SearchAsync(authorId, User.GetUserId(), offset, count); /// /// Creates a discussion. @@ -47,11 +45,33 @@ public async Task> GetDiscussions( [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.Forbidden)] public async Task> CreateDiscussion([FromBody] DiscussionRequest request) { - var authorId = - User.GetUserId() - ?? throw new ForbiddenException(); - - var response = await discussionsService.CreateAsync(authorId, request.Title, request.Description); + var response = await discussions.CreateAsync(User.GetRequiredUserId(), request.Title, request.Description); return CreatedAtAction(nameof(GetDiscussionById), new { DiscussionId = response.Id }, response); } + + /// + /// Add a reaction to a comment + /// + [HttpPost("{discussionId:guid}/reactions"), Authorize] + [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(DiscussionDto), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.InternalServerError)] + [ProducesResponseType(typeof(ValidationProblemDetails), (int)HttpStatusCode.BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.Forbidden)] + [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.NotFound)] + public async Task AddReaction([FromRoute] Guid discussionId, [FromBody] string reaction) => + await discussions.AddReactionAsync(User.GetRequiredUserId(), discussionId, reaction); + + /// + /// Remove a reaction from a comment + /// + [HttpDelete("{commentId:guid}/reactions"), Authorize] + [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(DiscussionDto), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.InternalServerError)] + [ProducesResponseType(typeof(ValidationProblemDetails), (int)HttpStatusCode.BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.Forbidden)] + [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.NotFound)] + public async Task RemoveReaction([FromRoute] Guid discussionId, [FromBody] string reaction) => + await discussions.AddReactionAsync(User.GetRequiredUserId(), discussionId, reaction); } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Api/v1/Controllers/LookupController.cs b/src/CrowdParlay.Social.Api/v1/Controllers/LookupController.cs new file mode 100644 index 0000000..61ea629 --- /dev/null +++ b/src/CrowdParlay.Social.Api/v1/Controllers/LookupController.cs @@ -0,0 +1,13 @@ +using System.Net.Mime; +using CrowdParlay.Social.Domain.ValueObjects; +using Microsoft.AspNetCore.Mvc; + +namespace CrowdParlay.Social.Api.v1.Controllers; + +[ApiController, ApiRoute("[controller]")] +public class LookupController : ControllerBase +{ + [HttpGet("reactions")] + [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] + public IReadOnlySet GetAvailableReactions() => Reaction.AllowedValues; +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/Abstractions/ICommentsService.cs b/src/CrowdParlay.Social.Application/Abstractions/ICommentsService.cs index 0934995..ca8daa7 100644 --- a/src/CrowdParlay.Social.Application/Abstractions/ICommentsService.cs +++ b/src/CrowdParlay.Social.Application/Abstractions/ICommentsService.cs @@ -1,14 +1,17 @@ using CrowdParlay.Social.Application.DTOs; using CrowdParlay.Social.Domain.DTOs; +using CrowdParlay.Social.Domain.ValueObjects; namespace CrowdParlay.Social.Application.Abstractions; public interface ICommentsService { - public Task GetByIdAsync(Guid id); - public Task> SearchAsync(Guid? discussionId, Guid? authorId, int offset, int count); + public Task GetByIdAsync(Guid commentId, Guid? viewerId); + public Task> SearchAsync(Guid? discussionId, Guid? authorId, Guid? viewerId, int offset, int count); public Task CreateAsync(Guid authorId, Guid discussionId, string content); - public Task> GetRepliesToCommentAsync(Guid parentCommentId, int offset, int count); + public Task> GetRepliesToCommentAsync(Guid parentCommentId, Guid? viewerId, int offset, int count); public Task ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content); + public Task AddReactionAsync(Guid authorId, Guid commentId, Reaction reaction); + public Task RemoveReactionAsync(Guid authorId, Guid commentId, Reaction reaction); public Task DeleteAsync(Guid id); } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/Abstractions/IDiscussionsService.cs b/src/CrowdParlay.Social.Application/Abstractions/IDiscussionsService.cs index 0d03c80..b87e385 100644 --- a/src/CrowdParlay.Social.Application/Abstractions/IDiscussionsService.cs +++ b/src/CrowdParlay.Social.Application/Abstractions/IDiscussionsService.cs @@ -1,12 +1,14 @@ using CrowdParlay.Social.Application.DTOs; using CrowdParlay.Social.Domain.DTOs; +using CrowdParlay.Social.Domain.ValueObjects; namespace CrowdParlay.Social.Application.Abstractions; public interface IDiscussionsService { - public Task GetByIdAsync(Guid id); - public Task> GetAllAsync(int offset, int count); - public Task> GetByAuthorAsync(Guid authorId, int offset, int count); + public Task GetByIdAsync(Guid discussionId, Guid? viewerId); + public Task> SearchAsync(Guid? authorId, Guid? viewerId, int offset, int count); public Task CreateAsync(Guid authorId, string title, string description); + public Task AddReactionAsync(Guid authorId, Guid discussionId, Reaction reaction); + public Task RemoveReactionAsync(Guid authorId, Guid discussionId, Reaction reaction); } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/DTOs/CommentDto.cs b/src/CrowdParlay.Social.Application/DTOs/CommentDto.cs index 3fe301e..3bb0183 100644 --- a/src/CrowdParlay.Social.Application/DTOs/CommentDto.cs +++ b/src/CrowdParlay.Social.Application/DTOs/CommentDto.cs @@ -8,4 +8,6 @@ public class CommentDto public required DateTimeOffset CreatedAt { get; set; } public required int ReplyCount { get; set; } public required IEnumerable FirstRepliesAuthors { get; set; } + public required IDictionary ReactionCounters { get; set; } + public required ISet ViewerReactions { get; set; } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/DTOs/DiscussionDto.cs b/src/CrowdParlay.Social.Application/DTOs/DiscussionDto.cs index b48ab24..f5e7ecd 100644 --- a/src/CrowdParlay.Social.Application/DTOs/DiscussionDto.cs +++ b/src/CrowdParlay.Social.Application/DTOs/DiscussionDto.cs @@ -7,4 +7,6 @@ public class DiscussionDto public required string Description { get; set; } public required AuthorDto? Author { get; set; } public required DateTimeOffset CreatedAt { get; set; } + public required IDictionary ReactionCounters { get; set; } + public required ISet ViewerReactions { get; set; } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/Services/CommentsService.cs b/src/CrowdParlay.Social.Application/Services/CommentsService.cs index 8741278..734365e 100644 --- a/src/CrowdParlay.Social.Application/Services/CommentsService.cs +++ b/src/CrowdParlay.Social.Application/Services/CommentsService.cs @@ -3,6 +3,7 @@ using CrowdParlay.Social.Domain.Abstractions; using CrowdParlay.Social.Domain.DTOs; using CrowdParlay.Social.Domain.Entities; +using CrowdParlay.Social.Domain.ValueObjects; using Mapster; namespace CrowdParlay.Social.Application.Services; @@ -13,15 +14,15 @@ public class CommentsService( IUsersService usersService) : ICommentsService { - public async Task GetByIdAsync(Guid id) + public async Task GetByIdAsync(Guid commentId, Guid? viewerId) { - var comment = await commentsRepository.GetByIdAsync(id); + var comment = await commentsRepository.GetByIdAsync(commentId, viewerId); return await EnrichAsync(comment); } - public async Task> SearchAsync(Guid? discussionId, Guid? authorId, int offset, int count) + public async Task> SearchAsync(Guid? discussionId, Guid? authorId, Guid? viewerId, int offset, int count) { - var page = await commentsRepository.SearchAsync(discussionId, authorId, offset, count); + var page = await commentsRepository.SearchAsync(discussionId, authorId, viewerId, offset, count); return new Page { TotalCount = page.TotalCount, @@ -35,7 +36,7 @@ public async Task CreateAsync(Guid authorId, Guid discussionId, stri await using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) { var commentId = await unitOfWork.CommentsRepository.CreateAsync(authorId, discussionId, content); - comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId); + comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId, authorId); } // TODO: notify clients via SignalR @@ -43,9 +44,9 @@ public async Task CreateAsync(Guid authorId, Guid discussionId, stri return await EnrichAsync(comment); } - public async Task> GetRepliesToCommentAsync(Guid parentCommentId, int offset, int count) + public async Task> GetRepliesToCommentAsync(Guid parentCommentId, Guid? viewerId, int offset, int count) { - var page = await commentsRepository.GetRepliesToCommentAsync(parentCommentId, offset, count); + var page = await commentsRepository.SearchAsync(parentCommentId, authorId: null, viewerId, offset, count); return new Page { TotalCount = page.TotalCount, @@ -59,32 +60,32 @@ public async Task ReplyToCommentAsync(Guid authorId, Guid parentComm await using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) { var commentId = await unitOfWork.CommentsRepository.ReplyToCommentAsync(authorId, parentCommentId, content); - comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId); + comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId, authorId); } return await EnrichAsync(comment); } - public async Task AddReactionAsync(Guid authorId, Guid commentId, string reaction) + public async Task AddReactionAsync(Guid authorId, Guid commentId, Reaction reaction) { Comment comment; await using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) { await unitOfWork.ReactionsRepository.AddAsync(authorId, commentId, reaction); - comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId); + comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId, authorId); await unitOfWork.CommitAsync(); } return await EnrichAsync(comment); } - public async Task RemoveReactionAsync(Guid authorId, Guid commentId, string reaction) + public async Task RemoveReactionAsync(Guid authorId, Guid commentId, Reaction reaction) { Comment comment; await using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) { await unitOfWork.ReactionsRepository.RemoveAsync(authorId, commentId, reaction); - comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId); + comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId, authorId); await unitOfWork.CommitAsync(); } @@ -105,7 +106,9 @@ private async Task EnrichAsync(Comment comment) Author = author.Adapt(), CreatedAt = comment.CreatedAt, ReplyCount = comment.ReplyCount, - FirstRepliesAuthors = firstRepliesAuthors.Values.Adapt>() + FirstRepliesAuthors = firstRepliesAuthors.Values.Adapt>(), + ReactionCounters = comment.ReactionCounters, + ViewerReactions = comment.ViewerReactions }; } @@ -122,7 +125,9 @@ private async Task> EnrichAsync(IReadOnlyList c CreatedAt = comment.CreatedAt, ReplyCount = comment.ReplyCount, FirstRepliesAuthors = comment.FirstRepliesAuthorIds - .Select(replyAuthorId => authorsById[replyAuthorId].Adapt()) + .Select(replyAuthorId => authorsById[replyAuthorId].Adapt()), + ReactionCounters = comment.ReactionCounters, + ViewerReactions = comment.ViewerReactions }); } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs b/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs index c0785c9..1cef385 100644 --- a/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs +++ b/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs @@ -3,6 +3,7 @@ using CrowdParlay.Social.Domain.Abstractions; using CrowdParlay.Social.Domain.DTOs; using CrowdParlay.Social.Domain.Entities; +using CrowdParlay.Social.Domain.ValueObjects; using Mapster; namespace CrowdParlay.Social.Application.Services; @@ -13,15 +14,15 @@ public class DiscussionsService( IUsersService usersService) : IDiscussionsService { - public async Task GetByIdAsync(Guid id) + public async Task GetByIdAsync(Guid discussionId, Guid? viewerId) { - var discussion = await discussionsRepository.GetByIdAsync(id); + var discussion = await discussionsRepository.GetByIdAsync(discussionId, viewerId); return await EnrichAsync(discussion); } - public async Task> GetAllAsync(int offset, int count) + public async Task> SearchAsync(Guid? authorId, Guid? viewerId, int offset, int count) { - var page = await discussionsRepository.GetAllAsync(offset, count); + var page = await discussionsRepository.SearchAsync(authorId, viewerId, offset, count); return new Page { TotalCount = page.TotalCount, @@ -29,23 +30,39 @@ public async Task> GetAllAsync(int offset, int count) }; } - public async Task> GetByAuthorAsync(Guid authorId, int offset, int count) + public async Task CreateAsync(Guid authorId, string title, string description) { - var page = await discussionsRepository.GetByAuthorAsync(authorId, offset, count); - return new Page + Discussion discussion; + await using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) { - TotalCount = page.TotalCount, - Items = await EnrichAsync(page.Items.ToArray()) - }; + var discussionId = await unitOfWork.DiscussionsRepository.CreateAsync(authorId, title, description); + discussion = await unitOfWork.DiscussionsRepository.GetByIdAsync(discussionId, authorId); + } + + return await EnrichAsync(discussion); } - public async Task CreateAsync(Guid authorId, string title, string description) + public async Task AddReactionAsync(Guid authorId, Guid discussionId, Reaction reaction) { Discussion discussion; await using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) { - var discussionId = await unitOfWork.DiscussionsRepository.CreateAsync(authorId, title, description); - discussion = await unitOfWork.DiscussionsRepository.GetByIdAsync(discussionId); + await unitOfWork.ReactionsRepository.AddAsync(authorId, discussionId, reaction); + discussion = await unitOfWork.DiscussionsRepository.GetByIdAsync(discussionId, authorId); + await unitOfWork.CommitAsync(); + } + + return await EnrichAsync(discussion); + } + + public async Task RemoveReactionAsync(Guid authorId, Guid discussionId, Reaction reaction) + { + Discussion discussion; + await using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) + { + await unitOfWork.ReactionsRepository.RemoveAsync(authorId, discussionId, reaction); + discussion = await unitOfWork.DiscussionsRepository.GetByIdAsync(discussionId, authorId); + await unitOfWork.CommitAsync(); } return await EnrichAsync(discussion); @@ -60,7 +77,9 @@ private async Task EnrichAsync(Discussion discussion) Title = discussion.Title, Description = discussion.Description, Author = author.Adapt(), - CreatedAt = discussion.CreatedAt + CreatedAt = discussion.CreatedAt, + ReactionCounters = discussion.ReactionCounters, + ViewerReactions = discussion.ViewerReactions }; } @@ -75,7 +94,9 @@ private async Task> EnrichAsync(IReadOnlyList(), - CreatedAt = discussion.CreatedAt + CreatedAt = discussion.CreatedAt, + ReactionCounters = discussion.ReactionCounters, + ViewerReactions = discussion.ViewerReactions }); } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/Abstractions/ICommentsRepository.cs b/src/CrowdParlay.Social.Domain/Abstractions/ICommentsRepository.cs index 96b2d5c..b79368b 100644 --- a/src/CrowdParlay.Social.Domain/Abstractions/ICommentsRepository.cs +++ b/src/CrowdParlay.Social.Domain/Abstractions/ICommentsRepository.cs @@ -5,10 +5,9 @@ namespace CrowdParlay.Social.Domain.Abstractions; public interface ICommentsRepository { - public Task GetByIdAsync(Guid id); - public Task> SearchAsync(Guid? discussionId, Guid? authorId, int offset, int count); + public Task GetByIdAsync(Guid commentId, Guid? viewerId); + public Task> SearchAsync(Guid? subjectId, Guid? authorId, Guid? viewerId, int offset, int count); public Task CreateAsync(Guid authorId, Guid discussionId, string content); - public Task> GetRepliesToCommentAsync(Guid parentCommentId, int offset, int count); public Task ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content); - public Task DeleteAsync(Guid id); + public Task DeleteAsync(Guid commentId); } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/Abstractions/IDiscussionsRepository.cs b/src/CrowdParlay.Social.Domain/Abstractions/IDiscussionsRepository.cs index 6944432..b14f0d5 100644 --- a/src/CrowdParlay.Social.Domain/Abstractions/IDiscussionsRepository.cs +++ b/src/CrowdParlay.Social.Domain/Abstractions/IDiscussionsRepository.cs @@ -5,8 +5,7 @@ namespace CrowdParlay.Social.Domain.Abstractions; public interface IDiscussionsRepository { - public Task GetByIdAsync(Guid id); - public Task> GetAllAsync(int offset, int count); - public Task> GetByAuthorAsync(Guid authorId, int offset, int count); + public Task GetByIdAsync(Guid discussionId, Guid? viewerId); + public Task> SearchAsync(Guid? authorId, Guid? viewerId, int offset, int count); public Task CreateAsync(Guid authorId, string title, string description); } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/Abstractions/IReactionsRepository.cs b/src/CrowdParlay.Social.Domain/Abstractions/IReactionsRepository.cs index 37d5866..caa1775 100644 --- a/src/CrowdParlay.Social.Domain/Abstractions/IReactionsRepository.cs +++ b/src/CrowdParlay.Social.Domain/Abstractions/IReactionsRepository.cs @@ -1,8 +1,10 @@ +using CrowdParlay.Social.Domain.ValueObjects; + namespace CrowdParlay.Social.Domain.Abstractions; public interface IReactionsRepository { - public Task AddAsync(Guid authorId, Guid subjectId, string reaction); - public Task RemoveAsync(Guid authorId, Guid subjectId, string reaction); - public Task> GetAllAsync(Guid authorId, Guid subjectId); + public Task AddAsync(Guid authorId, Guid subjectId, Reaction reaction); + public Task RemoveAsync(Guid authorId, Guid subjectId, Reaction reaction); + public Task> GetAllAsync(Guid authorId, Guid subjectId); } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/CrowdParlay.Social.Domain.csproj b/src/CrowdParlay.Social.Domain/CrowdParlay.Social.Domain.csproj index 3a63532..b580e96 100644 --- a/src/CrowdParlay.Social.Domain/CrowdParlay.Social.Domain.csproj +++ b/src/CrowdParlay.Social.Domain/CrowdParlay.Social.Domain.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/src/CrowdParlay.Social.Domain/DTOs/ReactionCounter.cs b/src/CrowdParlay.Social.Domain/DTOs/ReactionCounter.cs deleted file mode 100644 index 6680132..0000000 --- a/src/CrowdParlay.Social.Domain/DTOs/ReactionCounter.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CrowdParlay.Social.Domain.DTOs; - -public class ReactionCounter -{ - public required string Reaction { get; set; } - public required int Count { get; set; } -} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/Entities/Comment.cs b/src/CrowdParlay.Social.Domain/Entities/Comment.cs index bf298a1..f56d89e 100644 --- a/src/CrowdParlay.Social.Domain/Entities/Comment.cs +++ b/src/CrowdParlay.Social.Domain/Entities/Comment.cs @@ -1,5 +1,3 @@ -using CrowdParlay.Social.Domain.DTOs; - namespace CrowdParlay.Social.Domain.Entities; public class Comment @@ -10,5 +8,6 @@ public class Comment public required DateTimeOffset CreatedAt { get; set; } public required int ReplyCount { get; set; } public required ISet FirstRepliesAuthorIds { get; set; } - public required ISet Reactions { get; set; } + public required IDictionary ReactionCounters { get; set; } + public required ISet ViewerReactions { get; set; } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/Entities/Discussion.cs b/src/CrowdParlay.Social.Domain/Entities/Discussion.cs index 82a2a87..1cf59c4 100644 --- a/src/CrowdParlay.Social.Domain/Entities/Discussion.cs +++ b/src/CrowdParlay.Social.Domain/Entities/Discussion.cs @@ -1,5 +1,3 @@ -using CrowdParlay.Social.Domain.DTOs; - namespace CrowdParlay.Social.Domain.Entities; public class Discussion @@ -9,5 +7,6 @@ public class Discussion public required string Description { get; set; } public required Guid AuthorId { get; set; } public required DateTimeOffset CreatedAt { get; set; } - public required ISet Reactions { get; set; } + public required IDictionary ReactionCounters { get; set; } + public required ISet ViewerReactions { get; set; } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/ReactionJsonConverter.cs b/src/CrowdParlay.Social.Domain/ReactionJsonConverter.cs new file mode 100644 index 0000000..7b0d07f --- /dev/null +++ b/src/CrowdParlay.Social.Domain/ReactionJsonConverter.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using CrowdParlay.Social.Domain.ValueObjects; + +namespace CrowdParlay.Social.Api; + +public class ReactionJsonConverter : JsonConverter +{ + public override Reaction? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException(); + + var value = reader.GetString() ?? throw new JsonException(); + return new Reaction(value); + } + + public override void Write(Utf8JsonWriter writer, Reaction value, JsonSerializerOptions options) => + writer.WriteStringValue(value); +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/ReactionMapsterAdapterConfigurator.cs b/src/CrowdParlay.Social.Domain/ReactionMapsterAdapterConfigurator.cs new file mode 100644 index 0000000..b9b5749 --- /dev/null +++ b/src/CrowdParlay.Social.Domain/ReactionMapsterAdapterConfigurator.cs @@ -0,0 +1,16 @@ +using CrowdParlay.Social.Domain.ValueObjects; +using Mapster; + +namespace CrowdParlay.Social.Domain; + +public class ReactionMapsterAdapterConfigurator +{ + public static void Configure() + { + TypeAdapterConfig.NewConfig() + .MapWith(source => new Reaction(source)); + + TypeAdapterConfig.NewConfig() + .MapWith(source => source.Value); + } +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/ValueObjects/Reaction.cs b/src/CrowdParlay.Social.Domain/ValueObjects/Reaction.cs new file mode 100644 index 0000000..0eba517 --- /dev/null +++ b/src/CrowdParlay.Social.Domain/ValueObjects/Reaction.cs @@ -0,0 +1,61 @@ +using System.Text.Json.Serialization; +using CrowdParlay.Social.Api; + +namespace CrowdParlay.Social.Domain.ValueObjects; + +[JsonConverter(typeof(ReactionJsonConverter))] +public class Reaction : IEquatable +{ + public readonly string Value; + + public Reaction(string value) + { + if (!AllowedValues.Contains(value)) + throw new ArgumentException("Invalid reaction value.", nameof(value)); + + Value = value; + } + + public static readonly IReadOnlySet AllowedValues = new HashSet + { + "\ud83c\udf46", // Eggplant + "\ud83e\udd74", // Woozy Face + "\ud83d\udc85", // Nail Polish + "\u2764\ufe0f", // Red Heart + "\u2764\ufe0f", // Red Heart + "\ud83e\udd2e", // Face Vomiting + "\ud83c\udf7e", // Bottle with Popping Cork + "\ud83e\udd21", // Clown Face + "\ud83d\ude0e", // Smiling Face with Sunglasses + "\ud83d\udd25", // Fire + "\ud83c\udfc6", // Trophy + "\ud83c\udf08", // Rainbow + "\ud83d\ude2d", // Loudly Crying Face + "\ud83e\udd84", // Unicorn + "\ud83d\udc4d", // Thumbs Up + "\ud83d\udc4e", // Thumbs Down + "спскнчл", + "чел))" + }; + + public static implicit operator string(Reaction reaction) => reaction.Value; + public static implicit operator Reaction(string reaction) => new(reaction); + + public override string ToString() => Value; + + public bool Equals(Reaction? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return Value == other.Value; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + return obj.GetType() == GetType() && Equals((Reaction)obj); + } + + public override int GetHashCode() => Value.GetHashCode(); +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs index 43ba148..ff6505d 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs @@ -1,3 +1,4 @@ +using CrowdParlay.Social.Domain; using CrowdParlay.Social.Domain.Abstractions; using CrowdParlay.Social.Infrastructure.Persistence.Services; using Microsoft.Extensions.Configuration; @@ -20,6 +21,8 @@ public static IServiceCollection AddPersistence(this IServiceCollection services // ReSharper disable once InconsistentNaming private static IServiceCollection AddNeo4j(this IServiceCollection services, IConfiguration configuration) { + ReactionMapsterAdapterConfigurator.Configure(); + var uri = configuration["NEO4J_URI"] ?? throw new InvalidOperationException("NEO4J_URI is not set!"); diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs index 0941e2a..66c3725 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs @@ -1,3 +1,4 @@ +using System.Text; using CrowdParlay.Social.Application.Exceptions; using CrowdParlay.Social.Domain.Abstractions; using CrowdParlay.Social.Domain.DTOs; @@ -9,28 +10,43 @@ namespace CrowdParlay.Social.Infrastructure.Persistence.Services; public class CommentsRepository(IAsyncQueryRunner runner) : ICommentsRepository { - public async Task GetByIdAsync(Guid id) + public async Task GetByIdAsync(Guid commentId, Guid? viewerId) { var data = await runner.RunAsync( """ - MATCH (comment:Comment { Id: $id })-[:AUTHORED_BY]->(author:Author) + MATCH (comment:Comment { Id: $commentId })-[:AUTHORED_BY]->(author:Author) OPTIONAL MATCH (replyAuthor:Author)<-[:AUTHORED_BY]-(reply:Comment)-[:REPLIES_TO]->(comment) + OPTIONAL MATCH (comment)<-[reaction:REACTED_TO]-(:Author) + OPTIONAL MATCH (comment)<-[viewerReaction:REACTED_TO]-(:Author { Id: $viewerId }) - WITH comment, author, COUNT(reply) AS replyCount, + WITH comment, author, COUNT(reply) AS replyCount, reaction, + COLLECT(viewerReaction.Value) AS viewerReactions, CASE WHEN COUNT(reply) > 0 THEN COLLECT(DISTINCT replyAuthor.Id)[0..3] ELSE [] END AS firstRepliesAuthorIds - + + WITH comment, author, replyCount, firstRepliesAuthorIds, viewerReactions, reaction, + COUNT(reaction) AS reactionCount + + WITH comment, author, replyCount, firstRepliesAuthorIds, viewerReactions, + apoc.map.fromPairs(COLLECT([reaction.Value, reactionCount])) AS reactions + RETURN { Id: comment.Id, Content: comment.Content, AuthorId: author.Id, CreatedAt: comment.CreatedAt, ReplyCount: replyCount, - FirstRepliesAuthorIds: firstRepliesAuthorIds + FirstRepliesAuthorIds: firstRepliesAuthorIds, + Reactions: reactions, + ViewerReactions: viewerReactions } """, - new { id = id.ToString() }); + new + { + commentId = commentId.ToString(), + viewerId = viewerId?.ToString() + }); if (await data.PeekAsync() is null) throw new NotFoundException(); @@ -39,29 +55,41 @@ ELSE [] END AS firstRepliesAuthorIds return record[0].Adapt(); } - public async Task> SearchAsync(Guid? discussionId, Guid? authorId, int offset, int count) + public async Task> SearchAsync(Guid? subjectId, Guid? authorId, Guid? viewerId, int offset, int count) { - var matchSelector = authorId is not null - ? "MATCH (author:Author { Id: $authorId })<-[:AUTHORED_BY]-(comment:Comment)" - : "MATCH (author:Author)<-[:AUTHORED_BY]-(comment:Comment)"; + var matchSelector = new StringBuilder("MATCH (comment:Comment)-[:AUTHORED_BY]->(author:Author)"); - if (discussionId is not null) - matchSelector += "-[:REPLIES_TO]->(discussion:Discussion { Id: $discussionId })"; + if (subjectId is not null) + { + matchSelector.AppendLine("MATCH (comment)-[:REPLIES_TO]->(subject { Id: $subjectId })"); + matchSelector.AppendLine("WHERE (subject:Comment OR subject:Discussion)"); + } + + if (authorId is not null) + matchSelector.AppendLine("WHERE author.Id = $authorId"); var data = await runner.RunAsync( matchSelector + """ OPTIONAL MATCH (deepReplyAuthor:Author)<-[:AUTHORED_BY]-(deepReply:Comment)-[:REPLIES_TO*]->(comment) - WITH author, comment, deepReplyAuthor, deepReply + OPTIONAL MATCH (comment)<-[reaction:REACTED_TO]-(:Author) + OPTIONAL MATCH (comment)<-[viewerReaction:REACTED_TO]-(:Author { Id: $viewerId }) + + WITH author, comment, deepReplyAuthor, deepReply, reaction, + COLLECT(viewerReaction.Value) AS viewerReactions + + WITH author, comment, deepReplyAuthor, deepReply, viewerReactions, reaction, + COUNT(reaction) AS reactionCount + + WITH author, comment, deepReplyAuthor, deepReply, viewerReactions, + apoc.map.fromPairs(COLLECT([reaction.Value, reactionCount])) AS reactions + ORDER BY comment.CreatedAt, deepReply.CreatedAt DESC - WITH author, comment, { - DeepReplyCount: COUNT(deepReply), - FirstDeepRepliesAuthorIds: CASE WHEN COUNT(deepReply) > 0 - THEN COLLECT(DISTINCT deepReplyAuthor.Id)[0..3] - ELSE [] END - } AS deepRepliesData + WITH author, comment, viewerReactions, reactions, + COUNT(deepReply) AS deepReplyCount, + COLLECT(DISTINCT deepReplyAuthor.Id)[0..3] AS firstDeepRepliesAuthorIds RETURN { TotalCount: COUNT(comment), @@ -70,15 +98,18 @@ ELSE [] END Content: comment.Content, AuthorId: author.Id, CreatedAt: comment.CreatedAt, - ReplyCount: deepRepliesData.DeepReplyCount, - FirstRepliesAuthorIds: deepRepliesData.FirstDeepRepliesAuthorIds + ReplyCount: deepReplyCount, + FirstRepliesAuthorIds: firstDeepRepliesAuthorIds, + Reactions: reactions, + ViewerReactions: viewerReactions })[$offset..$offset + $count] } """, new { - discussionId = discussionId.ToString(), - authorId = authorId.ToString(), + subjectId = subjectId?.ToString(), + authorId = authorId?.ToString(), + viewerId = viewerId?.ToString(), offset, count }); @@ -124,59 +155,6 @@ RETURN comment.Id return record[0].Adapt(); } - public async Task> GetRepliesToCommentAsync(Guid parentCommentId, int offset, int count) - { - var data = await runner.RunAsync( - """ - MATCH (author:Author)<-[:AUTHORED_BY]-(comment:Comment)-[:REPLIES_TO]->(parent:Comment { Id: $parentCommentId }) - - WITH author, comment, COUNT(comment) AS totalCount - ORDER BY comment.CreatedAt - SKIP $offset - LIMIT $count - - OPTIONAL MATCH (replyAuthor:Author)<-[:AUTHORED_BY]-(reply:Comment)-[:REPLIES_TO]->(comment) - - WITH totalCount, author, comment, COUNT(reply) AS replyCount, - CASE WHEN COUNT(reply) > 0 - THEN COLLECT(DISTINCT replyAuthor.Id)[0..3] - ELSE [] END AS firstRepliesAuthorIds - - WITH totalCount, - COLLECT({ - Id: comment.Id, - Content: comment.Content, - AuthorId: author.Id, - CreatedAt: datetime(comment.CreatedAt), - ReplyCount: replyCount, - FirstRepliesAuthorIds: firstRepliesAuthorIds - }) AS comments - - RETURN { - TotalCount: totalCount, - Items: comments - } - """, - new - { - parentCommentId = parentCommentId.ToString(), - offset, - count - }); - - if (await data.PeekAsync() is null) - { - return new Page - { - TotalCount = 0, - Items = Enumerable.Empty() - }; - } - - var record = await data.SingleAsync(); - return record[0].Adapt>(); - } - public async Task ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content) { var cursor = await runner.RunAsync( @@ -197,7 +175,7 @@ RETURN reply.Id authorId = authorId.ToString(), content }); - + if (await cursor.PeekAsync() is null) throw new NotFoundException(); @@ -205,15 +183,16 @@ RETURN reply.Id return record[0].Adapt(); } - public async Task DeleteAsync(Guid id) + public async Task DeleteAsync(Guid commentId) { var data = await runner.RunAsync( """ - OPTIONAL MATCH (comment:Comment { Id: $id }) - DETACH DELETE comment + OPTIONAL MATCH (comment:Comment { Id: $commentId }) + OPTIONAL MATCH (reply:Comment)-[:REPLIES_TO*]->(comment) + DETACH DELETE comment, reply RETURN COUNT(comment) = 0 """, - new { id = id.ToString() }); + new { commentId = commentId.ToString() }); var record = await data.SingleAsync(); var notFount = record[0].As(); diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs index f0286c9..f2c28b4 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs @@ -9,20 +9,36 @@ namespace CrowdParlay.Social.Infrastructure.Persistence.Services; public class DiscussionsRepository(IAsyncQueryRunner runner) : IDiscussionsRepository { - public async Task GetByIdAsync(Guid id) + public async Task GetByIdAsync(Guid discussionId, Guid? viewerId) { var data = await runner.RunAsync( """ - MATCH (discussion:Discussion { Id: $id })-[:AUTHORED_BY]->(author:Author) + MATCH (discussion:Discussion { Id: $discussionId })-[:AUTHORED_BY]->(author:Author) + OPTIONAL MATCH (discussion)<-[reaction:REACTED_TO]-(:Author) + OPTIONAL MATCH (discussion)<-[viewerReaction:REACTED_TO]-(:Author { Id: $viewerId }) + + WITH author, discussion, reaction, + COLLECT(viewerReaction.Value) AS viewerReactions, + COUNT(reaction) AS reactionCount + + WITH author, discussion, viewerReactions, + apoc.map.fromPairs(COLLECT([reaction.Value, reactionCount])) AS reactions + RETURN { Id: discussion.Id, Title: discussion.Title, Description: discussion.Description, AuthorId: author.Id, - CreatedAt: discussion.CreatedAt + CreatedAt: discussion.CreatedAt, + Reactions: reactions, + ViewerReactions: viewerReactions } """, - new { id = id.ToString() }); + new + { + discussionId = discussionId.ToString(), + viewerId = viewerId?.ToString() + }); if (await data.PeekAsync() is null) throw new NotFoundException(); @@ -31,48 +47,27 @@ public async Task GetByIdAsync(Guid id) return record[0].Adapt(); } - public async Task> GetAllAsync(int offset, int count) + public async Task> SearchAsync(Guid? authorId, Guid? viewerId, int offset, int count) { + var matchSelector = authorId is not null + ? "MATCH (discussion:Discussion)-[:AUTHORED_BY]->(author:Author { Id: $authorId })" + : "MATCH (discussion:Discussion)-[:AUTHORED_BY]->(author:Author)"; + var data = await runner.RunAsync( + matchSelector + """ - MATCH (discussion:Discussion)-[:AUTHORED_BY]->(author:Author) - WITH discussion, author ORDER BY discussion.CreatedAt DESC - RETURN { - TotalCount: COUNT(discussion), - Items: COLLECT({ - Id: discussion.Id, - Title: discussion.Title, - Description: discussion.Description, - AuthorId: author.Id, - CreatedAt: discussion.CreatedAt - })[$offset..$offset + $count] - } - """, - new - { - offset, - count - }); + OPTIONAL MATCH (discussion)<-[reaction:REACTED_TO]-(:Author) + OPTIONAL MATCH (discussion)<-[viewerReaction:REACTED_TO]-(:Author { Id: $viewerId }) - if (await data.PeekAsync() is null) - { - return new Page - { - TotalCount = 0, - Items = Enumerable.Empty() - }; - } + WITH author, discussion, reaction, + COLLECT(viewerReaction.Value) AS viewerReactions, + COUNT(reaction) AS reactionCount - var record = await data.SingleAsync(); - return record[0].Adapt>(); - } + WITH author, discussion, viewerReactions, + apoc.map.fromPairs(COLLECT([reaction.Value, reactionCount])) AS reactions + + ORDER BY discussion.CreatedAt DESC - public async Task> GetByAuthorAsync(Guid authorId, int offset, int count) - { - var data = await runner.RunAsync( - """ - MATCH (discussion:Discussion)-[:AUTHORED_BY]->(author:Author { Id: $authorId }) - WITH discussion, author ORDER BY discussion.CreatedAt DESC RETURN { TotalCount: COUNT(discussion), Items: COLLECT({ @@ -80,13 +75,16 @@ public async Task> GetByAuthorAsync(Guid authorId, int offset, Title: discussion.Title, Description: discussion.Description, AuthorId: author.Id, - CreatedAt: discussion.CreatedAt + CreatedAt: discussion.CreatedAt, + Reactions: reactions, + ViewerReactions: viewerReactions })[$offset..$offset + $count] } """, new { - authorId = authorId.ToString(), + authorId = authorId?.ToString(), + viewerId = viewerId?.ToString(), offset, count }); diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/ReactionsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/ReactionsRepository.cs index 51b018e..9973794 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/ReactionsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/ReactionsRepository.cs @@ -1,41 +1,42 @@ using CrowdParlay.Social.Application.Exceptions; using CrowdParlay.Social.Domain.Abstractions; +using CrowdParlay.Social.Domain.ValueObjects; using Mapster; using Neo4j.Driver; -using Neo4j.Driver.Preview.Mapping; namespace CrowdParlay.Social.Infrastructure.Persistence.Services; public class ReactionsRepository(IAsyncQueryRunner runner) : IReactionsRepository { - public async Task AddAsync(Guid authorId, Guid subjectId, string reaction) + public async Task AddAsync(Guid authorId, Guid subjectId, Reaction reaction) { var data = await runner.RunAsync( """ - OPTIONAL MATCH (subject { Id: $subjectId }) + MATCH (subject { Id: $subjectId }) WHERE (subject:Comment OR subject:Discussion) - MERGE (author:Author { Id: $authorId })-[reaction:REACTED_TO { Reaction: $reaction }]->(subject) - RETURN COUNT(reaction) = 0 + MERGE (author:Author { Id: $authorId }) + MERGE (author)-[reaction:REACTED_TO { Value: $reaction }]->(subject) + RETURN reaction IS NULL """, new { authorId = authorId.ToString(), subjectId = subjectId.ToString(), - reaction + reaction = reaction.ToString() }); var record = await data.SingleAsync(); - var notFount = record[0].As(); - - if (notFount) + var notFound = record[0].As(); + + if (notFound) throw new NotFoundException(); } - public async Task RemoveAsync(Guid authorId, Guid subjectId, string reaction) + public async Task RemoveAsync(Guid authorId, Guid subjectId, Reaction reaction) { var data = await runner.RunAsync( """ - OPTIONAL MATCH (author:Author { Id: $authorId })-[reaction:REACTED_TO { Reaction: $reaction }]->(subject { Id: $subjectId }) + OPTIONAL MATCH (author:Author { Id: $authorId })-[reaction:REACTED_TO { Value: $reaction }]->(subject { Id: $subjectId }) WHERE (subject:Comment OR subject:Discussion) DELETE reaction RETURN COUNT(reaction) = 0 @@ -44,28 +45,32 @@ RETURN COUNT(reaction) = 0 { authorId = authorId.ToString(), subjectId = subjectId.ToString(), - reaction + reaction = reaction.ToString() }); var record = await data.SingleAsync(); - var notFount = record[0].As(); + var notFound = record[0].As(); - if (notFount) + if (notFound) throw new NotFoundException(); } - public async Task> GetAllAsync(Guid authorId, Guid subjectId) + public async Task> GetAllAsync(Guid authorId, Guid subjectId) { var data = await runner.RunAsync( """ - MATCH (author:Author)-[reaction:REACTED_TO]->(subject { Id: $subjectId }) + MATCH (author:Author { Id: $authorId })-[reaction:REACTED_TO]->(subject { Id: $subjectId }) WHERE (subject:Comment OR subject:Discussion) - RETURN reaction.Reaction + RETURN reaction.Value """, - new { subjectId = subjectId.ToString() }); + new + { + authorId = authorId.ToString(), + subjectId = subjectId.ToString() + }); return await data - .Select(record => record[0].As()) + .Select(record => record[0].As().Adapt()) .ToHashSetAsync(); } } \ No newline at end of file diff --git a/tests/CrowdParlay.Social.IntegrationTests/CrowdParlay.Social.IntegrationTests.csproj b/tests/CrowdParlay.Social.IntegrationTests/CrowdParlay.Social.IntegrationTests.csproj index 05dbd26..3b7eb9e 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/CrowdParlay.Social.IntegrationTests.csproj +++ b/tests/CrowdParlay.Social.IntegrationTests/CrowdParlay.Social.IntegrationTests.csproj @@ -17,7 +17,8 @@ - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/CrowdParlay.Social.IntegrationTests/Fixtures/WebApplicationContext.cs b/tests/CrowdParlay.Social.IntegrationTests/Fixtures/WebApplicationContext.cs index 0366b4d..928d07c 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Fixtures/WebApplicationContext.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Fixtures/WebApplicationContext.cs @@ -33,6 +33,7 @@ private static Neo4jConfiguration SetupNeo4j() var neo4j = new Neo4jBuilder() .WithExposedPort(7474) .WithPortBinding(7474, true) + .WithEnvironment("NEO4JLABS_PLUGINS", "[\"apoc\"]") .Build(); AsyncContext.Run(async () => await neo4j.StartAsync()); @@ -56,7 +57,7 @@ await session.RunAsync( })-[:AUTHORED_BY]->(author:Author { Id: "df194a2d-368c-43ea-b48d-66042f74691d" }) """); }); - + return configuration; } diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthenticationTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthenticationTests.cs index f9f8cd2..a2e174f 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthenticationTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthenticationTests.cs @@ -4,7 +4,7 @@ namespace CrowdParlay.Social.IntegrationTests.Tests; -public class AuthenticationTests(WebApplicationContext context) : IClassFixture +public class AuthenticationTests(WebApplicationContext context) : IAssemblyFixture { private readonly HttpClient _client = context.Server.CreateClient(); diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs index 0bf1f5a..06f003c 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs @@ -7,7 +7,7 @@ namespace CrowdParlay.Social.IntegrationTests.Tests; -public class CommentsRepositoryTests(WebApplicationContext context) : IClassFixture +public class CommentsRepositoryTests(WebApplicationContext context) : IAssemblyFixture { private readonly IServiceProvider _services = context.Services; @@ -28,7 +28,7 @@ public async Task CreateComment() // Act var commentId = await comments.CreateAsync(authorId, discussionId, "Comment content"); - var comment = await comments.GetByIdAsync(commentId); + var comment = await comments.GetByIdAsync(commentId, authorId); // Assert comment.AuthorId.Should().Be(authorId); @@ -69,6 +69,7 @@ public async Task SearchComments() var authorId2 = Guid.NewGuid(); var authorId3 = Guid.NewGuid(); var authorId4 = Guid.NewGuid(); + var viewerId = Guid.NewGuid(); var discussionId = await discussions.CreateAsync( authorId: authorId1, @@ -89,14 +90,15 @@ public async Task SearchComments() var commentId112 = await comments.ReplyToCommentAsync(authorId2, commentId1, "Comment 112"); var commentId121 = await comments.ReplyToCommentAsync(authorId4, commentId1, "Comment 121"); - var comment1 = await comments.GetByIdAsync(commentId1); - var comment2 = await comments.GetByIdAsync(commentId2); - var comment3 = await comments.GetByIdAsync(commentId3); + var comment1 = await comments.GetByIdAsync(commentId1, viewerId); + var comment2 = await comments.GetByIdAsync(commentId2, viewerId); + var comment3 = await comments.GetByIdAsync(commentId3, viewerId); // Act var page = await comments.SearchAsync( - discussionId: discussionId, + discussionId, authorId: null, + viewerId, offset: 0, count: 2); @@ -115,7 +117,7 @@ public async Task GetComment_WithUnknownId_ThrowsNotFoundException() var comments = scope.ServiceProvider.GetRequiredService(); // Act - Func getComment = async () => await comments.GetByIdAsync(Guid.NewGuid()); + Func getComment = async () => await comments.GetByIdAsync(Guid.NewGuid(), Guid.NewGuid()); // Assert await getComment.Should().ThrowAsync(); @@ -133,10 +135,10 @@ public async Task GetRepliesToComment() var discussionId = await discussions.CreateAsync(authorId, "Discussion", "Test discussion."); var commentId = await comments.CreateAsync(authorId, discussionId, "Comment content"); var replyId = await comments.ReplyToCommentAsync(authorId, commentId, "Reply content"); - var reply = await comments.GetByIdAsync(replyId); + var reply = await comments.GetByIdAsync(replyId, authorId); // Act - var page = await comments.GetRepliesToCommentAsync(commentId, offset: 0, count: 1); + var page = await comments.SearchAsync(commentId, authorId: null, authorId, offset: 0, count: 1); // Assert page.Should().BeEquivalentTo(new Page diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsRepositoryTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsRepositoryTests.cs index 8104297..6d582c6 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsRepositoryTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsRepositoryTests.cs @@ -2,35 +2,10 @@ namespace CrowdParlay.Social.IntegrationTests.Tests; -public class DiscussionsRepositoryTests(WebApplicationContext context) : IClassFixture +public class DiscussionsRepositoryTests(WebApplicationContext context) : IAssemblyFixture { private readonly IServiceProvider _services = context.Services; - [Fact(DisplayName = "Get all discussions")] - public async Task GetAllDiscussions() - { - // Arrange - await using var scope = _services.CreateAsyncScope(); - var discussions = scope.ServiceProvider.GetRequiredService(); - - Guid[] expectedDiscussionIds = - [ - await discussions.CreateAsync(Guid.NewGuid(), "Discussion 1", "bla bla bla"), - await discussions.CreateAsync(Guid.NewGuid(), "Discussion 2", "numa numa e"), - await discussions.CreateAsync(Guid.NewGuid(), "Discussion 3", "bara bara bara") - ]; - - var expectedDiscussions = expectedDiscussionIds.Select(discussionId => - discussions.GetByIdAsync(discussionId).Result).ToArray(); - - // Act - var response = await discussions.GetAllAsync(0, 2); - - // Assert - response.Items.Should().BeEquivalentTo(expectedDiscussions.TakeLast(2).Reverse()); - response.TotalCount.Should().BeGreaterOrEqualTo(3); - } - [Fact(DisplayName = "Get discussions by author")] public async Task GetDiscussionsByAuthor() { @@ -46,10 +21,10 @@ await discussions.CreateAsync(authorId, "Discussion 2", "numa numa e") ]; var expectedDiscussions = expectedDiscussionIds.Select(discussionId => - discussions.GetByIdAsync(discussionId).Result).ToArray(); + discussions.GetByIdAsync(discussionId, authorId).Result).ToArray(); // Act - var response = await discussions.GetByAuthorAsync(authorId, 0, 10); + var response = await discussions.SearchAsync(authorId, authorId, 0, 10); // Assert response.Items.Should().BeEquivalentTo(expectedDiscussions.Reverse()); @@ -63,7 +38,7 @@ public async Task GetNoDiscussionsByAuthor() var discussions = scope.ServiceProvider.GetRequiredService(); // Act - var response = await discussions.GetByAuthorAsync(Guid.NewGuid(), 0, 10); + var response = await discussions.SearchAsync(Guid.NewGuid(), Guid.NewGuid(), 0, 10); // Assert response.Items.Should().BeEmpty(); diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/HttpContractsTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/HttpContractsTests.cs index 6d3a544..92b74a8 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/HttpContractsTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/HttpContractsTests.cs @@ -3,7 +3,7 @@ namespace CrowdParlay.Social.IntegrationTests.Tests; -public class HttpContractsTests(WebApplicationContext context) : IClassFixture +public class HttpContractsTests(WebApplicationContext context) : IAssemblyFixture { private readonly HttpClient _client = context.Server.CreateClient(); diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsRepositoryTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsRepositoryTests.cs index 3f4b1f9..cb46a9a 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsRepositoryTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsRepositoryTests.cs @@ -1,8 +1,9 @@ using CrowdParlay.Social.Domain.Abstractions; +using CrowdParlay.Social.Domain.ValueObjects; namespace CrowdParlay.Social.IntegrationTests.Tests; -public class ReactionsRepositoryTests(WebApplicationContext context) : IClassFixture +public class ReactionsRepositoryTests(WebApplicationContext context) : IAssemblyFixture { private readonly IServiceProvider _services = context.Services; @@ -17,8 +18,8 @@ public async Task AddReaction_MultipleTimes_AddsReaction() var authorId = Guid.NewGuid(); var discussionId = await discussionsRepository.CreateAsync(Guid.NewGuid(), "Title", "Description"); - const string thumbUp = "\ud83d\udc4d"; - const string thumbDown = "\ud83d\udc4e"; + var thumbUp = new Reaction("\ud83d\udc4d"); + var thumbDown = new Reaction("\ud83d\udc4e"); // Act for (var i = 0; i < 4; i++) diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/SignalRTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/SignalRTests.cs index 18b98f4..54fc14a 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/SignalRTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/SignalRTests.cs @@ -5,7 +5,7 @@ namespace CrowdParlay.Social.IntegrationTests.Tests; -public class SignalRTests(WebApplicationContext context) : IClassFixture +public class SignalRTests(WebApplicationContext context) : IAssemblyFixture { private readonly IServiceProvider _services = context.Services; private readonly HttpClient _client = context.Server.CreateClient(); @@ -33,7 +33,9 @@ public async Task ListenToNewCommentsInDiscussion() }, CreatedAt = DateTimeOffset.Now, ReplyCount = 0, - FirstRepliesAuthors = [] + FirstRepliesAuthors = [], + ReactionCounters = new Dictionary(), + ViewerReactions = new HashSet() }; // Act diff --git a/tests/CrowdParlay.Social.UnitTests/CrowdParlay.Social.UnitTests.csproj b/tests/CrowdParlay.Social.UnitTests/CrowdParlay.Social.UnitTests.csproj index 6e30f29..215e87d 100644 --- a/tests/CrowdParlay.Social.UnitTests/CrowdParlay.Social.UnitTests.csproj +++ b/tests/CrowdParlay.Social.UnitTests/CrowdParlay.Social.UnitTests.csproj @@ -14,7 +14,7 @@ - +