From 8d2bccc5de7164ae743d3234f32f82e9b31f236f Mon Sep 17 00:00:00 2001 From: undrcrxwn <69521267+undrcrxwn@users.noreply.github.com> Date: Sun, 1 Jun 2025 17:11:47 +0300 Subject: [PATCH 01/12] chore: migrate to .NET 9 --- .../CrowdParlay.Social.Api.csproj | 22 +++++++++---------- .../CrowdParlay.Social.Application.csproj | 20 ++++++++--------- .../CrowdParlay.Social.Domain.csproj | 2 +- ...Social.Infrastructure.Communication.csproj | 12 +++++----- ...y.Social.Infrastructure.Persistence.csproj | 10 ++++----- ...CrowdParlay.Social.IntegrationTests.csproj | 20 ++++++++--------- .../Tests/AuthenticationTests.cs | 4 ++-- .../CrowdParlay.Social.UnitTests.csproj | 20 +++++++++++------ 8 files changed, 58 insertions(+), 52 deletions(-) diff --git a/src/CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj b/src/CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj index ea980ce..5d39314 100644 --- a/src/CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj +++ b/src/CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 enable enable Linux @@ -10,19 +10,19 @@ - - - + + + - - - - - + + + + + - - + + diff --git a/src/CrowdParlay.Social.Application/CrowdParlay.Social.Application.csproj b/src/CrowdParlay.Social.Application/CrowdParlay.Social.Application.csproj index b7c722f..b08ee04 100644 --- a/src/CrowdParlay.Social.Application/CrowdParlay.Social.Application.csproj +++ b/src/CrowdParlay.Social.Application/CrowdParlay.Social.Application.csproj @@ -1,23 +1,23 @@ - net8.0 + net9.0 enable enable - - + + - - - - - - - + + + + + + + diff --git a/src/CrowdParlay.Social.Domain/CrowdParlay.Social.Domain.csproj b/src/CrowdParlay.Social.Domain/CrowdParlay.Social.Domain.csproj index b580e96..961de39 100644 --- a/src/CrowdParlay.Social.Domain/CrowdParlay.Social.Domain.csproj +++ b/src/CrowdParlay.Social.Domain/CrowdParlay.Social.Domain.csproj @@ -1,7 +1,7 @@ ο»Ώ - net8.0 + net9.0 enable enable diff --git a/src/CrowdParlay.Social.Infrastructure.Communication/CrowdParlay.Social.Infrastructure.Communication.csproj b/src/CrowdParlay.Social.Infrastructure.Communication/CrowdParlay.Social.Infrastructure.Communication.csproj index 78cdaa0..8c5a6f5 100644 --- a/src/CrowdParlay.Social.Infrastructure.Communication/CrowdParlay.Social.Infrastructure.Communication.csproj +++ b/src/CrowdParlay.Social.Infrastructure.Communication/CrowdParlay.Social.Infrastructure.Communication.csproj @@ -1,20 +1,20 @@ ο»Ώ - net8.0 + net9.0 enable enable - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + 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 936fb4f..3dffec2 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/CrowdParlay.Social.Infrastructure.Persistence.csproj +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/CrowdParlay.Social.Infrastructure.Persistence.csproj @@ -1,16 +1,16 @@ - net8.0 + net9.0 enable enable - - - - + + + + diff --git a/tests/CrowdParlay.Social.IntegrationTests/CrowdParlay.Social.IntegrationTests.csproj b/tests/CrowdParlay.Social.IntegrationTests/CrowdParlay.Social.IntegrationTests.csproj index 3b7eb9e..b3dd490 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/CrowdParlay.Social.IntegrationTests.csproj +++ b/tests/CrowdParlay.Social.IntegrationTests/CrowdParlay.Social.IntegrationTests.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 enable enable @@ -10,20 +10,20 @@ - - - - + + + + - - - + + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthenticationTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthenticationTests.cs index ff24a6b..477d988 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthenticationTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthenticationTests.cs @@ -23,7 +23,7 @@ public async Task CreateDiscussionWithAccessToken() var response = await _client.SendAsync(request); // Assert - response.Should().HaveStatusCode(HttpStatusCode.Created); + response.StatusCode.Should().Be(HttpStatusCode.Created); } [Fact(DisplayName = "Search discussions returns discussions with viewer reactions")] @@ -50,7 +50,7 @@ public async Task SearchReactionsWithAccessToken() var response = await _client.SendAsync(request); // Assert - response.Should().HaveStatusCode(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.OK); var page = await response.Content.ReadFromJsonAsync>(GlobalSerializerOptions.SnakeCase); page?.Items.Should().ContainSingle().Which.ViewerReactions.Should().BeEquivalentTo(reactions); } diff --git a/tests/CrowdParlay.Social.UnitTests/CrowdParlay.Social.UnitTests.csproj b/tests/CrowdParlay.Social.UnitTests/CrowdParlay.Social.UnitTests.csproj index 215e87d..ddb31e5 100644 --- a/tests/CrowdParlay.Social.UnitTests/CrowdParlay.Social.UnitTests.csproj +++ b/tests/CrowdParlay.Social.UnitTests/CrowdParlay.Social.UnitTests.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 enable enable @@ -10,12 +10,18 @@ - - - - - - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + From 06a7caadbcc3ae2efc4e89a8ba1c5df288119e3f 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: Sun, 1 Jun 2025 17:18:22 +0300 Subject: [PATCH 02/12] chore(ci): use .NET 9 SDK in build & test workflow --- .github/workflows/test.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fc9ffbb..8601d4e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,9 +20,7 @@ jobs: - name: βš™οΈ Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: | - 7.0.x - 8.0.x + dotnet-version: 9.0.x - name: πŸ“₯ Restore dependencies run: dotnet restore - name: βš’ Build solution @@ -35,4 +33,4 @@ jobs: with: name: .NET test results path: "**/TestResults/*.trx" - reporter: dotnet-trx \ No newline at end of file + reporter: dotnet-trx From 8fc8441e036854114ced847a674a530414c84737 Mon Sep 17 00:00:00 2001 From: undrcrxwn <69521267+undrcrxwn@users.noreply.github.com> Date: Sun, 1 Jun 2025 17:21:14 +0300 Subject: [PATCH 03/12] chore: make Dockerfile use .NET 9 SDK --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 809b6f4..ce61f47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -ο»ΏFROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ο»ΏFROM mcr.microsoft.com/dotnet/sdk:9.0 AS build WORKDIR /app COPY /src . RUN dotnet restore "CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj" RUN dotnet publish "CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj" -c Release -o out -FROM mcr.microsoft.com/dotnet/aspnet:8.0 +FROM mcr.microsoft.com/dotnet/aspnet:9.0 WORKDIR /app COPY --from=build /app/out . COPY /neo4j/migrations ./neo4j/migrations From 463caed68d42eb51730c98ecbca44b9b8fc2f3cc Mon Sep 17 00:00:00 2001 From: undrcrxwn <69521267+undrcrxwn@users.noreply.github.com> Date: Sun, 8 Jun 2025 11:04:42 +0300 Subject: [PATCH 04/12] feat: migrate from Neo4j to MongoDB --- Dockerfile | 8 +- neo4j/migrations/V010__Add_indexes.cypher | 3 - ..._Remove_excessive_author_properties.cypher | 2 - .../Consumers/UserEventConsumer.cs | 12 - .../Extensions/ConfigureServices.cs | 2 - .../Hubs/CommentsHub.cs | 20 +- .../v1/Controllers/CommentsController.cs | 69 +--- .../v1/Controllers/DiscussionsController.cs | 57 ++- .../Abstractions/ICommentsService.cs | 13 +- .../Abstractions/IDiscussionsService.cs | 8 +- .../Abstractions/IReactionsService.cs | 7 - .../Abstractions/ISubjectsService.cs | 7 + .../ConfigureServices.cs | 1 - .../DTOs/CommentRequest.cs | 2 +- .../DTOs/CommentResponse.cs | 6 +- .../DTOs/DiscussionRequest.cs | 2 +- .../DTOs/DiscussionResponse.cs | 4 +- .../Services/CommentsService.cs | 85 +++-- .../Services/DiscussionsService.cs | 47 +-- .../Services/ReactionsService.cs | 26 -- .../Services/SubjectsService.cs | 27 ++ .../Abstractions/IAuthorsRepository.cs | 6 - .../Abstractions/ICommentsRepository.cs | 14 +- .../Abstractions/IDiscussionsRepository.cs | 8 +- .../Abstractions/IReactionsRepository.cs | 8 - .../Abstractions/ISubjectsRepository.cs | 11 + .../Abstractions/IUnitOfWork.cs | 3 +- .../Entities/Comment.cs | 9 +- .../Entities/Discussion.cs | 8 +- .../ReactionMapsterAdapterConfigurator.cs | 16 - .../ConfigureServices.cs | 50 +-- ...y.Social.Infrastructure.Persistence.csproj | 2 +- .../Models/CommentDocument.cs | 19 + .../Models/DiscussionDocument.cs | 17 + .../Models/ISubjectDocument.cs | 12 + .../MongoDbSettings.cs | 9 + .../Services/AuthorsRepository.cs | 10 - .../Services/CommentsRepository.cs | 324 ++++++++---------- .../Services/DiscussionsRepository.cs | 258 ++++++-------- .../Services/GenericSubjectsRepository.cs | 86 +++++ .../Services/ReactionsRepository.cs | 73 ---- .../Services/StartupConfigurator.cs | 31 ++ .../Services/UnitOfWork.cs | 20 +- .../Services/UnitOfWorkFactory.cs | 10 +- .../Configurations.cs | 3 +- ...CrowdParlay.Social.IntegrationTests.csproj | 2 +- .../Fixtures/WebApplicationContext.cs | 44 +-- .../Services/TestWebApplicationFactory.cs | 36 +- .../Tests/AuthenticationTests.cs | 9 +- .../Tests/CommentsRepositoryTests.cs | 71 ++-- .../Tests/DiscussionsRepositoryTests.cs | 45 ++- .../Tests/HttpContractsTests.cs | 12 +- .../Tests/ReactionsRepositoryTests.cs | 49 ++- .../Tests/SignalRTests.cs | 9 +- 54 files changed, 844 insertions(+), 848 deletions(-) delete mode 100644 neo4j/migrations/V010__Add_indexes.cypher delete mode 100644 neo4j/migrations/V020__Remove_excessive_author_properties.cypher delete mode 100644 src/CrowdParlay.Social.Api/Consumers/UserEventConsumer.cs delete mode 100644 src/CrowdParlay.Social.Application/Abstractions/IReactionsService.cs create mode 100644 src/CrowdParlay.Social.Application/Abstractions/ISubjectsService.cs delete mode 100644 src/CrowdParlay.Social.Application/Services/ReactionsService.cs create mode 100644 src/CrowdParlay.Social.Application/Services/SubjectsService.cs delete mode 100644 src/CrowdParlay.Social.Domain/Abstractions/IAuthorsRepository.cs delete mode 100644 src/CrowdParlay.Social.Domain/Abstractions/IReactionsRepository.cs create mode 100644 src/CrowdParlay.Social.Domain/Abstractions/ISubjectsRepository.cs delete mode 100644 src/CrowdParlay.Social.Domain/ReactionMapsterAdapterConfigurator.cs create mode 100644 src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs create mode 100644 src/CrowdParlay.Social.Infrastructure.Persistence/Models/DiscussionDocument.cs create mode 100644 src/CrowdParlay.Social.Infrastructure.Persistence/Models/ISubjectDocument.cs create mode 100644 src/CrowdParlay.Social.Infrastructure.Persistence/MongoDbSettings.cs delete mode 100644 src/CrowdParlay.Social.Infrastructure.Persistence/Services/AuthorsRepository.cs create mode 100644 src/CrowdParlay.Social.Infrastructure.Persistence/Services/GenericSubjectsRepository.cs delete mode 100644 src/CrowdParlay.Social.Infrastructure.Persistence/Services/ReactionsRepository.cs create mode 100644 src/CrowdParlay.Social.Infrastructure.Persistence/Services/StartupConfigurator.cs diff --git a/Dockerfile b/Dockerfile index ce61f47..7439138 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,11 +8,5 @@ RUN dotnet publish "CrowdParlay.Social.Api/CrowdParlay.Social.Api.csproj" -c Rel FROM mcr.microsoft.com/dotnet/aspnet:9.0 WORKDIR /app COPY --from=build /app/out . -COPY /neo4j/migrations ./neo4j/migrations -RUN apt-get update && apt-get install -y curl unzip -RUN curl -LO https://github.com/michael-simons/neo4j-migrations/releases/download/2.9.3/neo4j-migrations-2.9.3-linux-x86_64.zip -RUN unzip -j neo4j-migrations-2.9.3-linux-x86_64.zip neo4j-migrations-2.9.3-linux-x86_64/bin/neo4j-migrations -RUN rm neo4j-migrations-2.9.3-linux-x86_64.zip - -ENTRYPOINT ["sh", "-c", "./neo4j-migrations -a $NEO4J_URI -u $NEO4J_USERNAME -p $NEO4J_PASSWORD migrate && dotnet CrowdParlay.Social.Api.dll"] +ENTRYPOINT ["sh", "-c", "dotnet CrowdParlay.Social.Api.dll"] diff --git a/neo4j/migrations/V010__Add_indexes.cypher b/neo4j/migrations/V010__Add_indexes.cypher deleted file mode 100644 index e7d19b4..0000000 --- a/neo4j/migrations/V010__Add_indexes.cypher +++ /dev/null @@ -1,3 +0,0 @@ -CREATE INDEX author_id_idx FOR (author:Author) ON (author.Id); -CREATE INDEX discussion_id_idx FOR (discussion:Discussion) ON (discussion.Id); -CREATE INDEX comment_id_idx FOR (comment:Comment) ON (comment.Id); diff --git a/neo4j/migrations/V020__Remove_excessive_author_properties.cypher b/neo4j/migrations/V020__Remove_excessive_author_properties.cypher deleted file mode 100644 index 9dfbe5f..0000000 --- a/neo4j/migrations/V020__Remove_excessive_author_properties.cypher +++ /dev/null @@ -1,2 +0,0 @@ -MATCH (author:Author) -REMOVE author.Username, author.DisplayName, author.AvatarUrl; diff --git a/src/CrowdParlay.Social.Api/Consumers/UserEventConsumer.cs b/src/CrowdParlay.Social.Api/Consumers/UserEventConsumer.cs deleted file mode 100644 index a22f1dc..0000000 --- a/src/CrowdParlay.Social.Api/Consumers/UserEventConsumer.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CrowdParlay.Communication; -using CrowdParlay.Social.Domain.Abstractions; -using MassTransit; - -namespace CrowdParlay.Social.Api.Consumers; - -// ReSharper disable once ClassNeverInstantiated.Global -public class UserEventConsumer(IAuthorsRepository authors) : IConsumer -{ - public async Task Consume(ConsumeContext context) => - await authors.EnsureCreatedAsync(Guid.Parse(context.Message.UserId)); -} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Api/Extensions/ConfigureServices.cs b/src/CrowdParlay.Social.Api/Extensions/ConfigureServices.cs index dd7545b..b7898fc 100644 --- a/src/CrowdParlay.Social.Api/Extensions/ConfigureServices.cs +++ b/src/CrowdParlay.Social.Api/Extensions/ConfigureServices.cs @@ -1,5 +1,4 @@ using CrowdParlay.Communication; -using CrowdParlay.Social.Api.Consumers; using CrowdParlay.Social.Api.Services; using MassTransit; @@ -21,7 +20,6 @@ public static IServiceCollection AddApi(this IServiceCollection services, IConfi return services.AddMassTransit(bus => { - bus.AddConsumersFromNamespaceContaining(); bus.UsingRabbitMq((context, configurator) => { var amqpServerUrl = diff --git a/src/CrowdParlay.Social.Api/Hubs/CommentsHub.cs b/src/CrowdParlay.Social.Api/Hubs/CommentsHub.cs index 0a6f167..4f75391 100644 --- a/src/CrowdParlay.Social.Api/Hubs/CommentsHub.cs +++ b/src/CrowdParlay.Social.Api/Hubs/CommentsHub.cs @@ -19,25 +19,23 @@ public override async Task OnDisconnectedAsync(Exception? exception) await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupNames.NewCommentInDiscussion(discussionId)); } - private Guid GetDiscussionIdFromQuery() => - Guid.TryParse(GetSingleQueryParameterValueFromQuery(DiscussionIdQueryParameterName), out var discussionId) - ? discussionId - : throw new ValidationException(DiscussionIdQueryParameterName, ["Must be a valid UUID."]); - - private string GetSingleQueryParameterValueFromQuery(string key) + private string GetDiscussionIdFromQuery() { var query = Context.GetHttpContext()?.Request.Query - ?? throw new InvalidOperationException("Cannot access request's query parameters, since HttpContext is null."); + ?? throw new InvalidOperationException( + "Cannot access request's query parameters, since HttpContext is null."); - return query.TryGetValue(key, out var values) - ? values.SingleOrDefault() ?? throw new ValidationException(key, ["Must have single value."]) - : throw new ValidationException(key, ["Query parameter is required."]); + return query.TryGetValue(DiscussionIdQueryParameterName, out var values) + ? values.SingleOrDefault() ?? + throw new ValidationException(DiscussionIdQueryParameterName, ["Must have single value."]) + : throw new ValidationException(DiscussionIdQueryParameterName, ["Query parameter is required."]); } public static class GroupNames { - public static string NewCommentInDiscussion(Guid discussionId) => $"{Events.NewComment.ToString()}/{discussionId}"; + public static string NewCommentInDiscussion(string discussionId) => + $"{Events.NewComment.ToString()}/{discussionId}"; } public enum Events diff --git a/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs b/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs index 51507e1..df4d0ab 100644 --- a/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs +++ b/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs @@ -1,113 +1,74 @@ using System.Net.Mime; using CrowdParlay.Social.Api.Extensions; -using CrowdParlay.Social.Api.Hubs; using CrowdParlay.Social.Application.Abstractions; using CrowdParlay.Social.Application.DTOs; using CrowdParlay.Social.Domain.DTOs; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.SignalR; using static Microsoft.AspNetCore.Http.StatusCodes; namespace CrowdParlay.Social.Api.v1.Controllers; [ApiController, ApiRoute("[controller]")] -public class CommentsController( - ICommentsService commentsService, - IReactionsService reactionsService, - IHubContext commentHub) : ControllerBase +public class CommentsController(ICommentsService commentsService) : ControllerBase { /// /// Returns comment with the specified ID. /// - [HttpGet("{commentId:guid}")] + [HttpGet("{commentId}")] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(Status200OK)] [ProducesResponseType(Status404NotFound)] [ProducesResponseType(Status500InternalServerError)] - public async Task GetById([FromRoute] Guid commentId) => + public async Task GetById([FromRoute] string commentId) => await commentsService.GetByIdAsync(commentId, User.GetUserId()); - - /// - /// Get comments by filters. - /// - [HttpGet] - [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] - [ProducesResponseType>(Status200OK)] - [ProducesResponseType(Status400BadRequest)] - [ProducesResponseType(Status500InternalServerError)] - public async Task> Search( - [FromQuery] Guid? discussionId, - [FromQuery] Guid? authorId, - [FromQuery, BindRequired] int offset, - [FromQuery, BindRequired] int count) => - await commentsService.SearchAsync(discussionId, authorId, User.GetUserId(), offset, count); - - /// - /// Creates a top-level comment in discussion. - /// - [HttpPost, Authorize] - [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] - [ProducesResponseType(Status201Created)] - [ProducesResponseType(Status400BadRequest)] - [ProducesResponseType(Status403Forbidden)] - [ProducesResponseType(Status500InternalServerError)] - public async Task> Create([FromBody] CommentRequest request) - { - var response = await commentsService.CreateAsync(User.GetRequiredUserId(), request.DiscussionId, request.Content); - - _ = commentHub.Clients - .Group(CommentsHub.GroupNames.NewCommentInDiscussion(request.DiscussionId)) - .SendCoreAsync(CommentsHub.Events.NewComment.ToString(), [response]); - - return CreatedAtAction(nameof(GetById), new { commentId = response.Id }, response); - } - + /// /// Get replies to the comment with the specified ID. /// - [HttpGet("{parentCommentId:guid}/replies")] + [HttpGet("{commentId}/replies")] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType>(Status200OK)] [ProducesResponseType(Status400BadRequest)] [ProducesResponseType(Status404NotFound)] [ProducesResponseType(Status500InternalServerError)] public async Task> GetReplies( - [FromRoute] Guid parentCommentId, + [FromRoute] string commentId, + [FromQuery, BindRequired] bool flatten, [FromQuery, BindRequired] int offset, [FromQuery, BindRequired] int count) => - await commentsService.GetRepliesToCommentAsync(parentCommentId, User.GetUserId(), offset, count); - + await commentsService.GetRepliesAsync(commentId, flatten, User.GetUserId(), offset, count); + /// /// Creates a reply to the comment with the specified ID. /// - [HttpPost("{parentCommentId:guid}/replies"), Authorize] + [HttpPost("{commentId}/replies"), Authorize] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(Status201Created)] [ProducesResponseType(Status400BadRequest)] [ProducesResponseType(Status403Forbidden)] [ProducesResponseType(Status404NotFound)] [ProducesResponseType(Status500InternalServerError)] - public async Task> Reply([FromRoute] Guid parentCommentId, [FromBody] ReplyRequest request) + public async Task> Reply([FromRoute] string commentId, [FromBody] ReplyRequest request) { - var response = await commentsService.ReplyToCommentAsync(User.GetRequiredUserId(), parentCommentId, request.Content); + var response = await commentsService.ReplyToCommentAsync(commentId, User.GetRequiredUserId(), request.Content); return CreatedAtAction(nameof(GetById), new { commentId = response.Id }, response); } /// /// Sets reactions to a comment. /// - [HttpPost("{commentId:guid}/reactions"), Authorize] + [HttpPost("{commentId}/reactions"), Authorize] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(Status204NoContent)] [ProducesResponseType(Status400BadRequest)] [ProducesResponseType(Status403Forbidden)] [ProducesResponseType(Status404NotFound)] [ProducesResponseType(Status500InternalServerError)] - public async Task React([FromRoute] Guid commentId, [FromBody] ISet reactions) + public async Task React([FromRoute] string commentId, [FromBody] ISet reactions) { - await reactionsService.SetAsync(commentId, User.GetRequiredUserId(), reactions); + await commentsService.SetReactionsAsync(commentId, User.GetRequiredUserId(), reactions); return NoContent(); } } \ 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 d20e421..8868ce5 100644 --- a/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs +++ b/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs @@ -1,27 +1,32 @@ using System.Net.Mime; using CrowdParlay.Social.Api.Extensions; +using CrowdParlay.Social.Api.Hubs; using CrowdParlay.Social.Application.Abstractions; using CrowdParlay.Social.Application.DTOs; using CrowdParlay.Social.Domain.DTOs; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.SignalR; using static Microsoft.AspNetCore.Http.StatusCodes; namespace CrowdParlay.Social.Api.v1.Controllers; [ApiController, ApiRoute("[controller]")] -public class DiscussionsController(IDiscussionsService discussionsService, IReactionsService reactionsService) : ControllerBase +public class DiscussionsController( + IDiscussionsService discussionsService, + ICommentsService commentsService, + IHubContext commentHub) : ControllerBase { /// /// Returns discussion with the specified ID. /// - [HttpGet("{discussionId:guid}")] + [HttpGet("{discussionId}")] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(Status200OK)] [ProducesResponseType(Status404NotFound)] [ProducesResponseType(Status500InternalServerError)] - public async Task GetById([FromRoute] Guid discussionId) => + public async Task GetById([FromRoute] string discussionId) => await discussionsService.GetByIdAsync(discussionId, User.GetUserId()); /// @@ -37,6 +42,39 @@ public async Task> Search( [FromQuery, BindRequired] int count) => await discussionsService.SearchAsync(authorId, User.GetUserId(), offset, count); + /// + /// Returns comments to the specified discussion. + /// + [HttpGet("{discussionId}/comments")] + [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] + [ProducesResponseType>(Status200OK)] + [ProducesResponseType(Status500InternalServerError)] + public async Task> GetComments( + [FromRoute] string discussionId, + [FromQuery, BindRequired] int offset, + [FromQuery, BindRequired] int count) => + await commentsService.GetRepliesAsync(discussionId, false, User.GetUserId(), offset, count); + + /// + /// Creates a top-level comment in discussion. + /// + [HttpPost("{discussionId}/comments"), Authorize] + [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] + [ProducesResponseType(Status201Created)] + [ProducesResponseType(Status400BadRequest)] + [ProducesResponseType(Status403Forbidden)] + [ProducesResponseType(Status500InternalServerError)] + public async Task> Reply([FromBody] CommentRequest request) + { + var response = await commentsService.ReplyToDiscussionAsync(request.DiscussionId, User.GetRequiredUserId(), request.Content); + + _ = commentHub.Clients + .Group(CommentsHub.GroupNames.NewCommentInDiscussion(request.DiscussionId)) + .SendCoreAsync(CommentsHub.Events.NewComment.ToString(), [response]); + + return CreatedAtAction(nameof(CommentsController.GetById), "Comments", new { commentId = response.Id }, response); + } + /// /// Creates a discussion. /// @@ -47,35 +85,36 @@ public async Task> Search( [ProducesResponseType(Status500InternalServerError)] public async Task> Create([FromBody] DiscussionRequest request) { - var response = await discussionsService.CreateAsync(User.GetRequiredUserId(), request.Title, request.Description); + var response = await discussionsService.CreateAsync(User.GetRequiredUserId(), request.Title, request.Content); return CreatedAtAction(nameof(GetById), new { DiscussionId = response.Id }, response); } /// /// Modifies a discussion. /// - [HttpPatch("{discussionId:guid}"), Authorize] + [HttpPatch("{discussionId}"), Authorize] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(Status200OK)] [ProducesResponseType(Status403Forbidden)] [ProducesResponseType(Status404NotFound)] [ProducesResponseType(Status500InternalServerError)] - public async Task Update([FromRoute] Guid discussionId, [FromBody] UpdateDiscussionRequest request) => + public async Task Update([FromRoute] string discussionId, + [FromBody] UpdateDiscussionRequest request) => await discussionsService.UpdateAsync(discussionId, User.GetRequiredUserId(), request); /// /// Sets reactions to a discussion. /// - [HttpPost("{discussionId:guid}/reactions"), Authorize] + [HttpPost("{discussionId}/reactions"), Authorize] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(Status204NoContent)] [ProducesResponseType(Status400BadRequest)] [ProducesResponseType(Status403Forbidden)] [ProducesResponseType(Status404NotFound)] [ProducesResponseType(Status500InternalServerError)] - public async Task React([FromRoute] Guid discussionId, [FromBody] ISet reactions) + public async Task React([FromRoute] string discussionId, [FromBody] ISet reactions) { - await reactionsService.SetAsync(discussionId, User.GetRequiredUserId(), reactions); + await discussionsService.SetReactionsAsync(discussionId, User.GetRequiredUserId(), reactions); return NoContent(); } } \ 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 0cc497a..d607c6e 100644 --- a/src/CrowdParlay.Social.Application/Abstractions/ICommentsService.cs +++ b/src/CrowdParlay.Social.Application/Abstractions/ICommentsService.cs @@ -3,12 +3,11 @@ namespace CrowdParlay.Social.Application.Abstractions; -public interface ICommentsService +public interface ICommentsService : ISubjectsService { - 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, Guid? viewerId, int offset, int count); - public Task ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content); - public Task DeleteAsync(Guid id); + public Task GetByIdAsync(string commentId, Guid? viewerId); + public Task> GetRepliesAsync(string subjectId, bool flatten, Guid? viewerId, int offset, int count); + public Task ReplyToDiscussionAsync(string discussionId, Guid authorId, string content); + public Task ReplyToCommentAsync(string commentId, Guid authorId, string content); + public Task DeleteAsync(string commentId); } \ 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 15e9199..ce0b662 100644 --- a/src/CrowdParlay.Social.Application/Abstractions/IDiscussionsService.cs +++ b/src/CrowdParlay.Social.Application/Abstractions/IDiscussionsService.cs @@ -3,10 +3,10 @@ namespace CrowdParlay.Social.Application.Abstractions; -public interface IDiscussionsService +public interface IDiscussionsService : ISubjectsService { - public Task GetByIdAsync(Guid discussionId, Guid? viewerId); + public Task GetByIdAsync(string 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 UpdateAsync(Guid discussionId, Guid viewerId, UpdateDiscussionRequest request); + public Task CreateAsync(Guid authorId, string title, string content); + public Task UpdateAsync(string discussionId, Guid viewerId, UpdateDiscussionRequest request); } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/Abstractions/IReactionsService.cs b/src/CrowdParlay.Social.Application/Abstractions/IReactionsService.cs deleted file mode 100644 index d9bcc40..0000000 --- a/src/CrowdParlay.Social.Application/Abstractions/IReactionsService.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CrowdParlay.Social.Application.Abstractions; - -public interface IReactionsService -{ - public Task> GetAsync(Guid subjectId, Guid viewerId); - public Task SetAsync(Guid subjectId, Guid viewerId, ISet reactions); -} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/Abstractions/ISubjectsService.cs b/src/CrowdParlay.Social.Application/Abstractions/ISubjectsService.cs new file mode 100644 index 0000000..426fd70 --- /dev/null +++ b/src/CrowdParlay.Social.Application/Abstractions/ISubjectsService.cs @@ -0,0 +1,7 @@ +namespace CrowdParlay.Social.Application.Abstractions; + +public interface ISubjectsService +{ + public Task> GetReactionsAsync(string subjectId, Guid authorId); + public Task SetReactionsAsync(string subjectId, Guid authorId, ISet reactions); +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/ConfigureServices.cs b/src/CrowdParlay.Social.Application/ConfigureServices.cs index cc82b0f..f7497e2 100644 --- a/src/CrowdParlay.Social.Application/ConfigureServices.cs +++ b/src/CrowdParlay.Social.Application/ConfigureServices.cs @@ -11,6 +11,5 @@ public static class ConfigureServicesExtensions public static IServiceCollection AddApplication(this IServiceCollection services) => services .AddScoped() .AddScoped() - .AddScoped() .AddValidatorsFromAssembly(Assembly.GetExecutingAssembly(), includeInternalTypes: true); } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/DTOs/CommentRequest.cs b/src/CrowdParlay.Social.Application/DTOs/CommentRequest.cs index c8526fa..141df88 100644 --- a/src/CrowdParlay.Social.Application/DTOs/CommentRequest.cs +++ b/src/CrowdParlay.Social.Application/DTOs/CommentRequest.cs @@ -1,3 +1,3 @@ namespace CrowdParlay.Social.Application.DTOs; -public record CommentRequest(Guid DiscussionId, string Content); \ No newline at end of file +public record CommentRequest(string DiscussionId, string Content); \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/DTOs/CommentResponse.cs b/src/CrowdParlay.Social.Application/DTOs/CommentResponse.cs index 7824000..2eaa608 100644 --- a/src/CrowdParlay.Social.Application/DTOs/CommentResponse.cs +++ b/src/CrowdParlay.Social.Application/DTOs/CommentResponse.cs @@ -2,12 +2,12 @@ namespace CrowdParlay.Social.Application.DTOs; public class CommentResponse { - public required Guid Id { get; set; } + public required string Id { get; set; } public required string Content { get; set; } public required AuthorResponse? Author { get; set; } public required DateTimeOffset CreatedAt { get; set; } - public required int ReplyCount { get; set; } - public required IEnumerable LastRepliesAuthors { get; set; } + public required int CommentCount { get; set; } + public required IEnumerable LastCommentsAuthors { 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/DiscussionRequest.cs b/src/CrowdParlay.Social.Application/DTOs/DiscussionRequest.cs index c1d2e7c..bab7f0f 100644 --- a/src/CrowdParlay.Social.Application/DTOs/DiscussionRequest.cs +++ b/src/CrowdParlay.Social.Application/DTOs/DiscussionRequest.cs @@ -1,3 +1,3 @@ namespace CrowdParlay.Social.Application.DTOs; -public record DiscussionRequest(string Title, string Description); \ No newline at end of file +public record DiscussionRequest(string Title, string Content); \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/DTOs/DiscussionResponse.cs b/src/CrowdParlay.Social.Application/DTOs/DiscussionResponse.cs index 2729b6c..087f3fa 100644 --- a/src/CrowdParlay.Social.Application/DTOs/DiscussionResponse.cs +++ b/src/CrowdParlay.Social.Application/DTOs/DiscussionResponse.cs @@ -2,9 +2,9 @@ namespace CrowdParlay.Social.Application.DTOs; public class DiscussionResponse { - public required Guid Id { get; set; } + public required string Id { get; set; } public required string Title { get; set; } - public required string Description { get; set; } + public required string Content { get; set; } public required AuthorResponse? Author { get; set; } public required DateTimeOffset CreatedAt { get; set; } public required int CommentCount { get; set; } diff --git a/src/CrowdParlay.Social.Application/Services/CommentsService.cs b/src/CrowdParlay.Social.Application/Services/CommentsService.cs index 834c608..177f6e9 100644 --- a/src/CrowdParlay.Social.Application/Services/CommentsService.cs +++ b/src/CrowdParlay.Social.Application/Services/CommentsService.cs @@ -11,86 +11,109 @@ namespace CrowdParlay.Social.Application.Services; public class CommentsService( IUnitOfWorkFactory unitOfWorkFactory, ICommentsRepository commentsRepository, + IDiscussionsRepository discussionsRepository, IUsersService usersService) : ICommentsService { - public async Task GetByIdAsync(Guid commentId, Guid? viewerId) + private readonly ISubjectsService _subjectsService = new SubjectsService(commentsRepository); + + public async Task GetByIdAsync(string commentId, Guid? viewerId) { var comment = await commentsRepository.GetByIdAsync(commentId, viewerId); return await EnrichAsync(comment); } - public async Task> SearchAsync(Guid? discussionId, Guid? authorId, Guid? viewerId, int offset, int count) + public async Task> GetRepliesAsync(string subjectId, bool flatten, Guid? viewerId, int offset, int count) { - var page = await commentsRepository.SearchAsync(discussionId, authorId, viewerId, offset, count); + var page = await commentsRepository.GetRepliesAsync(subjectId, flatten, viewerId, offset, count); return new Page { TotalCount = page.TotalCount, Items = await EnrichAsync(page.Items.ToArray()) }; } + + public async Task ReplyToDiscussionAsync(string discussionId, Guid authorId, string content) + { + _ = await discussionsRepository.GetByIdAsync(discussionId, null); + return await CreateAsync(discussionId, authorId, content); + } + + public async Task ReplyToCommentAsync(string commentId, Guid authorId, string content) + { + _ = await commentsRepository.GetByIdAsync(commentId, null); + return await CreateAsync(commentId, authorId, content); + } - public async Task CreateAsync(Guid authorId, Guid discussionId, string content) + private async Task CreateAsync(string subjectId, Guid authorId, string content) { - var source = new ActivitySource("test source"); + var source = new ActivitySource(nameof(CommentsService)); using var activity = source.CreateActivity("Create comment", ActivityKind.Server); Comment comment; - await using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) + using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) { - var commentId = await unitOfWork.CommentsRepository.CreateAsync(authorId, discussionId, content); + var commentId = await unitOfWork.CommentsRepository.CreateAsync(subjectId, authorId, content); comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId, authorId); await unitOfWork.CommitAsync(); } - // TODO: notify clients via SignalR + using (source.CreateActivity("Update dependant metadata", ActivityKind.Server)) + { + var ancestors = await commentsRepository.GetAncestorsAsync(comment.Id, null); + await commentsRepository.IncludeCommentInAncestorsMetadataAsync(ancestors, authorId); + + var discussionId = ancestors.LastOrDefault()?.SubjectId ?? subjectId; + await discussionsRepository.IncludeCommentInMetadataAsync(discussionId, authorId); + } return await EnrichAsync(comment); } - public async Task> GetRepliesToCommentAsync(Guid parentCommentId, Guid? viewerId, int offset, int count) - { - var page = await commentsRepository.SearchAsync(parentCommentId, authorId: null, viewerId, offset, count); - return new Page - { - TotalCount = page.TotalCount, - Items = await EnrichAsync(page.Items.ToArray()) - }; - } + public async Task> GetReactionsAsync(string commentId, Guid authorId) => + await _subjectsService.GetReactionsAsync(commentId, authorId); + + public async Task SetReactionsAsync(string commentId, Guid authorId, ISet reactions) => + await _subjectsService.SetReactionsAsync(commentId, authorId, reactions); - public async Task ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content) + public async Task DeleteAsync(string commentId) { - Comment comment; - await using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) + var source = new ActivitySource(nameof(CommentsService)); + using var activity = source.CreateActivity("Delete comment", ActivityKind.Server); + + var comment = await commentsRepository.GetByIdAsync(commentId, null); + using (source.CreateActivity("Update dependant metadata", ActivityKind.Server)) { - var commentId = await unitOfWork.CommentsRepository.ReplyToCommentAsync(authorId, parentCommentId, content); - comment = await unitOfWork.CommentsRepository.GetByIdAsync(commentId, authorId); - await unitOfWork.CommitAsync(); + var ancestors = await commentsRepository.GetAncestorsAsync(commentId, null); + await commentsRepository.ExcludeCommentFromAncestorsMetadataAsync(ancestors); + + var discussionId = ancestors.LastOrDefault()?.SubjectId ?? comment.SubjectId; + await discussionsRepository.ExcludeCommentFromMetadataAsync(discussionId); } - return await EnrichAsync(comment); + await commentsRepository.DeleteAsync(commentId); } - public async Task DeleteAsync(Guid id) => await commentsRepository.DeleteAsync(id); - private async Task EnrichAsync(Comment comment) => (await EnrichAsync([comment])).First(); private async Task> EnrichAsync(IReadOnlyList comments) { - var authorIds = comments.SelectMany(comment => comment.LastRepliesAuthorIds.Append(comment.AuthorId)).ToHashSet(); - var authorsById = await usersService.GetUsersAsync(authorIds); + var authorIds = comments + .SelectMany(comment => comment.LastCommentsAuthorIds.Append(comment.AuthorId)) + .ToHashSet(); + var authorsById = await usersService.GetUsersAsync(authorIds); return comments.Select(comment => new CommentResponse { Id = comment.Id, Content = comment.Content, Author = authorsById[comment.AuthorId].Adapt(), CreatedAt = comment.CreatedAt, - ReplyCount = comment.ReplyCount, - LastRepliesAuthors = comment.LastRepliesAuthorIds + CommentCount = comment.CommentCount, + LastCommentsAuthors = comment.LastCommentsAuthorIds .Select(replyAuthorId => authorsById[replyAuthorId].Adapt()), ReactionCounters = comment.ReactionCounters, - ViewerReactions = comment.ViewerReactions + ViewerReactions = comment.ViewerReactions.ToHashSet() }); } } \ 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 5942440..54a5dd9 100644 --- a/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs +++ b/src/CrowdParlay.Social.Application/Services/DiscussionsService.cs @@ -14,7 +14,9 @@ public class DiscussionsService( IUsersService usersService) : IDiscussionsService { - public async Task GetByIdAsync(Guid discussionId, Guid? viewerId) + private readonly ISubjectsService _subjectsService = new SubjectsService(discussionsRepository); + + public async Task GetByIdAsync(string discussionId, Guid? viewerId) { var discussion = await discussionsRepository.GetByIdAsync(discussionId, viewerId); return await EnrichAsync(discussion); @@ -30,12 +32,13 @@ public async Task> SearchAsync(Guid? authorId, Guid? vi }; } - public async Task CreateAsync(Guid authorId, string title, string description) + public async Task CreateAsync(Guid authorId, string title, string content) { Discussion discussion; - await using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) + + using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) { - var discussionId = await unitOfWork.DiscussionsRepository.CreateAsync(authorId, title, description); + var discussionId = await discussionsRepository.CreateAsync(authorId, title, content); discussion = await unitOfWork.DiscussionsRepository.GetByIdAsync(discussionId, authorId); await unitOfWork.CommitAsync(); } @@ -43,43 +46,45 @@ public async Task CreateAsync(Guid authorId, string title, s return await EnrichAsync(discussion); } - public async Task UpdateAsync(Guid discussionId, Guid viewerId, UpdateDiscussionRequest request) + public async Task UpdateAsync(string discussionId, Guid viewerId, + UpdateDiscussionRequest request) { - Discussion discussion; - await using (var unitOfWork = await unitOfWorkFactory.CreateAsync()) - { - discussion = await unitOfWork.DiscussionsRepository.GetByIdAsync(discussionId, viewerId); - if (discussion.AuthorId != viewerId) - throw new ForbiddenException("Cannot modify a discussion created by another user."); - - await unitOfWork.DiscussionsRepository.UpdateAsync(discussionId, request.Title, request.Description); - discussion = await unitOfWork.DiscussionsRepository.GetByIdAsync(discussionId, viewerId); - - await unitOfWork.CommitAsync(); - } + var discussion = await discussionsRepository.GetByIdAsync(discussionId, viewerId); + if (discussion.AuthorId != viewerId) + throw new ForbiddenException("Cannot modify a discussion created by another user."); + await discussionsRepository.UpdateAsync(discussionId, request.Title, request.Description); + discussion = await discussionsRepository.GetByIdAsync(discussionId, viewerId); return await EnrichAsync(discussion); } + public async Task> GetReactionsAsync(string discussionId, Guid authorId) => + await _subjectsService.GetReactionsAsync(discussionId, authorId); + + public async Task SetReactionsAsync(string discussionId, Guid authorId, ISet reactions) => + await _subjectsService.SetReactionsAsync(discussionId, authorId, reactions); + private async Task EnrichAsync(Discussion discussion) => (await EnrichAsync([discussion])).First(); private async Task> EnrichAsync(IReadOnlyList discussions) { - var authorIds = discussions.SelectMany(discussion => discussion.LastCommentsAuthorIds.Append(discussion.AuthorId)).ToHashSet(); + var authorIds = discussions + .SelectMany(discussion => discussion.LastCommentsAuthorIds.Append(discussion.AuthorId)) + .ToHashSet(); + var authorsById = await usersService.GetUsersAsync(authorIds); - return discussions.Select(discussion => new DiscussionResponse { Id = discussion.Id, Title = discussion.Title, - Description = discussion.Description, + Content = discussion.Content, Author = authorsById[discussion.AuthorId].Adapt(), CreatedAt = discussion.CreatedAt, CommentCount = discussion.CommentCount, LastCommentsAuthors = discussion.LastCommentsAuthorIds .Select(replyAuthorId => authorsById[replyAuthorId].Adapt()), ReactionCounters = discussion.ReactionCounters, - ViewerReactions = discussion.ViewerReactions + ViewerReactions = discussion.ViewerReactions.ToHashSet() }); } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/Services/ReactionsService.cs b/src/CrowdParlay.Social.Application/Services/ReactionsService.cs deleted file mode 100644 index f64cc63..0000000 --- a/src/CrowdParlay.Social.Application/Services/ReactionsService.cs +++ /dev/null @@ -1,26 +0,0 @@ -using CrowdParlay.Social.Application.Abstractions; -using CrowdParlay.Social.Application.Exceptions; -using CrowdParlay.Social.Domain.Abstractions; -using CrowdParlay.Social.Domain.ValueObjects; - -namespace CrowdParlay.Social.Application.Services; - -public class ReactionsService(IUnitOfWorkFactory unitOfWorkFactory, IReactionsRepository reactionsRepository) : IReactionsService -{ - public async Task> GetAsync(Guid subjectId, Guid viewerId) => - await reactionsRepository.GetAsync(subjectId, viewerId); - - public async Task SetAsync(Guid subjectId, Guid viewerId, ISet reactions) - { - await using var unitOfWork = await unitOfWorkFactory.CreateAsync(); - - var alreadyExistingSubjectReactions = await unitOfWork.ReactionsRepository.GetAsync(subjectId); - var allowedReactions = Reaction.AllowedValues.Union(alreadyExistingSubjectReactions).ToHashSet(); - - if (!reactions.IsSubsetOf(allowedReactions)) - throw new ForbiddenException("Such reaction set is not allowed."); - - await unitOfWork.ReactionsRepository.SetAsync(subjectId, viewerId, reactions); - await unitOfWork.CommitAsync(); - } -} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Application/Services/SubjectsService.cs b/src/CrowdParlay.Social.Application/Services/SubjectsService.cs new file mode 100644 index 0000000..7022d98 --- /dev/null +++ b/src/CrowdParlay.Social.Application/Services/SubjectsService.cs @@ -0,0 +1,27 @@ +using CrowdParlay.Social.Application.Abstractions; +using CrowdParlay.Social.Application.Exceptions; +using CrowdParlay.Social.Domain.Abstractions; +using CrowdParlay.Social.Domain.ValueObjects; + +namespace CrowdParlay.Social.Application.Services; + +public class SubjectsService(ISubjectsRepository subjectsRepository) : ISubjectsService +{ + public async Task> GetReactionsAsync(string subjectId, Guid authorId) => + await subjectsRepository.GetReactionsAsync(subjectId, authorId); + + public async Task SetReactionsAsync(string subjectId, Guid authorId, ISet newReactions) + { + var oldReactions = await subjectsRepository.GetReactionsAsync(subjectId, authorId); + var allowedReactions = Reaction.AllowedValues.Union(oldReactions).ToArray(); + + if (!newReactions.IsSubsetOf(allowedReactions)) + throw new ForbiddenException("Such reaction set is not allowed."); + + await subjectsRepository.SetReactionsAsync(subjectId, authorId, newReactions); + + var reactionsToAdd = newReactions.Except(oldReactions).ToArray(); + var reactionsToRemove = oldReactions.Except(newReactions).ToArray(); + await subjectsRepository.UpdateReactionCountersAsync(subjectId, reactionsToAdd, reactionsToRemove); + } +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/Abstractions/IAuthorsRepository.cs b/src/CrowdParlay.Social.Domain/Abstractions/IAuthorsRepository.cs deleted file mode 100644 index af2c518..0000000 --- a/src/CrowdParlay.Social.Domain/Abstractions/IAuthorsRepository.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CrowdParlay.Social.Domain.Abstractions; - -public interface IAuthorsRepository -{ - public Task EnsureCreatedAsync(Guid authorId); -} \ 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 b79368b..8a082f7 100644 --- a/src/CrowdParlay.Social.Domain/Abstractions/ICommentsRepository.cs +++ b/src/CrowdParlay.Social.Domain/Abstractions/ICommentsRepository.cs @@ -3,11 +3,13 @@ namespace CrowdParlay.Social.Domain.Abstractions; -public interface ICommentsRepository +public interface ICommentsRepository : ISubjectsRepository { - 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 ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content); - public Task DeleteAsync(Guid commentId); + public Task GetByIdAsync(string commentId, Guid? viewerId); + public Task> GetRepliesAsync(string subjectId, bool flatten, Guid? viewerId, int offset, int count); + public Task CreateAsync(string? subjectId, Guid authorId, string content); + public Task> GetAncestorsAsync(string commentId, Guid? viewerId); + public Task IncludeCommentInAncestorsMetadataAsync(IEnumerable ancestors, Guid authorId); + public Task ExcludeCommentFromAncestorsMetadataAsync(IEnumerable ancestors); + public Task DeleteAsync(string 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 00811f5..cd26d9e 100644 --- a/src/CrowdParlay.Social.Domain/Abstractions/IDiscussionsRepository.cs +++ b/src/CrowdParlay.Social.Domain/Abstractions/IDiscussionsRepository.cs @@ -3,10 +3,10 @@ namespace CrowdParlay.Social.Domain.Abstractions; -public interface IDiscussionsRepository +public interface IDiscussionsRepository : ISubjectsRepository { - public Task GetByIdAsync(Guid discussionId, Guid? viewerId); + public Task GetByIdAsync(string 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 UpdateAsync(Guid discussionId, string? title = null, string? description = null); + public Task CreateAsync(Guid authorId, string title, string content); + public Task UpdateAsync(string discussionId, string? title = null, string? content = null); } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/Abstractions/IReactionsRepository.cs b/src/CrowdParlay.Social.Domain/Abstractions/IReactionsRepository.cs deleted file mode 100644 index 0bbe9fd..0000000 --- a/src/CrowdParlay.Social.Domain/Abstractions/IReactionsRepository.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CrowdParlay.Social.Domain.Abstractions; - -public interface IReactionsRepository -{ - public Task> GetAsync(Guid subjectId); - public Task> GetAsync(Guid subjectId, Guid viewerId); - public Task SetAsync(Guid subjectId, Guid viewerId, ISet reactions); -} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/Abstractions/ISubjectsRepository.cs b/src/CrowdParlay.Social.Domain/Abstractions/ISubjectsRepository.cs new file mode 100644 index 0000000..4a9e5e6 --- /dev/null +++ b/src/CrowdParlay.Social.Domain/Abstractions/ISubjectsRepository.cs @@ -0,0 +1,11 @@ +namespace CrowdParlay.Social.Domain.Abstractions; + +public interface ISubjectsRepository +{ + public Task> GetReactionsAsync(string subjectId, Guid authorId); + public Task SetReactionsAsync(string subjectId, Guid authorId, ISet reactions); + public Task UpdateReactionCountersAsync(string subjectId, IEnumerable reactionsToAdd, IEnumerable reactionsToRemove); + public Task IncludeCommentInMetadataAsync(string discussionId, Guid authorId); + public Task ExcludeCommentFromMetadataAsync(string discussionId); + +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/Abstractions/IUnitOfWork.cs b/src/CrowdParlay.Social.Domain/Abstractions/IUnitOfWork.cs index 44b524c..7a7139b 100644 --- a/src/CrowdParlay.Social.Domain/Abstractions/IUnitOfWork.cs +++ b/src/CrowdParlay.Social.Domain/Abstractions/IUnitOfWork.cs @@ -1,10 +1,9 @@ namespace CrowdParlay.Social.Domain.Abstractions; -public interface IUnitOfWork : IAsyncDisposable +public interface IUnitOfWork : IDisposable { public IDiscussionsRepository DiscussionsRepository { get; } public ICommentsRepository CommentsRepository { get; } - public IReactionsRepository ReactionsRepository { get; } Task CommitAsync(); Task RollbackAsync(); diff --git a/src/CrowdParlay.Social.Domain/Entities/Comment.cs b/src/CrowdParlay.Social.Domain/Entities/Comment.cs index f122d33..6b9e4bc 100644 --- a/src/CrowdParlay.Social.Domain/Entities/Comment.cs +++ b/src/CrowdParlay.Social.Domain/Entities/Comment.cs @@ -2,12 +2,13 @@ namespace CrowdParlay.Social.Domain.Entities; public class Comment { - public required Guid Id { get; set; } + public required string Id { get; set; } + public required string SubjectId { get; set; } public required string Content { get; set; } public required Guid AuthorId { get; set; } public required DateTimeOffset CreatedAt { get; set; } - public required int ReplyCount { get; set; } - public required ISet LastRepliesAuthorIds { get; set; } + public required int CommentCount { get; set; } + public required IList LastCommentsAuthorIds { get; set; } public required IDictionary ReactionCounters { get; set; } - public required ISet ViewerReactions { get; set; } + public required IList 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 e4ba88f..b9c5247 100644 --- a/src/CrowdParlay.Social.Domain/Entities/Discussion.cs +++ b/src/CrowdParlay.Social.Domain/Entities/Discussion.cs @@ -2,13 +2,13 @@ namespace CrowdParlay.Social.Domain.Entities; public class Discussion { - public required Guid Id { get; set; } + public required string Id { get; set; } public required string Title { get; set; } - public required string Description { get; set; } + public required string Content { get; set; } public required Guid AuthorId { get; set; } public required DateTimeOffset CreatedAt { get; set; } public required int CommentCount { get; set; } - public required ISet LastCommentsAuthorIds { get; set; } + public required IList LastCommentsAuthorIds { get; set; } public required IDictionary ReactionCounters { get; set; } - public required ISet ViewerReactions { get; set; } + public required IList ViewerReactions { get; set; } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Domain/ReactionMapsterAdapterConfigurator.cs b/src/CrowdParlay.Social.Domain/ReactionMapsterAdapterConfigurator.cs deleted file mode 100644 index b9b5749..0000000 --- a/src/CrowdParlay.Social.Domain/ReactionMapsterAdapterConfigurator.cs +++ /dev/null @@ -1,16 +0,0 @@ -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.Infrastructure.Persistence/ConfigureServices.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs index 3aed0a5..cd125a1 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs @@ -1,42 +1,46 @@ -using CrowdParlay.Social.Domain; using CrowdParlay.Social.Domain.Abstractions; using CrowdParlay.Social.Infrastructure.Persistence.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Neo4j.Driver; +using Microsoft.Extensions.Options; +using MongoDB.Driver; namespace CrowdParlay.Social.Infrastructure.Persistence; public static class ConfigurePersistenceExtensions { public static IServiceCollection AddPersistence(this IServiceCollection services, IConfiguration configuration) => services - .AddNeo4j(configuration) - .AddScoped() + .AddMongoDb(configuration) .AddScoped() .AddScoped() - .AddScoped() - .AddScoped(provider => provider.GetRequiredService().AsyncSession()) - .AddScoped(provider => provider.GetRequiredService()) - .AddScoped(); + .AddScoped() + .AddHostedService(); - // ReSharper disable once InconsistentNaming - private static IServiceCollection AddNeo4j(this IServiceCollection services, IConfiguration configuration) + private static IServiceCollection AddMongoDb(this IServiceCollection services, IConfiguration configuration) { - ReactionMapsterAdapterConfigurator.Configure(); - - var uri = - configuration["NEO4J_URI"] ?? - throw new InvalidOperationException("NEO4J_URI is not set!"); + services + .AddOptions() + .ValidateDataAnnotations() + .ValidateOnStart() + .Bind(configuration.GetSection("MongoDb")); - var username = - configuration["NEO4J_USERNAME"] ?? - throw new InvalidOperationException("NEO4J_USERNAME is not set!"); + services.AddSingleton(provider => + { + var settings = provider.GetRequiredService>(); + return new MongoClient(settings.Value.ConnectionString); + }); - var password = - configuration["NEO4J_PASSWORD"] ?? - throw new InvalidOperationException("NEO4J_PASSWORD is not set!"); + services.AddScoped(provider => + { + var client = provider.GetRequiredService(); + return client.StartSession(); + }); - var driver = GraphDatabase.Driver(uri, AuthTokens.Basic(username, password)); - return services.AddSingleton(driver); + return services.AddScoped(provider => + { + var session = provider.GetRequiredService(); + var settings = provider.GetRequiredService>(); + return session.Client.GetDatabase(settings.Value.Database); + }); } } \ 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 3dffec2..4ea9d58 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/CrowdParlay.Social.Infrastructure.Persistence.csproj +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/CrowdParlay.Social.Infrastructure.Persistence.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs new file mode 100644 index 0000000..7ecbe16 --- /dev/null +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs @@ -0,0 +1,19 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace CrowdParlay.Social.Infrastructure.Persistence.Models; + +public class CommentDocument : ISubjectDocument +{ + [BsonId] public required ObjectId Id { get; set; } + public required DateTimeOffset CreatedAt { get; set; } + public required Guid AuthorId { get; set; } + public required ObjectId SubjectId { get; set; } + public required string Content { get; set; } + public required int CommentCount { get; set; } + public required IList LastCommentsAuthorIds { get; set; } + public required IDictionary ReactionCounters { get; set; } + public required IDictionary ReactionsByAuthorId { get; set; } + [BsonIgnoreIfNull] public IList? Ancestors { get; set; } + [BsonIgnoreIfNull] public IList? Ascendants { get; set; } +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Models/DiscussionDocument.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/DiscussionDocument.cs new file mode 100644 index 0000000..ab48338 --- /dev/null +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/DiscussionDocument.cs @@ -0,0 +1,17 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace CrowdParlay.Social.Infrastructure.Persistence.Models; + +public class DiscussionDocument : ISubjectDocument +{ + [BsonId] public required ObjectId Id { get; set; } + public required DateTimeOffset CreatedAt { get; set; } + public required Guid AuthorId { get; set; } + public required string Title { get; set; } + public required string Content { get; set; } + public required int CommentCount { get; set; } + public required IList LastCommentsAuthorIds { get; set; } + public required IDictionary ReactionCounters { get; set; } + public required IDictionary ReactionsByAuthorId { get; set; } +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Models/ISubjectDocument.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/ISubjectDocument.cs new file mode 100644 index 0000000..a5d35a4 --- /dev/null +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/ISubjectDocument.cs @@ -0,0 +1,12 @@ +using MongoDB.Bson; + +namespace CrowdParlay.Social.Infrastructure.Persistence.Models; + +public interface ISubjectDocument +{ + public ObjectId Id { get; set; } + public IDictionary ReactionCounters { get; set; } + public IDictionary ReactionsByAuthorId { get; set; } + public int CommentCount { get; set; } + public IList LastCommentsAuthorIds { get; set; } +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/MongoDbSettings.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/MongoDbSettings.cs new file mode 100644 index 0000000..c5b0e2f --- /dev/null +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/MongoDbSettings.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace CrowdParlay.Social.Infrastructure.Persistence; + +internal record MongoDbSettings +{ + [Required] public required string ConnectionString { get; init; } + [Required] public required string Database { get; init; } +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/AuthorsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/AuthorsRepository.cs deleted file mode 100644 index 5131e09..0000000 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/AuthorsRepository.cs +++ /dev/null @@ -1,10 +0,0 @@ -using CrowdParlay.Social.Domain.Abstractions; -using Neo4j.Driver; - -namespace CrowdParlay.Social.Infrastructure.Persistence.Services; - -public class AuthorsRepository(IAsyncQueryRunner runner) : IAuthorsRepository -{ - public async Task EnsureCreatedAsync(Guid authorId) => - await runner.RunAsync("MERGE (author:Author { Id: $authorId })", new { authorId = authorId.ToString() }); -} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs index 09a6e1e..5a27a9a 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs @@ -1,206 +1,186 @@ -using System.Text; +using System.Linq.Expressions; using CrowdParlay.Social.Application.Exceptions; using CrowdParlay.Social.Domain.Abstractions; using CrowdParlay.Social.Domain.DTOs; using CrowdParlay.Social.Domain.Entities; -using Mapster; -using Neo4j.Driver; +using CrowdParlay.Social.Infrastructure.Persistence.Models; +using MongoDB.Bson; +using MongoDB.Driver; +using static MongoDB.Driver.PipelineStageDefinitionBuilder; +using static MongoDB.Driver.Builders; +using static MongoDB.Driver.PipelineDefinition; namespace CrowdParlay.Social.Infrastructure.Persistence.Services; -public class CommentsRepository(IAsyncQueryRunner runner) : ICommentsRepository +public class CommentsRepository(IClientSessionHandle session, IMongoDatabase database) : ICommentsRepository { - public async Task GetByIdAsync(Guid commentId, Guid? viewerId) - { - var data = await runner.RunAsync( - """ - MATCH (comment:Comment { Id: $commentId })-[:AUTHORED_BY]->(author:Author) - OPTIONAL MATCH (deepReplyAuthor:Author)<-[:AUTHORED_BY]-(deepReply:Comment)-[:REPLIES_TO*]->(comment) - OPTIONAL MATCH (comment)<-[reaction:REACTED_TO]-(:Author) - OPTIONAL MATCH (comment)<-[viewerReaction:REACTED_TO]-(:Author { Id: $viewerId }) - - WITH author, comment, deepReply, deepReplyAuthor, reaction, - collect(viewerReaction.Value) AS viewerReactions - - WITH author, comment, deepReply, deepReplyAuthor, viewerReactions, - reaction.Value AS reactionValue, count(reaction) AS reactionCount - - WITH author, comment, deepReply, deepReplyAuthor, viewerReactions, - apoc.map.fromPairs(collect([reactionValue, reactionCount])) AS reactionCounters - - ORDER BY deepReply.CreatedAt DESC - - WITH author, comment, viewerReactions, reactionCounters, - count(deepReply) AS deepReplyCount, - collect(DISTINCT deepReplyAuthor.Id)[0..3] AS lastDeepRepliesAuthorIds - - RETURN { - Id: comment.Id, - Content: comment.Content, - AuthorId: author.Id, - CreatedAt: comment.CreatedAt, - ReplyCount: deepReplyCount, - LastRepliesAuthorIds: lastDeepRepliesAuthorIds, - ReactionCounters: reactionCounters, - ViewerReactions: viewerReactions - } - """, - new - { - commentId = commentId.ToString(), - viewerId = viewerId?.ToString() - }); + private readonly IMongoCollection _comments = database.GetCollection("comments"); + private readonly ISubjectsRepository _subjectsRepository = new GenericSubjectsRepository(session, database, "comments"); - if (await data.PeekAsync() is null) - throw new NotFoundException(); + public async Task GetByIdAsync(string commentId, Guid? viewerId) + { + var pipeline = _comments + .Find(session, comment => comment.Id == ObjectId.Parse(commentId)) + .Project(CreateCommentProjectionExpression(viewerId)); - var record = await data.SingleAsync(); - return record[0].Adapt(); + return await pipeline.FirstOrDefaultAsync() ?? throw new NotFoundException(); } - public async Task> SearchAsync(Guid? subjectId, Guid? authorId, Guid? viewerId, int offset, int count) + public async Task> GetRepliesAsync( + string subjectId, bool flatten, Guid? viewerId, int offset, int count) { - var matchSelector = new StringBuilder("MATCH (comment:Comment)-[:AUTHORED_BY]->(author:Author)"); + var matchPipeline = _comments.Aggregate(session) + .Match(comment => comment.SubjectId == ObjectId.Parse(subjectId)); - if (subjectId is not null) + if (flatten) { - matchSelector.AppendLine("MATCH (comment)-[:REPLIES_TO]->(subject { Id: $subjectId })"); - matchSelector.AppendLine("WHERE (subject:Comment OR subject:Discussion)"); + matchPipeline = matchPipeline + .GraphLookup, + CommentDocument>( + from: _comments, + startWith: comment => comment.Id, + connectFromField: comment => comment.Id, + connectToField: comment => comment.SubjectId, + @as: comment => comment.Ascendants!); } - 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) - - 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.Value AS reactionValue, count(reaction) AS reactionCount - - WITH author, comment, deepReplyAuthor, deepReply, viewerReactions, - apoc.map.fromPairs(collect([reactionValue, reactionCount])) AS reactionCounters - - ORDER BY comment.CreatedAt, deepReply.CreatedAt DESC - - WITH author, comment, viewerReactions, reactionCounters, - COUNT(deepReply) AS deepReplyCount, - COLLECT(DISTINCT deepReplyAuthor.Id)[0..3] AS lastDeepRepliesAuthorIds - - RETURN { - TotalCount: COUNT(comment), - Items: COLLECT({ - Id: comment.Id, - Content: comment.Content, - AuthorId: author.Id, - CreatedAt: comment.CreatedAt, - ReplyCount: deepReplyCount, - LastRepliesAuthorIds: lastDeepRepliesAuthorIds, - ReactionCounters: reactionCounters, - ViewerReactions: viewerReactions - })[$offset..$offset + $count] - } - """, - new - { - subjectId = subjectId?.ToString(), - authorId = authorId?.ToString(), - viewerId = viewerId?.ToString(), - offset, - count - }); - - if (await data.PeekAsync() is null) + var pipeline = matchPipeline.Facet( + new AggregateFacet( + "items", + PipelineDefinition.Create( + [ + Sort(Builders.Sort.Ascending(comment => comment.CreatedAt)), + Skip(offset), + Limit(count), + Project(CreateCommentProjectionExpression(viewerId)) + ]) + ), + new AggregateFacet( + "totalCount", + Create([Count()]) + ) + ); + + var result = await pipeline.FirstOrDefaultAsync() ?? throw new NotFoundException(); + return new Page { - return new Page - { - TotalCount = 0, - Items = [] - }; - } + Items = result.Facets[0].Output() ?? Enumerable.Empty(), + TotalCount = (int)(result.Facets[1].Output().FirstOrDefault()?.Count ?? 0) + }; + } - var record = await data.SingleAsync(); - return record[0].Adapt>(); + public async Task CreateAsync(string? subjectId, Guid authorId, string content) + { + var reply = new CommentDocument + { + Id = ObjectId.GenerateNewId(), + CreatedAt = DateTimeOffset.UtcNow, + AuthorId = authorId, + SubjectId = ObjectId.Parse(subjectId), + Content = content, + CommentCount = 0, + LastCommentsAuthorIds = [], + ReactionCounters = new Dictionary(), + ReactionsByAuthorId = new Dictionary() + }; + + await _comments.InsertOneAsync(session, reply); + return reply.Id.ToString(); } - public async Task CreateAsync(Guid authorId, Guid discussionId, string content) + public async Task> GetAncestorsAsync(string commentId, Guid? viewerId) => await _comments.Aggregate(session) + .Match(comment => comment.Id == ObjectId.Parse(commentId)) + .GraphLookup, CommentDocument>( + from: _comments, + startWith: comment => comment.SubjectId, + connectFromField: comment => comment.SubjectId, + connectToField: comment => comment.Id, + @as: comment => comment.Ancestors!) + .Project(comment => comment.Ancestors!.Select(ancestor => new Comment + { + Id = ancestor.Id.ToString(), + SubjectId = ancestor.SubjectId.ToString(), + CreatedAt = ancestor.CreatedAt, + AuthorId = ancestor.AuthorId, + Content = ancestor.Content, + CommentCount = ancestor.CommentCount, + LastCommentsAuthorIds = ancestor.LastCommentsAuthorIds, + ReactionCounters = ancestor.ReactionCounters, + ViewerReactions = + viewerId.HasValue && + ancestor.ReactionsByAuthorId.ContainsKey(viewerId.Value.ToString()) + ? ancestor.ReactionsByAuthorId[viewerId.Value.ToString()] + : Array.Empty() + }).ToList()) + .FirstOrDefaultAsync(); + + public async Task IncludeCommentInAncestorsMetadataAsync(IEnumerable ancestors, Guid authorId) { - 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 + var updates = ancestors.Select(ancestor => { - authorId = authorId.ToString(), - discussionId = discussionId.ToString(), - content - }); - - if (await data.PeekAsync() is null) - throw new NotFoundException(); + var newLastCommentsAuthorIds = ancestor.LastCommentsAuthorIds + .Append(authorId) + .Distinct() + .TakeLast(5) + .ToList(); + + var filter = Filter.Eq(comment => comment.Id, ObjectId.Parse(ancestor.Id)); + var update = Update + .Set(comment => comment.LastCommentsAuthorIds, newLastCommentsAuthorIds) + .Inc(comment => comment.CommentCount, 1); + + return new UpdateOneModel(filter, update); + }) + .ToList(); - var record = await data.SingleAsync(); - return record[0].Adapt(); + if (updates.Any()) + await _comments.BulkWriteAsync(session, updates); } - public async Task ReplyToCommentAsync(Guid authorId, Guid parentCommentId, string content) + public async Task ExcludeCommentFromAncestorsMetadataAsync(IEnumerable ancestors) { - 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 - }); + var filter = Filter.In(comment => comment.Id, ancestors.Select(comment => ObjectId.Parse(comment.Id))); + var update = Update.Inc(comment => comment.CommentCount, -1); + await _comments.UpdateManyAsync(session, filter, update); + } - if (await cursor.PeekAsync() is null) - throw new NotFoundException(); + public async Task IncludeCommentInMetadataAsync(string discussionId, Guid authorId) => + await _subjectsRepository.IncludeCommentInMetadataAsync(discussionId, authorId); - var record = await cursor.SingleAsync(); - return record[0].Adapt(); - } + public async Task ExcludeCommentFromMetadataAsync(string discussionId) => + await _subjectsRepository.ExcludeCommentFromMetadataAsync(discussionId); - public async Task DeleteAsync(Guid commentId) + public async Task DeleteAsync(string commentId) { - var data = await runner.RunAsync( - """ - OPTIONAL MATCH (comment:Comment { Id: $commentId }) - OPTIONAL MATCH (reply:Comment)-[:REPLIES_TO*]->(comment) - DETACH DELETE comment, reply - RETURN COUNT(comment) = 0 - """, - new { commentId = commentId.ToString() }); - - var record = await data.SingleAsync(); - var notFount = record[0].As(); - - if (notFount) + var deleteResult = await _comments.DeleteOneAsync(session, comment => comment.Id == ObjectId.Parse(commentId)); + if (deleteResult.DeletedCount == 0) throw new NotFoundException(); } + + public async Task> GetReactionsAsync(string commentId, Guid authorId) => + await _subjectsRepository.GetReactionsAsync(commentId, authorId); + + public async Task SetReactionsAsync(string commentId, Guid authorId, ISet reactions) => + await _subjectsRepository.SetReactionsAsync(commentId, authorId, reactions); + + public async Task UpdateReactionCountersAsync(string commentId, IEnumerable reactionsToAdd, IEnumerable reactionsToRemove) => + await _subjectsRepository.UpdateReactionCountersAsync(commentId, reactionsToAdd, reactionsToRemove); + + private static Expression> CreateCommentProjectionExpression(Guid? viewerId) => comment => new Comment + { + Id = comment.Id.ToString(), + SubjectId = comment.SubjectId.ToString(), + CreatedAt = comment.CreatedAt, + AuthorId = comment.AuthorId, + Content = comment.Content, + CommentCount = comment.CommentCount, + LastCommentsAuthorIds = comment.LastCommentsAuthorIds, + ReactionCounters = comment.ReactionCounters, + ViewerReactions = + viewerId.HasValue && + comment.ReactionsByAuthorId.ContainsKey(viewerId.Value.ToString()) + ? comment.ReactionsByAuthorId[viewerId.Value.ToString()] + : Array.Empty() + }; } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs index cb3aa2b..87c64f4 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs @@ -1,174 +1,132 @@ +using System.Linq.Expressions; using CrowdParlay.Social.Application.Exceptions; using CrowdParlay.Social.Domain.Abstractions; using CrowdParlay.Social.Domain.DTOs; using CrowdParlay.Social.Domain.Entities; -using Mapster; -using Neo4j.Driver; +using CrowdParlay.Social.Infrastructure.Persistence.Models; +using MongoDB.Bson; +using MongoDB.Driver; +using static MongoDB.Driver.PipelineStageDefinitionBuilder; +using static MongoDB.Driver.Builders; +using static MongoDB.Driver.PipelineDefinition; namespace CrowdParlay.Social.Infrastructure.Persistence.Services; -public class DiscussionsRepository(IAsyncQueryRunner runner) : IDiscussionsRepository +public class DiscussionsRepository(IClientSessionHandle session, IMongoDatabase database) : IDiscussionsRepository { - public async Task GetByIdAsync(Guid discussionId, Guid? viewerId) + private readonly IMongoCollection _discussions = database.GetCollection("discussions"); + private readonly ISubjectsRepository _subjectsRepository = new GenericSubjectsRepository(session, database, "discussions"); + + public async Task GetByIdAsync(string discussionId, Guid? viewerId) { - var data = await runner.RunAsync( - """ - MATCH (discussion:Discussion { Id: $discussionId })-[:AUTHORED_BY]->(author:Author) - - OPTIONAL MATCH (deepReplyAuthor:Author)<-[:AUTHORED_BY]-(deepReply:Comment)-[:REPLIES_TO*]->(discussion) - OPTIONAL MATCH (discussion)<-[reaction:REACTED_TO]-(:Author) - OPTIONAL MATCH (discussion)<-[viewerReaction:REACTED_TO]-(:Author { Id: $viewerId }) - - WITH author, discussion, deepReply, deepReplyAuthor, reaction, - collect(viewerReaction.Value) AS viewerReactions - - WITH author, discussion, deepReply, deepReplyAuthor, viewerReactions, - reaction.Value AS reactionValue, count(reaction) AS reactionCount - - WITH author, discussion, deepReply, deepReplyAuthor, viewerReactions, - apoc.map.fromPairs(collect([reactionValue, reactionCount])) AS reactionCounters - - ORDER BY deepReply.CreatedAt DESC - - WITH author, discussion, viewerReactions, reactionCounters, - count(deepReply) AS deepReplyCount, - collect(DISTINCT deepReplyAuthor.Id)[0..3] AS lastDeepRepliesAuthorIds - - RETURN { - Id: discussion.Id, - Title: discussion.Title, - Description: discussion.Description, - AuthorId: author.Id, - CreatedAt: discussion.CreatedAt, - CommentCount: deepReplyCount, - LastCommentsAuthorIds: lastDeepRepliesAuthorIds, - ReactionCounters: reactionCounters, - ViewerReactions: viewerReactions - } - """, - new - { - discussionId = discussionId.ToString(), - viewerId = viewerId?.ToString() - }); - - if (await data.PeekAsync() is null) - throw new NotFoundException(); + var pipeline = _discussions + .Find(session, discussion => discussion.Id == ObjectId.Parse(discussionId)) + .Project(CreateDiscussionProjectionExpression(viewerId)); - var record = await data.SingleAsync(); - return record[0].Adapt(); + return await pipeline.FirstOrDefaultAsync() ?? throw new NotFoundException(); } 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 + - """ - OPTIONAL MATCH (deepReplyAuthor:Author)<-[:AUTHORED_BY]-(deepReply:Comment)-[:REPLIES_TO*]->(discussion) - - OPTIONAL MATCH (discussion)<-[reaction:REACTED_TO]-(:Author) - OPTIONAL MATCH (discussion)<-[viewerReaction:REACTED_TO]-(:Author { Id: $authorId }) - - WITH author, discussion, deepReplyAuthor, deepReply, reaction, - collect(viewerReaction.Value) AS viewerReactions - - WITH author, discussion, deepReplyAuthor, deepReply, viewerReactions, - reaction.Value AS reactionValue, count(reaction) AS reactionCount - - WITH author, discussion, deepReplyAuthor, deepReply, viewerReactions, - apoc.map.fromPairs(collect([reactionValue, reactionCount])) AS reactionCounters - - ORDER BY discussion.CreatedAt, deepReply.CreatedAt DESC - - WITH author, discussion, viewerReactions, reactionCounters, - COUNT(deepReply) AS deepReplyCount, - COLLECT(DISTINCT deepReplyAuthor.Id)[0..3] AS lastDeepRepliesAuthorIds - - RETURN { - TotalCount: COUNT(discussion), - Items: COLLECT({ - Id: discussion.Id, - Title: discussion.Title, - Description: discussion.Description, - AuthorId: author.Id, - CreatedAt: discussion.CreatedAt, - CommentCount: deepReplyCount, - LastCommentsAuthorIds: lastDeepRepliesAuthorIds, - ReactionCounters: reactionCounters, - ViewerReactions: viewerReactions - })[$offset..$offset + $count] - } - """, - new - { - authorId = authorId?.ToString(), - viewerId = viewerId?.ToString(), - offset, - count - }); - - if (await data.PeekAsync() is null) + var pipeline = _discussions.Aggregate(session) + .Match(authorId.HasValue + ? Filter.Eq(discussion => discussion.AuthorId, authorId.Value) + : FilterDefinition.Empty) + .Facet( + new AggregateFacet( + "items", + PipelineDefinition.Create( + [ + Sort(Builders.Sort.Ascending(discussion => discussion.CreatedAt)), + Skip(offset), + Limit(count), + Project(CreateDiscussionProjectionExpression(viewerId)) + ]) + ), + new AggregateFacet( + "totalCount", + Create([Count()]) + ) + ); + + var result = await pipeline.FirstOrDefaultAsync() ?? throw new NotFoundException(); + return new Page { - return new Page - { - TotalCount = 0, - Items = [] - }; - } - - var record = await data.SingleAsync(); - return record[0].Adapt>(); + Items = result.Facets[0].Output() ?? Enumerable.Empty(), + TotalCount = (int)(result.Facets[1].Output().FirstOrDefault()?.Count ?? 0) + }; } - public async Task CreateAsync(Guid authorId, string title, string description) + public async Task CreateAsync(Guid authorId, string title, string content) { - 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 discussion = new DiscussionDocument + { + Id = ObjectId.Empty, + AuthorId = authorId, + CreatedAt = DateTimeOffset.UtcNow, + Title = title, + Content = content, + CommentCount = 0, + LastCommentsAuthorIds = [], + ReactionCounters = new Dictionary(), + ReactionsByAuthorId = new Dictionary() + }; + + await _discussions.InsertOneAsync(session, discussion); + return discussion.Id.ToString(); } - public async Task UpdateAsync(Guid discussionId, string? title, string? description) + public async Task UpdateAsync(string discussionId, string? title, string? content) { - var data = await runner.RunAsync( - """ - MATCH (d:Discussion { Id: $discussionId }) - SET d.Title: COALESCE($title, d.Title) - SET d.Description: COALESCE($description, d.Description) - RETURN COUNT(*) - """, - new - { - discussionId = discussionId.ToString(), - title, - description - }); - - var record = await data.SingleAsync(); - var notFound = record[0].As() == 0; - - if (notFound) + var updates = new List>(2); + + if (title is not null) + updates.Add(Update.Set(discussion => discussion.Title, title)); + + if (content is not null) + updates.Add(Update.Set(discussion => discussion.Content, content)); + + if (updates.Count == 0) + return; + + var filter = Filter.Eq(discussion => discussion.Id, ObjectId.Parse(discussionId)); + var update = Update.Combine(updates); + var result = await _discussions.UpdateOneAsync(session, filter, update); + + if (result.MatchedCount == 0) throw new NotFoundException(); } + + public async Task> GetReactionsAsync(string discussionId, Guid authorId) => + await _subjectsRepository.GetReactionsAsync(discussionId, authorId); + + public async Task SetReactionsAsync(string discussionId, Guid authorId, ISet reactions) => + await _subjectsRepository.SetReactionsAsync(discussionId, authorId, reactions); + + public async Task UpdateReactionCountersAsync(string subjectId, IEnumerable reactionsToAdd, IEnumerable reactionsToRemove) => + await _subjectsRepository.UpdateReactionCountersAsync(subjectId, reactionsToAdd, reactionsToRemove); + + public async Task IncludeCommentInMetadataAsync(string discussionId, Guid authorId) => + await _subjectsRepository.IncludeCommentInMetadataAsync(discussionId, authorId); + + public async Task ExcludeCommentFromMetadataAsync(string discussionId) => + await _subjectsRepository.ExcludeCommentFromMetadataAsync(discussionId); + + private static Expression> CreateDiscussionProjectionExpression(Guid? viewerId) => discussion => new Discussion + { + Id = discussion.Id.ToString(), + Title = discussion.Title, + Content = discussion.Content, + AuthorId = discussion.AuthorId, + CreatedAt = discussion.CreatedAt, + CommentCount = discussion.CommentCount, + LastCommentsAuthorIds = discussion.LastCommentsAuthorIds, + ReactionCounters = discussion.ReactionCounters, + ViewerReactions = + viewerId.HasValue && + discussion.ReactionsByAuthorId.ContainsKey(viewerId.Value.ToString()) + ? discussion.ReactionsByAuthorId[viewerId.Value.ToString()] + : Array.Empty() + }; } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GenericSubjectsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GenericSubjectsRepository.cs new file mode 100644 index 0000000..4bded23 --- /dev/null +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GenericSubjectsRepository.cs @@ -0,0 +1,86 @@ +using CrowdParlay.Social.Application.Exceptions; +using CrowdParlay.Social.Domain.Abstractions; +using CrowdParlay.Social.Infrastructure.Persistence.Models; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace CrowdParlay.Social.Infrastructure.Persistence.Services; + +public class GenericSubjectsRepository(IClientSessionHandle session, IMongoDatabase database, string collection) + : ISubjectsRepository where TDocument : ISubjectDocument +{ + private readonly IMongoCollection _subjects = database.GetCollection(collection); + + public async Task> GetReactionsAsync(string subjectId, Guid authorId) + { + var pipeline = _subjects + .Find(session, subject => subject.Id == ObjectId.Parse(subjectId)) + .Project(subject => subject.ReactionsByAuthorId.ContainsKey(authorId.ToString()) + ? subject.ReactionsByAuthorId[authorId.ToString()] + : new string[] { }); + + var reactions = await pipeline.FirstOrDefaultAsync() ?? throw new NotFoundException(); + return reactions.ToHashSet(); + } + + public async Task SetReactionsAsync(string subjectId, Guid authorId, ISet reactions) + { + var filter = Builders.Filter.Eq( + subject => subject.Id, + ObjectId.Parse(subjectId)); + + var update = Builders.Update.Set>( + subject => subject.ReactionsByAuthorId[authorId.ToString()], + reactions); + + var result = await _subjects.UpdateOneAsync(session, filter, update); + if (result.MatchedCount == 0) + throw new NotFoundException(); + } + + public async Task UpdateReactionCountersAsync(string subjectId, IEnumerable reactionsToAdd, IEnumerable reactionsToRemove) + { + var increments = reactionsToAdd.Select(reaction => + Builders.Update.Inc(subject => subject.ReactionCounters[reaction], 1)); + + var decrements = reactionsToRemove.Select(reaction => + Builders.Update.Inc(subject => subject.ReactionCounters[reaction], -1)); + + var filter = Builders.Filter.Eq(subject => subject.Id, ObjectId.Parse(subjectId)); + var update = Builders.Update.Combine(increments.Union(decrements)); + var result = await _subjects.UpdateOneAsync(session, filter, update); + + if (result.MatchedCount == 0) + throw new NotFoundException(); + } + + public async Task IncludeCommentInMetadataAsync(string subjectId, Guid authorId) + { + var oldLastCommentsAuthorIds = + await _subjects + .Find(session, subject => subject.Id == ObjectId.Parse(subjectId)) + .Project(subject => subject.LastCommentsAuthorIds) + .FirstOrDefaultAsync() + ?? throw new NotFoundException(); + + var newLastCommentAuthorIds = oldLastCommentsAuthorIds + .Append(authorId) + .Distinct() + .TakeLast(5) + .ToList(); + + var filter = Builders.Filter.Eq(subject => subject.Id, ObjectId.Parse(subjectId)); + var update = Builders.Update + .Set(subject => subject.LastCommentsAuthorIds, newLastCommentAuthorIds) + .Inc(subject => subject.CommentCount, 1); + + await _subjects.UpdateOneAsync(session, filter, update); + } + + public async Task ExcludeCommentFromMetadataAsync(string subjectId) + { + var filter = Builders.Filter.Eq(subject => subject.Id, ObjectId.Parse(subjectId)); + var update = Builders.Update.Inc(subject => subject.CommentCount, -1); + await _subjects.UpdateOneAsync(session, filter, update); + } +} \ 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 deleted file mode 100644 index 92a4dc1..0000000 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/ReactionsRepository.cs +++ /dev/null @@ -1,73 +0,0 @@ -using CrowdParlay.Social.Application.Exceptions; -using CrowdParlay.Social.Domain.Abstractions; -using Neo4j.Driver; - -namespace CrowdParlay.Social.Infrastructure.Persistence.Services; - -public class ReactionsRepository(IAsyncQueryRunner runner) : IReactionsRepository -{ - public async Task> GetAsync(Guid subjectId) - { - var data = await runner.RunAsync( - """ - MATCH (:Author)-[reaction:REACTED_TO]->(subject { Id: $subjectId }) - WHERE (subject:Comment OR subject:Discussion) - RETURN DISTINCT reaction.Value - """, - new { subjectId = subjectId.ToString() }); - - return await data - .Select(record => record[0].As()) - .ToHashSetAsync(); - } - - public async Task> GetAsync(Guid subjectId, Guid viewerId) - { - var data = await runner.RunAsync( - """ - MATCH (:Author { Id: $viewerId })-[reaction:REACTED_TO]->(subject { Id: $subjectId }) - WHERE (subject:Comment OR subject:Discussion) - RETURN reaction.Value - """, - new - { - subjectId = subjectId.ToString(), - viewerId = viewerId.ToString() - }); - - return await data - .Select(record => record[0].As()) - .ToHashSetAsync(); - } - - public async Task SetAsync(Guid subjectId, Guid viewerId, ISet reactions) - { - var data = await runner.RunAsync( - """ - MATCH (viewer:Author { Id: $viewerId }), (subject { Id: $subjectId }) - WHERE subject:Comment OR subject:Discussion - OPTIONAL MATCH (viewer)-[reaction:REACTED_TO]->(subject) - - DELETE reaction - - WITH DISTINCT viewer, subject - FOREACH (newReactionValue IN $reactions | - CREATE (viewer)-[:REACTED_TO { Value: newReactionValue }]->(subject) - ) - - RETURN COUNT(*) - """, - new - { - subjectId = subjectId.ToString(), - viewerId = viewerId.ToString(), - reactions - }); - - var record = await data.SingleAsync(); - var notFound = record[0].As() == 0; - - if (notFound) - throw new NotFoundException(); - } -} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/StartupConfigurator.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/StartupConfigurator.cs new file mode 100644 index 0000000..1b12189 --- /dev/null +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/StartupConfigurator.cs @@ -0,0 +1,31 @@ +using CrowdParlay.Social.Domain; +using Microsoft.Extensions.Hosting; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Conventions; +using MongoDB.Bson.Serialization.Serializers; + +namespace CrowdParlay.Social.Infrastructure.Persistence.Services; + +public class StartupConfigurator : IHostedService +{ + private static int _executed; + + public Task StartAsync(CancellationToken cancellationToken) + { + // Ensure this code runs only once, so that global state configurators + // don't get called multiple times when running integration tests. + if (Interlocked.CompareExchange(ref _executed, 1, 0) == 1) + return Task.CompletedTask; + + BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); + ConventionRegistry.Register( + name: "CamelCaseElementNameConvention", + conventions: new ConventionPack { new CamelCaseElementNameConvention() }, + filter: _ => true); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} \ 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 index a744ca7..0f74172 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/UnitOfWork.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/UnitOfWork.cs @@ -1,23 +1,17 @@ using CrowdParlay.Social.Domain.Abstractions; -using Neo4j.Driver; +using MongoDB.Driver; namespace CrowdParlay.Social.Infrastructure.Persistence.Services; -public class UnitOfWork(IAsyncTransaction transaction) : IUnitOfWork +public class UnitOfWork(IClientSessionHandle session, IMongoDatabase database) : IUnitOfWork { - private readonly Lazy _authorsRepository = new(() => new(transaction)); - public IAuthorsRepository AuthorsRepository => _authorsRepository.Value; - - private readonly Lazy _discussionsRepository = new(() => new(transaction)); + private readonly Lazy _discussionsRepository = new(() => new(session, database)); public IDiscussionsRepository DiscussionsRepository => _discussionsRepository.Value; - private readonly Lazy _commentsRepository = new(() => new(transaction)); + private readonly Lazy _commentsRepository = new(() => new(session, database)); 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(); + public async Task CommitAsync() => await session.CommitTransactionAsync(); + public async Task RollbackAsync() => await session.AbortTransactionAsync(); + public void Dispose() => session.Dispose(); } \ 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 index 6901bca..e32f2df 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/UnitOfWorkFactory.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/UnitOfWorkFactory.cs @@ -1,13 +1,15 @@ using CrowdParlay.Social.Domain.Abstractions; -using Neo4j.Driver; +using Microsoft.Extensions.Options; +using MongoDB.Driver; namespace CrowdParlay.Social.Infrastructure.Persistence.Services; -public class UnitOfWorkFactory(IAsyncSession session) : IUnitOfWorkFactory +internal class UnitOfWorkFactory(IMongoClient client, IOptions settings) : IUnitOfWorkFactory { public async Task CreateAsync() { - var transaction = await session.BeginTransactionAsync(); - return new UnitOfWork(transaction); + var session = await client.StartSessionAsync(); + session.StartTransaction(); + return new UnitOfWork(session, client.GetDatabase(settings.Value.Database)); } } \ No newline at end of file diff --git a/tests/CrowdParlay.Social.IntegrationTests/Configurations.cs b/tests/CrowdParlay.Social.IntegrationTests/Configurations.cs index 0fee52b..ad303d8 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Configurations.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Configurations.cs @@ -1,5 +1,4 @@ namespace CrowdParlay.Social.IntegrationTests; -// ReSharper disable once InconsistentNaming -public record Neo4jConfiguration(string Uri, string Username, string Password); +public record MongoDbConfiguration(string ConnectionString, string Database); public record RedisConfiguration(string ConnectionString); \ 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 b3dd490..b839ac7 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/CrowdParlay.Social.IntegrationTests.csproj +++ b/tests/CrowdParlay.Social.IntegrationTests/CrowdParlay.Social.IntegrationTests.csproj @@ -15,7 +15,7 @@ - + diff --git a/tests/CrowdParlay.Social.IntegrationTests/Fixtures/WebApplicationContext.cs b/tests/CrowdParlay.Social.IntegrationTests/Fixtures/WebApplicationContext.cs index 928d07c..6ae0bed 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Fixtures/WebApplicationContext.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Fixtures/WebApplicationContext.cs @@ -1,9 +1,8 @@ ο»Ώusing CrowdParlay.Social.Api; using CrowdParlay.Social.IntegrationTests.Services; using Microsoft.AspNetCore.TestHost; -using Neo4j.Driver; using Nito.AsyncEx; -using Testcontainers.Neo4j; +using Testcontainers.MongoDb; using Testcontainers.Redis; namespace CrowdParlay.Social.IntegrationTests.Fixtures; @@ -16,49 +15,20 @@ public class WebApplicationContext public WebApplicationContext() { - // ReSharper disable once InconsistentNaming - var neo4jConfiguration = SetupNeo4j(); + var mongoDbConfiguration = SetupMongoDb(); var redisConfiguration = SetupRedis(); - var webApplicationFactory = new TestWebApplicationFactory(neo4jConfiguration, redisConfiguration); + var webApplicationFactory = new TestWebApplicationFactory(mongoDbConfiguration, redisConfiguration); Services = webApplicationFactory.Services; Server = webApplicationFactory.Server; } - // ReSharper disable once InconsistentNaming - private static Neo4jConfiguration SetupNeo4j() + private static MongoDbConfiguration SetupMongoDb() { - // ReSharper disable once InconsistentNaming - var neo4j = new Neo4jBuilder() - .WithExposedPort(7474) - .WithPortBinding(7474, true) - .WithEnvironment("NEO4JLABS_PLUGINS", "[\"apoc\"]") - .Build(); - - AsyncContext.Run(async () => await neo4j.StartAsync()); - - var configuration = new Neo4jConfiguration( - Uri: neo4j.GetConnectionString(), - Username: "neo4j", - Password: "neo4j"); - - var driver = GraphDatabase.Driver(configuration.Uri, AuthTokens.Basic(configuration.Username, configuration.Password)); - AsyncContext.Run(async () => - { - await using var session = driver.AsyncSession(); - await session.RunAsync( - """ - CREATE (discussion:Discussion { - Id: "6ef436dc-8e38-4a4b-b0e7-ff9fcd55ac0e", - Title: "Discussion about pets", - Description: "I like dogs and cats.", - CreatedAt: datetime() - })-[:AUTHORED_BY]->(author:Author { Id: "df194a2d-368c-43ea-b48d-66042f74691d" }) - """); - }); - - return configuration; + var mongoDb = new MongoDbBuilder().WithReplicaSet().Build(); + AsyncContext.Run(async () => await mongoDb.StartAsync()); + return new MongoDbConfiguration(mongoDb.GetConnectionString(), "social"); } private static RedisConfiguration SetupRedis() diff --git a/tests/CrowdParlay.Social.IntegrationTests/Services/TestWebApplicationFactory.cs b/tests/CrowdParlay.Social.IntegrationTests/Services/TestWebApplicationFactory.cs index 359559a..1b7154e 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Services/TestWebApplicationFactory.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Services/TestWebApplicationFactory.cs @@ -1,5 +1,4 @@ -ο»Ώusing CrowdParlay.Social.Api.Consumers; -using CrowdParlay.Social.Infrastructure.Communication.Services; +ο»Ώusing CrowdParlay.Social.Infrastructure.Communication.Services; using MassTransit; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -9,25 +8,24 @@ namespace CrowdParlay.Social.IntegrationTests.Services; internal class TestWebApplicationFactory( - // ReSharper disable once InconsistentNaming - Neo4jConfiguration neo4jConfiguration, + MongoDbConfiguration mongoDbConfiguration, RedisConfiguration redisConfiguration) : WebApplicationFactory where TProgram : class { protected override void ConfigureWebHost(IWebHostBuilder builder) { - builder.ConfigureAppConfiguration(configuration => configuration.AddInMemoryCollection(new Dictionary - { - ["NEO4J_URI"] = neo4jConfiguration.Uri, - ["NEO4J_USERNAME"] = neo4jConfiguration.Username, - ["NEO4J_PASSWORD"] = neo4jConfiguration.Password, - ["REDIS_CONNECTION_STRING"] = redisConfiguration.ConnectionString, - ["USERS_GRPC_ADDRESS"] = "https://users:5104", - ["TELEMETRY_SOURCE_NAME"] = "Social", - ["TELEMETRY_OTLP_EXPORTER_ENDPOINT"] = "http://localhost:8200", - ["CORS_ORIGINS"] = "http://localhost;http://localhost:1234", - ["DATA_PROTECTION_REDIS_CONNECTION_STRING"] = redisConfiguration.ConnectionString, - })); + builder.ConfigureAppConfiguration(configuration => configuration.AddInMemoryCollection( + new Dictionary + { + ["MongoDb:ConnectionString"] = mongoDbConfiguration.ConnectionString, + ["MongoDb:Database"] = mongoDbConfiguration.Database, + ["REDIS_CONNECTION_STRING"] = redisConfiguration.ConnectionString, + ["USERS_GRPC_ADDRESS"] = "https://users:5104", + ["TELEMETRY_SOURCE_NAME"] = "Social", + ["TELEMETRY_OTLP_EXPORTER_ENDPOINT"] = "http://localhost:8200", + ["CORS_ORIGINS"] = "http://localhost;http://localhost:1234", + ["DATA_PROTECTION_REDIS_CONNECTION_STRING"] = redisConfiguration.ConnectionString, + })); builder.ConfigureServices(services => { @@ -42,11 +40,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) foreach (var descriptor in massTransitDescriptors) services.Remove(descriptor); - services.AddMassTransitTestHarness(bus => - { - bus.AddDelayedMessageScheduler(); - bus.AddConsumersFromNamespaceContaining(); - }); + services.AddMassTransitTestHarness(bus => bus.AddDelayedMessageScheduler()); }); } } \ No newline at end of file diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthenticationTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthenticationTests.cs index 477d988..1794057 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthenticationTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/AuthenticationTests.cs @@ -33,15 +33,12 @@ public async Task SearchReactionsWithAccessToken() HashSet reactions = ["\u2764\ufe0f", "\ud83c\udf08"]; await using var scope = _services.CreateAsyncScope(); - var authorsRepository = scope.ServiceProvider.GetRequiredService(); - var discussionsService = scope.ServiceProvider.GetRequiredService(); - var reactionsService = scope.ServiceProvider.GetRequiredService(); + var discussionsRepository = scope.ServiceProvider.GetRequiredService(); var authorId = Guid.NewGuid(); - await authorsRepository.EnsureCreatedAsync(authorId); - var discussion = await discussionsService.CreateAsync(authorId, "Test discussion", "Description."); - await reactionsService.SetAsync(discussion.Id, authorId, reactions); + var discussionId = await discussionsRepository.CreateAsync(authorId, "Test discussion", "Description."); + await discussionsRepository.SetReactionsAsync(discussionId, authorId, reactions); var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/discussions?offset=0&count=10&authorId={authorId}"); request.Headers.Add("Authorization", "Bearer " + Authorization.ProduceAccessToken(authorId)); diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs index 251a3c6..52131d4 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs @@ -4,6 +4,7 @@ using CrowdParlay.Social.Domain.Abstractions; using CrowdParlay.Social.Domain.DTOs; using CrowdParlay.Social.Domain.Entities; +using MongoDB.Bson; namespace CrowdParlay.Social.IntegrationTests.Tests; @@ -17,24 +18,25 @@ public async Task CreateComment() // Arrange await using var scope = _services.CreateAsyncScope(); - var discussions = scope.ServiceProvider.GetRequiredService(); - var comments = scope.ServiceProvider.GetRequiredService(); + var discussions = scope.ServiceProvider.GetRequiredService(); + var comments = scope.ServiceProvider.GetRequiredService(); var authorId = Guid.NewGuid(); - var discussionId = await discussions.CreateAsync( + var discussion = await discussions.CreateAsync( authorId: authorId, title: "Discussion", - description: "Test discussion."); - + content: "Test discussion."); + // Act - var commentId = await comments.CreateAsync(authorId, discussionId, "Comment content"); - var comment = await comments.GetByIdAsync(commentId, authorId); + var comment = await comments.ReplyToDiscussionAsync(discussion.Id, authorId, "Comment content"); + await comments.ReplyToCommentAsync(comment.Id, authorId, "Reply content"); + comment = await comments.GetByIdAsync(comment.Id, authorId); // Assert - comment.AuthorId.Should().Be(authorId); + comment.Author!.Id.Should().Be(authorId); comment.Content.Should().Be("Comment content"); - comment.ReplyCount.Should().Be(0); - comment.LastRepliesAuthorIds.Should().BeEmpty(); + comment.CommentCount.Should().Be(1); + comment.LastCommentsAuthors.Should().ContainSingle().Which.Id.Should().Be(authorId); comment.CreatedAt.Should().BeCloseTo(DateTime.Now, TimeSpan.FromMinutes(1)); comment.ReactionCounters.Should().BeEmpty(); comment.ViewerReactions.Should().BeEmpty(); @@ -76,30 +78,30 @@ public async Task SearchComments() var discussionId = await discussions.CreateAsync( authorId: authorId1, title: "Discussion", - description: "Test discussion."); + content: "Test discussion."); - 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 commentId1 = await comments.CreateAsync(discussionId, authorId1, "Comment 1"); + var commentId2 = await comments.CreateAsync(discussionId, authorId1, "Comment 2"); + var commentId3 = await comments.CreateAsync(discussionId, authorId4, "Comment 3"); - 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 commentId11 = await comments.CreateAsync(commentId1, authorId1, "Comment 11"); + var commentId12 = await comments.CreateAsync(commentId1, authorId1, "Comment 12"); + var commentId13 = await comments.CreateAsync(commentId1, authorId3, "Comment 13"); + var commentId14 = await comments.CreateAsync(commentId1, authorId4, "Comment 14"); + var commentId21 = await comments.CreateAsync(commentId2, authorId3, "Comment 21"); - 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"); + var commentId111 = await comments.CreateAsync(commentId1, authorId1, "Comment 111"); + var commentId112 = await comments.CreateAsync(commentId1, authorId2, "Comment 112"); + var commentId121 = await comments.CreateAsync(commentId1, authorId4, "Comment 121"); 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( + var page = await comments.GetRepliesAsync( discussionId, - authorId: null, + flatten: false, viewerId, offset: 0, count: 2); @@ -108,7 +110,7 @@ public async Task SearchComments() page.TotalCount.Should().Be(3); page.Items.Should().HaveCount(2); page.Items.Should().BeEquivalentTo([comment1, comment2]); - page.Items.First().LastRepliesAuthorIds.Should().BeEquivalentTo([authorId4, authorId2, authorId1]); + //page.Items.First().LastRepliesAuthorIds.Should().BeEquivalentTo([authorId4, authorId2, authorId1]); page.Items.Should().OnlyContain(comment => comment.ReactionCounters.Count == 0); page.Items.Should().OnlyContain(comment => comment.ViewerReactions.Count == 0); } @@ -121,7 +123,8 @@ public async Task GetComment_WithUnknownId_ThrowsNotFoundException() var comments = scope.ServiceProvider.GetRequiredService(); // Act - Func getComment = async () => await comments.GetByIdAsync(Guid.NewGuid(), Guid.NewGuid()); + var getComment = async () => + await comments.GetByIdAsync(ObjectId.GenerateNewId().ToString(), Guid.NewGuid()); // Assert await getComment.Should().ThrowAsync(); @@ -137,12 +140,12 @@ public async Task GetRepliesToComment() var authorId = Guid.NewGuid(); 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 commentId = await comments.CreateAsync(discussionId, authorId, "Comment content"); + var replyId = await comments.CreateAsync(commentId, authorId, "Reply content"); var reply = await comments.GetByIdAsync(replyId, authorId); // Act - var page = await comments.SearchAsync(commentId, authorId: null, authorId, offset: 0, count: 1); + var page = await comments.GetRepliesAsync(commentId, true, authorId, offset: 0, count: 1); // Assert page.Should().BeEquivalentTo(new Page @@ -152,16 +155,16 @@ public async Task GetRepliesToComment() }); } - [Fact(DisplayName = "Create comment with unknown author and discussion")] - public async Task CreateComment_WithUnknownAuthorAndDiscussion_ThrowsNotFoundException() + [Fact(DisplayName = "Create comment with unknown discussion")] + public async Task CreateComment_WithUnknownDiscussion_ThrowsNotFoundException() { // Arrange await using var scope = _services.CreateAsyncScope(); - var comments = scope.ServiceProvider.GetRequiredService(); + var comments = scope.ServiceProvider.GetRequiredService(); // Act - Func createComment = async () => - await comments.CreateAsync(Guid.NewGuid(), Guid.NewGuid(), "Comment content"); + var createComment = async () => + await comments.ReplyToDiscussionAsync(ObjectId.GenerateNewId().ToString(), Guid.NewGuid(), "Comment content"); // Assert await createComment.Should().ThrowAsync(); diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsRepositoryTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsRepositoryTests.cs index 23cd878..06ef2c9 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsRepositoryTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsRepositoryTests.cs @@ -14,7 +14,7 @@ public async Task GetDiscussionsByAuthor() var discussions = scope.ServiceProvider.GetRequiredService(); var authorId = Guid.NewGuid(); - Guid[] expectedDiscussionIds = + string[] expectedDiscussionIds = [ await discussions.CreateAsync(authorId, "Discussion 1", "bla bla bla"), await discussions.CreateAsync(authorId, "Discussion 2", "numa numa e") @@ -51,32 +51,45 @@ public async Task GetDiscussionWithReactions() { // Arrange await using var scope = _services.CreateAsyncScope(); - var authorsRepository = scope.ServiceProvider.GetRequiredService(); - var discussionsRepository = scope.ServiceProvider.GetRequiredService(); - var reactionsRepository = scope.ServiceProvider.GetRequiredService(); + var discussionsService = scope.ServiceProvider.GetRequiredService(); - var authorId = Guid.NewGuid(); - await authorsRepository.EnsureCreatedAsync(authorId); + const string heart = "\u2764\ufe0f"; + const string thumbUp = "\ud83d\udc4d"; + const string thumbDown = "\ud83d\udc4e"; + var authorId = Guid.NewGuid(); var viewerId = Guid.NewGuid(); - await authorsRepository.EnsureCreatedAsync(viewerId); - await discussionsRepository.CreateAsync(viewerId, "Discussion 1", "bla bla bla"); - var discussionId = await discussionsRepository.CreateAsync(authorId, "Discussion 2", "bla bla bla"); + await discussionsService.CreateAsync(viewerId, "Discussion 1", "bla bla bla"); + var discussion = await discussionsService.CreateAsync(authorId, "Discussion 2", "bla bla bla"); - await reactionsRepository.SetAsync(discussionId, authorId, new HashSet { "thumb-up", "heart" }); - await reactionsRepository.SetAsync(discussionId, viewerId, new HashSet { "thumb-up", "rainbow" }); + await discussionsService.SetReactionsAsync(discussion.Id, authorId, new HashSet { thumbUp, heart }); + await discussionsService.SetReactionsAsync(discussion.Id, viewerId, new HashSet { thumbUp, thumbDown }); // Act - var discussion = await discussionsRepository.GetByIdAsync(discussionId, viewerId); + discussion = await discussionsService.GetByIdAsync(discussion.Id, viewerId); // Assert - discussion.ViewerReactions.Should().BeEquivalentTo("thumb-up", "rainbow"); + discussion.ViewerReactions.Should().BeEquivalentTo(thumbUp, thumbDown); discussion.ReactionCounters.Should().BeEquivalentTo(new Dictionary { - ["thumb-up"] = 2, - ["heart"] = 1, - ["rainbow"] = 1 + { thumbUp, 2 }, + { heart, 1 }, + { thumbDown, 1 } }); } + + [Theory(DisplayName = "Create discussions in parallel")] + [InlineData(1), InlineData(2), InlineData(100)] + public async Task CreateDiscussionsInParallel(int degreeOfParallelism) + { + var tasks = Enumerable.Range(0, degreeOfParallelism).Select(i => Task.Run(async () => + { + await using var scope = _services.CreateAsyncScope(); + var discussionsService = scope.ServiceProvider.GetRequiredService(); + await discussionsService.CreateAsync(Guid.NewGuid(), $"Discussion {i}", $"Content {i}"); + })); + + await Task.WhenAll(tasks); + } } \ No newline at end of file diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/HttpContractsTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/HttpContractsTests.cs index 01ce350..ac5716a 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/HttpContractsTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/HttpContractsTests.cs @@ -1,11 +1,14 @@ using System.Net.Http.Json; using System.Text.Json; +using CrowdParlay.Social.Domain.Abstractions; +using MongoDB.Bson; namespace CrowdParlay.Social.IntegrationTests.Tests; public class HttpContractsTests(WebApplicationContext context) : IAssemblyFixture { private readonly HttpClient _client = context.Server.CreateClient(); + private readonly IServiceProvider _services = context.Services; [Fact(DisplayName = "Preflight request to SignalR returns specific CORS allowed origins")] public async Task PreflightRequestToSignalRReturnsSpecificCorsAllowedOrigins() @@ -32,7 +35,7 @@ public async Task NotFoundIsReturnedAsProblemDetails() const string expected = """{"status":404,"detail":"The requested resource doesn\u0027t exist."}"""; // Act - var response = await _client.GetAsync($"/api/v1/comments/{Guid.Empty}"); + var response = await _client.GetAsync($"/api/v1/comments/{ObjectId.Empty}"); var actual = await response.Content.ReadAsStringAsync(); // Assert @@ -42,8 +45,13 @@ public async Task NotFoundIsReturnedAsProblemDetails() [Fact(DisplayName = "Responses are JSON-serialized in snake case")] public async Task ResponsesAreJsonSerializedInSnakeCase() { + // Arrange + await using var scope = _services.CreateAsyncScope(); + var discussions = scope.ServiceProvider.GetRequiredService(); + var discussionId = await discussions.CreateAsync(Guid.NewGuid(), "Title", "Content"); + // Act - var response = await _client.GetAsync("/api/v1/discussions/6ef436dc-8e38-4a4b-b0e7-ff9fcd55ac0e"); + var response = await _client.GetAsync($"/api/v1/discussions/{discussionId}"); var rawResponseContent = await response.Content.ReadAsStringAsync(); var discussion = await response.Content.ReadFromJsonAsync(GlobalSerializerOptions.SnakeCase); var serializedInSnakeCase = JsonSerializer.Serialize(discussion, GlobalSerializerOptions.SnakeCase); diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsRepositoryTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsRepositoryTests.cs index b76db6e..8722677 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsRepositoryTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsRepositoryTests.cs @@ -21,24 +21,19 @@ public async Task SetReactions_OnlyWorksWithAllowedReactionSets() HashSet heartAndOldInvalid = [heart, oldInvalid]; await using var scope = _services.CreateAsyncScope(); - var authorsRepository = scope.ServiceProvider.GetRequiredService(); + var discussionsService = scope.ServiceProvider.GetRequiredService(); var discussionsRepository = scope.ServiceProvider.GetRequiredService(); - var reactionsRepository = scope.ServiceProvider.GetRequiredService(); - var reactionsService = scope.ServiceProvider.GetRequiredService(); var viewerId = Guid.NewGuid(); - await authorsRepository.EnsureCreatedAsync(viewerId); - - var discussionId = await discussionsRepository.CreateAsync(viewerId, "Title", "Description"); - await reactionsRepository.SetAsync(discussionId, viewerId, new HashSet { oldInvalid }); + var discussion = await discussionsService.CreateAsync(viewerId, "Title", "Description"); // Act - await reactionsService.SetAsync(discussionId, viewerId, heartAndOldInvalid); - await reactionsService.SetAsync(discussionId, viewerId, new HashSet()); - await reactionsService.SetAsync(discussionId, viewerId, thumbs); + await discussionsRepository.SetReactionsAsync(discussion.Id, viewerId, heartAndOldInvalid); + await discussionsRepository.SetReactionsAsync(discussion.Id, viewerId, new HashSet()); + await discussionsRepository.SetReactionsAsync(discussion.Id, viewerId, thumbs); - var reactWithNewInvalid = async () => await reactionsService.SetAsync(discussionId, viewerId, thumbsAndNewInvalid); - var reactions = await reactionsService.GetAsync(discussionId, viewerId); + var reactWithNewInvalid = async () => await discussionsService.SetReactionsAsync(discussion.Id, viewerId, thumbsAndNewInvalid); + var reactions = await discussionsService.GetReactionsAsync(discussion.Id, viewerId); // Assert await reactWithNewInvalid.Should().ThrowAsync(); @@ -50,27 +45,31 @@ public async Task SetReactions_OverwritesExistingReactions() { // Arrange await using var scope = _services.CreateAsyncScope(); - var authorsRepository = scope.ServiceProvider.GetRequiredService(); - var discussionsRepository = scope.ServiceProvider.GetRequiredService(); - var reactionsRepository = scope.ServiceProvider.GetRequiredService(); + var discussionsService = scope.ServiceProvider.GetRequiredService(); var viewerId = Guid.NewGuid(); - await authorsRepository.EnsureCreatedAsync(viewerId); - var discussionId = await discussionsRepository.CreateAsync(viewerId, "Title", "Description"); + var discussion = await discussionsService.CreateAsync(viewerId, "Title", "Description"); + + const string eggplant = "\ud83c\udf46"; + const string woozyFace = "\ud83e\udd74"; + const string nailPolish = "\ud83d\udc85"; + const string redHeart = "\u2764\ufe0f"; // Act - await reactionsRepository.SetAsync(discussionId, viewerId, new HashSet { "a" }); - await reactionsRepository.SetAsync(discussionId, viewerId, new HashSet { "a", "b" }); - await reactionsRepository.SetAsync(discussionId, viewerId, new HashSet { "a", "b", "c" }); - await reactionsRepository.SetAsync(discussionId, viewerId, new HashSet { "b", "d" }); + await discussionsService.SetReactionsAsync(discussion.Id, viewerId, new HashSet { eggplant }); + await discussionsService.SetReactionsAsync(discussion.Id, viewerId, new HashSet { eggplant, woozyFace }); + await discussionsService.SetReactionsAsync(discussion.Id, viewerId, new HashSet { eggplant, woozyFace, nailPolish }); + await discussionsService.SetReactionsAsync(discussion.Id, viewerId, new HashSet { woozyFace, redHeart }); // Assert - var discussion = await discussionsRepository.GetByIdAsync(discussionId, viewerId); - discussion.ViewerReactions.Should().BeEquivalentTo(["b", "d"]); + discussion = await discussionsService.GetByIdAsync(discussion.Id, viewerId); + discussion.ViewerReactions.Should().BeEquivalentTo(woozyFace, redHeart); discussion.ReactionCounters.Should().BeEquivalentTo(new Dictionary { - { "b", 1 }, - { "d", 1 } + { eggplant, 0 }, + { woozyFace, 1 }, + { nailPolish, 0 }, + { redHeart, 1 } }); } } \ No newline at end of file diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/SignalRTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/SignalRTests.cs index 21e67ea..3278be6 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/SignalRTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/SignalRTests.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR.Client; +using MongoDB.Bson; namespace CrowdParlay.Social.IntegrationTests.Tests; @@ -19,10 +20,10 @@ public async Task ListenToNewCommentsInDiscussion() var commentsHub = scope.ServiceProvider.GetRequiredService>(); var newComments = new List(); - var discussionId = new Guid("6ef436dc-8e38-4a4b-b0e7-ff9fcd55ac0e"); + var discussionId = ObjectId.GenerateNewId().ToString(); var expectedComment = new CommentResponse { - Id = Guid.NewGuid(), + Id = ObjectId.GenerateNewId().ToString(), Content = "Sample comment.", Author = new AuthorResponse { @@ -32,8 +33,8 @@ public async Task ListenToNewCommentsInDiscussion() AvatarUrl = null }, CreatedAt = DateTimeOffset.Now, - ReplyCount = 0, - LastRepliesAuthors = [], + CommentCount = 0, + LastCommentsAuthors = [], ReactionCounters = new Dictionary(), ViewerReactions = new HashSet() }; From 7a1a643c0f23ba19050ab64bd0ff0b5fe4edb9ef Mon Sep 17 00:00:00 2001 From: undrcrxwn <69521267+undrcrxwn@users.noreply.github.com> Date: Sun, 8 Jun 2025 23:15:43 +0300 Subject: [PATCH 05/12] fix(comments): fix GraphLookup logic in GetRepliesAsync --- .../Services/CommentsService.cs | 8 +- .../Models/CommentDocument.cs | 1 + .../Services/CommentsRepository.cs | 53 +++-- .../Services/GenericSubjectsRepository.cs | 4 +- .../Tests/CommentsRepositoryTests.cs | 172 --------------- .../Tests/CommentsServiceTests.cs | 196 ++++++++++++++++-- .../Tests/UsersServiceTests.cs | 37 ++++ 7 files changed, 243 insertions(+), 228 deletions(-) delete mode 100644 tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs create mode 100644 tests/CrowdParlay.Social.IntegrationTests/Tests/UsersServiceTests.cs diff --git a/src/CrowdParlay.Social.Application/Services/CommentsService.cs b/src/CrowdParlay.Social.Application/Services/CommentsService.cs index 177f6e9..1e18d94 100644 --- a/src/CrowdParlay.Social.Application/Services/CommentsService.cs +++ b/src/CrowdParlay.Social.Application/Services/CommentsService.cs @@ -32,16 +32,16 @@ public async Task> GetRepliesAsync(string subjectId, bool Items = await EnrichAsync(page.Items.ToArray()) }; } - + public async Task ReplyToDiscussionAsync(string discussionId, Guid authorId, string content) { - _ = await discussionsRepository.GetByIdAsync(discussionId, null); + await discussionsRepository.GetByIdAsync(discussionId, null); return await CreateAsync(discussionId, authorId, content); } - + public async Task ReplyToCommentAsync(string commentId, Guid authorId, string content) { - _ = await commentsRepository.GetByIdAsync(commentId, null); + await commentsRepository.GetByIdAsync(commentId, null); return await CreateAsync(commentId, authorId, content); } diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs index 7ecbe16..5d86cdd 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs @@ -16,4 +16,5 @@ public class CommentDocument : ISubjectDocument public required IDictionary ReactionsByAuthorId { get; set; } [BsonIgnoreIfNull] public IList? Ancestors { get; set; } [BsonIgnoreIfNull] public IList? Ascendants { get; set; } + [BsonIgnoreIfNull] public int? Depth { get; set; } } \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs index 5a27a9a..bc3cade 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs @@ -27,22 +27,28 @@ public async Task GetByIdAsync(string commentId, Guid? viewerId) return await pipeline.FirstOrDefaultAsync() ?? throw new NotFoundException(); } - public async Task> GetRepliesAsync( - string subjectId, bool flatten, Guid? viewerId, int offset, int count) + public async Task> GetRepliesAsync(string subjectId, bool flatten, Guid? viewerId, int offset, int count) { - var matchPipeline = _comments.Aggregate(session) - .Match(comment => comment.SubjectId == ObjectId.Parse(subjectId)); + var matchPipeline = _comments.Aggregate(session); if (flatten) { matchPipeline = matchPipeline - .GraphLookup, - CommentDocument>( + .Limit(1) + .GraphLookup, CommentDocument>( from: _comments, - startWith: comment => comment.Id, + startWith: _ => ObjectId.Parse(subjectId), connectFromField: comment => comment.Id, connectToField: comment => comment.SubjectId, - @as: comment => comment.Ascendants!); + @as: comment => comment.Ascendants!, + depthField: comment => comment.Depth!.Value) + .Unwind(comment => comment.Ascendants) + .ReplaceRoot("$ascendants"); + } + else + { + matchPipeline = matchPipeline + .Match(comment => comment.SubjectId == ObjectId.Parse(subjectId)); } var pipeline = matchPipeline.Facet( @@ -91,38 +97,27 @@ public async Task CreateAsync(string? subjectId, Guid authorId, string c public async Task> GetAncestorsAsync(string commentId, Guid? viewerId) => await _comments.Aggregate(session) .Match(comment => comment.Id == ObjectId.Parse(commentId)) - .GraphLookup, CommentDocument>( + .GraphLookup, CommentDocument>( from: _comments, startWith: comment => comment.SubjectId, connectFromField: comment => comment.SubjectId, connectToField: comment => comment.Id, - @as: comment => comment.Ancestors!) - .Project(comment => comment.Ancestors!.Select(ancestor => new Comment - { - Id = ancestor.Id.ToString(), - SubjectId = ancestor.SubjectId.ToString(), - CreatedAt = ancestor.CreatedAt, - AuthorId = ancestor.AuthorId, - Content = ancestor.Content, - CommentCount = ancestor.CommentCount, - LastCommentsAuthorIds = ancestor.LastCommentsAuthorIds, - ReactionCounters = ancestor.ReactionCounters, - ViewerReactions = - viewerId.HasValue && - ancestor.ReactionsByAuthorId.ContainsKey(viewerId.Value.ToString()) - ? ancestor.ReactionsByAuthorId[viewerId.Value.ToString()] - : Array.Empty() - }).ToList()) - .FirstOrDefaultAsync(); + @as: comment => comment.Ancestors!, + depthField: comment => comment.Depth!.Value) + .Unwind(comment => comment.Ancestors) + .ReplaceRoot("$ancestors") + .SortBy(comment => comment.Depth) + .Project(CreateCommentProjectionExpression(viewerId)) + .ToListAsync(); public async Task IncludeCommentInAncestorsMetadataAsync(IEnumerable ancestors, Guid authorId) { var updates = ancestors.Select(ancestor => { var newLastCommentsAuthorIds = ancestor.LastCommentsAuthorIds + .Except([authorId]) .Append(authorId) - .Distinct() - .TakeLast(5) + .TakeLast(3) .ToList(); var filter = Filter.Eq(comment => comment.Id, ObjectId.Parse(ancestor.Id)); diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GenericSubjectsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GenericSubjectsRepository.cs index 4bded23..a0c425e 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GenericSubjectsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/GenericSubjectsRepository.cs @@ -64,9 +64,9 @@ await _subjects ?? throw new NotFoundException(); var newLastCommentAuthorIds = oldLastCommentsAuthorIds + .Except([authorId]) .Append(authorId) - .Distinct() - .TakeLast(5) + .TakeLast(3) .ToList(); var filter = Builders.Filter.Eq(subject => subject.Id, ObjectId.Parse(subjectId)); diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs deleted file mode 100644 index 52131d4..0000000 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs +++ /dev/null @@ -1,172 +0,0 @@ -// ReSharper disable UnusedVariable -// ReSharper disable RedundantAssignment - -using CrowdParlay.Social.Domain.Abstractions; -using CrowdParlay.Social.Domain.DTOs; -using CrowdParlay.Social.Domain.Entities; -using MongoDB.Bson; - -namespace CrowdParlay.Social.IntegrationTests.Tests; - -public class CommentsRepositoryTests(WebApplicationContext context) : IAssemblyFixture -{ - private readonly IServiceProvider _services = context.Services; - - [Fact(DisplayName = "Create comment")] - public async Task CreateComment() - { - // Arrange - await using var scope = _services.CreateAsyncScope(); - - var discussions = scope.ServiceProvider.GetRequiredService(); - var comments = scope.ServiceProvider.GetRequiredService(); - - var authorId = Guid.NewGuid(); - var discussion = await discussions.CreateAsync( - authorId: authorId, - title: "Discussion", - content: "Test discussion."); - - // Act - var comment = await comments.ReplyToDiscussionAsync(discussion.Id, authorId, "Comment content"); - await comments.ReplyToCommentAsync(comment.Id, authorId, "Reply content"); - comment = await comments.GetByIdAsync(comment.Id, authorId); - - // Assert - comment.Author!.Id.Should().Be(authorId); - comment.Content.Should().Be("Comment content"); - comment.CommentCount.Should().Be(1); - comment.LastCommentsAuthors.Should().ContainSingle().Which.Id.Should().Be(authorId); - comment.CreatedAt.Should().BeCloseTo(DateTime.Now, TimeSpan.FromMinutes(1)); - comment.ReactionCounters.Should().BeEmpty(); - comment.ViewerReactions.Should().BeEmpty(); - } - - [Fact(DisplayName = "Search comments")] - public async Task SearchComments() - { - /* - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ COMMENT β”‚ AUTHOR β”‚ - β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ - β”‚ 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 β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - */ - - // Arrange - await using var scope = _services.CreateAsyncScope(); - - var discussions = 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 viewerId = Guid.NewGuid(); - - var discussionId = await discussions.CreateAsync( - authorId: authorId1, - title: "Discussion", - content: "Test discussion."); - - var commentId1 = await comments.CreateAsync(discussionId, authorId1, "Comment 1"); - var commentId2 = await comments.CreateAsync(discussionId, authorId1, "Comment 2"); - var commentId3 = await comments.CreateAsync(discussionId, authorId4, "Comment 3"); - - var commentId11 = await comments.CreateAsync(commentId1, authorId1, "Comment 11"); - var commentId12 = await comments.CreateAsync(commentId1, authorId1, "Comment 12"); - var commentId13 = await comments.CreateAsync(commentId1, authorId3, "Comment 13"); - var commentId14 = await comments.CreateAsync(commentId1, authorId4, "Comment 14"); - var commentId21 = await comments.CreateAsync(commentId2, authorId3, "Comment 21"); - - var commentId111 = await comments.CreateAsync(commentId1, authorId1, "Comment 111"); - var commentId112 = await comments.CreateAsync(commentId1, authorId2, "Comment 112"); - var commentId121 = await comments.CreateAsync(commentId1, authorId4, "Comment 121"); - - 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.GetRepliesAsync( - discussionId, - flatten: false, - viewerId, - offset: 0, - count: 2); - - // Assert - page.TotalCount.Should().Be(3); - page.Items.Should().HaveCount(2); - page.Items.Should().BeEquivalentTo([comment1, comment2]); - //page.Items.First().LastRepliesAuthorIds.Should().BeEquivalentTo([authorId4, authorId2, authorId1]); - page.Items.Should().OnlyContain(comment => comment.ReactionCounters.Count == 0); - page.Items.Should().OnlyContain(comment => comment.ViewerReactions.Count == 0); - } - - [Fact(DisplayName = "Get comment with unknown ID")] - public async Task GetComment_WithUnknownId_ThrowsNotFoundException() - { - // Arrange - await using var scope = _services.CreateAsyncScope(); - var comments = scope.ServiceProvider.GetRequiredService(); - - // Act - var getComment = async () => - await comments.GetByIdAsync(ObjectId.GenerateNewId().ToString(), Guid.NewGuid()); - - // Assert - await getComment.Should().ThrowAsync(); - } - - [Fact(DisplayName = "Get replies to comment")] - public async Task GetRepliesToComment() - { - // Arrange - await using var scope = _services.CreateAsyncScope(); - var discussions = scope.ServiceProvider.GetRequiredService(); - var comments = scope.ServiceProvider.GetRequiredService(); - - var authorId = Guid.NewGuid(); - var discussionId = await discussions.CreateAsync(authorId, "Discussion", "Test discussion."); - var commentId = await comments.CreateAsync(discussionId, authorId, "Comment content"); - var replyId = await comments.CreateAsync(commentId, authorId, "Reply content"); - var reply = await comments.GetByIdAsync(replyId, authorId); - - // Act - var page = await comments.GetRepliesAsync(commentId, true, authorId, offset: 0, count: 1); - - // Assert - page.Should().BeEquivalentTo(new Page - { - TotalCount = 1, - Items = [reply] - }); - } - - [Fact(DisplayName = "Create comment with unknown discussion")] - public async Task CreateComment_WithUnknownDiscussion_ThrowsNotFoundException() - { - // Arrange - await using var scope = _services.CreateAsyncScope(); - var comments = scope.ServiceProvider.GetRequiredService(); - - // Act - var createComment = async () => - await comments.ReplyToDiscussionAsync(ObjectId.GenerateNewId().ToString(), Guid.NewGuid(), "Comment content"); - - // Assert - await createComment.Should().ThrowAsync(); - } -} \ No newline at end of file diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsServiceTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsServiceTests.cs index 4b613da..6d07276 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsServiceTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsServiceTests.cs @@ -1,37 +1,191 @@ -using System.Text.Json; -using StackExchange.Redis; +using CrowdParlay.Social.Domain.DTOs; +using MongoDB.Bson; namespace CrowdParlay.Social.IntegrationTests.Tests; -public class CommentsServiceTests(WebApplicationContext context) : IClassFixture +public class CommentsServiceTests(WebApplicationContext context) : IAssemblyFixture { private readonly IServiceProvider _services = context.Services; - [Fact(DisplayName = "Get user by ID returns cached user")] - public async Task GetUserByIdReturnsCachedUser() + [Fact(DisplayName = "Create comment")] + public async Task CreateComment() { // Arrange await using var scope = _services.CreateAsyncScope(); - var usersService = scope.ServiceProvider.GetRequiredService(); - var redisDatabase = scope.ServiceProvider.GetRequiredService(); - var expectedUser = new UserDto + var discussions = scope.ServiceProvider.GetRequiredService(); + var comments = scope.ServiceProvider.GetRequiredService(); + + var authorId = Guid.NewGuid(); + var discussion = await discussions.CreateAsync( + authorId: authorId, + title: "Discussion", + content: "Test discussion."); + + // Act + var comment = await comments.ReplyToDiscussionAsync(discussion.Id, authorId, "Comment content"); + await comments.ReplyToCommentAsync(comment.Id, authorId, "Reply content"); + comment = await comments.GetByIdAsync(comment.Id, authorId); + + // Assert + comment.Author!.Id.Should().Be(authorId); + comment.Content.Should().Be("Comment content"); + comment.CommentCount.Should().Be(1); + comment.LastCommentsAuthors.Should().ContainSingle().Which.Id.Should().Be(authorId); + comment.CreatedAt.Should().BeCloseTo(DateTime.Now, TimeSpan.FromMinutes(1)); + comment.ReactionCounters.Should().BeEmpty(); + comment.ViewerReactions.Should().BeEmpty(); + } + + [Theory(DisplayName = "Get replies")] + [InlineData(true), InlineData(false)] + public async Task GetReplies(bool flatten) + { + /* + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ COMMENT β”‚ AUTHOR β”‚ + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ + β”‚ 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 β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + */ + + // Arrange + await using var scope = _services.CreateAsyncScope(); + + var discussions = 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 viewerId = Guid.NewGuid(); + + var discussion = await discussions.CreateAsync( + authorId: authorId1, + title: "Discussion", + content: "Test discussion."); + + // ReSharper disable UnusedVariable + // ReSharper disable RedundantAssignment + + var comment1 = await comments.ReplyToDiscussionAsync(discussion.Id, authorId1, "Comment 1"); + + var comment11 = await comments.ReplyToCommentAsync(comment1.Id, authorId1, "Comment 11"); + var comment12 = await comments.ReplyToCommentAsync(comment1.Id, authorId1, "Comment 12"); + + var comment2 = await comments.ReplyToDiscussionAsync(discussion.Id, authorId1, "Comment 2"); + + var comment13 = await comments.ReplyToCommentAsync(comment1.Id, authorId3, "Comment 13"); + var comment14 = await comments.ReplyToCommentAsync(comment1.Id, authorId4, "Comment 14"); + var comment21 = await comments.ReplyToCommentAsync(comment2.Id, authorId3, "Comment 21"); + + var comment111 = await comments.ReplyToCommentAsync(comment11.Id, authorId1, "Comment 111"); + var comment112 = await comments.ReplyToCommentAsync(comment11.Id, authorId2, "Comment 112"); + + var comment3 = await comments.ReplyToDiscussionAsync(discussion.Id, authorId4, "Comment 3"); + + var comment121 = await comments.ReplyToCommentAsync(comment12.Id, authorId4, "Comment 121"); + + comment1 = await comments.GetByIdAsync(comment1.Id, viewerId); + comment11 = await comments.GetByIdAsync(comment11.Id, viewerId); + comment111 = await comments.GetByIdAsync(comment111.Id, viewerId); + comment112 = await comments.GetByIdAsync(comment112.Id, viewerId); + comment12 = await comments.GetByIdAsync(comment12.Id, viewerId); + comment121 = await comments.GetByIdAsync(comment121.Id, viewerId); + comment13 = await comments.GetByIdAsync(comment13.Id, viewerId); + comment14 = await comments.GetByIdAsync(comment14.Id, viewerId); + comment2 = await comments.GetByIdAsync(comment2.Id, viewerId); + comment21 = await comments.GetByIdAsync(comment21.Id, viewerId); + comment3 = await comments.GetByIdAsync(comment3.Id, viewerId); + + // ReSharper restore UnusedVariable + // ReSharper restore RedundantAssignment + + // Act + var page = await comments.GetRepliesAsync(discussion.Id, flatten, viewerId, offset: 0, count: 5); + + // Assert + if (flatten) { - Id = Guid.NewGuid(), - Username = "cached_user", - DisplayName = "Cached user", - AvatarUrl = null - }; - - await redisDatabase.StringSetAsync( - expectedUser.Id.ToString(), - JsonSerializer.Serialize(expectedUser), - TimeSpan.FromMinutes(1)); + page.TotalCount.Should().Be(11); + page.Items.Should().HaveCount(5); + page.Items.Should().BeEquivalentTo([comment1, comment11, comment12, comment2, comment13]); + } + else + { + page.TotalCount.Should().Be(3); + page.Items.Should().HaveCount(3); + page.Items.Should().BeEquivalentTo([comment1, comment2, comment3]); + page.Items.First().LastCommentsAuthors.Select(author => author.Id).Should().BeEquivalentTo([authorId1, authorId2, authorId4]); + } + page.Items.First().LastCommentsAuthors.Select(author => author.Id).Should().BeEquivalentTo([authorId1, authorId2, authorId4]); + page.Items.Should().OnlyContain(comment => comment.ReactionCounters.Count == 0); + page.Items.Should().OnlyContain(comment => comment.ViewerReactions.Count == 0); + } + + [Fact(DisplayName = "Get comment with unknown ID")] + public async Task GetComment_WithUnknownId_ThrowsNotFoundException() + { + // Arrange + await using var scope = _services.CreateAsyncScope(); + var comments = scope.ServiceProvider.GetRequiredService(); + + // Act + var getComment = async () => + await comments.GetByIdAsync(ObjectId.GenerateNewId().ToString(), Guid.NewGuid()); + + // Assert + await getComment.Should().ThrowAsync(); + } + + [Fact(DisplayName = "Get replies to comment")] + public async Task GetRepliesToComment() + { + // Arrange + await using var scope = _services.CreateAsyncScope(); + var discussions = 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.ReplyToDiscussionAsync(discussion.Id, authorId, "Comment content"); + var reply = await comments.ReplyToCommentAsync(comment.Id, authorId, "Reply content"); + + // Act + var page = await comments.GetRepliesAsync(comment.Id, true, authorId, offset: 0, count: 1); + + // Assert + page.Should().BeEquivalentTo(new Page + { + TotalCount = 1, + Items = [reply] + }); + } + + [Fact(DisplayName = "Create comment with unknown discussion")] + public async Task CreateComment_WithUnknownDiscussion_ThrowsNotFoundException() + { + // Arrange + await using var scope = _services.CreateAsyncScope(); + var comments = scope.ServiceProvider.GetRequiredService(); + // Act - var actualUser = await usersService.GetByIdAsync(expectedUser.Id); + var createComment = async () => + await comments.ReplyToDiscussionAsync(ObjectId.GenerateNewId().ToString(), Guid.NewGuid(), "Comment content"); // Assert - actualUser.Should().BeEquivalentTo(expectedUser); + await createComment.Should().ThrowAsync(); } -} +} \ No newline at end of file diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/UsersServiceTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/UsersServiceTests.cs new file mode 100644 index 0000000..9e4c725 --- /dev/null +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/UsersServiceTests.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using StackExchange.Redis; + +namespace CrowdParlay.Social.IntegrationTests.Tests; + +public class UsersServiceTests(WebApplicationContext context) : IClassFixture +{ + private readonly IServiceProvider _services = context.Services; + + [Fact(DisplayName = "Get user by ID returns cached user")] + public async Task GetUserByIdReturnsCachedUser() + { + // Arrange + await using var scope = _services.CreateAsyncScope(); + var usersService = scope.ServiceProvider.GetRequiredService(); + var redisDatabase = scope.ServiceProvider.GetRequiredService(); + + var expectedUser = new UserDto + { + Id = Guid.NewGuid(), + Username = "cached_user", + DisplayName = "Cached user", + AvatarUrl = null + }; + + await redisDatabase.StringSetAsync( + expectedUser.Id.ToString(), + JsonSerializer.Serialize(expectedUser), + TimeSpan.FromMinutes(1)); + + // Act + var actualUser = await usersService.GetByIdAsync(expectedUser.Id); + + // Assert + actualUser.Should().BeEquivalentTo(expectedUser); + } +} \ No newline at end of file From e02f40d7c30868ebfe38b82dea42bdf74c9f3401 Mon Sep 17 00:00:00 2001 From: undrcrxwn <69521267+undrcrxwn@users.noreply.github.com> Date: Sun, 8 Jun 2025 23:18:28 +0300 Subject: [PATCH 06/12] refactor: remove redundant using directives and specify DebuggerDisplay on types --- .../Entities/Comment.cs | 4 ++++ .../Entities/Discussion.cs | 4 ++++ .../Models/CommentDocument.cs | 2 ++ .../Models/DiscussionDocument.cs | 2 ++ .../Services/StartupConfigurator.cs | 1 - .../Services/TestWebApplicationFactory.cs | 23 +++++++++---------- 6 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/CrowdParlay.Social.Domain/Entities/Comment.cs b/src/CrowdParlay.Social.Domain/Entities/Comment.cs index 6b9e4bc..b28710c 100644 --- a/src/CrowdParlay.Social.Domain/Entities/Comment.cs +++ b/src/CrowdParlay.Social.Domain/Entities/Comment.cs @@ -1,5 +1,9 @@ +using System.Diagnostics; + namespace CrowdParlay.Social.Domain.Entities; + +[DebuggerDisplay("{Id} by {AuthorId} in reply to {SubjectId}")] public class Comment { public required string Id { get; set; } diff --git a/src/CrowdParlay.Social.Domain/Entities/Discussion.cs b/src/CrowdParlay.Social.Domain/Entities/Discussion.cs index b9c5247..9ec1e2b 100644 --- a/src/CrowdParlay.Social.Domain/Entities/Discussion.cs +++ b/src/CrowdParlay.Social.Domain/Entities/Discussion.cs @@ -1,5 +1,9 @@ +using System.Diagnostics; + namespace CrowdParlay.Social.Domain.Entities; + +[DebuggerDisplay("{Id} by {AuthorId}")] public class Discussion { public required string Id { get; set; } diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs index 5d86cdd..4222c1f 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs @@ -1,8 +1,10 @@ +using System.Diagnostics; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; namespace CrowdParlay.Social.Infrastructure.Persistence.Models; +[DebuggerDisplay("{Id} by {AuthorId} in reply to {SubjectId}")] public class CommentDocument : ISubjectDocument { [BsonId] public required ObjectId Id { get; set; } diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Models/DiscussionDocument.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/DiscussionDocument.cs index ab48338..acf8db1 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Models/DiscussionDocument.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/DiscussionDocument.cs @@ -1,8 +1,10 @@ +using System.Diagnostics; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; namespace CrowdParlay.Social.Infrastructure.Persistence.Models; +[DebuggerDisplay("{Id} by {AuthorId}")] public class DiscussionDocument : ISubjectDocument { [BsonId] public required ObjectId Id { get; set; } diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/StartupConfigurator.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/StartupConfigurator.cs index 1b12189..78cc9ec 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/StartupConfigurator.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/StartupConfigurator.cs @@ -1,4 +1,3 @@ -using CrowdParlay.Social.Domain; using Microsoft.Extensions.Hosting; using MongoDB.Bson; using MongoDB.Bson.Serialization; diff --git a/tests/CrowdParlay.Social.IntegrationTests/Services/TestWebApplicationFactory.cs b/tests/CrowdParlay.Social.IntegrationTests/Services/TestWebApplicationFactory.cs index 1b7154e..be014ba 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Services/TestWebApplicationFactory.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Services/TestWebApplicationFactory.cs @@ -14,18 +14,17 @@ internal class TestWebApplicationFactory( { protected override void ConfigureWebHost(IWebHostBuilder builder) { - builder.ConfigureAppConfiguration(configuration => configuration.AddInMemoryCollection( - new Dictionary - { - ["MongoDb:ConnectionString"] = mongoDbConfiguration.ConnectionString, - ["MongoDb:Database"] = mongoDbConfiguration.Database, - ["REDIS_CONNECTION_STRING"] = redisConfiguration.ConnectionString, - ["USERS_GRPC_ADDRESS"] = "https://users:5104", - ["TELEMETRY_SOURCE_NAME"] = "Social", - ["TELEMETRY_OTLP_EXPORTER_ENDPOINT"] = "http://localhost:8200", - ["CORS_ORIGINS"] = "http://localhost;http://localhost:1234", - ["DATA_PROTECTION_REDIS_CONNECTION_STRING"] = redisConfiguration.ConnectionString, - })); + builder.ConfigureAppConfiguration(configuration => configuration.AddInMemoryCollection(new Dictionary + { + ["MongoDb:ConnectionString"] = mongoDbConfiguration.ConnectionString, + ["MongoDb:Database"] = mongoDbConfiguration.Database, + ["REDIS_CONNECTION_STRING"] = redisConfiguration.ConnectionString, + ["USERS_GRPC_ADDRESS"] = "https://users:5104", + ["TELEMETRY_SOURCE_NAME"] = "Social", + ["TELEMETRY_OTLP_EXPORTER_ENDPOINT"] = "http://localhost:8200", + ["CORS_ORIGINS"] = "http://localhost;http://localhost:1234", + ["DATA_PROTECTION_REDIS_CONNECTION_STRING"] = redisConfiguration.ConnectionString, + })); builder.ConfigureServices(services => { From 3907e2a649695b542246ca6a96688c90915ebdb9 Mon Sep 17 00:00:00 2001 From: undrcrxwn <69521267+undrcrxwn@users.noreply.github.com> Date: Sun, 8 Jun 2025 23:22:40 +0300 Subject: [PATCH 07/12] tests: rename test classes --- .../Tests/{CommentsServiceTests.cs => CommentsTests.cs} | 2 +- .../{DiscussionsRepositoryTests.cs => DiscussionsTests.cs} | 2 +- .../Tests/{ReactionsRepositoryTests.cs => ReactionsTests.cs} | 2 +- .../Tests/{UsersServiceTests.cs => UsersTests.cs} | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename tests/CrowdParlay.Social.IntegrationTests/Tests/{CommentsServiceTests.cs => CommentsTests.cs} (98%) rename tests/CrowdParlay.Social.IntegrationTests/Tests/{DiscussionsRepositoryTests.cs => DiscussionsTests.cs} (97%) rename tests/CrowdParlay.Social.IntegrationTests/Tests/{ReactionsRepositoryTests.cs => ReactionsTests.cs} (96%) rename tests/CrowdParlay.Social.IntegrationTests/Tests/{UsersServiceTests.cs => UsersTests.cs} (92%) diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsServiceTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsTests.cs similarity index 98% rename from tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsServiceTests.cs rename to tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsTests.cs index 6d07276..0d0492d 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsServiceTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsTests.cs @@ -3,7 +3,7 @@ namespace CrowdParlay.Social.IntegrationTests.Tests; -public class CommentsServiceTests(WebApplicationContext context) : IAssemblyFixture +public class CommentsTests(WebApplicationContext context) : IAssemblyFixture { private readonly IServiceProvider _services = context.Services; diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsRepositoryTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsTests.cs similarity index 97% rename from tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsRepositoryTests.cs rename to tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsTests.cs index 06ef2c9..ac01aa8 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsRepositoryTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsTests.cs @@ -2,7 +2,7 @@ namespace CrowdParlay.Social.IntegrationTests.Tests; -public class DiscussionsRepositoryTests(WebApplicationContext context) : IAssemblyFixture +public class DiscussionsTests(WebApplicationContext context) : IAssemblyFixture { private readonly IServiceProvider _services = context.Services; diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsRepositoryTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsTests.cs similarity index 96% rename from tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsRepositoryTests.cs rename to tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsTests.cs index 8722677..247917f 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsRepositoryTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsTests.cs @@ -2,7 +2,7 @@ namespace CrowdParlay.Social.IntegrationTests.Tests; -public class ReactionsRepositoryTests(WebApplicationContext context) : IAssemblyFixture +public class ReactionsTests(WebApplicationContext context) : IAssemblyFixture { private readonly IServiceProvider _services = context.Services; diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/UsersServiceTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/UsersTests.cs similarity index 92% rename from tests/CrowdParlay.Social.IntegrationTests/Tests/UsersServiceTests.cs rename to tests/CrowdParlay.Social.IntegrationTests/Tests/UsersTests.cs index 9e4c725..2b37a77 100644 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/UsersServiceTests.cs +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/UsersTests.cs @@ -3,7 +3,7 @@ namespace CrowdParlay.Social.IntegrationTests.Tests; -public class UsersServiceTests(WebApplicationContext context) : IClassFixture +public class UsersTests(WebApplicationContext context) : IClassFixture { private readonly IServiceProvider _services = context.Services; From 97dfc230ac598d1e3e890f5d2ddc19491c448854 Mon Sep 17 00:00:00 2001 From: undrcrxwn <69521267+undrcrxwn@users.noreply.github.com> Date: Sun, 8 Jun 2025 23:34:22 +0300 Subject: [PATCH 08/12] chore: remove obsolete submodule configurations from .gitmodules --- .gitmodules | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.gitmodules b/.gitmodules index 0436067..1c59439 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,9 +1,3 @@ -[submodule "src/CrowdParlay.Social.Infrastructure.gRPC/Users"] - path = src/CrowdParlay.Social.Infrastructure.gRPC/Users - url = https://github.com/crowdparlay/users-proto -[submodule "src/CrowdParlay.Social.Application/gRPC/Users"] - path = src/CrowdParlay.Social.Application/gRPC/Users - url = https://github.com/crowdparlay/users-proto [submodule "src/CrowdParlay.Social.Infrastructure.Communication/gRPC/Users"] path = src/CrowdParlay.Social.Infrastructure.Communication/gRPC/Users url = https://github.com/crowdparlay/users-proto.git From 4015f69005a9f22571e2f97877856d3d4c28fe07 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, 9 Jun 2025 00:43:12 +0300 Subject: [PATCH 09/12] chore: update README.md --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7b663ed..8f7daf7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,10 @@ # Crowd Parlay's *social* microserviceβ€€[![Test](https://github.com/crowdparlay/social/actions/workflows/test.yml/badge.svg)](https://github.com/crowdparlay/social/actions/workflows/test.yml) -- **languages:** C# Cypher -- **technologies:** .NET 8 ASP.NET Core RabbitMQ Neo4j SSE OpenAPI -- **dependencies:** MassTransit SignalR MediatR FluentValidation Mapster Swashbuckle Testcontainers + +A high-performance, cloud-native microservice enabling discussions, threaded comments, emoji/text reactions, and real-time event publishing via message broker. Designed with a clean onion architecture and test-driven development, it integrates seamlessly with other services through resilient, cache-optimized API calls. Built on a NoSQL store with embedded documents and strategic indexing for fast reads. Basic telemetry and observability are integrated, with support for future expansion. Fully automated CI/CD pipelines enable one-click production deployments. + +- **Stack:** C# 13 .NET 9 ASP.NET Core MongoDb.Driver MassTransit OpenTelemetry SignalR FluentValidation Mapster Swashbuckle/OpenAPI Testcontainers +- **Environment:** MongoDB Redis RabbitMQ Elastic APM [crowdparlay/users](https://github.com/crowdparlay/users) +- **Adopted but retired:** Neo4j MediatR + +> [!NOTE] +> This Git repository contains Submodules. Don’t forget to clone with `--recurse-submodules` From f6f243c0dff570f971096c3a0a9a9a8b6ba3fbc1 Mon Sep 17 00:00:00 2001 From: undrcrxwn <69521267+undrcrxwn@users.noreply.github.com> Date: Mon, 9 Jun 2025 14:03:51 +0300 Subject: [PATCH 10/12] feat: create missing MongoDB indexes --- .../ConfigureServices.cs | 3 +- .../MongoDbConstants.cs | 10 +++++ .../Services/CommentsRepository.cs | 7 ++-- .../Services/DatabaseInitializer.cs | 41 +++++++++++++++++++ .../Services/DiscussionsRepository.cs | 7 ++-- 5 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 src/CrowdParlay.Social.Infrastructure.Persistence/MongoDbConstants.cs create mode 100644 src/CrowdParlay.Social.Infrastructure.Persistence/Services/DatabaseInitializer.cs diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs index cd125a1..c6c1fcc 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs @@ -14,7 +14,8 @@ public static IServiceCollection AddPersistence(this IServiceCollection services .AddScoped() .AddScoped() .AddScoped() - .AddHostedService(); + .AddHostedService() + .AddHostedService(); private static IServiceCollection AddMongoDb(this IServiceCollection services, IConfiguration configuration) { diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/MongoDbConstants.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/MongoDbConstants.cs new file mode 100644 index 0000000..1f1042f --- /dev/null +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/MongoDbConstants.cs @@ -0,0 +1,10 @@ +namespace CrowdParlay.Social.Infrastructure.Persistence; + +public static class MongoDbConstants +{ + public static class Collections + { + public const string Comments = "comments"; + public const string Discussions = "discussions"; + } +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs index bc3cade..8610f21 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs @@ -6,6 +6,7 @@ using CrowdParlay.Social.Infrastructure.Persistence.Models; using MongoDB.Bson; using MongoDB.Driver; +using static CrowdParlay.Social.Infrastructure.Persistence.MongoDbConstants; using static MongoDB.Driver.PipelineStageDefinitionBuilder; using static MongoDB.Driver.Builders; using static MongoDB.Driver.PipelineDefinition _comments = database.GetCollection("comments"); - private readonly ISubjectsRepository _subjectsRepository = new GenericSubjectsRepository(session, database, "comments"); + private readonly IMongoCollection _comments = database.GetCollection(Collections.Comments); + private readonly ISubjectsRepository _subjectsRepository = new GenericSubjectsRepository(session, database, Collections.Comments); public async Task GetByIdAsync(string commentId, Guid? viewerId) { @@ -56,7 +57,7 @@ public async Task> GetRepliesAsync(string subjectId, bool flatten, "items", PipelineDefinition.Create( [ - Sort(Builders.Sort.Ascending(comment => comment.CreatedAt)), + Sort(Builders.Sort.Ascending(comment => comment.Id)), Skip(offset), Limit(count), Project(CreateCommentProjectionExpression(viewerId)) diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DatabaseInitializer.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DatabaseInitializer.cs new file mode 100644 index 0000000..e0fdfbe --- /dev/null +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DatabaseInitializer.cs @@ -0,0 +1,41 @@ +using CrowdParlay.Social.Infrastructure.Persistence.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MongoDB.Driver; +using static CrowdParlay.Social.Infrastructure.Persistence.MongoDbConstants; + +namespace CrowdParlay.Social.Infrastructure.Persistence.Services; + +public class DatabaseInitializer(IServiceScopeFactory serviceScopeFactory) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + await using var scope = serviceScopeFactory.CreateAsyncScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + var commentsIndexModels = new CreateIndexModel[] + { + new(Builders.IndexKeys + .Ascending(comment => comment.SubjectId) + .Ascending(comment => comment.Id)), + new(Builders.IndexKeys + .Ascending(comment => comment.AuthorId) + .Ascending(comment => comment.Id)) + }; + + var comments = database.GetCollection(Collections.Comments); + await comments.Indexes.CreateManyAsync(commentsIndexModels, cancellationToken: cancellationToken); + + var discussionsIndexModels = new CreateIndexModel[] + { + new(Builders.IndexKeys + .Ascending(comment => comment.AuthorId) + .Ascending(comment => comment.Id)) + }; + + var discussions = database.GetCollection(Collections.Discussions); + await discussions.Indexes.CreateManyAsync(discussionsIndexModels, cancellationToken: cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs index 87c64f4..2c7db99 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs @@ -6,6 +6,7 @@ using CrowdParlay.Social.Infrastructure.Persistence.Models; using MongoDB.Bson; using MongoDB.Driver; +using static CrowdParlay.Social.Infrastructure.Persistence.MongoDbConstants; using static MongoDB.Driver.PipelineStageDefinitionBuilder; using static MongoDB.Driver.Builders; using static MongoDB.Driver.PipelineDefinition _discussions = database.GetCollection("discussions"); - private readonly ISubjectsRepository _subjectsRepository = new GenericSubjectsRepository(session, database, "discussions"); + private readonly IMongoCollection _discussions = database.GetCollection(Collections.Discussions); + private readonly ISubjectsRepository _subjectsRepository = new GenericSubjectsRepository(session, database, Collections.Discussions); public async Task GetByIdAsync(string discussionId, Guid? viewerId) { @@ -38,7 +39,7 @@ public async Task> SearchAsync(Guid? authorId, Guid? viewerId, "items", PipelineDefinition.Create( [ - Sort(Builders.Sort.Ascending(discussion => discussion.CreatedAt)), + Sort(Builders.Sort.Ascending(discussion => discussion.Id)), Skip(offset), Limit(count), Project(CreateDiscussionProjectionExpression(viewerId)) From c19bee1902e7d21a9b379068f93d6551948f962c Mon Sep 17 00:00:00 2001 From: undrcrxwn <69521267+undrcrxwn@users.noreply.github.com> Date: Mon, 9 Jun 2025 14:23:24 +0300 Subject: [PATCH 11/12] docs(controllers): refine endpoint documentation for clarity and completeness --- .../v1/Controllers/CommentsController.cs | 21 +++++++++--- .../v1/Controllers/DiscussionsController.cs | 34 +++++++++++++++---- .../v1/Controllers/LookupController.cs | 4 +++ 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs b/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs index df4d0ab..c0c64d3 100644 --- a/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs +++ b/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs @@ -14,8 +14,10 @@ namespace CrowdParlay.Social.Api.v1.Controllers; public class CommentsController(ICommentsService commentsService) : ControllerBase { /// - /// Returns comment with the specified ID. + /// Retrieves a comment by its unique identifier. /// + /// The unique identifier of the comment to retrieve. + /// The requested comment details. [HttpGet("{commentId}")] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(Status200OK)] @@ -25,8 +27,13 @@ public async Task GetById([FromRoute] string commentId) => await commentsService.GetByIdAsync(commentId, User.GetUserId()); /// - /// Get replies to the comment with the specified ID. + /// Retrieves replies to a specified comment with configurable tree traversal and pagination. /// + /// The unique identifier of the parent comment. + /// When true, returns all nested replies in a flat structure; otherwise returns direct children only. + /// The number of items to skip before starting to return results (pagination offset). + /// The maximum number of items to return (pagination limit). + /// A paginated list of comment replies. [HttpGet("{commentId}/replies")] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType>(Status200OK)] @@ -41,8 +48,11 @@ public async Task> GetReplies( await commentsService.GetRepliesAsync(commentId, flatten, User.GetUserId(), offset, count); /// - /// Creates a reply to the comment with the specified ID. + /// Creates a new reply to a specified comment. Requires authenticated user. /// + /// The unique identifier of the comment being replied to. + /// The content of the reply. + /// The newly created reply comment details. [HttpPost("{commentId}/replies"), Authorize] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(Status201Created)] @@ -57,8 +67,11 @@ public async Task> Reply([FromRoute] string commen } /// - /// Sets reactions to a comment. + /// Updates reactions on a specified comment. Requires authenticated user. /// + /// The unique identifier of the comment to react to. + /// The set of reaction identifiers to apply. + /// No content response upon successful update. [HttpPost("{commentId}/reactions"), Authorize] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(Status204NoContent)] diff --git a/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs b/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs index 8868ce5..625c44c 100644 --- a/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs +++ b/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs @@ -19,8 +19,10 @@ public class DiscussionsController( IHubContext commentHub) : ControllerBase { /// - /// Returns discussion with the specified ID. + /// Retrieves a discussion by its unique identifier. /// + /// The unique identifier of the discussion to retrieve. + /// The requested discussion details. [HttpGet("{discussionId}")] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(Status200OK)] @@ -30,8 +32,12 @@ public async Task GetById([FromRoute] string discussionId) = await discussionsService.GetByIdAsync(discussionId, User.GetUserId()); /// - /// Returns all discussions created by author with the specified ID. + /// Searches discussions filtered by author identifier with pagination. /// + /// Optional author identifier to filter discussions (when null, returns all discussions). + /// The number of items to skip before starting to return results (pagination offset). + /// The maximum number of items to return (pagination limit). + /// A paginated list of matching discussions. [HttpGet] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType>(Status200OK)] @@ -43,8 +49,12 @@ public async Task> Search( await discussionsService.SearchAsync(authorId, User.GetUserId(), offset, count); /// - /// Returns comments to the specified discussion. + /// Retrieves top-level comments for a specified discussion with pagination. /// + /// The unique identifier of the discussion. + /// The number of items to skip before starting to return results (pagination offset). + /// The maximum number of items to return (pagination limit). + /// A paginated list of top-level comments in the discussion. [HttpGet("{discussionId}/comments")] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType>(Status200OK)] @@ -56,8 +66,10 @@ public async Task> GetComments( await commentsService.GetRepliesAsync(discussionId, false, User.GetUserId(), offset, count); /// - /// Creates a top-level comment in discussion. + /// Creates a new top-level comment in a discussion. Requires authenticated user and triggers real-time notifications. /// + /// The comment content and target discussion identifier. + /// The newly created top-level comment details. [HttpPost("{discussionId}/comments"), Authorize] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(Status201Created)] @@ -76,8 +88,10 @@ public async Task> Reply([FromBody] CommentRequest } /// - /// Creates a discussion. + /// Creates a new discussion. Requires authenticated user. /// + /// The discussion title and content. + /// The newly created discussion details. [HttpPost, Authorize] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(Status201Created)] @@ -90,8 +104,11 @@ public async Task> Create([FromBody] Discussion } /// - /// Modifies a discussion. + /// Modifies an existing discussion. Requires authenticated user and ownership permissions. /// + /// The unique identifier of the discussion to update. + /// The updated discussion data. + /// The modified discussion details. [HttpPatch("{discussionId}"), Authorize] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(Status200OK)] @@ -103,8 +120,11 @@ public async Task Update([FromRoute] string discussionId, await discussionsService.UpdateAsync(discussionId, User.GetRequiredUserId(), request); /// - /// Sets reactions to a discussion. + /// Updates reactions on a specified discussion. Requires authenticated user. /// + /// The unique identifier of the discussion to react to. + /// The set of reaction identifiers to apply. + /// No content response upon successful update. [HttpPost("{discussionId}/reactions"), Authorize] [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(Status204NoContent)] diff --git a/src/CrowdParlay.Social.Api/v1/Controllers/LookupController.cs b/src/CrowdParlay.Social.Api/v1/Controllers/LookupController.cs index dacae67..ec2fdbf 100644 --- a/src/CrowdParlay.Social.Api/v1/Controllers/LookupController.cs +++ b/src/CrowdParlay.Social.Api/v1/Controllers/LookupController.cs @@ -7,6 +7,10 @@ namespace CrowdParlay.Social.Api.v1.Controllers; [ApiController, ApiRoute("[controller]")] public class LookupController : ControllerBase { + /// + /// Retrieves all available reactions. + /// + /// The complete set of allowed reaction values. [HttpGet("reactions"), Produces(MediaTypeNames.Application.Json)] public IReadOnlySet GetAvailableReactions() => Reaction.AllowedValues; } \ No newline at end of file From 98d6177e15d58c57a219a86379c5041aba145bd7 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, 9 Jun 2025 14:25:24 +0300 Subject: [PATCH 12/12] chore(ci): use .NET 9 SDK in OpenAPI workflow --- .github/workflows/openapi.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index c6f8e3e..169adfd 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -23,15 +23,13 @@ jobs: - name: βš™οΈ Setup .NET uses: actions/setup-dotnet@v1 with: - dotnet-version: | - 7.0.x - 8.0.x + dotnet-version: 9.0.x - name: βš’ Build solution run: dotnet build - name: βš™οΈ Restore .NET tools run: dotnet tool restore - name: πŸ“ Produce OpenAPI specification - run: dotnet swagger tofile --output openapi.yaml --yaml ./src/CrowdParlay.Social.Api/bin/Debug/net8.0/CrowdParlay.Social.Api.dll v1 + run: dotnet swagger tofile --output openapi.yaml --yaml ./src/CrowdParlay.Social.Api/bin/Debug/net9.0/CrowdParlay.Social.Api.dll v1 - name: πŸ”— Upload OpenAPI specification as release asset uses: actions/upload-release-asset@v1 env: