From dba754314a12e8f9294c5b691222e600d2ce5232 Mon Sep 17 00:00:00 2001 From: "Matthew D. Groves" Date: Fri, 1 Sep 2023 10:32:31 -0400 Subject: [PATCH] #22 #15 favorite and get article (endpoints go hand-in-hand) --- .../006_CreateCollectionForFavorites.cs | 27 +++++ Conduit/Conduit.Tests/Conduit.Tests.csproj | 1 + .../Handlers/CreateArticleHandlerTests.cs | 7 +- .../Handlers/FavoriteArticleHandlerTests.cs | 64 ++++++++++ .../Handlers/GetArticleHandlerTests.cs | 85 ++++++++++++++ .../ArticlesDataService/FavoriteTests.cs | 59 ++++++++++ .../Integration/CouchbaseIntegrationTest.cs | 2 + .../Users/Handlers/GetProfileHandlerTests.cs | 3 +- .../TestHelpers/Data/ArticleHelper.cs | 77 +++++++++++- .../Dto/CreateArticleRequestHelper.cs | 1 + .../TestHelpers/RandomHelpers.cs | 13 -- .../Handlers/CreateArticleHandlerTests.cs | 3 +- .../Users/Handlers/GetProfileHandlerTests.cs | 14 ++- .../Controllers/ArticlesController.cs | 57 ++++++++- .../Articles/Handlers/CreateArticleHandler.cs | 2 +- .../Handlers/FavoriteArticleRequest.cs | 43 +++++++ .../Articles/Handlers/GetArticleHandler.cs | 52 ++++++++ .../Articles/Handlers/GetArticleRequest.cs | 17 +++ .../Articles/Handlers/GetArticleResponse.cs | 32 +++++ .../Articles/Services/ArticlesDataService.cs | 111 +++++++++++++++++- .../Articles/Services/SlugService.cs | 4 +- .../Articles/ViewModels/ArticleViewModel.cs | 3 +- Conduit/Conduit.Web/Conduit.Web.csproj | 1 + .../IConduitFavoritesCollectionProvider.cs | 8 ++ .../Follows/Services/FollowsDataService.cs | 12 +- Conduit/Conduit.Web/Program.cs | 3 + .../Users/Handlers/GetProfileHandler.cs | 9 +- .../Users/Handlers/GetProfileRequest.cs | 2 +- .../Conduit.Web/Users/Services/AuthService.cs | 24 ++++ .../Users/Services/IAuthService.cs | 6 +- .../Users/Services/UserDataService.cs | 2 +- 31 files changed, 702 insertions(+), 42 deletions(-) create mode 100644 Conduit/Conduit.Migrations/006_CreateCollectionForFavorites.cs create mode 100644 Conduit/Conduit.Tests/Integration/Articles/Handlers/FavoriteArticleHandlerTests.cs create mode 100644 Conduit/Conduit.Tests/Integration/Articles/Handlers/GetArticleHandlerTests.cs create mode 100644 Conduit/Conduit.Tests/Integration/Articles/Services/ArticlesDataService/FavoriteTests.cs delete mode 100644 Conduit/Conduit.Tests/TestHelpers/RandomHelpers.cs create mode 100644 Conduit/Conduit.Web/Articles/Handlers/FavoriteArticleRequest.cs create mode 100644 Conduit/Conduit.Web/Articles/Handlers/GetArticleHandler.cs create mode 100644 Conduit/Conduit.Web/Articles/Handlers/GetArticleRequest.cs create mode 100644 Conduit/Conduit.Web/Articles/Handlers/GetArticleResponse.cs create mode 100644 Conduit/Conduit.Web/DataAccess/Providers/IConduitFavoritesCollectionProvider.cs diff --git a/Conduit/Conduit.Migrations/006_CreateCollectionForFavorites.cs b/Conduit/Conduit.Migrations/006_CreateCollectionForFavorites.cs new file mode 100644 index 0000000000..27271636c2 --- /dev/null +++ b/Conduit/Conduit.Migrations/006_CreateCollectionForFavorites.cs @@ -0,0 +1,27 @@ +using NoSqlMigrator.Infrastructure; + +namespace Conduit.Migrations; + +// Manual alternative: create a Favorites collection in _default scope +[Migration(6)] +public class CreateCollectionForFavorites : MigrateBase +{ + private readonly string? _scopeName; + + public CreateCollectionForFavorites() + { + _scopeName = _config["Couchbase:ScopeName"]; + } + + public override void Up() + { + Create.Collection("Favorites") + .InScope(_scopeName); + } + + public override void Down() + { + Delete.Collection("Favorites") + .FromScope(_scopeName); + } +} \ No newline at end of file diff --git a/Conduit/Conduit.Tests/Conduit.Tests.csproj b/Conduit/Conduit.Tests/Conduit.Tests.csproj index 07b6cd4603..8bb3ac22e1 100644 --- a/Conduit/Conduit.Tests/Conduit.Tests.csproj +++ b/Conduit/Conduit.Tests/Conduit.Tests.csproj @@ -43,6 +43,7 @@ + diff --git a/Conduit/Conduit.Tests/Integration/Articles/Handlers/CreateArticleHandlerTests.cs b/Conduit/Conduit.Tests/Integration/Articles/Handlers/CreateArticleHandlerTests.cs index cf025a6f81..998d946d80 100644 --- a/Conduit/Conduit.Tests/Integration/Articles/Handlers/CreateArticleHandlerTests.cs +++ b/Conduit/Conduit.Tests/Integration/Articles/Handlers/CreateArticleHandlerTests.cs @@ -18,6 +18,7 @@ public class CreateArticleHandlerTests : CouchbaseIntegrationTest private TagsDataService _tagsDataService; private IConduitArticlesCollectionProvider _articleCollectionProvider; private ArticlesDataService _articleDataService; + private IConduitFavoritesCollectionProvider _favoriteCollectionProvider; public override async Task Setup() { @@ -31,15 +32,19 @@ public override async Task Setup() b .AddScope("_default") .AddCollection("Articles"); + b + .AddScope("_default") + .AddCollection("Favorites"); }); _tagsCollectionProvider = ServiceProvider.GetRequiredService(); _articleCollectionProvider = ServiceProvider.GetRequiredService(); + _favoriteCollectionProvider = ServiceProvider.GetRequiredService(); // setup handler and dependencies _tagsDataService = new TagsDataService(_tagsCollectionProvider); var validator = new CreateArticleRequestValidator(_tagsDataService); - _articleDataService = new ArticlesDataService(_articleCollectionProvider); + _articleDataService = new ArticlesDataService(_articleCollectionProvider, _favoriteCollectionProvider); var slugService = new SlugService(new SlugHelper()); _handler = new CreateArticleHandler(validator, _articleDataService, slugService); } diff --git a/Conduit/Conduit.Tests/Integration/Articles/Handlers/FavoriteArticleHandlerTests.cs b/Conduit/Conduit.Tests/Integration/Articles/Handlers/FavoriteArticleHandlerTests.cs new file mode 100644 index 0000000000..3aac5a55d9 --- /dev/null +++ b/Conduit/Conduit.Tests/Integration/Articles/Handlers/FavoriteArticleHandlerTests.cs @@ -0,0 +1,64 @@ +using Conduit.Tests.TestHelpers.Data; +using Conduit.Web.Articles.Handlers; +using Conduit.Web.Articles.Services; +using Conduit.Web.DataAccess.Providers; +using Couchbase.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; + +namespace Conduit.Tests.Integration.Articles.Handlers; + +public class FavoriteArticleHandlerTests : CouchbaseIntegrationTest +{ + private IConduitArticlesCollectionProvider _articleCollectionProvider; + private FavoriteArticleHandler _handler; + private IConduitFavoritesCollectionProvider _favoritesCollectionProvider; + private ArticlesDataService _articleDataService; + private IConduitUsersCollectionProvider _usersCollectionProvider; + + public override async Task Setup() + { + await base.Setup(); + + ServiceCollection.AddCouchbaseBucket("ConduitIntegrationTests", b => + { + b + .AddScope("_default") + .AddCollection("Articles"); + b + .AddScope("_default") + .AddCollection("Favorites"); + b + .AddScope("_default") + .AddCollection("Users"); + }); + + _articleCollectionProvider = ServiceProvider.GetRequiredService(); + _favoritesCollectionProvider = ServiceProvider.GetRequiredService(); + _usersCollectionProvider = ServiceProvider.GetRequiredService(); + _articleDataService = new ArticlesDataService(_articleCollectionProvider, _favoritesCollectionProvider); + + // setup handler and dependencies + _handler = new FavoriteArticleHandler(_articleDataService); + } + + [Test] + public async Task FavoriteArticleHandler_adds_article_to_a_users_favorites() + { + // arrange + var user = await _usersCollectionProvider.CreateUserInDatabase(); + var article = await _articleCollectionProvider.CreateArticleInDatabase(); + var request = new FavoriteArticleRequest(); + request.Slug = article.Slug; + request.Username = user.Username; + + // act + var result = await _handler.Handle(request, CancellationToken.None); + + // assert + Assert.That(result.ValidationErrors == null || !result.ValidationErrors.Any(), Is.True); + await _favoritesCollectionProvider.AssertExists(user.Username, x => + { + Assert.That(x.Contains(request.Slug), Is.True); + }); + } +} \ No newline at end of file diff --git a/Conduit/Conduit.Tests/Integration/Articles/Handlers/GetArticleHandlerTests.cs b/Conduit/Conduit.Tests/Integration/Articles/Handlers/GetArticleHandlerTests.cs new file mode 100644 index 0000000000..49afd86ba2 --- /dev/null +++ b/Conduit/Conduit.Tests/Integration/Articles/Handlers/GetArticleHandlerTests.cs @@ -0,0 +1,85 @@ +using Conduit.Tests.TestHelpers; +using Conduit.Tests.TestHelpers.Data; +using Conduit.Web.Articles.Handlers; +using Conduit.Web.Articles.Services; +using Conduit.Web.DataAccess.Providers; +using Conduit.Web.Extensions; +using Conduit.Web.Follows.Services; +using Conduit.Web.Users.Services; +using Couchbase.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Conduit.Tests.Integration.Articles.Handlers; + +public class GetArticleHandlerTests : CouchbaseIntegrationTest +{ + private IConduitArticlesCollectionProvider _articleCollectionProvider; + private IConduitUsersCollectionProvider _usersCollectionProvider; + private IConduitFollowsCollectionProvider _followsCollectionProvider; + private ArticlesDataService _articleDataService; + private IConduitFavoritesCollectionProvider _favoriteCollectionProvider; + private GetArticleHandler _handler; + private AuthService _authService; + private UserDataService _userDataService; + private FollowsDataService _followDataService; + private Random _random; + + public override async Task Setup() + { + await base.Setup(); + + ServiceCollection.AddCouchbaseBucket("ConduitIntegrationTests", b => + { + b + .AddScope("_default") + .AddCollection("Users"); + b + .AddScope("_default") + .AddCollection("Articles"); + b + .AddScope("_default") + .AddCollection("Follows"); + b + .AddScope("_default") + .AddCollection("Favorites"); + }); + + _usersCollectionProvider = ServiceProvider.GetRequiredService(); + _articleCollectionProvider = ServiceProvider.GetRequiredService(); + _followsCollectionProvider = ServiceProvider.GetRequiredService(); + _favoriteCollectionProvider = ServiceProvider.GetRequiredService(); + + // setup handler and dependencies + var jwtSecrets = new JwtSecrets + { + Audience = "dummy-audience", + Issuer = "dummy-issuer", + SecurityKey = "dummy-securitykey" + }; + _authService = new AuthService(new OptionsWrapper(jwtSecrets)); + _articleDataService = new ArticlesDataService(_articleCollectionProvider, _favoriteCollectionProvider); + _followDataService = new FollowsDataService(_followsCollectionProvider, _authService); + _userDataService = new UserDataService(_usersCollectionProvider, _authService); + _handler = new GetArticleHandler(_articleDataService, _userDataService, _followDataService); + _random = new Random(); + } + + [Test] + public async Task GetArticleHandler_Returns_article() + { + // arrange + var currentUser = await _usersCollectionProvider.CreateUserInDatabase(); + var authorUser = await _usersCollectionProvider.CreateUserInDatabase(); + var article = await _articleCollectionProvider.CreateArticleInDatabase(authorUsername: authorUser.Username); + var request = new GetArticleRequest(article.Slug, currentUser.Username); + + // act + var result = await _handler.Handle(request, CancellationToken.None); + + // assert + Assert.That(result.ArticleView.Slug, Is.EqualTo(article.Slug)); + Assert.That(result.ArticleView.Title, Is.EqualTo(article.Title)); + Assert.That(result.ArticleView.Author.Username, Is.EqualTo(authorUser.Username)); + } +} \ 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 new file mode 100644 index 0000000000..dc47d4aa3e --- /dev/null +++ b/Conduit/Conduit.Tests/Integration/Articles/Services/ArticlesDataService/FavoriteTests.cs @@ -0,0 +1,59 @@ +using Conduit.Tests.TestHelpers.Data; +using Conduit.Web.DataAccess.Providers; +using Couchbase.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; + +namespace Conduit.Tests.Integration.Articles.Services.ArticlesDataService; + +[TestFixture] +public class FavoriteTests : CouchbaseIntegrationTest +{ + private IConduitArticlesCollectionProvider _articleCollectionProvider; + private IConduitFavoritesCollectionProvider _favoriteCollectionProvider; + private Web.Articles.Services.ArticlesDataService _articleDataService; + + [SetUp] + public override async Task Setup() + { + await base.Setup(); + + ServiceCollection.AddCouchbaseBucket("ConduitIntegrationTests", b => + { + b + .AddScope("_default") + .AddCollection("Articles"); + b + .AddScope("_default") + .AddCollection("Favorites"); + }); + + _articleCollectionProvider = ServiceProvider.GetRequiredService(); + _favoriteCollectionProvider = ServiceProvider.GetRequiredService(); + + _articleDataService = new Web.Articles.Services.ArticlesDataService( + _articleCollectionProvider, + _favoriteCollectionProvider); + } + + [TestCase(0,1)] + [TestCase(73,74)] + public async Task Favoriting_works_and_increases_count(int initialCount, int expectedCount) + { + // arrange + var article = await _articleCollectionProvider.CreateArticleInDatabase(favoritesCount: initialCount); + var user = UserHelper.CreateUser(); + + // act + await _articleDataService.Favorite(article.Slug, user.Username); + + // assert + await _favoriteCollectionProvider.AssertExists(user.Username, x => + { + Assert.That(x.Contains(article.Slug), Is.True); + }); + await _articleCollectionProvider.AssertExists(article.Slug, x => + { + Assert.That(x.FavoritesCount, Is.EqualTo(expectedCount)); + }); + } +} \ No newline at end of file diff --git a/Conduit/Conduit.Tests/Integration/CouchbaseIntegrationTest.cs b/Conduit/Conduit.Tests/Integration/CouchbaseIntegrationTest.cs index e73e48fcfd..702af4f2c5 100644 --- a/Conduit/Conduit.Tests/Integration/CouchbaseIntegrationTest.cs +++ b/Conduit/Conduit.Tests/Integration/CouchbaseIntegrationTest.cs @@ -34,6 +34,8 @@ public virtual async Task Setup() _config["Couchbase:Username"], _config["Couchbase:Password"]); + await _cluster.WaitUntilReadyAsync(TimeSpan.FromSeconds(30)); + var allBuckets = await _cluster.Buckets.GetAllBucketsAsync(); var doesBucketExist = allBuckets.Any(b => b.Key == _config["Couchbase:BucketName"]); if (!doesBucketExist) diff --git a/Conduit/Conduit.Tests/Integration/Users/Handlers/GetProfileHandlerTests.cs b/Conduit/Conduit.Tests/Integration/Users/Handlers/GetProfileHandlerTests.cs index cda0f8afaa..4b0528096e 100644 --- a/Conduit/Conduit.Tests/Integration/Users/Handlers/GetProfileHandlerTests.cs +++ b/Conduit/Conduit.Tests/Integration/Users/Handlers/GetProfileHandlerTests.cs @@ -35,7 +35,8 @@ public override async Task Setup() _authService = AuthServiceHelper.Create(); _getProfileHandler = new GetProfileHandler(new UserDataService(_usersCollectionProvider, _authService), new GetProfileRequestValidator(), - new FollowsDataService(_followsCollectionProvider, _authService)); + new FollowsDataService(_followsCollectionProvider, _authService), + _authService); } [Test] diff --git a/Conduit/Conduit.Tests/TestHelpers/Data/ArticleHelper.cs b/Conduit/Conduit.Tests/TestHelpers/Data/ArticleHelper.cs index 204a8a28bf..420ca348ce 100644 --- a/Conduit/Conduit.Tests/TestHelpers/Data/ArticleHelper.cs +++ b/Conduit/Conduit.Tests/TestHelpers/Data/ArticleHelper.cs @@ -1,5 +1,8 @@ using Conduit.Web.DataAccess.Models; using Conduit.Web.DataAccess.Providers; +using Conduit.Web.Articles.Services; +using Conduit.Web.Extensions; +using Slugify; namespace Conduit.Tests.TestHelpers.Data; @@ -19,8 +22,80 @@ public static async Task AssertExists(this IConduitArticlesCollectionProvider @t if (assertions != null) assertions(articleInDatabaseObj); - // if we made it this far, the user was retrieved + // if we made it this far, the article was retrieved // and the assertions passed Assert.That(true); } + + public static async Task AssertExists(this IConduitFavoritesCollectionProvider @this, string username, + Action>? assertions = null) + { + var collection = await @this.GetCollectionAsync(); + var favoritesInDatabase = await collection.GetAsync($"{username}::favorites"); + var favoritesInDatabaseObj = favoritesInDatabase.ContentAs>(); + + if (assertions != null) + assertions(favoritesInDatabaseObj); + + // if we made it this far, the favorites was retrieved + // and the assertions passed + Assert.That(true); + } + + public static async Task
CreateArticleInDatabase(this IConduitArticlesCollectionProvider @this, + int? favoritesCount = null, + DateTimeOffset? createdAt = null, + string? title = null, + string? description = null, + string? body = null, + string? slug = null, + List? tagList = null, + string? authorUsername = null, + bool? favorited = null) + { + var collection = await @this.GetCollectionAsync(); + + var article = CreateArticle(favoritesCount, createdAt, title, description, body, slug, tagList, authorUsername, + favorited); + + await collection.InsertAsync(article.Slug, article); + + return article; + } + + private static Article CreateArticle( + int? favoritesCount = null, + DateTimeOffset? createdAt = null, + string? title = null, + string? description = null, + string? body = null, + string? slug = null, + List? tagList = null, + string? authorUsername = null, + bool? favorited = null) + { + var random = new Random(); + favoritesCount ??= (int)random.NextInt64(0, 100); + createdAt ??= DateTimeOffset.Now; + title ??= "Title " + random.String(20); + description ??= "Description " + random.String(256); + authorUsername ??= "user-" + random.String(8); + body ??= "Body " + random.String(10000); + slug ??= new SlugService(new SlugHelper()).GenerateSlug(title); + tagList ??= new List { "Couchbase", "baseball"}; + favorited ??= true; + + var article = new Article(); + article.FavoritesCount = favoritesCount.Value; + article.CreatedAt = createdAt.Value; + article.Title = title; + article.Description = description; + article.Body = body; + article.Slug = slug; + article.TagList = tagList; + article.AuthorUsername = authorUsername; + article.Favorited = favorited.Value; + + return article; + } } \ No newline at end of file diff --git a/Conduit/Conduit.Tests/TestHelpers/Dto/CreateArticleRequestHelper.cs b/Conduit/Conduit.Tests/TestHelpers/Dto/CreateArticleRequestHelper.cs index 67a7757d8d..f36816a91c 100644 --- a/Conduit/Conduit.Tests/TestHelpers/Dto/CreateArticleRequestHelper.cs +++ b/Conduit/Conduit.Tests/TestHelpers/Dto/CreateArticleRequestHelper.cs @@ -1,5 +1,6 @@ using Conduit.Web.Articles.Handlers; using Conduit.Web.Articles.ViewModels; +using Conduit.Web.Extensions; namespace Conduit.Tests.TestHelpers.Dto; diff --git a/Conduit/Conduit.Tests/TestHelpers/RandomHelpers.cs b/Conduit/Conduit.Tests/TestHelpers/RandomHelpers.cs deleted file mode 100644 index 0f9580165e..0000000000 --- a/Conduit/Conduit.Tests/TestHelpers/RandomHelpers.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Conduit.Tests.TestHelpers; - -public static class RandomHelpers -{ - const string _defaultCharacterSet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - - public static string String(this Random @this, int length, string? characterPool = null) - { - var chars = characterPool ?? _defaultCharacterSet; - return new string(Enumerable.Repeat(chars, length) - .Select(s => s[@this.Next(s.Length)]).ToArray()); - } -} \ No newline at end of file diff --git a/Conduit/Conduit.Tests/Unit/Articles/Handlers/CreateArticleHandlerTests.cs b/Conduit/Conduit.Tests/Unit/Articles/Handlers/CreateArticleHandlerTests.cs index 3edb798ccb..47014940cb 100644 --- a/Conduit/Conduit.Tests/Unit/Articles/Handlers/CreateArticleHandlerTests.cs +++ b/Conduit/Conduit.Tests/Unit/Articles/Handlers/CreateArticleHandlerTests.cs @@ -2,6 +2,7 @@ using Conduit.Tests.TestHelpers.Dto; using Conduit.Web.Articles.Handlers; using Conduit.Web.Articles.Services; +using Conduit.Web.Extensions; using Moq; namespace Conduit.Tests.Unit.Articles.Handlers; @@ -168,7 +169,7 @@ public async Task Slug_service_is_used_to_generate_from_title() var request = CreateArticleRequestHelper.Create(); request.ArticleSubmission.Article.Title = "slugify this title"; _slugServiceMock.Setup(m => m.GenerateSlug(request.ArticleSubmission.Article.Title)) - .ReturnsAsync("slugified-title-a8d9a8ef"); + .Returns("slugified-title-a8d9a8ef"); // act var result = await _handler.Handle(request, CancellationToken.None); diff --git a/Conduit/Conduit.Tests/Unit/Users/Handlers/GetProfileHandlerTests.cs b/Conduit/Conduit.Tests/Unit/Users/Handlers/GetProfileHandlerTests.cs index f152613b91..e3de3c491f 100644 --- a/Conduit/Conduit.Tests/Unit/Users/Handlers/GetProfileHandlerTests.cs +++ b/Conduit/Conduit.Tests/Unit/Users/Handlers/GetProfileHandlerTests.cs @@ -5,6 +5,7 @@ using Conduit.Web.Follows.Services; using Conduit.Web.Users.Handlers; using Conduit.Web.Users.Services; +using Microsoft.Extensions.Options; using Moq; namespace Conduit.Tests.Unit.Users.Handlers; @@ -23,7 +24,16 @@ public void SetUp() _followDataServiceMock = new Mock(); // setup handler - _handler = new GetProfileHandler(_userDataServiceMock.Object, new GetProfileRequestValidator(), _followDataServiceMock.Object); + var jwtSecrets = new JwtSecrets + { + Audience = "doesntmatter-audience", + Issuer = "doesntmatter-issuer", + SecurityKey = "doesntmatter-securityKey" + }; + _handler = new GetProfileHandler(_userDataServiceMock.Object, + new GetProfileRequestValidator(), + _followDataServiceMock.Object, + new AuthService(new OptionsWrapper(jwtSecrets))); } [TestCase(false)] @@ -35,7 +45,7 @@ public async Task Profile_for_a_user_following_the_user_in_the_profile(bool isUs // arrange for user to NOT be followed var user = UserHelper.CreateUser(username: "SurlyDev"); - _followDataServiceMock.Setup(m => m.IsCurrentUserFollowing(currentUserToken, user.Username)) + _followDataServiceMock.Setup(m => m.IsCurrentUserFollowing("MattGroves", user.Username)) .ReturnsAsync(isUserFollowing); _userDataServiceMock.Setup(m => m.GetProfileByUsername(user.Username)) .ReturnsAsync(new DataServiceResult(user, DataResultStatus.Ok)); diff --git a/Conduit/Conduit.Web/Articles/Controllers/ArticlesController.cs b/Conduit/Conduit.Web/Articles/Controllers/ArticlesController.cs index 3c0e64f8cc..53698baa54 100644 --- a/Conduit/Conduit.Web/Articles/Controllers/ArticlesController.cs +++ b/Conduit/Conduit.Web/Articles/Controllers/ArticlesController.cs @@ -34,13 +34,11 @@ public ArticlesController(IMediator mediator, IAuthService authService) [HttpPost("/api/articles")] public async Task CreateArticle([FromBody] CreateArticleSubmitModel articleSubmission) { - // TODO: make these 3 lines into a convenience method in authservice - var authHeader = Request.Headers["Authorization"]; - var bearerToken = _authService.GetTokenFromHeader(authHeader); - var authorUsername = _authService.GetUsernameClaim(bearerToken).Value; + var claims = _authService.GetAllAuthInfo(Request.Headers["Authorization"]); + var authorUsername = claims.Username.Value; // get the author profile first - var authorProfile = await _mediator.Send(new GetProfileRequest(authorUsername, bearerToken)); + var authorProfile = await _mediator.Send(new GetProfileRequest(authorUsername, claims.BearerToken)); if (authorProfile.ValidationErrors?.Any() ?? false) return UnprocessableEntity(authorProfile.ValidationErrors.ToCsv()); @@ -54,4 +52,53 @@ public async Task CreateArticle([FromBody] CreateArticleSubmitMod articleView.Article.Author = authorProfile.ProfileView; return Ok(articleView); } + + [HttpPost] + [Route("/api/article/{slug}/favorite")] + [Authorize] + public async Task FavoriteArticle(string slug) + { + // get auth info + var claims = _authService.GetAllAuthInfo(Request.Headers["Authorization"]); + + // send request to favorite the article + var favoriteRequest = new FavoriteArticleRequest(); + 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); + } + + [HttpGet] + [Route("/api/article/{slug}")] + public async Task Get(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; + } + + // send request to get article + var getRequest = new GetArticleRequest(slug, username); + var getResponse = await _mediator.Send(getRequest); + if (getResponse.ValidationErrors?.Any() ?? false) + return UnprocessableEntity(getResponse.ValidationErrors.ToCsv()); + + return Ok(getResponse.ArticleView); + } } diff --git a/Conduit/Conduit.Web/Articles/Handlers/CreateArticleHandler.cs b/Conduit/Conduit.Web/Articles/Handlers/CreateArticleHandler.cs index 984db7f292..c600c8830b 100644 --- a/Conduit/Conduit.Web/Articles/Handlers/CreateArticleHandler.cs +++ b/Conduit/Conduit.Web/Articles/Handlers/CreateArticleHandler.cs @@ -37,7 +37,7 @@ public async Task Handle(CreateArticleRequest request, Ca Title = request.ArticleSubmission.Article.Title.Trim(), Description = request.ArticleSubmission.Article.Description.Trim(), Body = request.ArticleSubmission.Article.Body.Trim(), - Slug = await _slugService.GenerateSlug(request.ArticleSubmission.Article.Title.Trim()), + Slug = _slugService.GenerateSlug(request.ArticleSubmission.Article.Title.Trim()), TagList = request.ArticleSubmission.Article.Tags, CreatedAt = new DateTimeOffset(DateTime.Now), Favorited = false, // brand new article, no one can have favorited it yet diff --git a/Conduit/Conduit.Web/Articles/Handlers/FavoriteArticleRequest.cs b/Conduit/Conduit.Web/Articles/Handlers/FavoriteArticleRequest.cs new file mode 100644 index 0000000000..3a967d55da --- /dev/null +++ b/Conduit/Conduit.Web/Articles/Handlers/FavoriteArticleRequest.cs @@ -0,0 +1,43 @@ +using Conduit.Web.Articles.Services; +using Conduit.Web.DataAccess.Dto; +using FluentValidation.Results; +using MediatR; + +namespace Conduit.Web.Articles.Handlers; + +public class FavoriteArticleRequest : IRequest +{ + public string Username { get; set; } + public string Slug { get; set; } +} + +public class FavoriteArticleResponse +{ + public List ValidationErrors { get; set; } +} + +public class FavoriteArticleHandler : IRequestHandler +{ + private readonly IArticlesDataService _articlesDataService; + + public FavoriteArticleHandler(IArticlesDataService articlesDataService) + { + _articlesDataService = articlesDataService; + } + + public async Task Handle(FavoriteArticleRequest request, CancellationToken cancellationToken) + { + var articleExists = await _articlesDataService.Exists(request.Slug); + if (!articleExists) + { + return new FavoriteArticleResponse + { + ValidationErrors = new List { new ValidationFailure("", "Article not found.") } + }; + } + + await _articlesDataService.Favorite(request.Slug, request.Username); + + return new FavoriteArticleResponse(); + } +} \ No newline at end of file diff --git a/Conduit/Conduit.Web/Articles/Handlers/GetArticleHandler.cs b/Conduit/Conduit.Web/Articles/Handlers/GetArticleHandler.cs new file mode 100644 index 0000000000..5150824f6c --- /dev/null +++ b/Conduit/Conduit.Web/Articles/Handlers/GetArticleHandler.cs @@ -0,0 +1,52 @@ +using Conduit.Web.Articles.Services; +using Conduit.Web.Follows.Services; +using Conduit.Web.Users.Services; +using FluentValidation.Results; +using MediatR; + +namespace Conduit.Web.Articles.Handlers; + +public class GetArticleHandler : IRequestHandler +{ + private readonly IArticlesDataService _articlesDataService; + private readonly IUserDataService _userDataService; + private readonly IFollowDataService _followDataService; + + public GetArticleHandler(IArticlesDataService articlesDataService, IUserDataService userDataService, IFollowDataService followDataService) + { + _articlesDataService = articlesDataService; + _userDataService = userDataService; + _followDataService = followDataService; + } + + public async Task Handle(GetArticleRequest request, CancellationToken cancellationToken) + { + var articleExists = await _articlesDataService.Exists(request.Slug); + if (!articleExists) + { + return new GetArticleResponse + { + ValidationErrors = new List { new ValidationFailure("", "Article not found.") } + }; + } + + // build up response view: article, profile + var article = await _articlesDataService.Get(request.Slug); + var authorProfile = await _userDataService.GetProfileByUsername(article.DataResult.AuthorUsername); + var response = new GetArticleResponse(article.DataResult, authorProfile.DataResult); + + // check for profile following and article favoriting if the current user is logged in + if (request.IsUserAuthenticated) + { + response.ArticleView.Author.Following = await _followDataService.IsCurrentUserFollowing(request.CurrentUser, authorProfile.DataResult.Username); + response.ArticleView.Favorited = await _articlesDataService.IsFavorited(request.Slug, request.CurrentUser); + } + else + { + response.ArticleView.Author.Following = false; + response.ArticleView.Favorited = false; + } + + return response; + } +} \ No newline at end of file diff --git a/Conduit/Conduit.Web/Articles/Handlers/GetArticleRequest.cs b/Conduit/Conduit.Web/Articles/Handlers/GetArticleRequest.cs new file mode 100644 index 0000000000..6a023eab96 --- /dev/null +++ b/Conduit/Conduit.Web/Articles/Handlers/GetArticleRequest.cs @@ -0,0 +1,17 @@ +using MediatR; + +namespace Conduit.Web.Articles.Handlers; + +public class GetArticleRequest : IRequest +{ + public string Slug { get; } + public string CurrentUser { get; } + + public bool IsUserAuthenticated => !string.IsNullOrEmpty(CurrentUser); + + public GetArticleRequest(string slug, string currentUsername) + { + Slug = slug; + CurrentUser = currentUsername; + } +} \ No newline at end of file diff --git a/Conduit/Conduit.Web/Articles/Handlers/GetArticleResponse.cs b/Conduit/Conduit.Web/Articles/Handlers/GetArticleResponse.cs new file mode 100644 index 0000000000..4c70961835 --- /dev/null +++ b/Conduit/Conduit.Web/Articles/Handlers/GetArticleResponse.cs @@ -0,0 +1,32 @@ +using Conduit.Web.Articles.ViewModels; +using Conduit.Web.DataAccess.Models; +using Conduit.Web.Users.ViewModels; +using FluentValidation.Results; + +namespace Conduit.Web.Articles.Handlers; + +public class GetArticleResponse +{ + public GetArticleResponse() { } + + public GetArticleResponse(Article article, User user) + { + ArticleView = new ArticleViewModel(); + ArticleView.Slug = article.Slug; + ArticleView.Title = article.Title; + ArticleView.Description = article.Description; + ArticleView.Body = article.Body; + ArticleView.TagList = article.TagList; + ArticleView.CreatedAt = article.CreatedAt; + ArticleView.Favorited = article.Favorited; + ArticleView.FavoritesCount = article.FavoritesCount; + + ArticleView.Author = new ProfileViewModel(); + ArticleView.Author.Bio = user.Bio; + ArticleView.Author.Image = user.Image; + ArticleView.Author.Username = user.Username; + } + + public ArticleViewModel ArticleView { get; set; } + 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 18ca999d93..6a9decc0a2 100644 --- a/Conduit/Conduit.Web/Articles/Services/ArticlesDataService.cs +++ b/Conduit/Conduit.Web/Articles/Services/ArticlesDataService.cs @@ -1,20 +1,36 @@ -using Conduit.Web.DataAccess.Models; +using Conduit.Web.DataAccess.Dto; +using Conduit.Web.DataAccess.Models; using Conduit.Web.DataAccess.Providers; +using Couchbase.Core.Exceptions.KeyValue; +using Couchbase.KeyValue; +using Couchbase.Transactions; +using Couchbase.Transactions.Config; namespace Conduit.Web.Articles.Services; public interface IArticlesDataService { Task Create(Article articleToInsert); + Task Favorite(string slug, string username); + Task Exists(string slug); + Task> Get(string slug); + Task IsFavorited(string slug, string username); } public class ArticlesDataService : IArticlesDataService { private readonly IConduitArticlesCollectionProvider _articlesCollectionProvider; + private readonly IConduitFavoritesCollectionProvider _favoritesCollectionProvider; - public ArticlesDataService(IConduitArticlesCollectionProvider articlesCollectionProvider) + private string FavoriteDocId(string username) + { + return $"{username}::favorites"; + } + + public ArticlesDataService(IConduitArticlesCollectionProvider articlesCollectionProvider, IConduitFavoritesCollectionProvider favoritesCollectionProvider) { _articlesCollectionProvider = articlesCollectionProvider; + _favoritesCollectionProvider = favoritesCollectionProvider; } public async Task Create(Article articleToInsert) @@ -23,4 +39,95 @@ public async Task Create(Article articleToInsert) await collection.InsertAsync(articleToInsert.Slug, articleToInsert); } + + public async Task Exists(string slug) + { + var articlesCollection = await _articlesCollectionProvider.GetCollectionAsync(); + var articleExists = await articlesCollection.ExistsAsync(slug); + return articleExists.Exists; + } + + public async Task> Get(string slug) + { + var articlesCollection = await _articlesCollectionProvider.GetCollectionAsync(); + var articleDoc = await articlesCollection.GetAsync(slug); + var article = articleDoc.ContentAs
(); + article.Slug = slug; + return new DataServiceResult
(article, DataResultStatus.Ok); + } + + public async Task> Get(string slug, string username = null) + { + var collection = await _articlesCollectionProvider.GetCollectionAsync(); + + var articleDoc = await collection.GetAsync(slug); + var article = articleDoc.ContentAs
(); + if(!string.IsNullOrEmpty(username)) + article.Favorited = await IsFavorited(slug, username); + return new DataServiceResult
(article, DataResultStatus.Ok); + } + + public async Task IsFavorited(string slug, string username) + { + var collection = await _favoritesCollectionProvider.GetCollectionAsync(); + var set = collection.Set(FavoriteDocId(username)); + return await set.ContainsAsync(slug); + } + + public async Task Favorite(string slug, string username) + { + // create favorites document if necessary + await EnsureFavoritesDocumentExists(username); + + // start transaction + var articlesCollection = await _articlesCollectionProvider.GetCollectionAsync(); + var favoriteCollection = await _favoritesCollectionProvider.GetCollectionAsync(); + var cluster = favoriteCollection.Scope.Bucket.Cluster; + + var config = TransactionConfigBuilder.Create(); + config.DurabilityLevel(DurabilityLevel.None); + 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, bail out) + var favoritesDoc = await context.GetAsync(favoriteCollection, favoriteKey); + var favorites = favoritesDoc.ContentAs>(); + if (favorites.Contains(slug)) + { + await context.RollbackAsync(); + return; + } + + // add slug to favorites document + favorites.Add(slug); + await context.ReplaceAsync(favoritesDoc, favorites); + + // increment favorite count in article + var articleDoc = await context.GetAsync(articlesCollection, slug); + var article = articleDoc.ContentAs
(); + article.FavoritesCount++; + await context.ReplaceAsync(articleDoc, article); + }); + } + + private async Task EnsureFavoritesDocumentExists(string username) + { + var favoriteDocId = FavoriteDocId(username); + var collection = await _favoritesCollectionProvider.GetCollectionAsync(); + var favoritesDoc = await collection.ExistsAsync(favoriteDocId); + if (favoritesDoc.Exists) + return; + + try + { + await collection.InsertAsync(favoriteDocId, new List()); + } + catch (DocumentExistsException ex) + { + // if this exception happens, that's fine! I just want the document to exist + } + } } \ No newline at end of file diff --git a/Conduit/Conduit.Web/Articles/Services/SlugService.cs b/Conduit/Conduit.Web/Articles/Services/SlugService.cs index ffe2ac489e..7964a3d046 100644 --- a/Conduit/Conduit.Web/Articles/Services/SlugService.cs +++ b/Conduit/Conduit.Web/Articles/Services/SlugService.cs @@ -5,7 +5,7 @@ namespace Conduit.Web.Articles.Services; public interface ISlugService { - Task GenerateSlug(string title); + string GenerateSlug(string title); } public class SlugService : ISlugService @@ -25,7 +25,7 @@ public SlugService(ISlugHelper slugHelper) /// /// Title like "foo bar baz" /// Slugified string - public async Task GenerateSlug(string title) + public string GenerateSlug(string title) { var slug = _slugHelper.GenerateSlug(title); diff --git a/Conduit/Conduit.Web/Articles/ViewModels/ArticleViewModel.cs b/Conduit/Conduit.Web/Articles/ViewModels/ArticleViewModel.cs index 8a36f36812..274b19f40e 100644 --- a/Conduit/Conduit.Web/Articles/ViewModels/ArticleViewModel.cs +++ b/Conduit/Conduit.Web/Articles/ViewModels/ArticleViewModel.cs @@ -1,4 +1,5 @@ -using Conduit.Web.Users.ViewModels; +using Conduit.Web.DataAccess.Models; +using Conduit.Web.Users.ViewModels; namespace Conduit.Web.Articles.ViewModels; diff --git a/Conduit/Conduit.Web/Conduit.Web.csproj b/Conduit/Conduit.Web/Conduit.Web.csproj index a59b0b140b..f49c885473 100644 --- a/Conduit/Conduit.Web/Conduit.Web.csproj +++ b/Conduit/Conduit.Web/Conduit.Web.csproj @@ -11,6 +11,7 @@ + diff --git a/Conduit/Conduit.Web/DataAccess/Providers/IConduitFavoritesCollectionProvider.cs b/Conduit/Conduit.Web/DataAccess/Providers/IConduitFavoritesCollectionProvider.cs new file mode 100644 index 0000000000..a683b02543 --- /dev/null +++ b/Conduit/Conduit.Web/DataAccess/Providers/IConduitFavoritesCollectionProvider.cs @@ -0,0 +1,8 @@ +using Couchbase.Extensions.DependencyInjection; + +namespace Conduit.Web.DataAccess.Providers; + +public interface IConduitFavoritesCollectionProvider : INamedCollectionProvider +{ + +} \ No newline at end of file diff --git a/Conduit/Conduit.Web/Follows/Services/FollowsDataService.cs b/Conduit/Conduit.Web/Follows/Services/FollowsDataService.cs index 4c52f1810d..38b5a0b385 100644 --- a/Conduit/Conduit.Web/Follows/Services/FollowsDataService.cs +++ b/Conduit/Conduit.Web/Follows/Services/FollowsDataService.cs @@ -8,7 +8,7 @@ public interface IFollowDataService { Task FollowUser(string userToFollow, string followerUsername); Task UnfollowUser(string userToUnfollow, string followerUsername); - Task IsCurrentUserFollowing(string currentUserBearerToken, string username); + Task IsCurrentUserFollowing(string currentUser, string username); } public class FollowsDataService : IFollowDataService @@ -44,15 +44,15 @@ public async Task UnfollowUser(string userToUnfollow, string followerUsername) await set.RemoveAsync(userToUnfollow); } - public async Task IsCurrentUserFollowing(string currentUserBearerToken, string username) + public async Task IsCurrentUserFollowing(string currentUsername, string username) { - // TODO: can't follow yourself - - var currentUserUsername = _authService.GetUsernameClaim(currentUserBearerToken); + // can't follow yourself + if (currentUsername == username) + return false; var collection = await _followsCollectionProvider.GetCollectionAsync(); - var followKey = $"{currentUserUsername.Value}::follows"; + var followKey = $"{currentUsername}::follows"; var set = collection.Set(followKey); diff --git a/Conduit/Conduit.Web/Program.cs b/Conduit/Conduit.Web/Program.cs index 95425cf2e1..03c60b6d65 100644 --- a/Conduit/Conduit.Web/Program.cs +++ b/Conduit/Conduit.Web/Program.cs @@ -95,6 +95,9 @@ public static void AddConduitServiceDependencies(this IServiceCollection @this, b .AddScope(configManager["Couchbase:ScopeName"]) .AddCollection(configManager["Couchbase:ArticlesCollectionName"]); + b + .AddScope(configManager["Couchbase:ScopeName"]) + .AddCollection("Favorites"); }); } } diff --git a/Conduit/Conduit.Web/Users/Handlers/GetProfileHandler.cs b/Conduit/Conduit.Web/Users/Handlers/GetProfileHandler.cs index 3e9637b147..9d86fa5c54 100644 --- a/Conduit/Conduit.Web/Users/Handlers/GetProfileHandler.cs +++ b/Conduit/Conduit.Web/Users/Handlers/GetProfileHandler.cs @@ -12,12 +12,14 @@ public class GetProfileHandler : IRequestHandler _validator; private readonly IFollowDataService _followDataService; + private readonly IAuthService _authService; - public GetProfileHandler(IUserDataService userDataService, IValidator validator, IFollowDataService followDataService) + public GetProfileHandler(IUserDataService userDataService, IValidator validator, IFollowDataService followDataService, IAuthService authService) { _userDataService = userDataService; _validator = validator; _followDataService = followDataService; + _authService = authService; } public async Task Handle(GetProfileRequest request, CancellationToken cancellationToken) @@ -40,9 +42,10 @@ public async Task Handle(GetProfileRequest request, Cancellati // if JWT is specified, use that to determine if the logged-in user is following this profile bool isCurrentUserFollowing = false; - if (!string.IsNullOrEmpty(request.OptionalBearerToken)) + if (_authService.IsUserAuthenticated(request.OptionalBearerToken)) { - isCurrentUserFollowing = await _followDataService.IsCurrentUserFollowing(request.OptionalBearerToken, request.Username); + var currentUsernameClaim = _authService.GetUsernameClaim(request.OptionalBearerToken); + isCurrentUserFollowing = await _followDataService.IsCurrentUserFollowing(currentUsernameClaim.Value, request.Username); } return new GetProfileResult diff --git a/Conduit/Conduit.Web/Users/Handlers/GetProfileRequest.cs b/Conduit/Conduit.Web/Users/Handlers/GetProfileRequest.cs index 46adf2c6c4..572b93d9e4 100644 --- a/Conduit/Conduit.Web/Users/Handlers/GetProfileRequest.cs +++ b/Conduit/Conduit.Web/Users/Handlers/GetProfileRequest.cs @@ -4,7 +4,7 @@ namespace Conduit.Web.Users.Handlers; public class GetProfileRequest : IRequest { - public GetProfileRequest(string username, string optionalBearerToken) + public GetProfileRequest(string username, string optionalBearerToken = null) { Username = username; OptionalBearerToken = optionalBearerToken; diff --git a/Conduit/Conduit.Web/Users/Services/AuthService.cs b/Conduit/Conduit.Web/Users/Services/AuthService.cs index bf6faf49c5..8a490ff731 100644 --- a/Conduit/Conduit.Web/Users/Services/AuthService.cs +++ b/Conduit/Conduit.Web/Users/Services/AuthService.cs @@ -108,9 +108,33 @@ public ClaimResult GetUsernameClaim(string bearerToken) } } + public AllInfo GetAllAuthInfo(string authorizationHeader) + { + var bearerToken = GetTokenFromHeader(authorizationHeader); + var all = new AllInfo + { + BearerToken = bearerToken, + Email = GetEmailClaim(bearerToken), + Username = GetUsernameClaim(bearerToken) + }; + return all; + } + + public bool IsUserAuthenticated(string bearerToken) + { + return !string.IsNullOrEmpty(bearerToken); + } + public class ClaimResult { public string Value { get; set; } public bool IsNotFound { get; set; } } + + public class AllInfo + { + public string BearerToken { get; set; } + public ClaimResult Username { get; set; } + public ClaimResult Email { get; set; } + } } \ No newline at end of file diff --git a/Conduit/Conduit.Web/Users/Services/IAuthService.cs b/Conduit/Conduit.Web/Users/Services/IAuthService.cs index da52fcc43f..cf56fbc6e4 100644 --- a/Conduit/Conduit.Web/Users/Services/IAuthService.cs +++ b/Conduit/Conduit.Web/Users/Services/IAuthService.cs @@ -1,4 +1,6 @@ -namespace Conduit.Web.Users.Services; +using Microsoft.Extensions.Primitives; + +namespace Conduit.Web.Users.Services; public interface IAuthService { @@ -9,4 +11,6 @@ public interface IAuthService string GetTokenFromHeader(string bearerTokenHeader); AuthService.ClaimResult GetEmailClaim(string bearerToken); AuthService.ClaimResult GetUsernameClaim(string bearerToken); + AuthService.AllInfo GetAllAuthInfo(string authorizationHeader); + bool IsUserAuthenticated(string bearerToken); } \ No newline at end of file diff --git a/Conduit/Conduit.Web/Users/Services/UserDataService.cs b/Conduit/Conduit.Web/Users/Services/UserDataService.cs index 1ffb0b9c74..9492e93da6 100644 --- a/Conduit/Conduit.Web/Users/Services/UserDataService.cs +++ b/Conduit/Conduit.Web/Users/Services/UserDataService.cs @@ -179,7 +179,7 @@ public async Task> GetProfileByUsername(string username) { Bio = user.Bio, Image = user.Image, - Username = user.Username + Username = username }, DataResultStatus.Ok); } } \ No newline at end of file