Skip to content

Commit

Permalink
#23 Unfavorite endpoint and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
mgroves committed Sep 12, 2023
1 parent f0dc5d1 commit d55ca7f
Show file tree
Hide file tree
Showing 8 changed files with 283 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -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<IConduitUsersCollectionProvider>();

// 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<IConduitArticlesCollectionProvider>();
_favoriteCollectionProvider = service.GetRequiredService<IConduitFavoritesCollectionProvider>();
_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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<IConduitArticlesCollectionProvider>();
_favoriteCollectionProvider = ServiceProvider.GetRequiredService<IConduitFavoritesCollectionProvider>();

_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
{
Expand Down
25 changes: 25 additions & 0 deletions Conduit/Conduit.Tests/TestHelpers/Data/FavoritesHelper.cs
Original file line number Diff line number Diff line change
@@ -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<string>(ArticlesDataService.FavoriteDocId(username));
await set.AddAsync(articleSlug.GetArticleKey());

return;
}
}
36 changes: 36 additions & 0 deletions Conduit/Conduit.Web/Articles/Controllers/ArticlesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,42 @@ public async Task<IActionResult> FavoriteArticle(string slug)
return Ok(getResponse.ArticleView);
}

/// <summary>
/// Remove a favorite of the logged-in user's
/// </summary>
/// <remarks>
///
/// </remarks>
/// <param name="slug">Article slug</param>
/// <returns>Article (with profile of author embedded)</returns>
/// <response code="200">Successful favorite, returns the favorited Article</response>
/// <response code="401">Unauthorized, likely because credentials are incorrect</response>
/// <response code="422">Article was unable to be favorited</response>
[HttpDelete]
[Route("/api/article/{slug}/favorite")]
[Authorize]
public async Task<IActionResult> 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);
}

/// <summary>
/// Get an article (authorization optional)
/// </summary>
Expand Down
31 changes: 31 additions & 0 deletions Conduit/Conduit.Web/Articles/Handlers/UnfavoriteArticleHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Conduit.Web.Articles.Services;
using FluentValidation.Results;
using MediatR;

namespace Conduit.Web.Articles.Handlers;

public class UnfavoriteArticleHandler : IRequestHandler<UnfavoriteArticleRequest, UnfavoriteArticleResponse>
{
private readonly IArticlesDataService _articlesDataService;

public UnfavoriteArticleHandler(IArticlesDataService articlesDataService)
{
_articlesDataService = articlesDataService;
}

public async Task<UnfavoriteArticleResponse> Handle(UnfavoriteArticleRequest request, CancellationToken cancellationToken)
{
var articleExists = await _articlesDataService.Exists(request.Slug);
if (!articleExists)
{
return new UnfavoriteArticleResponse
{
ValidationErrors = new List<ValidationFailure> { new ValidationFailure("", "Article not found.") }
};
}

await _articlesDataService.Unfavorite(request.Slug, request.Username);

return new UnfavoriteArticleResponse();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using MediatR;

namespace Conduit.Web.Articles.Handlers;

public class UnfavoriteArticleRequest : IRequest<UnfavoriteArticleResponse>
{
public string Username { get; set; }
public string Slug { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using FluentValidation.Results;

namespace Conduit.Web.Articles.Handlers;

public class UnfavoriteArticleResponse
{
public List<ValidationFailure> ValidationErrors { get; set; }
}
77 changes: 70 additions & 7 deletions Conduit/Conduit.Web/Articles/Services/ArticlesDataService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -13,6 +14,7 @@ public interface IArticlesDataService
{
Task Create(Article articleToInsert);
Task Favorite(string slug, string username);
Task Unfavorite(string slug, string username);
Task<bool> Exists(string slug);
Task<DataServiceResult<Article>> Get(string slug);
Task<bool> IsFavorited(string slug, string username);
Expand All @@ -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;
Expand Down Expand Up @@ -206,16 +203,82 @@ public async Task Favorite(string slug, string username)
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<List<string>>();
// 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>();
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<bool> 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<string>());
}
catch (DocumentExistsException ex)
Expand Down

0 comments on commit d55ca7f

Please sign in to comment.