From 26fa72c3b42393c117d791022e96962b2f99615a 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, 19 Feb 2024 17:16:38 +0300 Subject: [PATCH] feat: migrate to Neo4j.Driver, fix DateTime conversion --- .../Consumers/UserEventConsumer.cs | 2 +- .../Extensions/ConfigureServices.cs | 2 +- .../v1/Controllers/CommentsController.cs | 16 +- .../Abstractions/ICommentRepository.cs | 4 +- .../CrowdParlay.Social.Application.csproj | 1 - .../DTOs/CommentDto.cs | 2 +- .../DTOs/Page.cs | 13 + .../ConfigureServices.cs | 6 +- ...y.Social.Infrastructure.Persistence.csproj | 2 +- .../Services/AuthorRepository.cs | 140 +++++--- .../Services/CommentRepository.cs | 333 ++++++++++-------- .../Services/DiscussionRepository.cs | 182 +++++----- .../Services/GraphClientInitializer.cs | 19 +- ...CrowdParlay.Social.IntegrationTests.csproj | 2 +- .../Fixtures/WebApplicationContext.cs | 1 - .../Services/TestWebApplicationFactory.cs | 2 +- ...llerTests.cs => AuthorsRepositoryTests.cs} | 20 +- .../Tests/CommentsControllerTests.cs | 154 -------- .../Tests/CommentsRepositoryTests.cs | 115 ++++++ 19 files changed, 520 insertions(+), 496 deletions(-) rename src/{CrowdParlay.Social.Application => CrowdParlay.Social.Api}/Consumers/UserEventConsumer.cs (95%) create mode 100644 src/CrowdParlay.Social.Application/DTOs/Page.cs rename tests/CrowdParlay.Social.IntegrationTests/Tests/{AuthorsControllerTests.cs => AuthorsRepositoryTests.cs} (51%) delete mode 100644 tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsControllerTests.cs create mode 100644 tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs diff --git a/src/CrowdParlay.Social.Application/Consumers/UserEventConsumer.cs b/src/CrowdParlay.Social.Api/Consumers/UserEventConsumer.cs similarity index 95% rename from src/CrowdParlay.Social.Application/Consumers/UserEventConsumer.cs rename to src/CrowdParlay.Social.Api/Consumers/UserEventConsumer.cs index bc7817f..7023483 100644 --- a/src/CrowdParlay.Social.Application/Consumers/UserEventConsumer.cs +++ b/src/CrowdParlay.Social.Api/Consumers/UserEventConsumer.cs @@ -2,7 +2,7 @@ using CrowdParlay.Social.Application.Abstractions; using MassTransit; -namespace CrowdParlay.Social.Application.Consumers; +namespace CrowdParlay.Social.Api.Consumers; public class UserEventConsumer : IConsumer, IConsumer, IConsumer { diff --git a/src/CrowdParlay.Social.Api/Extensions/ConfigureServices.cs b/src/CrowdParlay.Social.Api/Extensions/ConfigureServices.cs index ed6378c..82bc7e9 100644 --- a/src/CrowdParlay.Social.Api/Extensions/ConfigureServices.cs +++ b/src/CrowdParlay.Social.Api/Extensions/ConfigureServices.cs @@ -1,6 +1,6 @@ using CrowdParlay.Communication; +using CrowdParlay.Social.Api.Consumers; using CrowdParlay.Social.Api.Middlewares; -using CrowdParlay.Social.Application.Consumers; using MassTransit; namespace CrowdParlay.Social.Api.Extensions; diff --git a/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs b/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs index 783c7e5..5ddaa6e 100644 --- a/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs +++ b/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs @@ -33,12 +33,12 @@ public async Task GetCommentById([FromRoute] Guid commentId) => [ProducesResponseType(typeof(IEnumerable), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(Problem), (int)HttpStatusCode.InternalServerError)] [ProducesResponseType(typeof(ValidationProblem), (int)HttpStatusCode.BadRequest)] - public async Task> SearchComments( + public async Task> SearchComments( [FromQuery] Guid? discussionId, [FromQuery] Guid? authorId, - [FromQuery, BindRequired] int page, - [FromQuery, BindRequired] int size) => - await _comments.SearchAsync(discussionId, authorId, page, size); + [FromQuery, BindRequired] int offset, + [FromQuery, BindRequired] int count) => + await _comments.SearchAsync(discussionId, authorId, offset, count); /// /// Creates a top-level comment in discussion. @@ -66,11 +66,11 @@ public async Task> Create([FromBody] CommentRequest req [ProducesResponseType(typeof(Problem), (int)HttpStatusCode.InternalServerError)] [ProducesResponseType(typeof(ValidationProblem), (int)HttpStatusCode.BadRequest)] [ProducesResponseType(typeof(Problem), (int)HttpStatusCode.NotFound)] - public async Task> GetRepliesToComment( + public async Task> GetRepliesToComment( [FromRoute] Guid parentCommentId, - [FromQuery, BindRequired] int page, - [FromQuery, BindRequired] int size) => - await _comments.GetRepliesToCommentAsync(parentCommentId, page, size); + [FromQuery, BindRequired] int offset, + [FromQuery, BindRequired] int count) => + await _comments.GetRepliesToCommentAsync(parentCommentId, offset, count); /// /// Creates a reply to the comment with the specified ID. diff --git a/src/CrowdParlay.Social.Application/Abstractions/ICommentRepository.cs b/src/CrowdParlay.Social.Application/Abstractions/ICommentRepository.cs index 5e1e8d5..eae5c95 100644 --- a/src/CrowdParlay.Social.Application/Abstractions/ICommentRepository.cs +++ b/src/CrowdParlay.Social.Application/Abstractions/ICommentRepository.cs @@ -5,9 +5,9 @@ namespace CrowdParlay.Social.Application.Abstractions; public interface ICommentRepository { public Task GetByIdAsync(Guid id); - public Task> SearchAsync(Guid? discussionId, Guid? authorId, int page, int size); + public Task> SearchAsync(Guid? discussionId, Guid? authorId, int offset, int count); public Task CreateAsync(Guid authorId, Guid discussionId, string content); - public Task> GetRepliesToCommentAsync(Guid parentCommentId, int page, int size); + public Task> GetRepliesToCommentAsync(Guid parentCommentId, int offset, int count); 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.Application/CrowdParlay.Social.Application.csproj b/src/CrowdParlay.Social.Application/CrowdParlay.Social.Application.csproj index 8b1f8b5..c903f06 100644 --- a/src/CrowdParlay.Social.Application/CrowdParlay.Social.Application.csproj +++ b/src/CrowdParlay.Social.Application/CrowdParlay.Social.Application.csproj @@ -15,7 +15,6 @@ - diff --git a/src/CrowdParlay.Social.Application/DTOs/CommentDto.cs b/src/CrowdParlay.Social.Application/DTOs/CommentDto.cs index 2e1782a..588457a 100644 --- a/src/CrowdParlay.Social.Application/DTOs/CommentDto.cs +++ b/src/CrowdParlay.Social.Application/DTOs/CommentDto.cs @@ -5,7 +5,7 @@ public class CommentDto public Guid Id { get; set; } public string Content { get; set; } public AuthorDto Author { get; set; } - public DateTime CreatedAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } public int ReplyCount { get; set; } public IEnumerable FirstRepliesAuthors { get; set; } = Enumerable.Empty(); } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/DTOs/Page.cs b/src/CrowdParlay.Social.Application/DTOs/Page.cs new file mode 100644 index 0000000..86f702c --- /dev/null +++ b/src/CrowdParlay.Social.Application/DTOs/Page.cs @@ -0,0 +1,13 @@ +namespace CrowdParlay.Social.Application.DTOs; + +public class Page +{ + public required int TotalCount { get; set; } + public required IEnumerable Items { get; set; } + + public static Page Empty => new() + { + TotalCount = 0, + Items = Enumerable.Empty() + }; +} \ 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 a86a6da..6680ef2 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs @@ -2,7 +2,7 @@ using CrowdParlay.Social.Infrastructure.Persistence.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Neo4jClient; +using Neo4j.Driver; namespace CrowdParlay.Social.Infrastructure.Persistence; @@ -30,7 +30,7 @@ private static IServiceCollection AddNeo4j(this IServiceCollection services, ICo configuration["NEO4J_PASSWORD"] ?? throw new InvalidOperationException("NEO4J_PASSWORD is not set!"); - var client = new BoltGraphClient(uri, username, password); - return services.AddSingleton(client); + var driver = GraphDatabase.Driver(uri, AuthTokens.Basic(username, password)); + return services.AddSingleton(driver); } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/CrowdParlay.Social.Infrastructure.Persistence.csproj b/src/CrowdParlay.Social.Infrastructure.Persistence/CrowdParlay.Social.Infrastructure.Persistence.csproj index 2ff7e30..16531b5 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/CrowdParlay.Social.Infrastructure.Persistence.csproj +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/CrowdParlay.Social.Infrastructure.Persistence.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/AuthorRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/AuthorRepository.cs index baa6ddf..4ae261e 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/AuthorRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/AuthorRepository.cs @@ -1,89 +1,127 @@ using CrowdParlay.Social.Application.Abstractions; using CrowdParlay.Social.Application.DTOs; using CrowdParlay.Social.Application.Exceptions; -using Neo4jClient; +using Mapster; +using Neo4j.Driver; namespace CrowdParlay.Social.Infrastructure.Persistence.Services; public class AuthorRepository : IAuthorRepository { - private readonly IGraphClient _graphClient; + private readonly IDriver _driver; - public AuthorRepository(IGraphClient graphClient) => _graphClient = graphClient; + public AuthorRepository(IDriver driver) => _driver = driver; public async Task GetByIdAsync(Guid id) { - var results = await _graphClient.Cypher - .WithParams(new { id }) - .Match("(a:Author { Id: $id })") - .Return("a") - .ResultsAsync; + await using var session = _driver.AsyncSession(); + return await session.ExecuteReadAsync(async runner => + { + var data = await runner.RunAsync( + """ + MATCH (author:Author { Id: $id }) + RETURN { + Id: author.Id, + Username: author.Username, + DisplayName: author.DisplayName, + AvatarUrl: author.AvatarUrl + } + """, + new { id = id.ToString() }); - return - results.SingleOrDefault() - ?? throw new NotFoundException(); + var record = await data.SingleAsync(); + return record[0].Adapt(); + }); } public async Task CreateAsync(Guid id, string username, string displayName, string? avatarUrl) { - var results = await _graphClient.Cypher - .WithParams(new - { - id, - username, - displayName, - avatarUrl - }) - .Create( + await using var session = _driver.AsyncSession(); + return await session.ExecuteWriteAsync(async runner => + { + var data = await runner.RunAsync( """ - (a:Author { + CREATE (author:Author { Id: $id, Username: $username, DisplayName: $displayName, AvatarUrl: $avatarUrl }) - """) - .Return("a") - .ResultsAsync; + RETURN { + Id: author.Id, + Username: author.Username, + DisplayName: author.DisplayName, + AvatarUrl: author.AvatarUrl + } + """, + new + { + id = id.ToString(), + username, + displayName, + avatarUrl + }); - return results.Single(); + var record = await data.SingleAsync(); + return record[0].Adapt(); + }); } public async Task UpdateAsync(Guid id, string username, string displayName, string? avatarUrl) { - var results = await _graphClient.Cypher - .WithParams(new - { - id, - username, - displayName, - avatarUrl - }) - .Match("(a:Author { Id: $id })") - .Set( + await using var session = _driver.AsyncSession(); + return await session.ExecuteWriteAsync(async runner => + { + var data = await runner.RunAsync( """ - a.Username = $username, - a.DisplayName = $displayName, - a.AvatarUrl = $avatarUrl - """) - .Return("a") - .ResultsAsync; + CREATE (author:Author { + Id: $id, + Username: $username, + DisplayName: $displayName, + AvatarUrl: $avatarUrl + }) + MATCH (author:Author { Id: $id }) + SET author.Username = $username, + author.DisplayName = $displayName, + author.AvatarUrl = $avatarUrl + RETURN { + Id: author.Id, + Username: author.Username, + DisplayName: author.DisplayName, + AvatarUrl: author.AvatarUrl + } + """, + new + { + id = id.ToString(), + username, + displayName, + avatarUrl + }); - return - results.SingleOrDefault() - ?? throw new NotFoundException(); + var record = await data.SingleAsync(); + return record[0].Adapt(); + }); } public async Task DeleteAsync(Guid id) { - var results = await _graphClient.Cypher - .WithParams(new { id }) - .OptionalMatch("(a:Author { Id: $id })") - .Delete("a") - .Return("COUNT(a) = 0") - .ResultsAsync; + await using var session = _driver.AsyncSession(); + var notFount = await session.ExecuteWriteAsync(async runner => + { + var data = await runner.RunAsync( + """ + OPTIONAL MATCH (author:Author { Id: $id }) + DETACH DELETE author + RETURN COUNT(author) = 0 + """, + new { id = id.ToString() }); + + var record = await data.SingleAsync(); + return record[0].As(); + }); - if (results.Single()) + if (notFount) throw new NotFoundException(); } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentRepository.cs index 3ddc3b7..71a915d 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentRepository.cs @@ -1,35 +1,34 @@ using CrowdParlay.Social.Application.Abstractions; using CrowdParlay.Social.Application.DTOs; using CrowdParlay.Social.Application.Exceptions; -using Neo4jClient; +using Mapster; +using Neo4j.Driver; namespace CrowdParlay.Social.Infrastructure.Persistence.Services; public class CommentRepository : ICommentRepository { - private readonly IGraphClient _graphClient; + private readonly IDriver _driver; - public CommentRepository(IGraphClient graphClient) => _graphClient = graphClient; + public CommentRepository(IDriver driver) => _driver = driver; public async Task GetByIdAsync(Guid id) { - var results = await _graphClient.Cypher - .WithParams(new { id }) - .Match("(comment:Comment { Id: $id })-[:AUTHORED_BY]->(author:Author)") - .OptionalMatch("(replyAuthor:Author)<-[:AUTHORED_BY]-(reply:Comment)-[:REPLIES_TO]->(comment)") - .With( + await using var session = _driver.AsyncSession(); + return await session.ExecuteReadAsync(async runner => + { + var data = await runner.RunAsync( """ - comment, author, COUNT(reply) AS replyCount, - CASE WHEN COUNT(reply) > 0 THEN COLLECT(DISTINCT { - Id: replyAuthor.Id, - Username: replyAuthor.Username, - DisplayName: replyAuthor.DisplayName, - AvatarUrl: replyAuthor.AvatarUrl - })[0..3] ELSE [] END AS firstRepliesAuthors - """) - .With( - """ - { + 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 { + Id: replyAuthor.Id, + Username: replyAuthor.Username, + DisplayName: replyAuthor.DisplayName, + AvatarUrl: replyAuthor.AvatarUrl + })[0..3] ELSE [] END AS firstRepliesAuthors + RETURN { Id: comment.Id, Content: comment.Content, Author: { @@ -38,92 +37,94 @@ CASE WHEN COUNT(reply) > 0 THEN COLLECT(DISTINCT { DisplayName: author.DisplayName, AvatarUrl: author.AvatarUrl }, - CreatedAt: datetime(comment.CreatedAt), + CreatedAt: comment.CreatedAt, ReplyCount: replyCount, FirstRepliesAuthors: firstRepliesAuthors } - AS comment - """) - .Return("comment") - .ResultsAsync; - - return - results.SingleOrDefault() - ?? throw new NotFoundException(); + """, + new { id = id.ToString() }); + + var record = await data.SingleAsync(); + return record[0].Adapt(); + }); } - public async Task> SearchAsync(Guid? discussionId, Guid? authorId, int page, int size) + public async Task> SearchAsync(Guid? discussionId, Guid? authorId, int offset, int count) { - var query = _graphClient.Cypher.WithParams(new { discussionId, authorId }); - var matchSelector = authorId is not null - ? "(author:Author { Id: $authorId })<-[:AUTHORED_BY]-(comment:Comment)" - : "(author:Author)<-[:AUTHORED_BY]-(comment:Comment)"; + ? "MATCH (author:Author { Id: $authorId })<-[:AUTHORED_BY]-(comment:Comment)" + : "MATCH (author:Author)<-[:AUTHORED_BY]-(comment:Comment)"; if (discussionId is not null) matchSelector += "-[:REPLIES_TO]->(discussion:Discussion { Id: $discussionId })"; - return await query - .Match(matchSelector) - .With("*") - .OrderBy("comment.CreatedAt") - .Skip(page * size) - .Limit(size) - .OptionalMatch("(replyAuthor:Author)<-[:AUTHORED_BY]-(reply:Comment)-[:REPLIES_TO]->(comment)") - .With( - """ - comment, author, COUNT(reply) AS replyCount, - CASE WHEN COUNT(reply) > 0 THEN COLLECT(DISTINCT { - Id: replyAuthor.Id, - Username: replyAuthor.Username, - DisplayName: replyAuthor.DisplayName, - AvatarUrl: replyAuthor.AvatarUrl - })[0..3] ELSE [] END AS firstRepliesAuthors - """) - .With( + await using var session = _driver.AsyncSession(); + return await session.ExecuteReadAsync(async runner => + { + var data = await runner.RunAsync( + matchSelector + """ - { - Id: comment.Id, - Content: comment.Content, - Author: { - Id: author.Id, - Username: author.Username, - DisplayName: author.DisplayName, - AvatarUrl: author.AvatarUrl - }, - CreatedAt: datetime(comment.CreatedAt), - ReplyCount: replyCount, - FirstRepliesAuthors: firstRepliesAuthors + 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, { + DeepReplyCount: COUNT(deepReply), + FirstDeepRepliesAuthors: CASE WHEN COUNT(deepReply) > 0 THEN COLLECT(DISTINCT { + Id: deepReplyAuthor.Id, + Username: deepReplyAuthor.Username, + DisplayName: deepReplyAuthor.DisplayName, + AvatarUrl: deepReplyAuthor.AvatarUrl + })[0..3] ELSE [] END + } AS deepRepliesData + + RETURN { + TotalCount: COUNT(comment), + Items: COLLECT({ + Id: comment.Id, + Content: comment.Content, + Author: { + Id: author.Id, + Username: author.Username, + DisplayName: author.DisplayName, + AvatarUrl: author.AvatarUrl + }, + CreatedAt: comment.CreatedAt, + ReplyCount: deepRepliesData.DeepReplyCount, + FirstRepliesAuthors: deepRepliesData.FirstDeepRepliesAuthors + })[$offset..$offset + $count] } - AS comment - """) - .Return("comment") - .ResultsAsync; + """, + new + { + discussionId = discussionId.ToString(), + authorId = authorId.ToString(), + offset, + count + }); + + var record = await data.SingleAsync(); + return record[0].Adapt>(); + }); } public async Task CreateAsync(Guid authorId, Guid discussionId, string content) { - var results = await _graphClient.Cypher - .WithParams(new - { - authorId, - content, - discussionId - }) - .Match("(author:Author {Id: $authorId})") - .Match("(discussion:Discussion {Id: $discussionId})") - .Create( + await using var session = _driver.AsyncSession(); + return await session.ExecuteWriteAsync(async runner => + { + var data = await runner.RunAsync( """ - (comment:Comment { + MATCH (author:Author { Id: $authorId }) + MATCH (discussion:Discussion { Id: $discussionId }) + CREATE (comment:Comment { Id: randomUUID(), Content: $content, CreatedAt: datetime() }) - """) - .Create("(discussion)<-[:REPLIES_TO]-(comment)-[:AUTHORED_BY]->(author)") - .With( - """ - { + CREATE (discussion)<-[:REPLIES_TO]-(comment)-[:AUTHORED_BY]->(author) + RETURN { Id: comment.Id, Content: comment.Content, Author: { @@ -132,81 +133,88 @@ public async Task CreateAsync(Guid authorId, Guid discussionId, stri DisplayName: author.DisplayName, AvatarUrl: author.AvatarUrl }, - CreatedAt: datetime(comment.CreatedAt) + CreatedAt: datetime(), + ReplyCount: 0, + FirstRepliesAuthors: [] } - AS comment - """ - ) - .Return("comment") - .ResultsAsync; + """, + new + { + authorId = authorId.ToString(), + discussionId = discussionId.ToString(), + content + }); - return results.Single(); + var record = await data.SingleAsync(); + return record[0].Adapt(); + }); } - public async Task> GetRepliesToCommentAsync(Guid parentCommentId, int page, int size) + public async Task> GetRepliesToCommentAsync(Guid parentCommentId, int offset, int count) { - return await _graphClient.Cypher - .WithParams(new { parentCommentId }) - .Match("(author:Author)<-[:AUTHORED_BY]-(comment:Comment)-[:REPLIES_TO]->(parent:Comment { Id: $parentCommentId })") - .With("*") - .OrderBy("comment.CreatedAt") - .Skip(page * size) - .Limit(size) - .OptionalMatch("(replyAuthor:Author)<-[:AUTHORED_BY]-(reply:Comment)-[:REPLIES_TO]->(comment)") - .With( - """ - comment, author, COUNT(reply) AS replyCount, - CASE WHEN COUNT(reply) > 0 THEN COLLECT(DISTINCT { - Id: replyAuthor.Id, - Username: replyAuthor.Username, - DisplayName: replyAuthor.DisplayName, - AvatarUrl: replyAuthor.AvatarUrl - })[0..3] ELSE [] END AS firstRepliesAuthors - """) - .With( + await using var session = _driver.AsyncSession(); + return await session.ExecuteReadAsync(async runner => + { + var data = await runner.RunAsync( """ - { - Id: comment.Id, - Content: comment.Content, - Author: { - Id: author.Id, - Username: author.Username, - DisplayName: author.DisplayName, - AvatarUrl: author.AvatarUrl - }, - CreatedAt: datetime(comment.CreatedAt), - ReplyCount: replyCount, - FirstRepliesAuthors: firstRepliesAuthors + 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, comment, author, COUNT(reply) AS replyCount, + CASE WHEN COUNT(reply) > 0 THEN COLLECT(DISTINCT { + Id: replyAuthor.Id, + Username: replyAuthor.Username, + DisplayName: replyAuthor.DisplayName, + AvatarUrl: replyAuthor.AvatarUrl + })[0..3] ELSE [] END AS firstRepliesAuthors + RETURN { + TotalCount = totalCount, + Items = { + Id: comment.Id, + Content: comment.Content, + Author: { + Id: author.Id, + Username: author.Username, + DisplayName: author.DisplayName, + AvatarUrl: author.AvatarUrl + }, + CreatedAt: datetime(comment.CreatedAt), + ReplyCount: replyCount, + FirstRepliesAuthors: firstRepliesAuthors + } } - AS comment - """) - .Return("comment") - .ResultsAsync; + """, + new + { + parentCommentId = parentCommentId.ToString(), + offset, + count + }); + + var record = await data.SingleAsync(); + return record[0].Adapt>(); + }); } public async Task ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content) { - var results = await _graphClient.Cypher - .WithParams(new - { - authorId, - content, - parentCommentId - }) - .Match("(replyAuthor:Author {Id: $authorId})") - .Match("(parent:Comment {Id: $parentCommentId})") - .Create( + await using var session = _driver.AsyncSession(); + return await session.ExecuteWriteAsync(async runner => + { + var data = await runner.RunAsync( """ - (reply:Comment { + MATCH (replyAuthor:Author { Id: $authorId }) + MATCH (parent:Comment {Id: $parentCommentId}) + CREATE (reply:Comment { Id: randomUUID(), Content: $content, CreatedAt: datetime() }) - """) - .Create("(parent)<-[:REPLIES_TO]-(reply)-[:AUTHORED_BY]->(replyAuthor)") - .With( - """ - { + CREATE (parent)<-[:REPLIES_TO]-(reply)-[:AUTHORED_BY]->(replyAuthor) + RETURN { Id: reply.Id, Content: reply.Content, Author: { @@ -219,24 +227,37 @@ public async Task ReplyToCommentAsync(Guid authorId, Guid parentComm ReplyCount: 0, FirstRepliesAuthors: [] } - AS reply - """) - .Return("reply") - .ResultsAsync; + """, + new + { + parentCommentId = parentCommentId.ToString(), + authorId = authorId.ToString(), + content + }); - return results.Single(); + var record = await data.SingleAsync(); + return record[0].Adapt(); + }); } public async Task DeleteAsync(Guid id) { - var results = await _graphClient.Cypher - .WithParams(new { id }) - .OptionalMatch("(comment:Comment { Id: $id })") - .DetachDelete("comment") - .Return("COUNT(comment) = 0") - .ResultsAsync; - - if (results.Single()) + 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(); + }); + + if (notFount) throw new NotFoundException(); } -} +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionRepository.cs index 43bb4d2..b6cb4b0 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionRepository.cs @@ -1,123 +1,131 @@ using CrowdParlay.Social.Application.Abstractions; using CrowdParlay.Social.Application.DTOs; -using CrowdParlay.Social.Application.Exceptions; -using Neo4jClient; +using Mapster; +using Neo4j.Driver; namespace CrowdParlay.Social.Infrastructure.Persistence.Services; public class DiscussionRepository : IDiscussionRepository { - private readonly IGraphClient _graphClient; + private readonly IDriver _driver; - public DiscussionRepository(IGraphClient graphClient) => _graphClient = graphClient; + public DiscussionRepository(IDriver driver) => _driver = driver; public async Task GetByIdAsync(Guid id) { - var results = await _graphClient.Cypher - .WithParams(new { id }) - .Match("(d:Discussion { Id: $id })-[:AUTHORED_BY]->(a:Author)") - .With( + await using var session = _driver.AsyncSession(); + return await session.ExecuteReadAsync(async runner => + { + var data = await runner.RunAsync( """ - { - Id: d.Id, - Title: d.Title, - Description: d.Description, + MATCH (discussion:Discussion { Id: $id })-[:AUTHORED_BY]->(author:Author) + RETURN { + Id: discussion.Id, + Title: discussion.Title, + Description: discussion.Description, Author: { - Id: a.Id, - Username: a.Username, - DisplayName: a.DisplayName, - AvatarUrl: a.AvatarUrl + Id: author.Id, + Username: author.Username, + DisplayName: author.DisplayName, + AvatarUrl: author.AvatarUrl } } - AS d - """) - .Return("d") - .ResultsAsync; + """, + new { id = id.ToString() }); - return - results.SingleOrDefault() - ?? throw new NotFoundException(); + var record = await data.SingleAsync(); + return record[0].Adapt(); + }); } - public async Task> GetAllAsync() => await _graphClient.Cypher - .Match("(d:Discussion)-[:AUTHORED_BY]->(a:Author)") - .With( - """ - { - Id: d.Id, - Title: d.Title, - Description: d.Description, - Author: { - Id: a.Id, - Username: a.Username, - DisplayName: a.DisplayName, - AvatarUrl: a.AvatarUrl + public async Task> GetAllAsync() + { + await using var session = _driver.AsyncSession(); + return await session.ExecuteReadAsync(async runner => + { + var data = await runner.RunAsync( + """ + MATCH (discussion:Discussion)-[:AUTHORED_BY]->(author:Author) + RETURN { + Id: discussion.Id, + Title: discussion.Title, + Description: discussion.Description, + Author: { + Id: author.Id, + Username: author.Username, + DisplayName: author.DisplayName, + AvatarUrl: author.AvatarUrl + } } - } - AS d - """) - .Return("d") - .ResultsAsync; + """); + + var record = await data.SingleAsync(); + return record[0].Adapt>(); + }); + } - public async Task> GetByAuthorAsync(Guid authorId) => await _graphClient.Cypher - .WithParams(new { authorId }) - .Match("(d:Discussion)-[:AUTHORED_BY]->(a:Author { Id: $authorId })") - .With( - """ - { - Id: d.Id, - Title: d.Title, - Description: d.Description, - Author: { - Id: a.Id, - Username: a.Username, - DisplayName: a.DisplayName, - AvatarUrl: a.AvatarUrl + public async Task> GetByAuthorAsync(Guid authorId) + { + await using var session = _driver.AsyncSession(); + return await session.ExecuteReadAsync(async runner => + { + var data = await runner.RunAsync( + """ + MATCH (discussion:Discussion)-[:AUTHORED_BY]->(author:Author { Id: $authorId }) + RETURN { + Id: discussion.Id, + Title: discussion.Title, + Description: discussion.Description, + Author: { + Id: author.Id, + Username: author.Username, + DisplayName: author.DisplayName, + AvatarUrl: author.AvatarUrl + } } - } - AS d - """) - .Return("d") - .ResultsAsync; + """, new { authorId = authorId.ToString() }); + + var record = await data.SingleAsync(); + return record[0].Adapt>(); + }); + } public async Task CreateAsync(Guid authorId, string title, string description) { - var results = await _graphClient.Cypher - .WithParams(new - { - authorId, - title, - description - }) - .Match("(a:Author {Id: $authorId})") - .Create( + await using var session = _driver.AsyncSession(); + return await session.ExecuteWriteAsync(async runner => + { + var data = await runner.RunAsync( """ - (d:Discussion { + MATCH (author:Author { Id: $authorId }) + CREATE (discussion:Discussion { Id: randomUUID(), Title: $title, Description: $description, CreatedAt: datetime() }) - """) - .Create("(d)-[:AUTHORED_BY]->(a)") - .With( - """ - { - Id: d.Id, - Title: d.Title, - Description: d.Description, + CREATE (discussion)-[:AUTHORED_BY]->(author) + RETURN { + Id: discussion.Id, + Title: discussion.Title, + Description: discussion.Description, Author: { - Id: a.Id, - Username: a.Username, - DisplayName: a.DisplayName, - AvatarUrl: a.AvatarUrl + Id: author.Id, + Username: author.Username, + DisplayName: author.DisplayName, + AvatarUrl: author.AvatarUrl } } - AS d - """) - .Return("d") - .ResultsAsync; + """, + new + { + authorId = authorId.ToString(), + title, + description + }); - return results.Single(); + var record = await data.SingleAsync(); + return record[0].Adapt(); + }); } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GraphClientInitializer.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GraphClientInitializer.cs index 73f7366..3cd8e66 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GraphClientInitializer.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GraphClientInitializer.cs @@ -1,5 +1,5 @@ using Microsoft.Extensions.Hosting; -using Neo4jClient; +using Neo4j.Driver; namespace CrowdParlay.Social.Infrastructure.Persistence.Services; @@ -8,21 +8,16 @@ namespace CrowdParlay.Social.Infrastructure.Persistence.Services; /// public class GraphClientInitializer : IHostedService { - private readonly IGraphClient _graphClient; + private readonly IDriver _driver; - public GraphClientInitializer(IGraphClient graphClient) => _graphClient = graphClient; + public GraphClientInitializer(IDriver driver) => _driver = driver; public async Task StartAsync(CancellationToken cancellationToken) { - await _graphClient.ConnectAsync(); - await _graphClient.Cypher - .Create("CONSTRAINT unique_author_id IF NOT EXISTS FOR (a:Author) REQUIRE a.Id IS UNIQUE") - .ExecuteWithoutResultsAsync(); + await using var session = _driver.AsyncSession(); + await session.ExecuteWriteAsync(async runner => + await runner.RunAsync("CREATE CONSTRAINT unique_author_id IF NOT EXISTS FOR (a:Author) REQUIRE a.Id IS UNIQUE")); } - public Task StopAsync(CancellationToken cancellationToken) - { - _graphClient.Dispose(); - return Task.CompletedTask; - } + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } \ 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 9878872..751c236 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/CrowdParlay.Social.IntegrationTests.csproj +++ b/tests/CrowdParlay.Social.IntegrationTests/CrowdParlay.Social.IntegrationTests.csproj @@ -17,7 +17,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/CrowdParlay.Social.IntegrationTests/Fixtures/WebApplicationContext.cs b/tests/CrowdParlay.Social.IntegrationTests/Fixtures/WebApplicationContext.cs index b8ff0f8..3bbc97d 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Fixtures/WebApplicationContext.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Fixtures/WebApplicationContext.cs @@ -1,6 +1,5 @@ using CrowdParlay.Social.Api; using CrowdParlay.Social.IntegrationTests.Services; -using Microsoft.Extensions.DependencyInjection; using Nito.AsyncEx; using Testcontainers.Neo4j; diff --git a/tests/CrowdParlay.Social.IntegrationTests/Services/TestWebApplicationFactory.cs b/tests/CrowdParlay.Social.IntegrationTests/Services/TestWebApplicationFactory.cs index 08caaae..e7378aa 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Services/TestWebApplicationFactory.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Services/TestWebApplicationFactory.cs @@ -1,4 +1,4 @@ -using CrowdParlay.Social.Application.Consumers; +using CrowdParlay.Social.Api.Consumers; using MassTransit; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthorsControllerTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthorsRepositoryTests.cs similarity index 51% rename from tests/CrowdParlay.Social.IntegrationTests/Tests/AuthorsControllerTests.cs rename to tests/CrowdParlay.Social.IntegrationTests/Tests/AuthorsRepositoryTests.cs index 7823207..4997678 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthorsControllerTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthorsRepositoryTests.cs @@ -1,22 +1,14 @@ -using System.Net; -using System.Net.Http.Json; using CrowdParlay.Social.Application.Abstractions; -using CrowdParlay.Social.Application.DTOs; using CrowdParlay.Social.IntegrationTests.Fixtures; using Microsoft.Extensions.DependencyInjection; namespace CrowdParlay.Social.IntegrationTests.Tests; -public class AuthorsControllerTests : IClassFixture +public class AuthorsRepositoryTests : IClassFixture { - private readonly HttpClient _client; private readonly IServiceProvider _services; - public AuthorsControllerTests(WebApplicationContext context) - { - _client = context.Client; - _services = context.Services; - } + public AuthorsRepositoryTests(WebApplicationContext context) => _services = context.Services; [Fact(DisplayName = "Get user by ID returns user")] public async Task GetAuthorById_Positive() @@ -24,18 +16,16 @@ public async Task GetAuthorById_Positive() // Arrange await using var scope = _services.CreateAsyncScope(); var authors = scope.ServiceProvider.GetRequiredService(); - var author = await authors.CreateAsync( + var expected = await authors.CreateAsync( id: Guid.NewGuid(), username: "compartmental", displayName: "Степной ишак", avatarUrl: null); // Act - var message = await _client.GetAsync($"/api/v1/authors/{author.Id}"); - message.StatusCode.Should().Be(HttpStatusCode.OK); + var actual = await authors.GetByIdAsync(expected.Id); // Assert - var response = await message.Content.ReadFromJsonAsync(GlobalSerializerOptions.SnakeCase); - response.Should().BeEquivalentTo(author); + actual.Should().BeEquivalentTo(expected); } } \ No newline at end of file diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsControllerTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsControllerTests.cs deleted file mode 100644 index af4142e..0000000 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsControllerTests.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; -using CrowdParlay.Social.Api.v1.DTOs; -using CrowdParlay.Social.Application.Abstractions; -using CrowdParlay.Social.Application.DTOs; -using CrowdParlay.Social.IntegrationTests.Fixtures; -using Microsoft.Extensions.DependencyInjection; - -namespace CrowdParlay.Social.IntegrationTests.Tests; - -public class CommentsControllerTests : IClassFixture -{ - private readonly HttpClient _client; - private readonly IServiceProvider _services; - - public CommentsControllerTests(WebApplicationContext context) - { - _client = context.Client; - _services = context.Services; - } - - [Fact(DisplayName = "Search comments returns comments")] - public async Task SearchComments_Positive() - { - // Arrange - await using var scope = _services.CreateAsyncScope(); - var authors = scope.ServiceProvider.GetRequiredService(); - var author = await authors.CreateAsync( - id: Guid.NewGuid(), - username: "drcrxwn", - displayName: "Степной ишак", - avatarUrl: null); - - // Create discussion - var serializedCreateDiscussionRequest = JsonSerializer.Serialize( - new DiscussionRequest("Test discussion", "Something"), - GlobalSerializerOptions.SnakeCase); - - var accessToken = Authorization.ProduceAccessToken(author.Id); - var createDiscussionResponse = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "/api/v1/discussions") - { - Content = new StringContent(serializedCreateDiscussionRequest, Encoding.UTF8, "application/json"), - Headers = { { "Authorization", $"Bearer {accessToken}" } } - }); - - createDiscussionResponse.Should().HaveStatusCode(HttpStatusCode.Created); - var discussion = await createDiscussionResponse.Content.ReadFromJsonAsync(GlobalSerializerOptions.SnakeCase); - - // Create first comment - var serializedCreateFirstCommentRequest = JsonSerializer.Serialize( - new CommentRequest(discussion!.Id, "This is the first comment."), - GlobalSerializerOptions.SnakeCase); - - var createFirstCommentResponse = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "/api/v1/comments") - { - Content = new StringContent(serializedCreateFirstCommentRequest, Encoding.UTF8, "application/json"), - Headers = { { "Authorization", $"Bearer {accessToken}" } } - }); - - createFirstCommentResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - // Create second comment - var serializedCreateSecondCommentRequest = JsonSerializer.Serialize( - new CommentRequest(discussion.Id, "This is the second comment."), - GlobalSerializerOptions.SnakeCase); - - var createSecondCommentResponse = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "/api/v1/comments") - { - Content = new StringContent(serializedCreateSecondCommentRequest, Encoding.UTF8, "application/json"), - Headers = { { "Authorization", $"Bearer {accessToken}" } } - }); - - createSecondCommentResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - // Act - var getCommentsResponse = await _client.GetAsync($"/api/v1/comments?discussionId={discussion.Id}&page=0&size=3"); - var comments = await getCommentsResponse.Content.ReadFromJsonAsync>(GlobalSerializerOptions.SnakeCase); - - // Assert - var commentList = comments.Should().NotBeNullOrEmpty() - .And.Subject.ToList(); - - commentList.Should().HaveCount(2) - .And.OnlyContain(comment => comment.ReplyCount == 0) - .And.OnlyContain(comment => comment.Author.Id == author.Id) - .And.Contain(comment => comment.Content == "This is the first comment.") - .And.Contain(comment => comment.Content == "This is the second comment."); - } - - [Fact(DisplayName = "Reply to comment creates reply")] - public async Task ReplyToComment_Positive() - { - // Arrange - await using var scope = _services.CreateAsyncScope(); - var authors = scope.ServiceProvider.GetRequiredService(); - var author = await authors.CreateAsync( - id: Guid.NewGuid(), - username: "zendet", - displayName: "Z E N D E T", - avatarUrl: null); - - // Create discussion - var serializedCreateDiscussionRequest = JsonSerializer.Serialize( - new DiscussionRequest("Test discussion", "Something"), - GlobalSerializerOptions.SnakeCase); - - var accessToken = Authorization.ProduceAccessToken(author.Id); - var createDiscussionResponse = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "/api/v1/discussions") - { - Content = new StringContent(serializedCreateDiscussionRequest, Encoding.UTF8, "application/json"), - Headers = { { "Authorization", $"Bearer {accessToken}" } } - }); - - createDiscussionResponse.Should().HaveStatusCode(HttpStatusCode.Created); - var discussion = await createDiscussionResponse.Content.ReadFromJsonAsync(GlobalSerializerOptions.SnakeCase); - - // Create parent comment - var serializedCreateCommentRequest = JsonSerializer.Serialize( - new CommentRequest(discussion!.Id, "Top-level comment!"), - GlobalSerializerOptions.SnakeCase); - - var createCommentResponse = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "/api/v1/comments") - { - Content = new StringContent(serializedCreateCommentRequest, Encoding.UTF8, "application/json"), - Headers = { { "Authorization", $"Bearer {accessToken}" } } - }); - - createCommentResponse.StatusCode.Should().Be(HttpStatusCode.Created); - var comment = await createCommentResponse.Content.ReadFromJsonAsync(GlobalSerializerOptions.SnakeCase); - - // Create reply comment - var serializedCreateReplyRequest = JsonSerializer.Serialize( - new ReplyRequest("Reply comment."), - GlobalSerializerOptions.SnakeCase); - - var createReplyResponse = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Post, $"/api/v1/comments/{comment!.Id}/replies") - { - Content = new StringContent(serializedCreateReplyRequest, Encoding.UTF8, "application/json"), - Headers = { { "Authorization", $"Bearer {accessToken}" } } - }); - - createReplyResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - // Assert - var getCommentResponse = await _client.GetAsync($"/api/v1/comments/{comment.Id}"); - getCommentResponse.StatusCode.Should().Be(HttpStatusCode.OK); - comment = await getCommentResponse.Content.ReadFromJsonAsync(GlobalSerializerOptions.SnakeCase); - - comment!.ReplyCount.Should().Be(1); - comment.FirstRepliesAuthors.Should().ContainSingle(x => x.Id == author.Id); - } -} \ No newline at end of file diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs new file mode 100644 index 0000000..afc47bd --- /dev/null +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs @@ -0,0 +1,115 @@ +using CrowdParlay.Social.Application.Abstractions; +using CrowdParlay.Social.IntegrationTests.Fixtures; +using Microsoft.Extensions.DependencyInjection; + +namespace CrowdParlay.Social.IntegrationTests.Tests; + +public class CommentsRepositoryTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly IServiceProvider _services; + + public CommentsRepositoryTests(WebApplicationContext context) + { + _client = context.Client; + _services = context.Services; + } + + [Fact(DisplayName = "Create comment")] + public async Task CreateComment() + { + // Arrange + await using var scope = _services.CreateAsyncScope(); + + var authors = scope.ServiceProvider.GetRequiredService(); + var discussions = scope.ServiceProvider.GetRequiredService(); + var comments = scope.ServiceProvider.GetRequiredService(); + + var author = await authors.CreateAsync(Guid.NewGuid(), "author12345", "Author 12345", null); + + var discussion = await discussions.CreateAsync( + authorId: author.Id, + title: "Discussion", + description: "Test discussion."); + + // Act + var comment = await comments.CreateAsync(author.Id, discussion.Id, "Comment content"); + + // Assert + comment.Author.Should().BeEquivalentTo(author); + comment.Content.Should().Be("Comment content"); + comment.ReplyCount.Should().Be(0); + comment.FirstRepliesAuthors.Should().BeEmpty(); + comment.CreatedAt.Should().BeCloseTo(DateTime.Now, TimeSpan.FromMinutes(1)); + } + + [Fact(DisplayName = "Search comments")] + public async Task SearchComments() + { + /* + ┌───────────────────────┬────────────────────┐ + │ COMMENT | AUTHOR │ + ├───────────────────────┼────────────────────┤ + │ comment1 │ author1 │ + │ • comment11 │ author1 │ + │ • comment111 │ author1 │ + │ • comment112 │ author2 │ + │ • comment12 │ author1 │ + │ • comment121 │ author4 │ + │ • comment13 │ author3 │ + │ • comment14 │ author4 │ + │ comment2 │ author1 │ + │ • comment21 │ author3 │ + │ comment3 │ author4 │ + └───────────────────────┴────────────────────┘ + */ + + // Arrange + await using var scope = _services.CreateAsyncScope(); + + var authors = scope.ServiceProvider.GetRequiredService(); + var discussions = scope.ServiceProvider.GetRequiredService(); + var comments = scope.ServiceProvider.GetRequiredService(); + + var author1 = await authors.CreateAsync(Guid.NewGuid(), "author_1", "Author 1", null); + var author2 = await authors.CreateAsync(Guid.NewGuid(), "author_2", "Author 2", null); + var author3 = await authors.CreateAsync(Guid.NewGuid(), "author_3", "Author 3", null); + var author4 = await authors.CreateAsync(Guid.NewGuid(), "author_4", "Author 4", null); + + var discussion = await discussions.CreateAsync( + authorId: author1.Id, + title: "Discussion", + description: "Test discussion."); + + var comment1 = await comments.CreateAsync(author1.Id, discussion.Id, "Comment 1"); + var comment2 = await comments.CreateAsync(author1.Id, discussion.Id, "Comment 2"); + var comment3 = await comments.CreateAsync(author4.Id, discussion.Id, "Comment 3"); + + var comment11 = await comments.ReplyToCommentAsync(author1.Id, comment1.Id, "Comment 11"); + var comment12 = await comments.ReplyToCommentAsync(author1.Id, comment1.Id, "Comment 12"); + var comment13 = await comments.ReplyToCommentAsync(author3.Id, comment1.Id, "Comment 13"); + var comment14 = await comments.ReplyToCommentAsync(author4.Id, comment1.Id, "Comment 14"); + var comment21 = await comments.ReplyToCommentAsync(author3.Id, comment2.Id, "Comment 21"); + + var comment111 = await comments.ReplyToCommentAsync(author1.Id, comment1.Id, "Comment 111"); + var comment112 = await comments.ReplyToCommentAsync(author2.Id, comment1.Id, "Comment 112"); + var comment121 = await comments.ReplyToCommentAsync(author4.Id, comment1.Id, "Comment 121"); + + comment1 = await comments.GetByIdAsync(comment1.Id); + comment2 = await comments.GetByIdAsync(comment2.Id); + comment3 = await comments.GetByIdAsync(comment3.Id); + + // Act + var page = await comments.SearchAsync( + discussionId: discussion.Id, + authorId: null, + offset: 0, + count: 2); + + // Assert + page.TotalCount.Should().Be(3); + page.Items.Should().HaveCount(2); + page.Items.Should().BeEquivalentTo(new[] { comment1, comment2 }); + page.Items.First().FirstRepliesAuthors.Should().BeEquivalentTo(new[] { author4, author2, author1 }); + } +} \ No newline at end of file