From 89268a92ec623b8464d6793b59690dc8e014b99c Mon Sep 17 00:00:00 2001 From: "Matthew D. Groves" Date: Fri, 22 Sep 2023 14:33:06 -0400 Subject: [PATCH] #20 get comments, query, tests, migration --- .../008_CreateIndexForGetComments.cs | 33 +++++++ .../AddCommentTests.cs | 2 +- .../GetCommentsTests.cs | 95 +++++++++++++++++++ .../Functional/FunctionalTestBase.cs | 4 +- .../TestHelpers/Data/CommentHelper.cs | 34 ++++++- .../Controllers/CommentsController.cs | 28 ++++++ .../Articles/Handlers/GetCommentsHandler.cs | 51 ++++++++++ .../Articles/Handlers/GetCommentsRequest.cs | 16 ++++ .../Articles/Handlers/GetCommentsResponse.cs | 10 ++ .../Articles/Services/ArticlesDataService.cs | 3 +- .../Articles/Services/CommentsDataService.cs | 60 +++++++++++- .../DataAccess/Models/ArticleListDataView.cs | 6 -- .../DataAccess/Models/CommentListDataView.cs | 17 ++++ .../DataAccess/Queries/GetComments.n1qlnb | 1 + 14 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 Conduit/Conduit.Migrations/008_CreateIndexForGetComments.cs create mode 100644 Conduit/Conduit.Tests/Functional/Articles/Controllers/CommentsControllerTests/GetCommentsTests.cs create mode 100644 Conduit/Conduit.Web/Articles/Handlers/GetCommentsHandler.cs create mode 100644 Conduit/Conduit.Web/Articles/Handlers/GetCommentsRequest.cs create mode 100644 Conduit/Conduit.Web/Articles/Handlers/GetCommentsResponse.cs delete mode 100644 Conduit/Conduit.Web/DataAccess/Models/ArticleListDataView.cs create mode 100644 Conduit/Conduit.Web/DataAccess/Models/CommentListDataView.cs create mode 100644 Conduit/Conduit.Web/DataAccess/Queries/GetComments.n1qlnb diff --git a/Conduit/Conduit.Migrations/008_CreateIndexForGetComments.cs b/Conduit/Conduit.Migrations/008_CreateIndexForGetComments.cs new file mode 100644 index 0000000000..eafec99979 --- /dev/null +++ b/Conduit/Conduit.Migrations/008_CreateIndexForGetComments.cs @@ -0,0 +1,33 @@ +using NoSqlMigrator.Infrastructure; + +namespace Conduit.Migrations; + +// Manual alternative: CREATE INDEX `idx_comments_author_follows2` ON `Conduit`.`_default`.`Comments`((meta().`id`),(distinct (array (`c`.`authorUsername`) for `c` in ((`Conduit`.`_default`).`Comments`) end))) +[Migration(8)] +public class CreateIndexForGetComments : MigrateBase +{ + private readonly string? _scopeName; + + public CreateIndexForGetComments() + { + _scopeName = _config["Couchbase:ScopeName"]; + } + + public override void Up() + { + // there are other options for more complex and/or covering indexes + // but this index is the simplest for the List Articles SQL++ query + Create.Index("ix_get_comments") + .OnScope(_scopeName) + .OnCollection("Comments") + .OnFieldRaw("META().`id`") + .OnFieldRaw("DISTINCT (array (`c`.`authorUsername`) for `c` in ((`Conduit`.`_default`).`Comments`"); + } + + public override void Down() + { + Delete.Index("ix_get_comments") + .FromScope(_scopeName) + .FromCollection("Comments"); + } +} \ No newline at end of file diff --git a/Conduit/Conduit.Tests/Functional/Articles/Controllers/CommentsControllerTests/AddCommentTests.cs b/Conduit/Conduit.Tests/Functional/Articles/Controllers/CommentsControllerTests/AddCommentTests.cs index 6d95f628d4..730ae0196c 100644 --- a/Conduit/Conduit.Tests/Functional/Articles/Controllers/CommentsControllerTests/AddCommentTests.cs +++ b/Conduit/Conduit.Tests/Functional/Articles/Controllers/CommentsControllerTests/AddCommentTests.cs @@ -106,7 +106,7 @@ public async Task Comment_gets_saved_to_database() // assert Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - await _commentsCollectionProvider.AssertExists($"{article.ArticleKey}::comments", x => + await _commentsCollectionProvider.AssertExists(article.Slug, x => { Assert.That(x.Any(c => c.AuthorUsername == _user.Username && c.Body == expectedBody)); }); diff --git a/Conduit/Conduit.Tests/Functional/Articles/Controllers/CommentsControllerTests/GetCommentsTests.cs b/Conduit/Conduit.Tests/Functional/Articles/Controllers/CommentsControllerTests/GetCommentsTests.cs new file mode 100644 index 0000000000..a7511f4646 --- /dev/null +++ b/Conduit/Conduit.Tests/Functional/Articles/Controllers/CommentsControllerTests/GetCommentsTests.cs @@ -0,0 +1,95 @@ +using System.Net; +using System.Net.Http.Headers; +using Conduit.Tests.TestHelpers; +using Conduit.Tests.TestHelpers.Data; +using Conduit.Web.DataAccess.Models; +using Conduit.Web.DataAccess.Providers; +using Conduit.Web.Extensions; +using Microsoft.Extensions.DependencyInjection; + +namespace Conduit.Tests.Functional.Articles.Controllers.CommentsControllerTests; + +[TestFixture] +public class GetCommentsTests : FunctionalTestBase +{ + private IConduitArticlesCollectionProvider _articleCollectionProvider; + private IConduitUsersCollectionProvider _usersCollectionProvider; + private User _user; + private Random _random; + private IConduitCommentsCollectionProvider _commentsCollectionProvider; + private IConduitFollowsCollectionProvider _followCollectionProvider; + + [SetUp] + public async Task Setup() + { + await base.Setup(); + + // setup database objects for arranging + var service = WebAppFactory.Services; + _articleCollectionProvider = service.GetRequiredService(); + _usersCollectionProvider = service.GetRequiredService(); + _commentsCollectionProvider = service.GetRequiredService(); + _followCollectionProvider = service.GetRequiredService(); + + // setup an authorized header + _user = await _usersCollectionProvider.CreateUserInDatabase(); + var jwtToken = AuthSvc.GenerateJwtToken(_user.Email, _user.Username); + WebClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", jwtToken); + + _random = new Random(); + } + + [Test] + public async Task Comments_for_an_article_are_returned() + { + // arrange - an article + var author = await _usersCollectionProvider.CreateUserInDatabase(); + var article = await _articleCollectionProvider.CreateArticleInDatabase(authorUsername: author.Username); + // arrange - two comments from two different users + var commenter1 = await _usersCollectionProvider.CreateUserInDatabase(); + var commenter2 = await _usersCollectionProvider.CreateUserInDatabase(); + var commentBody1 = _random.String(64); + var commentBody2 = _random.String(64); + await _commentsCollectionProvider.CreateCommentInDatabase(article.Slug, commenter1.Username, commentBody1); + await _commentsCollectionProvider.CreateCommentInDatabase(article.Slug, commenter2.Username, commentBody2); + + // act + var response = await WebClient.GetAsync($"/api/articles/{article.Slug}/comments"); + var responseString = await response.Content.ReadAsStringAsync(); + var articleViewModel = responseString.SubDoc>("comments"); + + // assert + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(articleViewModel.Count, Is.EqualTo(2)); + await _commentsCollectionProvider.AssertExists(article.Slug, x => + { + Assert.That(x.Any(c => c.Body == commentBody1)); + Assert.That(x.Any(c => c.Body == commentBody2)); + }); + } + + [TestCase(true)] + [TestCase(false)] + public async Task Comments_include_correct_following_information(bool doesFollowAuthor) + { + // arrange - an article + var author = await _usersCollectionProvider.CreateUserInDatabase(); + var article = await _articleCollectionProvider.CreateArticleInDatabase(authorUsername: author.Username); + // arrange - comment + var commenter = await _usersCollectionProvider.CreateUserInDatabase(); + await _commentsCollectionProvider.CreateCommentInDatabase(article.Slug, commenter.Username, _random.String(64)); + // arrange - follow (or not) the comment author + if (doesFollowAuthor) + await _followCollectionProvider.CreateFollow(commenter.Username, _user.Username); + + // act + var response = await WebClient.GetAsync($"/api/articles/{article.Slug}/comments"); + var responseString = await response.Content.ReadAsStringAsync(); + var articleViewModel = responseString.SubDoc>("comments"); + + // assert + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(articleViewModel.Count, Is.EqualTo(1)); + Assert.That(articleViewModel[0].Author.Following, Is.EqualTo(doesFollowAuthor)); + } +} \ No newline at end of file diff --git a/Conduit/Conduit.Tests/Functional/FunctionalTestBase.cs b/Conduit/Conduit.Tests/Functional/FunctionalTestBase.cs index 32208cc577..6dd020a0a0 100644 --- a/Conduit/Conduit.Tests/Functional/FunctionalTestBase.cs +++ b/Conduit/Conduit.Tests/Functional/FunctionalTestBase.cs @@ -1,6 +1,8 @@ using Conduit.Web; using Conduit.Web.Users.Services; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; namespace Conduit.Tests.Functional; @@ -23,4 +25,4 @@ public async Task TearDown() { // cleanup happens in GlobalFunctionalSetUp } -} \ No newline at end of file +} diff --git a/Conduit/Conduit.Tests/TestHelpers/Data/CommentHelper.cs b/Conduit/Conduit.Tests/TestHelpers/Data/CommentHelper.cs index 9b13ab6afb..855effff95 100644 --- a/Conduit/Conduit.Tests/TestHelpers/Data/CommentHelper.cs +++ b/Conduit/Conduit.Tests/TestHelpers/Data/CommentHelper.cs @@ -1,15 +1,18 @@ -using Conduit.Web.DataAccess.Models; +using Conduit.Web.Articles.Services; +using Conduit.Web.DataAccess.Models; using Conduit.Web.DataAccess.Providers; +using Conduit.Web.Extensions; using Couchbase.KeyValue; namespace Conduit.Tests.TestHelpers.Data; public static class CommentHelper { - public static async Task AssertExists(this IConduitCommentsCollectionProvider @this, string key, Action>? assertions = null) + public static async Task AssertExists(this IConduitCommentsCollectionProvider @this, string slug, Action>? assertions = null) { var collection = await @this.GetCollectionAsync(); + var key = CommentsDataService.GetCommentsKey(slug.GetArticleKey()); var doesExist = await collection.ExistsAsync(key); Assert.That(doesExist.Exists, Is.True); @@ -17,4 +20,31 @@ public static async Task AssertExists(this IConduitCommentsCollectionProvider @t if (assertions != null) assertions(listOfComments); } + + public static async Task CreateCommentInDatabase(this IConduitCommentsCollectionProvider @this, + string slug, + string username, + string? body = null, + ulong? id = null) + { + var random = new Random(); + + body ??= random.String(64); + id ??= (ulong)random.Next(100000, 200000); + + var collection = await @this.GetCollectionAsync(); + + var key = CommentsDataService.GetCommentsKey(slug.GetArticleKey()); + + var comments = collection.List(key); + var comment = new Comment + { + AuthorUsername = username, + Body = body, + CreatedAt = DateTimeOffset.Now, + Id = id.Value + }; + await comments.AddAsync(comment); + return comment; + } } \ No newline at end of file diff --git a/Conduit/Conduit.Web/Articles/Controllers/CommentsController.cs b/Conduit/Conduit.Web/Articles/Controllers/CommentsController.cs index d624d1823f..50e3ec9bc2 100644 --- a/Conduit/Conduit.Web/Articles/Controllers/CommentsController.cs +++ b/Conduit/Conduit.Web/Articles/Controllers/CommentsController.cs @@ -70,4 +70,32 @@ public async Task AddComment([FromRoute] string slug, [FromBody] return Ok(new { comment = viewModel }); } + + [HttpGet] + [AllowAnonymous] + [Route("/api/articles/{slug}/comments")] + public async Task GetComments([FromRoute] string slug) + { + // get (optional) auth info + string username = null; + var headers = Request.Headers["Authorization"]; + var isUserAnonymous = headers.All(string.IsNullOrEmpty); + + if (!isUserAnonymous) + { + var claims = _authService.GetAllAuthInfo(Request.Headers["Authorization"]); + username = claims.Username.Value; + } + + // make request to mediator + var request = new GetCommentsRequest(slug, username); + var response = await _mediator.Send(request); + + if (response.IsArticleNotFound) + return NotFound($"Article {slug} not found."); + if (response.IsFailed) + return StatusCode(500, "There was a problem adding that comment."); + + return Ok(new { comments = response.CommentsView }); + } } \ No newline at end of file diff --git a/Conduit/Conduit.Web/Articles/Handlers/GetCommentsHandler.cs b/Conduit/Conduit.Web/Articles/Handlers/GetCommentsHandler.cs new file mode 100644 index 0000000000..a4ae18d7fd --- /dev/null +++ b/Conduit/Conduit.Web/Articles/Handlers/GetCommentsHandler.cs @@ -0,0 +1,51 @@ +using Conduit.Web.Articles.Services; +using Conduit.Web.Articles.ViewModels; +using Conduit.Web.Users.ViewModels; +using MediatR; + +namespace Conduit.Web.Articles.Handlers; + +public class GetCommentsHandler : IRequestHandler +{ + private readonly IArticlesDataService _articlesDataService; + private readonly ICommentsDataService _commentsDataService; + + public GetCommentsHandler(IArticlesDataService articlesDataService, ICommentsDataService commentsDataService) + { + _articlesDataService = articlesDataService; + _commentsDataService = commentsDataService; + } + + public async Task Handle(GetCommentsRequest request, CancellationToken cancellationToken) + { + // article must exist + var doesArticleExist = await _articlesDataService.Exists(request.Slug); + if (!doesArticleExist) + { + return new GetCommentsResponse + { + IsArticleNotFound = true + }; + } + + var results = await _commentsDataService.Get(request.Slug, request.Username); + + return new GetCommentsResponse + { + CommentsView = results.DataResult.Select(x => new CommentViewModel + { + Id = x.Id, + Body = x.Body, + CreatedAt = x.CreatedAt, + UpdatedAt = x.CreatedAt, + Author = new ProfileViewModel + { + Bio = x.Author.Bio, + Following = x.Author.Following, + Image = x.Author.Image, + Username = x.Author.Username + } + }).ToList() + }; + } +} \ No newline at end of file diff --git a/Conduit/Conduit.Web/Articles/Handlers/GetCommentsRequest.cs b/Conduit/Conduit.Web/Articles/Handlers/GetCommentsRequest.cs new file mode 100644 index 0000000000..a82fb45377 --- /dev/null +++ b/Conduit/Conduit.Web/Articles/Handlers/GetCommentsRequest.cs @@ -0,0 +1,16 @@ +using Conduit.Web.DataAccess.Models; +using MediatR; + +namespace Conduit.Web.Articles.Handlers; + +public class GetCommentsRequest : IRequest +{ + public string? Username { get; set; } + public string Slug { get; set; } + + public GetCommentsRequest(string slug, string? username) + { + Slug = slug; + Username = username; + } +} \ No newline at end of file diff --git a/Conduit/Conduit.Web/Articles/Handlers/GetCommentsResponse.cs b/Conduit/Conduit.Web/Articles/Handlers/GetCommentsResponse.cs new file mode 100644 index 0000000000..bf7d09e876 --- /dev/null +++ b/Conduit/Conduit.Web/Articles/Handlers/GetCommentsResponse.cs @@ -0,0 +1,10 @@ +using Conduit.Web.Articles.ViewModels; + +namespace Conduit.Web.Articles.Handlers; + +public class GetCommentsResponse +{ + public bool IsArticleNotFound { get; set; } + public bool IsFailed { get; set; } + public List CommentsView { get; set; } +} \ No newline at end of file diff --git a/Conduit/Conduit.Web/Articles/Services/ArticlesDataService.cs b/Conduit/Conduit.Web/Articles/Services/ArticlesDataService.cs index 8da22727a2..a792405ebc 100644 --- a/Conduit/Conduit.Web/Articles/Services/ArticlesDataService.cs +++ b/Conduit/Conduit.Web/Articles/Services/ArticlesDataService.cs @@ -1,5 +1,4 @@ -using Conduit.Web.Articles.Handlers; -using Conduit.Web.Articles.ViewModels; +using Conduit.Web.Articles.ViewModels; using Conduit.Web.DataAccess.Dto; using Conduit.Web.DataAccess.Dto.Articles; using Conduit.Web.DataAccess.Models; diff --git a/Conduit/Conduit.Web/Articles/Services/CommentsDataService.cs b/Conduit/Conduit.Web/Articles/Services/CommentsDataService.cs index 1613c96d28..816c7d5e12 100644 --- a/Conduit/Conduit.Web/Articles/Services/CommentsDataService.cs +++ b/Conduit/Conduit.Web/Articles/Services/CommentsDataService.cs @@ -1,22 +1,27 @@ -using Conduit.Web.Articles.Handlers; -using Conduit.Web.DataAccess.Dto; +using Conduit.Web.DataAccess.Dto; using Conduit.Web.DataAccess.Models; using Conduit.Web.DataAccess.Providers; using Conduit.Web.Extensions; using Couchbase.KeyValue; +using Couchbase; +using Couchbase.Query; namespace Conduit.Web.Articles.Services; public interface ICommentsDataService { Task> Add(Comment newComment, string slug); + Task>> Get(string slug, string? currentUsername = null); } public class CommentsDataService : ICommentsDataService { private readonly IConduitCommentsCollectionProvider _commentsCollectionProvider; - public string GetCommentsKey(string articleKey) => $"{articleKey}::comments"; + // a virtual method so it can be overridden by a testing class if necessary + protected virtual QueryScanConsistency ScanConsistency => QueryScanConsistency.NotBounded; + + public static string GetCommentsKey(string articleKey) => $"{articleKey}::comments"; public CommentsDataService(IConduitCommentsCollectionProvider commentsCollectionProvider) { @@ -46,6 +51,55 @@ public async Task> Add(Comment newComment, string slu return new DataServiceResult(newComment, DataResultStatus.Ok); } + public async Task>> Get(string slug, string? currentUsername) + { + var loggedInJoin = ""; + if (currentUsername != null) + { + loggedInJoin = " LEFT JOIN Conduit._default.Follows follow ON ($currentUsername || \"::follows\") = META(follow).id "; + } + + var sql = $@"SELECT VALUE + {{ + c.body, + c.createdAt, + c.id, + ""author"" : {{ + author.bio, + author.image, + ""username"": c.authorUsername, + ""following"": ARRAY_CONTAINS(COALESCE(follow,[]), c.authorUsername) + }} + }} + + FROM Conduit._default.Comments c2 + UNNEST c2 AS c + JOIN Conduit._default.Users author ON c.authorUsername = META(author).id + + {loggedInJoin} + + /* parameterized with comments key */ + WHERE META(c2).id = $commentsKey;"; + + var collection = await _commentsCollectionProvider.GetCollectionAsync(); + var cluster = collection.Scope.Bucket.Cluster; + try + { + + var results = await cluster.QueryAsync(sql, options => + { + options.Parameter("commentsKey", GetCommentsKey(slug.GetArticleKey())); + options.Parameter("currentUsername", currentUsername); + options.ScanConsistency(ScanConsistency); + }); + return new DataServiceResult>(await results.Rows.ToListAsync(), DataResultStatus.Ok); + } + catch (Exception ex) + { + return new DataServiceResult>(null, DataResultStatus.Error); + } + } + private async Task GetNextCommentId(string slug) { var collection = await _commentsCollectionProvider.GetCollectionAsync(); diff --git a/Conduit/Conduit.Web/DataAccess/Models/ArticleListDataView.cs b/Conduit/Conduit.Web/DataAccess/Models/ArticleListDataView.cs deleted file mode 100644 index 742cf1ee6c..0000000000 --- a/Conduit/Conduit.Web/DataAccess/Models/ArticleListDataView.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Conduit.Web.DataAccess.Models; - -public class ArticleDataView -{ - -} \ No newline at end of file diff --git a/Conduit/Conduit.Web/DataAccess/Models/CommentListDataView.cs b/Conduit/Conduit.Web/DataAccess/Models/CommentListDataView.cs new file mode 100644 index 0000000000..32d2e11bc4 --- /dev/null +++ b/Conduit/Conduit.Web/DataAccess/Models/CommentListDataView.cs @@ -0,0 +1,17 @@ +namespace Conduit.Web.DataAccess.Models; + +public class CommentListDataView +{ + public string Body { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public ulong Id { get; init; } + public CommentListDataAuthorView Author { get; init; } +} + +public class CommentListDataAuthorView +{ + public string Bio { get; init; } + public string Image { get; init; } + public string Username { get; init; } + public bool Following { get; init; } +} \ No newline at end of file diff --git a/Conduit/Conduit.Web/DataAccess/Queries/GetComments.n1qlnb b/Conduit/Conduit.Web/DataAccess/Queries/GetComments.n1qlnb new file mode 100644 index 0000000000..a672cdbedd --- /dev/null +++ b/Conduit/Conduit.Web/DataAccess/Queries/GetComments.n1qlnb @@ -0,0 +1 @@ +{"cells":[{"kind":2,"language":"SQL++","value":"SELECT VALUE\r\n {\r\n c.body,\r\n c.createdAt,\r\n c.id,\r\n \"author\" : {\r\n author.bio,\r\n author.image,\r\n \"username\": c.authorUsername,\r\n \"following\": ARRAY_CONTAINS(COALESCE(follow,[]), c.authorUsername)\r\n }\r\n }\r\n\r\nFROM Conduit._default.Comments c2\r\nUNNEST c2 AS c\r\nJOIN Conduit._default.Users author ON c.authorUsername = META(author).id\r\n\r\n/* join to the logged in user if not anonymous */\r\n/* parameterized with logged in username */\r\nLEFT JOIN Conduit._default.Follows follow ON (\"mgroves\" || \"::follows\") = META(follow).id\r\n\r\n/* parameterized with comments key */\r\nWHERE META(c2).id = \"HLbwIpJEajZi::comments\";\r\n"}]} \ No newline at end of file