diff --git a/Conduit/Conduit.Tests/Functional/Articles/Controllers/ArticlesControllerTests/UnfavoriteTests.cs b/Conduit/Conduit.Tests/Functional/Articles/Controllers/ArticlesControllerTests/UnfavoriteTests.cs new file mode 100644 index 0000000000..8b4cd573dd --- /dev/null +++ b/Conduit/Conduit.Tests/Functional/Articles/Controllers/ArticlesControllerTests/UnfavoriteTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using Conduit.Web.DataAccess.Providers; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using Conduit.Tests.TestHelpers.Data; +using Conduit.Web.DataAccess.Models; + +namespace Conduit.Tests.Functional.Articles.Controllers.ArticlesControllerTests; + +[TestFixture] +public class UnfavoriteTests : FunctionalTestBase +{ + private IConduitUsersCollectionProvider _usersCollectionProvider; + private User _user; + private IConduitArticlesCollectionProvider _articleCollectionProvider; + private IConduitFavoritesCollectionProvider _favoriteCollectionProvider; + private Random _random; + + [SetUp] + public override async Task Setup() + { + await base.Setup(); + + // setup database objects for arranging + var service = WebAppFactory.Services; + _usersCollectionProvider = 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); + + // setup services + _articleCollectionProvider = service.GetRequiredService(); + _favoriteCollectionProvider = service.GetRequiredService(); + _random = new Random(); + } + + [Test] + public async Task Valid_unfavoriting_of_article() + { + // arrange + var author = await _usersCollectionProvider.CreateUserInDatabase(); + var article = await _articleCollectionProvider.CreateArticleInDatabase(authorUsername: author.Username); + await _favoriteCollectionProvider.AddFavoriteInDatabase(_user.Username, article.Slug); + + // act + var response = await WebClient.DeleteAsync($"api/article/{article.Slug}/favorite"); + + // assert + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } +} \ No newline at end of file diff --git a/Conduit/Conduit.Tests/Integration/Articles/Services/ArticlesDataService/FavoriteTests.cs b/Conduit/Conduit.Tests/Integration/Articles/Services/ArticlesDataService/FavoriteTests.cs index 8dd54f31d3..98d4c49f6a 100644 --- a/Conduit/Conduit.Tests/Integration/Articles/Services/ArticlesDataService/FavoriteTests.cs +++ b/Conduit/Conduit.Tests/Integration/Articles/Services/ArticlesDataService/FavoriteTests.cs @@ -6,6 +6,57 @@ namespace Conduit.Tests.Integration.Articles.Services.ArticlesDataService; +[TestFixture] +public class UnfavoriteTests : CouchbaseIntegrationTest +{ + private IConduitArticlesCollectionProvider _articleCollectionProvider; + private IConduitFavoritesCollectionProvider _favoriteCollectionProvider; + private IArticlesDataService _articleDataService; + + [SetUp] + public override async Task Setup() + { + await base.Setup(); + + _articleCollectionProvider = ServiceProvider.GetRequiredService(); + _favoriteCollectionProvider = ServiceProvider.GetRequiredService(); + + _articleDataService = new Web.Articles.Services.ArticlesDataService( + _articleCollectionProvider, + _favoriteCollectionProvider); + } + + [TestCase(1, 0)] + [TestCase(12, 11)] + public async Task Unfavoriting_works_and_decreases_count(int initialCount, int expectedCount) + { + // arrange + var article = await _articleCollectionProvider.CreateArticleInDatabase(favoritesCount: initialCount); + var user = UserHelper.CreateUser(); + await _favoriteCollectionProvider.AddFavoriteInDatabase(user.Username, article.Slug); + + // act + await _articleDataService.Unfavorite(article.Slug, user.Username); + + // assert + await _favoriteCollectionProvider.AssertExists(user.Username, x => + { + Assert.That(x.Contains(article.Slug.GetArticleKey()), Is.False); + }); + await _articleCollectionProvider.AssertExists(article.Slug, x => + { + Assert.That(x.FavoritesCount, Is.EqualTo(expectedCount)); + }); + } + + [Test] + [Ignore("TXNN-134 see comments")] + public async Task Unfavoriting_an_already_favorited_article_does_not_change_the_favorites() + { + // TODO + } +} + [TestFixture] public class FavoriteTests : CouchbaseIntegrationTest { diff --git a/Conduit/Conduit.Tests/TestHelpers/Data/FavoritesHelper.cs b/Conduit/Conduit.Tests/TestHelpers/Data/FavoritesHelper.cs new file mode 100644 index 0000000000..f328033518 --- /dev/null +++ b/Conduit/Conduit.Tests/TestHelpers/Data/FavoritesHelper.cs @@ -0,0 +1,25 @@ +using Conduit.Web.Articles.Services; +using Conduit.Web.DataAccess.Providers; +using Conduit.Web.Extensions; +using Couchbase.KeyValue; + +namespace Conduit.Tests.TestHelpers.Data; + +public static class FavoritesHelper +{ + public static async Task AddFavoriteInDatabase(this IConduitFavoritesCollectionProvider @this, + string? username = "", + string? articleSlug = "") + { + var random = new Random(); + username ??= $"valid-username-{random.String(8)}"; + articleSlug ??= $"valid-slug-{random.String(8)}::{random.String(10)}"; + + var collection = await @this.GetCollectionAsync(); + + var set = collection.Set(ArticlesDataService.FavoriteDocId(username)); + await set.AddAsync(articleSlug.GetArticleKey()); + + return; + } +} \ No newline at end of file diff --git a/Conduit/Conduit.Web/Articles/Controllers/ArticlesController.cs b/Conduit/Conduit.Web/Articles/Controllers/ArticlesController.cs index daba9731c4..951f4b00b9 100644 --- a/Conduit/Conduit.Web/Articles/Controllers/ArticlesController.cs +++ b/Conduit/Conduit.Web/Articles/Controllers/ArticlesController.cs @@ -93,6 +93,42 @@ public async Task FavoriteArticle(string slug) return Ok(getResponse.ArticleView); } + /// + /// Remove a favorite of the logged-in user's + /// + /// + /// + /// + /// Article slug + /// Article (with profile of author embedded) + /// Successful favorite, returns the favorited Article + /// Unauthorized, likely because credentials are incorrect + /// Article was unable to be favorited + [HttpDelete] + [Route("/api/article/{slug}/favorite")] + [Authorize] + public async Task UnfavoriteArticle(string slug) + { + // get auth info + var claims = _authService.GetAllAuthInfo(Request.Headers["Authorization"]); + + // send request to favorite the article + var favoriteRequest = new UnfavoriteArticleRequest(); + favoriteRequest.Username = claims.Username.Value; + favoriteRequest.Slug = slug; + var favoriteResponse = await _mediator.Send(favoriteRequest); + if (favoriteResponse.ValidationErrors?.Any() ?? false) + return UnprocessableEntity(favoriteResponse.ValidationErrors.ToCsv()); + + // ask handler for the article view + var getRequest = new GetArticleRequest(slug, claims.Username.Value); + var getResponse = await _mediator.Send(getRequest); + if (getResponse.ValidationErrors?.Any() ?? false) + return UnprocessableEntity(getResponse.ValidationErrors.ToCsv()); + + return Ok(getResponse.ArticleView); + } + /// /// Get an article (authorization optional) /// diff --git a/Conduit/Conduit.Web/Articles/Handlers/UnfavoriteArticleHandler.cs b/Conduit/Conduit.Web/Articles/Handlers/UnfavoriteArticleHandler.cs new file mode 100644 index 0000000000..c40d9dc4bf --- /dev/null +++ b/Conduit/Conduit.Web/Articles/Handlers/UnfavoriteArticleHandler.cs @@ -0,0 +1,31 @@ +using Conduit.Web.Articles.Services; +using FluentValidation.Results; +using MediatR; + +namespace Conduit.Web.Articles.Handlers; + +public class UnfavoriteArticleHandler : IRequestHandler +{ + private readonly IArticlesDataService _articlesDataService; + + public UnfavoriteArticleHandler(IArticlesDataService articlesDataService) + { + _articlesDataService = articlesDataService; + } + + public async Task Handle(UnfavoriteArticleRequest request, CancellationToken cancellationToken) + { + var articleExists = await _articlesDataService.Exists(request.Slug); + if (!articleExists) + { + return new UnfavoriteArticleResponse + { + ValidationErrors = new List { new ValidationFailure("", "Article not found.") } + }; + } + + await _articlesDataService.Unfavorite(request.Slug, request.Username); + + return new UnfavoriteArticleResponse(); + } +} \ No newline at end of file diff --git a/Conduit/Conduit.Web/Articles/Handlers/UnfavoriteArticleRequest.cs b/Conduit/Conduit.Web/Articles/Handlers/UnfavoriteArticleRequest.cs new file mode 100644 index 0000000000..37ca8b1635 --- /dev/null +++ b/Conduit/Conduit.Web/Articles/Handlers/UnfavoriteArticleRequest.cs @@ -0,0 +1,9 @@ +using MediatR; + +namespace Conduit.Web.Articles.Handlers; + +public class UnfavoriteArticleRequest : IRequest +{ + public string Username { get; set; } + public string Slug { get; set; } +} \ No newline at end of file diff --git a/Conduit/Conduit.Web/Articles/Handlers/UnfavoriteArticleResponse.cs b/Conduit/Conduit.Web/Articles/Handlers/UnfavoriteArticleResponse.cs new file mode 100644 index 0000000000..754ed02602 --- /dev/null +++ b/Conduit/Conduit.Web/Articles/Handlers/UnfavoriteArticleResponse.cs @@ -0,0 +1,8 @@ +using FluentValidation.Results; + +namespace Conduit.Web.Articles.Handlers; + +public class UnfavoriteArticleResponse +{ + public List ValidationErrors { 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 ac964b0b5b..ba69e935dc 100644 --- a/Conduit/Conduit.Web/Articles/Services/ArticlesDataService.cs +++ b/Conduit/Conduit.Web/Articles/Services/ArticlesDataService.cs @@ -2,6 +2,7 @@ using Conduit.Web.DataAccess.Models; using Conduit.Web.DataAccess.Providers; using Conduit.Web.Extensions; +using Couchbase; using Couchbase.Core.Exceptions.KeyValue; using Couchbase.KeyValue; using Couchbase.Transactions; @@ -13,6 +14,7 @@ public interface IArticlesDataService { Task Create(Article articleToInsert); Task Favorite(string slug, string username); + Task Unfavorite(string slug, string username); Task Exists(string slug); Task> Get(string slug); Task IsFavorited(string slug, string username); @@ -26,11 +28,6 @@ public class ArticlesDataService : IArticlesDataService private readonly IConduitArticlesCollectionProvider _articlesCollectionProvider; private readonly IConduitFavoritesCollectionProvider _favoritesCollectionProvider; - private string FavoriteDocId(string username) - { - return $"{username}::favorites"; - } - public ArticlesDataService(IConduitArticlesCollectionProvider articlesCollectionProvider, IConduitFavoritesCollectionProvider favoritesCollectionProvider) { _articlesCollectionProvider = articlesCollectionProvider; @@ -206,16 +203,82 @@ await transaction.RunAsync(async (context) => await transaction.DisposeAsync(); } - private async Task EnsureFavoritesDocumentExists(string username) + public async Task Unfavorite(string slug, string username) + { + // if there is no favorites document yet, then we're already done + var favoritesExist = await DoesFavoriteDocumentExist(username); + if (!favoritesExist) + return; + + // start transaction + var articlesCollection = await _articlesCollectionProvider.GetCollectionAsync(); + var favoriteCollection = await _favoritesCollectionProvider.GetCollectionAsync(); + var cluster = favoriteCollection.Scope.Bucket.Cluster; + + var config = TransactionConfigBuilder.Create(); + + // for single-node Couchbase, like for development, you must use None + // otherwise, use AT LEAST Majority durability +#if DEBUG + config.DurabilityLevel(DurabilityLevel.None); +#else + config.DurabilityLevel(DurabilityLevel.Majority); +#endif + + var transaction = Transactions.Create(cluster, config); + + await transaction.RunAsync(async (context) => + { + var favoriteKey = FavoriteDocId(username); + + // check to see if user has already favorited this article (if they have NOT, bail out) + var favoritesDoc = await context.GetAsync(favoriteCollection, favoriteKey); + var favorites = favoritesDoc.ContentAs>(); + // BUG? https://issues.couchbase.com/browse/TXNN-134 + if (!favorites.Contains(slug.GetArticleKey())) + { + await context.RollbackAsync(); + return; + } + + // remove article key (subset of slug) to favorites document + favorites.Remove(slug.GetArticleKey()); + await context.ReplaceAsync(favoritesDoc, favorites); + + // decrement favorite count in article + var articleDoc = await context.GetAsync(articlesCollection, slug.GetArticleKey()); + var article = articleDoc.ContentAs
(); + article.FavoritesCount--; + await context.ReplaceAsync(articleDoc, article); + await context.CommitAsync(); + }); + + await transaction.DisposeAsync(); + } + + public static string FavoriteDocId(string username) + { + return $"{username}::favorites"; + } + + private async Task DoesFavoriteDocumentExist(string username) { var favoriteDocId = FavoriteDocId(username); var collection = await _favoritesCollectionProvider.GetCollectionAsync(); var favoritesDoc = await collection.ExistsAsync(favoriteDocId); - if (favoritesDoc.Exists) + return favoritesDoc.Exists; + } + + private async Task EnsureFavoritesDocumentExists(string username) + { + var doesFavoriteDocExist = await DoesFavoriteDocumentExist(username); + if (doesFavoriteDocExist) return; try { + var favoriteDocId = FavoriteDocId(username); + var collection = await _favoritesCollectionProvider.GetCollectionAsync(); await collection.InsertAsync(favoriteDocId, new List()); } catch (DocumentExistsException ex)