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: 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 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 diff --git a/Dockerfile b/Dockerfile index 809b6f4..7439138 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,12 @@ -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 -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/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` 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/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.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..c0c64d3 100644 --- a/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs +++ b/src/CrowdParlay.Social.Api/v1/Controllers/CommentsController.cs @@ -1,113 +1,87 @@ 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. + /// Retrieves a comment by its unique identifier. /// - [HttpGet("{commentId:guid}")] + /// The unique identifier of the comment to retrieve. + /// The requested comment details. + [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. + /// Retrieves replies to a specified comment with configurable tree traversal and pagination. /// - [HttpGet("{parentCommentId:guid}/replies")] + /// 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)] [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. + /// Creates a new reply to a specified comment. Requires authenticated user. /// - [HttpPost("{parentCommentId:guid}/replies"), Authorize] + /// 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)] [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. + /// Updates reactions on a specified comment. Requires authenticated user. /// - [HttpPost("{commentId:guid}/reactions"), Authorize] + /// 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)] [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..625c44c 100644 --- a/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs +++ b/src/CrowdParlay.Social.Api/v1/Controllers/DiscussionsController.cs @@ -1,32 +1,43 @@ 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. + /// Retrieves a discussion by its unique identifier. /// - [HttpGet("{discussionId:guid}")] + /// The unique identifier of the discussion to retrieve. + /// The requested discussion details. + [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()); /// - /// 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)] @@ -38,8 +49,49 @@ public async Task> Search( await discussionsService.SearchAsync(authorId, User.GetUserId(), offset, count); /// - /// Creates a 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)] + [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 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)] + [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 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)] @@ -47,35 +99,42 @@ 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. + /// Modifies an existing discussion. Requires authenticated user and ownership permissions. /// - [HttpPatch("{discussionId:guid}"), Authorize] + /// 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)] [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. + /// Updates reactions on a specified discussion. Requires authenticated user. /// - [HttpPost("{discussionId:guid}/reactions"), Authorize] + /// 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)] [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.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 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/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.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..1e18d94 100644 --- a/src/CrowdParlay.Social.Application/Services/CommentsService.cs +++ b/src/CrowdParlay.Social.Application/Services/CommentsService.cs @@ -11,18 +11,21 @@ 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, @@ -30,67 +33,87 @@ public async Task> SearchAsync(Guid? discussionId, Guid? a }; } - public async Task CreateAsync(Guid authorId, Guid discussionId, string content) + 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); + } + + 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/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.Domain/Entities/Comment.cs b/src/CrowdParlay.Social.Domain/Entities/Comment.cs index f122d33..b28710c 100644 --- a/src/CrowdParlay.Social.Domain/Entities/Comment.cs +++ b/src/CrowdParlay.Social.Domain/Entities/Comment.cs @@ -1,13 +1,18 @@ +using System.Diagnostics; + namespace CrowdParlay.Social.Domain.Entities; + +[DebuggerDisplay("{Id} by {AuthorId} in reply to {SubjectId}")] 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..9ec1e2b 100644 --- a/src/CrowdParlay.Social.Domain/Entities/Discussion.cs +++ b/src/CrowdParlay.Social.Domain/Entities/Discussion.cs @@ -1,14 +1,18 @@ +using System.Diagnostics; + namespace CrowdParlay.Social.Domain.Entities; + +[DebuggerDisplay("{Id} by {AuthorId}")] 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.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/ConfigureServices.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs index 3aed0a5..c6c1fcc 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/ConfigureServices.cs @@ -1,42 +1,47 @@ -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() + .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 936fb4f..4ea9d58 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/src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs new file mode 100644 index 0000000..4222c1f --- /dev/null +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/CommentDocument.cs @@ -0,0 +1,22 @@ +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; } + 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; } + [BsonIgnoreIfNull] public int? Depth { 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..acf8db1 --- /dev/null +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Models/DiscussionDocument.cs @@ -0,0 +1,19 @@ +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; } + 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/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/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..8610f21 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/CommentsRepository.cs @@ -1,206 +1,182 @@ -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 CrowdParlay.Social.Infrastructure.Persistence.MongoDbConstants; +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(Collections.Comments); + private readonly ISubjectsRepository _subjectsRepository = new GenericSubjectsRepository(session, database, Collections.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); - 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 + .Limit(1) + .GraphLookup, CommentDocument>( + from: _comments, + startWith: _ => ObjectId.Parse(subjectId), + connectFromField: comment => comment.Id, + connectToField: comment => comment.SubjectId, + @as: comment => comment.Ascendants!, + depthField: comment => comment.Depth!.Value) + .Unwind(comment => comment.Ascendants) + .ReplaceRoot("$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) + else { - return new Page - { - TotalCount = 0, - Items = [] - }; + matchPipeline = matchPipeline + .Match(comment => comment.SubjectId == ObjectId.Parse(subjectId)); } - var record = await data.SingleAsync(); - return record[0].Adapt>(); + var pipeline = matchPipeline.Facet( + new AggregateFacet( + "items", + PipelineDefinition.Create( + [ + Sort(Builders.Sort.Ascending(comment => comment.Id)), + Skip(offset), + Limit(count), + Project(CreateCommentProjectionExpression(viewerId)) + ]) + ), + new AggregateFacet( + "totalCount", + Create([Count()]) + ) + ); + + var result = await pipeline.FirstOrDefaultAsync() ?? throw new NotFoundException(); + return new Page + { + Items = result.Facets[0].Output() ?? Enumerable.Empty(), + TotalCount = (int)(result.Facets[1].Output().FirstOrDefault()?.Count ?? 0) + }; } - public async Task CreateAsync(Guid authorId, Guid discussionId, string content) + public async Task CreateAsync(string? subjectId, Guid authorId, string content) { - var data = await runner.RunAsync( - """ - MATCH (discussion:Discussion { Id: $discussionId }) - MERGE (author:Author { Id: $authorId }) - CREATE (comment:Comment { - Id: randomUUID(), - Content: $content, - CreatedAt: datetime() - }) - CREATE (discussion)<-[:REPLIES_TO]-(comment)-[:AUTHORED_BY]->(author) - RETURN comment.Id - """, - new - { - authorId = authorId.ToString(), - discussionId = discussionId.ToString(), - content - }); - - if (await data.PeekAsync() is null) - throw new NotFoundException(); - - var record = await data.SingleAsync(); - return record[0].Adapt(); + 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 ReplyToCommentAsync(Guid authorId, Guid parentCommentId, 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!, + 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 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 + var updates = ancestors.Select(ancestor => { - parentCommentId = parentCommentId.ToString(), - authorId = authorId.ToString(), - content - }); + var newLastCommentsAuthorIds = ancestor.LastCommentsAuthorIds + .Except([authorId]) + .Append(authorId) + .TakeLast(3) + .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(); - if (await cursor.PeekAsync() is null) - throw new NotFoundException(); + if (updates.Any()) + await _comments.BulkWriteAsync(session, updates); + } - var record = await cursor.SingleAsync(); - return record[0].Adapt(); + public async Task ExcludeCommentFromAncestorsMetadataAsync(IEnumerable ancestors) + { + 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); } - public async Task DeleteAsync(Guid commentId) + public async Task IncludeCommentInMetadataAsync(string discussionId, Guid authorId) => + await _subjectsRepository.IncludeCommentInMetadataAsync(discussionId, authorId); + + public async Task ExcludeCommentFromMetadataAsync(string discussionId) => + await _subjectsRepository.ExcludeCommentFromMetadataAsync(discussionId); + + 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/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 cb3aa2b..2c7db99 100644 --- a/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/DiscussionsRepository.cs @@ -1,174 +1,133 @@ +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 CrowdParlay.Social.Infrastructure.Persistence.MongoDbConstants; +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(Collections.Discussions); + private readonly ISubjectsRepository _subjectsRepository = new GenericSubjectsRepository(session, database, Collections.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.Id)), + 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..a0c425e --- /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 + .Except([authorId]) + .Append(authorId) + .TakeLast(3) + .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..78cc9ec --- /dev/null +++ b/src/CrowdParlay.Social.Infrastructure.Persistence/Services/StartupConfigurator.cs @@ -0,0 +1,30 @@ +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 3b7eb9e..b839ac7 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/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..be014ba 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,8 +8,7 @@ namespace CrowdParlay.Social.IntegrationTests.Services; internal class TestWebApplicationFactory( - // ReSharper disable once InconsistentNaming - Neo4jConfiguration neo4jConfiguration, + MongoDbConfiguration mongoDbConfiguration, RedisConfiguration redisConfiguration) : WebApplicationFactory where TProgram : class { @@ -18,9 +16,8 @@ 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, + ["MongoDb:ConnectionString"] = mongoDbConfiguration.ConnectionString, + ["MongoDb:Database"] = mongoDbConfiguration.Database, ["REDIS_CONNECTION_STRING"] = redisConfiguration.ConnectionString, ["USERS_GRPC_ADDRESS"] = "https://users:5104", ["TELEMETRY_SOURCE_NAME"] = "Social", @@ -42,11 +39,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 ff24a6b..1794057 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")] @@ -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)); @@ -50,7 +47,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.IntegrationTests/Tests/CommentsRepositoryTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs deleted file mode 100644 index 251a3c6..0000000 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsRepositoryTests.cs +++ /dev/null @@ -1,169 +0,0 @@ -// ReSharper disable UnusedVariable -// ReSharper disable RedundantAssignment - -using CrowdParlay.Social.Domain.Abstractions; -using CrowdParlay.Social.Domain.DTOs; -using CrowdParlay.Social.Domain.Entities; - -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 discussionId = await discussions.CreateAsync( - authorId: authorId, - title: "Discussion", - description: "Test discussion."); - - // Act - var commentId = await comments.CreateAsync(authorId, discussionId, "Comment content"); - var comment = await comments.GetByIdAsync(commentId, authorId); - - // Assert - comment.AuthorId.Should().Be(authorId); - comment.Content.Should().Be("Comment content"); - comment.ReplyCount.Should().Be(0); - comment.LastRepliesAuthorIds.Should().BeEmpty(); - 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", - description: "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 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 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 comment1 = await comments.GetByIdAsync(commentId1, viewerId); - var comment2 = await comments.GetByIdAsync(commentId2, viewerId); - var comment3 = await comments.GetByIdAsync(commentId3, viewerId); - - // Act - var page = await comments.SearchAsync( - discussionId, - authorId: null, - 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 - Func getComment = async () => await comments.GetByIdAsync(Guid.NewGuid(), 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(authorId, discussionId, "Comment content"); - var replyId = await comments.ReplyToCommentAsync(authorId, commentId, "Reply content"); - var reply = await comments.GetByIdAsync(replyId, authorId); - - // Act - var page = await comments.SearchAsync(commentId, authorId: null, authorId, offset: 0, count: 1); - - // Assert - page.Should().BeEquivalentTo(new Page - { - TotalCount = 1, - Items = [reply] - }); - } - - [Fact(DisplayName = "Create comment with unknown author and discussion")] - public async Task CreateComment_WithUnknownAuthorAndDiscussion_ThrowsNotFoundException() - { - // Arrange - await using var scope = _services.CreateAsyncScope(); - var comments = scope.ServiceProvider.GetRequiredService(); - - // Act - Func createComment = async () => - await comments.CreateAsync(Guid.NewGuid(), 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 deleted file mode 100644 index 4b613da..0000000 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsServiceTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Text.Json; -using StackExchange.Redis; - -namespace CrowdParlay.Social.IntegrationTests.Tests; - -public class CommentsServiceTests(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); - } -} diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsTests.cs new file mode 100644 index 0000000..0d0492d --- /dev/null +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/CommentsTests.cs @@ -0,0 +1,191 @@ +using CrowdParlay.Social.Domain.DTOs; +using MongoDB.Bson; + +namespace CrowdParlay.Social.IntegrationTests.Tests; + +public class CommentsTests(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(); + } + + [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) + { + 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 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/DiscussionsRepositoryTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsTests.cs similarity index 55% rename from tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsRepositoryTests.cs rename to tests/CrowdParlay.Social.IntegrationTests/Tests/DiscussionsTests.cs index 23cd878..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; @@ -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 deleted file mode 100644 index b76db6e..0000000 --- a/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsRepositoryTests.cs +++ /dev/null @@ -1,76 +0,0 @@ -using CrowdParlay.Social.Domain.Abstractions; - -namespace CrowdParlay.Social.IntegrationTests.Tests; - -public class ReactionsRepositoryTests(WebApplicationContext context) : IAssemblyFixture -{ - private readonly IServiceProvider _services = context.Services; - - [Fact(DisplayName = "Set reactions")] - public async Task SetReactions_OnlyWorksWithAllowedReactionSets() - { - // Arrange - const string heart = "\u2764\ufe0f"; - const string thumbUp = "\ud83d\udc4d"; - const string thumbDown = "\ud83d\udc4e"; - const string oldInvalid = "This reaction is not allowed, but somebody has already reacted with it when it used to be allowed"; - const string newInvalid = "This reaction is not allowed, and nobody used it"; - - HashSet thumbs = [thumbUp, thumbDown]; - HashSet thumbsAndNewInvalid = [..thumbs, newInvalid]; - HashSet heartAndOldInvalid = [heart, oldInvalid]; - - await using var scope = _services.CreateAsyncScope(); - var authorsRepository = 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 }); - - // Act - await reactionsService.SetAsync(discussionId, viewerId, heartAndOldInvalid); - await reactionsService.SetAsync(discussionId, viewerId, new HashSet()); - await reactionsService.SetAsync(discussionId, viewerId, thumbs); - - var reactWithNewInvalid = async () => await reactionsService.SetAsync(discussionId, viewerId, thumbsAndNewInvalid); - var reactions = await reactionsService.GetAsync(discussionId, viewerId); - - // Assert - await reactWithNewInvalid.Should().ThrowAsync(); - reactions.Should().BeEquivalentTo(thumbs); - } - - [Fact(DisplayName = "Setting reactions overwrites existing reactions")] - 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 viewerId = Guid.NewGuid(); - await authorsRepository.EnsureCreatedAsync(viewerId); - var discussionId = await discussionsRepository.CreateAsync(viewerId, "Title", "Description"); - - // 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" }); - - // Assert - var discussion = await discussionsRepository.GetByIdAsync(discussionId, viewerId); - discussion.ViewerReactions.Should().BeEquivalentTo(["b", "d"]); - discussion.ReactionCounters.Should().BeEquivalentTo(new Dictionary - { - { "b", 1 }, - { "d", 1 } - }); - } -} \ No newline at end of file diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsTests.cs new file mode 100644 index 0000000..247917f --- /dev/null +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/ReactionsTests.cs @@ -0,0 +1,75 @@ +using CrowdParlay.Social.Domain.Abstractions; + +namespace CrowdParlay.Social.IntegrationTests.Tests; + +public class ReactionsTests(WebApplicationContext context) : IAssemblyFixture +{ + private readonly IServiceProvider _services = context.Services; + + [Fact(DisplayName = "Set reactions")] + public async Task SetReactions_OnlyWorksWithAllowedReactionSets() + { + // Arrange + const string heart = "\u2764\ufe0f"; + const string thumbUp = "\ud83d\udc4d"; + const string thumbDown = "\ud83d\udc4e"; + const string oldInvalid = "This reaction is not allowed, but somebody has already reacted with it when it used to be allowed"; + const string newInvalid = "This reaction is not allowed, and nobody used it"; + + HashSet thumbs = [thumbUp, thumbDown]; + HashSet thumbsAndNewInvalid = [..thumbs, newInvalid]; + HashSet heartAndOldInvalid = [heart, oldInvalid]; + + await using var scope = _services.CreateAsyncScope(); + var discussionsService = scope.ServiceProvider.GetRequiredService(); + var discussionsRepository = scope.ServiceProvider.GetRequiredService(); + + var viewerId = Guid.NewGuid(); + var discussion = await discussionsService.CreateAsync(viewerId, "Title", "Description"); + + // Act + 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 discussionsService.SetReactionsAsync(discussion.Id, viewerId, thumbsAndNewInvalid); + var reactions = await discussionsService.GetReactionsAsync(discussion.Id, viewerId); + + // Assert + await reactWithNewInvalid.Should().ThrowAsync(); + reactions.Should().BeEquivalentTo(thumbs); + } + + [Fact(DisplayName = "Setting reactions overwrites existing reactions")] + public async Task SetReactions_OverwritesExistingReactions() + { + // Arrange + await using var scope = _services.CreateAsyncScope(); + var discussionsService = scope.ServiceProvider.GetRequiredService(); + + var viewerId = Guid.NewGuid(); + 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 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 + discussion = await discussionsService.GetByIdAsync(discussion.Id, viewerId); + discussion.ViewerReactions.Should().BeEquivalentTo(woozyFace, redHeart); + discussion.ReactionCounters.Should().BeEquivalentTo(new Dictionary + { + { 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() }; diff --git a/tests/CrowdParlay.Social.IntegrationTests/Tests/UsersTests.cs b/tests/CrowdParlay.Social.IntegrationTests/Tests/UsersTests.cs new file mode 100644 index 0000000..2b37a77 --- /dev/null +++ b/tests/CrowdParlay.Social.IntegrationTests/Tests/UsersTests.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using StackExchange.Redis; + +namespace CrowdParlay.Social.IntegrationTests.Tests; + +public class UsersTests(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 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 +