From efe80c65756bf5afa2d08989e35e0ffbdbd6179f Mon Sep 17 00:00:00 2001 From: "Matthew D. Groves" Date: Tue, 26 Sep 2023 10:29:25 -0400 Subject: [PATCH] #21 delete comment endpoint and tests --- Conduit/Conduit.Tests/Conduit.Tests.csproj | 1 + .../CommentsDataService/DeleteTests.cs | 85 +++++++++++++++++++ .../GlobalCouchbaseIntegrationSetUp.cs | 3 + .../Controllers/CommentsController.cs | 25 ++++++ .../Articles/Handlers/DeleteCommentHandler.cs | 44 ++++++++++ .../Articles/Handlers/DeleteCommentRequest.cs | 10 +++ .../Handlers/DeleteCommentRequestValidator.cs | 16 ++++ .../Handlers/DeleteCommentResponse.cs | 12 +++ .../Articles/Services/CommentsDataService.cs | 54 ++++++++++-- .../DataAccess/Dto/DataResultStatus.cs | 3 +- 10 files changed, 244 insertions(+), 9 deletions(-) create mode 100644 Conduit/Conduit.Tests/Integration/Articles/Services/CommentsDataService/DeleteTests.cs create mode 100644 Conduit/Conduit.Web/Articles/Handlers/DeleteCommentHandler.cs create mode 100644 Conduit/Conduit.Web/Articles/Handlers/DeleteCommentRequest.cs create mode 100644 Conduit/Conduit.Web/Articles/Handlers/DeleteCommentRequestValidator.cs create mode 100644 Conduit/Conduit.Web/Articles/Handlers/DeleteCommentResponse.cs diff --git a/Conduit/Conduit.Tests/Conduit.Tests.csproj b/Conduit/Conduit.Tests/Conduit.Tests.csproj index cb2b1d7774..e763056c72 100644 --- a/Conduit/Conduit.Tests/Conduit.Tests.csproj +++ b/Conduit/Conduit.Tests/Conduit.Tests.csproj @@ -47,6 +47,7 @@ + diff --git a/Conduit/Conduit.Tests/Integration/Articles/Services/CommentsDataService/DeleteTests.cs b/Conduit/Conduit.Tests/Integration/Articles/Services/CommentsDataService/DeleteTests.cs new file mode 100644 index 0000000000..c4307674dd --- /dev/null +++ b/Conduit/Conduit.Tests/Integration/Articles/Services/CommentsDataService/DeleteTests.cs @@ -0,0 +1,85 @@ +using Conduit.Tests.TestHelpers.Data; +using Conduit.Web.DataAccess; +using Conduit.Web.DataAccess.Dto; +using Conduit.Web.DataAccess.Providers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Conduit.Tests.Integration.Articles.Services.CommentsDataService; + +[TestFixture] +public class DeleteTests : CouchbaseIntegrationTest +{ + private IConduitCommentsCollectionProvider _commentsCollectionProvider; + private IConduitArticlesCollectionProvider _articlesCollectionProvider; + private Web.Articles.Services.CommentsDataService _commentsDataService; + private IConduitUsersCollectionProvider _userCollectionProvider; + + [SetUp] + public override async Task Setup() + { + await base.Setup(); + + _commentsCollectionProvider = ServiceProvider.GetRequiredService(); + _articlesCollectionProvider = ServiceProvider.GetRequiredService(); + _userCollectionProvider = ServiceProvider.GetRequiredService(); + + var couchbaseOptions = new OptionsWrapper(new CouchbaseOptions()); + _commentsDataService = new Web.Articles.Services.CommentsDataService(_commentsCollectionProvider, couchbaseOptions); + } + + [Test] + public async Task Can_delete_a_comment_by_id() + { + // arrange an article + var author = await _userCollectionProvider.CreateUserInDatabase(); + var article = await _articlesCollectionProvider.CreateArticleInDatabase(authorUsername: author.Username); + var commenter = await _userCollectionProvider.CreateUserInDatabase(); + // arrange some comments on the article + var comment1 = await _commentsCollectionProvider.CreateCommentInDatabase(article.Slug, commenter.Username); + var comment2 = await _commentsCollectionProvider.CreateCommentInDatabase(article.Slug, commenter.Username); + + // act + var result = await _commentsDataService.Delete(comment1.Id, article.Slug, commenter.Username); + + // assert + Assert.That(result, Is.EqualTo(DataResultStatus.Ok)); + await _commentsCollectionProvider.AssertExists(article.Slug, x => + { + Assert.That(x.Count, Is.EqualTo(1)); + Assert.That(x[0].Id, Is.EqualTo(comment2.Id)); + }); + } + + [Test] + public async Task Id_must_exist() + { + // arrange an article + var author = await _userCollectionProvider.CreateUserInDatabase(); + var article = await _articlesCollectionProvider.CreateArticleInDatabase(authorUsername: author.Username); + var comment1 = await _commentsCollectionProvider.CreateCommentInDatabase(article.Slug, author.Username); + var idDoesntExist = comment1.Id + 1; + + // act + var result = await _commentsDataService.Delete(idDoesntExist, article.Slug, author.Username); + + // assert + Assert.That(result, Is.EqualTo(DataResultStatus.NotFound)); + } + + [Test] + public async Task Cant_delete_comments_that_you_didnt_author() + { + // arrange an article + var author = await _userCollectionProvider.CreateUserInDatabase(); + var article = await _articlesCollectionProvider.CreateArticleInDatabase(authorUsername: author.Username); + var comment1 = await _commentsCollectionProvider.CreateCommentInDatabase(article.Slug, author.Username); + var someOtherUser = await _userCollectionProvider.CreateUserInDatabase(); + + // act + var result = await _commentsDataService.Delete(comment1.Id, article.Slug, someOtherUser.Username); + + // assert + Assert.That(result, Is.EqualTo(DataResultStatus.Unauthorized)); + } +} \ No newline at end of file diff --git a/Conduit/Conduit.Tests/Integration/GlobalCouchbaseIntegrationSetUp.cs b/Conduit/Conduit.Tests/Integration/GlobalCouchbaseIntegrationSetUp.cs index 95ca482080..87baa2726c 100644 --- a/Conduit/Conduit.Tests/Integration/GlobalCouchbaseIntegrationSetUp.cs +++ b/Conduit/Conduit.Tests/Integration/GlobalCouchbaseIntegrationSetUp.cs @@ -78,6 +78,9 @@ public async Task RunBeforeAllTests() b .AddScope(_config["Couchbase:ScopeName"]) .AddCollection("Follows"); + b + .AddScope(_config["Couchbase:ScopeName"]) + .AddCollection("Comments"); }); ServiceCollection = services; diff --git a/Conduit/Conduit.Web/Articles/Controllers/CommentsController.cs b/Conduit/Conduit.Web/Articles/Controllers/CommentsController.cs index a3304a7431..09106cd08f 100644 --- a/Conduit/Conduit.Web/Articles/Controllers/CommentsController.cs +++ b/Conduit/Conduit.Web/Articles/Controllers/CommentsController.cs @@ -106,4 +106,29 @@ public async Task GetComments([FromRoute] string slug) return Ok(new { comments = response.CommentsView }); } + + [HttpDelete] + [Route("/api/articles/{slug}/comments/{commentId}")] + [Authorize] + public async Task DeleteComment([FromRoute] string slug, [FromRoute] ulong commentId) + { + var claims = _authService.GetAllAuthInfo(Request.Headers["Authorization"]); + var username = claims.Username.Value; + + var deleteCommentRequest = new DeleteCommentRequest { Slug = slug, CommentId = commentId, Username = username }; + var deleteCommentResponse = await _mediator.Send(deleteCommentRequest); + + if (deleteCommentResponse.IsArticleNotFound) + return NotFound($"Article {slug} not found."); + if (deleteCommentResponse.IsCommentNotFound) + return NotFound($"Comment with ID {commentId} not found."); + if (deleteCommentResponse.IsNotAuthorized) + return Unauthorized("You aren't allowed to delete that comment."); + if (deleteCommentResponse.IsFailed) + return StatusCode(500, "There was a problem deleting that comment."); + if (deleteCommentResponse.ValidationErrors?.Any() ?? false) + return UnprocessableEntity(deleteCommentResponse.ValidationErrors.ToCsv()); + + return Ok(); + } } \ No newline at end of file diff --git a/Conduit/Conduit.Web/Articles/Handlers/DeleteCommentHandler.cs b/Conduit/Conduit.Web/Articles/Handlers/DeleteCommentHandler.cs new file mode 100644 index 0000000000..37d126b4d5 --- /dev/null +++ b/Conduit/Conduit.Web/Articles/Handlers/DeleteCommentHandler.cs @@ -0,0 +1,44 @@ +using Conduit.Web.Articles.Services; +using Conduit.Web.DataAccess.Dto; +using FluentValidation; +using MediatR; + +namespace Conduit.Web.Articles.Handlers; + +public class DeleteCommentHandler : IRequestHandler +{ + private readonly IValidator _validator; + private readonly IArticlesDataService _articlesDataService; + private readonly ICommentsDataService _commentsDataService; + + public DeleteCommentHandler(IValidator validator, IArticlesDataService articlesDataService, ICommentsDataService commentsDataService) + { + _validator = validator; + _articlesDataService = articlesDataService; + _commentsDataService = commentsDataService; + } + + public async Task Handle(DeleteCommentRequest request, CancellationToken cancellationToken) + { + // validation + var validationResult = await _validator.ValidateAsync(request, cancellationToken); + if (!validationResult.IsValid) + return new DeleteCommentResponse { ValidationErrors = validationResult.Errors }; + + // return error if article doesn't exist + var doesArticleExist = await _articlesDataService.Exists(request.Slug); + if (!doesArticleExist) + return new DeleteCommentResponse { IsArticleNotFound = true }; + + var dataResponse = await _commentsDataService.Delete(request.CommentId, request.Slug, request.Username); + + if (dataResponse == DataResultStatus.NotFound) + return new DeleteCommentResponse { IsCommentNotFound = true }; + if (dataResponse == DataResultStatus.Unauthorized) + return new DeleteCommentResponse { IsNotAuthorized = true }; + if (dataResponse == DataResultStatus.Error) + return new DeleteCommentResponse { IsFailed = true }; + + return new DeleteCommentResponse(); + } +} \ No newline at end of file diff --git a/Conduit/Conduit.Web/Articles/Handlers/DeleteCommentRequest.cs b/Conduit/Conduit.Web/Articles/Handlers/DeleteCommentRequest.cs new file mode 100644 index 0000000000..089eed93e4 --- /dev/null +++ b/Conduit/Conduit.Web/Articles/Handlers/DeleteCommentRequest.cs @@ -0,0 +1,10 @@ +using MediatR; + +namespace Conduit.Web.Articles.Handlers; + +public class DeleteCommentRequest : IRequest +{ + public string Slug { get; init; } + public ulong CommentId { get; init; } + public string Username { get; init; } +} \ No newline at end of file diff --git a/Conduit/Conduit.Web/Articles/Handlers/DeleteCommentRequestValidator.cs b/Conduit/Conduit.Web/Articles/Handlers/DeleteCommentRequestValidator.cs new file mode 100644 index 0000000000..15bd9d6199 --- /dev/null +++ b/Conduit/Conduit.Web/Articles/Handlers/DeleteCommentRequestValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace Conduit.Web.Articles.Handlers; + +public class DeleteCommentRequestValidator : AbstractValidator +{ + public DeleteCommentRequestValidator() + { + RuleFor(x => x.Slug) + .Cascade(CascadeMode.Stop) + .NotEmpty().WithMessage("Slug is required."); + + RuleFor(x => x.CommentId) + .Must(v => v > 0).WithMessage("Must be a valid CommentID"); + } +} \ No newline at end of file diff --git a/Conduit/Conduit.Web/Articles/Handlers/DeleteCommentResponse.cs b/Conduit/Conduit.Web/Articles/Handlers/DeleteCommentResponse.cs new file mode 100644 index 0000000000..8bfe79cfab --- /dev/null +++ b/Conduit/Conduit.Web/Articles/Handlers/DeleteCommentResponse.cs @@ -0,0 +1,12 @@ +using FluentValidation.Results; + +namespace Conduit.Web.Articles.Handlers; + +public class DeleteCommentResponse +{ + public List ValidationErrors { get; set; } + public bool IsArticleNotFound { get; set; } + public bool IsNotAuthorized { get; set; } + public bool IsFailed { get; set; } + public bool IsCommentNotFound { get; set; } +} \ No newline at end of file diff --git a/Conduit/Conduit.Web/Articles/Services/CommentsDataService.cs b/Conduit/Conduit.Web/Articles/Services/CommentsDataService.cs index 735653fd4f..db3ba8db76 100644 --- a/Conduit/Conduit.Web/Articles/Services/CommentsDataService.cs +++ b/Conduit/Conduit.Web/Articles/Services/CommentsDataService.cs @@ -14,6 +14,7 @@ public interface ICommentsDataService { Task> Add(Comment newComment, string slug); Task>> Get(string slug, string? currentUsername = null); + Task Delete(ulong commentId, string slug, string username); } public class CommentsDataService : ICommentsDataService @@ -87,9 +88,9 @@ UNNEST c2 AS c {loggedInJoin} ;"; var cluster = collection.Scope.Bucket.Cluster; - // try - // { - // + try + { + var results = await cluster.QueryAsync(sql, options => { options.Parameter("commentsKey", GetCommentsKey(slug.GetArticleKey())); @@ -97,11 +98,48 @@ UNNEST c2 AS c options.ScanConsistency(_couchbaseOptions.Value.ScanConsistency); }); return new DataServiceResult>(await results.Rows.ToListAsync(), DataResultStatus.Ok); - // } - // catch (Exception ex) - // { - // return new DataServiceResult>(null, DataResultStatus.Error); - // } + } + catch (Exception ex) + { + return new DataServiceResult>(null, DataResultStatus.Error); + } + } + + public async Task Delete(ulong commentId, string slug, string username) + { + var collection = await _commentsCollectionProvider.GetCollectionAsync(); + + try + { + var commentsList = collection.List(GetCommentsKey(slug.GetArticleKey())); + + // comment with ID must exist + var comment = commentsList.SingleOrDefault(c => c.Id == commentId); + if (comment == null) + return DataResultStatus.NotFound; + + // comment must be authored by the given user + var isAuthorized = comment.AuthorUsername == username; + if (!isAuthorized) + return DataResultStatus.Unauthorized; + + // delete comment + // this is a hacky workaround, see NCBC-3498 https://issues.couchbase.com/browse/NCBC-3498 + var list = await commentsList.ToListAsync(); + for (var i = 0; i < list.Count; i++) + { + if (list[i].Id == commentId) + { + await commentsList.RemoveAtAsync(i); + } + } + } + catch + { + return DataResultStatus.Error; + } + + return DataResultStatus.Ok; } private async Task GetNextCommentId(string slug) diff --git a/Conduit/Conduit.Web/DataAccess/Dto/DataResultStatus.cs b/Conduit/Conduit.Web/DataAccess/Dto/DataResultStatus.cs index 657fd2249e..e485b3c387 100644 --- a/Conduit/Conduit.Web/DataAccess/Dto/DataResultStatus.cs +++ b/Conduit/Conduit.Web/DataAccess/Dto/DataResultStatus.cs @@ -5,5 +5,6 @@ public enum DataResultStatus NotFound = 0, Ok = 1, FailedToInsert = 2, - Error = 3 + Error = 3, + Unauthorized = 4 } \ No newline at end of file