diff --git a/LinkDotNet.Blog.IntegrationTests/Web/Pages/BlogPostPageTests.cs b/LinkDotNet.Blog.IntegrationTests/Web/Pages/BlogPostPageTests.cs new file mode 100644 index 00000000..5788d8a7 --- /dev/null +++ b/LinkDotNet.Blog.IntegrationTests/Web/Pages/BlogPostPageTests.cs @@ -0,0 +1,67 @@ +using System.Threading.Tasks; +using Blazored.LocalStorage; +using Blazored.Toast.Services; +using Bunit; +using Bunit.TestDoubles; +using FluentAssertions; +using LinkDotNet.Blog.TestUtilities; +using LinkDotNet.Blog.Web.Pages; +using LinkDotNet.Blog.Web.Shared; +using LinkDotNet.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +namespace LinkDotNet.Blog.IntegrationTests.Web.Pages +{ + public class BlogPostPageTests : SqlDatabaseTestBase + { + [Fact] + public async Task ShouldAddLikeOnEvent() + { + var publishedPost = new BlogPostBuilder().WithLikes(2).IsPublished().Build(); + await BlogPostRepository.StoreAsync(publishedPost); + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.Services.AddScoped(_ => BlogPostRepository); + ctx.Services.AddScoped(_ => new Mock().Object); + ctx.Services.AddScoped(_ => new Mock().Object); + ctx.AddTestAuthorization().SetAuthorized("s"); + var cut = ctx.RenderComponent( + p => p.Add(b => b.BlogPostId, publishedPost.Id)); + var likeComponent = cut.FindComponent(); + likeComponent.SetParametersAndRender(c => c.Add(p => p.BlogPost, publishedPost)); + + likeComponent.Find("button").Click(); + + var fromDb = await DbContext.BlogPosts.AsNoTracking().SingleAsync(d => d.Id == publishedPost.Id); + fromDb.Likes.Should().Be(3); + } + + [Fact] + public async Task ShouldSubtractLikeOnEvent() + { + var publishedPost = new BlogPostBuilder().WithLikes(2).IsPublished().Build(); + await BlogPostRepository.StoreAsync(publishedPost); + using var ctx = new TestContext(); + var localStorage = new Mock(); + localStorage.Setup(l => l.ContainKeyAsync("hasLiked", default)).ReturnsAsync(true); + localStorage.Setup(l => l.GetItemAsync("hasLiked", default)).ReturnsAsync(true); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.Services.AddScoped(_ => BlogPostRepository); + ctx.Services.AddScoped(_ => localStorage.Object); + ctx.Services.AddScoped(_ => new Mock().Object); + ctx.AddTestAuthorization().SetAuthorized("s"); + var cut = ctx.RenderComponent( + p => p.Add(b => b.BlogPostId, publishedPost.Id)); + var likeComponent = cut.FindComponent(); + likeComponent.SetParametersAndRender(c => c.Add(p => p.BlogPost, publishedPost)); + + likeComponent.Find("button").Click(); + + var fromDb = await DbContext.BlogPosts.AsNoTracking().SingleAsync(d => d.Id == publishedPost.Id); + fromDb.Likes.Should().Be(1); + } + } +} \ No newline at end of file diff --git a/LinkDotNet.Blog.TestUtilities/BlogPostBuilder.cs b/LinkDotNet.Blog.TestUtilities/BlogPostBuilder.cs index b81e98f9..1b389662 100644 --- a/LinkDotNet.Blog.TestUtilities/BlogPostBuilder.cs +++ b/LinkDotNet.Blog.TestUtilities/BlogPostBuilder.cs @@ -10,6 +10,7 @@ public class BlogPostBuilder private string url = "localhost"; private bool isPublished = true; private string[] tags; + private int likes; public BlogPostBuilder WithTitle(string title) { @@ -47,9 +48,17 @@ public BlogPostBuilder IsPublished(bool isPublished = true) return this; } + public BlogPostBuilder WithLikes(int likes) + { + this.likes = likes; + return this; + } + public BlogPost Build() { - return BlogPost.Create(title, shortDescription, content, url, isPublished, tags); + var blogPost = BlogPost.Create(title, shortDescription, content, url, isPublished, tags); + blogPost.Likes = likes; + return blogPost; } } } \ No newline at end of file diff --git a/LinkDotNet.Blog.UnitTests/Web/Shared/LikeTests.cs b/LinkDotNet.Blog.UnitTests/Web/Shared/LikeTests.cs new file mode 100644 index 00000000..e9c84404 --- /dev/null +++ b/LinkDotNet.Blog.UnitTests/Web/Shared/LikeTests.cs @@ -0,0 +1,101 @@ +using Blazored.LocalStorage; +using Bunit; +using FluentAssertions; +using LinkDotNet.Blog.TestUtilities; +using LinkDotNet.Blog.Web.Shared; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +namespace LinkDotNet.Blog.UnitTests.Web.Shared +{ + public class LikeTests : TestContext + { + [Theory] + [InlineData(0, "0 Likes")] + [InlineData(1, "1 Like")] + [InlineData(2, "2 Likes")] + public void ShouldDisplayLikes(int likes, string expectedText) + { + Services.AddScoped(_ => new Mock().Object); + var blogPost = new BlogPostBuilder().WithLikes(likes).Build(); + var cut = RenderComponent( + p => p.Add(l => l.BlogPost, blogPost)); + + var label = cut.Find("small").TextContent; + + label.Should().Be(expectedText); + } + + [Fact] + public void ShouldInvokeEventWhenButtonClicked() + { + Services.AddScoped(_ => new Mock().Object); + var blogPost = new BlogPostBuilder().Build(); + var wasClicked = false; + var wasLike = false; + var cut = RenderComponent( + p => p.Add(l => l.BlogPost, blogPost) + .Add(l => l.OnBlogPostLiked, b => + { + wasClicked = true; + wasLike = b; + })); + + cut.Find("button").Click(); + + wasClicked.Should().BeTrue(); + wasLike.Should().BeTrue(); + } + + [Fact] + public void ShouldSetLocalStorageVariableOnClick() + { + var localStorage = new Mock(); + Services.AddScoped(_ => localStorage.Object); + var blogPost = new BlogPostBuilder().Build(); + var cut = RenderComponent( + p => p.Add(l => l.BlogPost, blogPost)); + + cut.Find("button").Click(); + + localStorage.Verify(l => l.SetItemAsync("hasLiked", true, default), Times.Once); + } + + [Fact] + public void ShouldCheckLocalStorageOnInit() + { + var localStorage = new Mock(); + localStorage.Setup(l => l.ContainKeyAsync("hasLiked", default)).ReturnsAsync(true); + localStorage.Setup(l => l.GetItemAsync("hasLiked", default)).ReturnsAsync(true); + Services.AddScoped(_ => localStorage.Object); + var blogPost = new BlogPostBuilder().Build(); + var wasLike = true; + var cut = RenderComponent( + p => p.Add(l => l.BlogPost, blogPost) + .Add(l => l.OnBlogPostLiked, b => wasLike = b)); + + cut.Find("button").Click(); + + wasLike.Should().BeFalse(); + } + + [Fact] + public void ShouldCheckStorageOnClickAgainAndDoNothingOnMismatch() + { + var localStorage = new Mock(); + Services.AddScoped(_ => localStorage.Object); + var blogPost = new BlogPostBuilder().Build(); + var wasClicked = false; + var cut = RenderComponent( + p => p.Add(l => l.BlogPost, blogPost) + .Add(l => l.OnBlogPostLiked, _ => wasClicked = true)); + localStorage.Setup(l => l.ContainKeyAsync("hasLiked", default)).ReturnsAsync(true); + localStorage.Setup(l => l.GetItemAsync("hasLiked", default)).ReturnsAsync(true); + + cut.Find("button").Click(); + + wasClicked.Should().BeFalse(); + } + } +} \ No newline at end of file diff --git a/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj b/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj index 19976ba5..92420050 100644 --- a/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj +++ b/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj @@ -6,6 +6,7 @@ + diff --git a/LinkDotNet.Blog.Web/Pages/BlogPostPage.razor b/LinkDotNet.Blog.Web/Pages/BlogPostPage.razor index 864d7f7c..1da503ee 100644 --- a/LinkDotNet.Blog.Web/Pages/BlogPostPage.razor +++ b/LinkDotNet.Blog.Web/Pages/BlogPostPage.razor @@ -32,6 +32,7 @@ else @(RenderMarkupString(BlogPost.Content)) + } @@ -55,4 +56,11 @@ else StateHasChanged(); } } + + private async Task UpdateLikes(bool hasLiked) + { + BlogPost = await _repository.GetByIdAsync(BlogPostId); + BlogPost.Likes = hasLiked ? BlogPost.Likes + 1 : BlogPost.Likes - 1; + await _repository.StoreAsync(BlogPost); + } } \ No newline at end of file diff --git a/LinkDotNet.Blog.Web/Shared/Like.razor b/LinkDotNet.Blog.Web/Shared/Like.razor new file mode 100644 index 00000000..387b92b6 --- /dev/null +++ b/LinkDotNet.Blog.Web/Shared/Like.razor @@ -0,0 +1,57 @@ +@using LinkDotNet.Domain +@using Blazored.LocalStorage +@using LinkDotNet.Infrastructure.Persistence +@inject ILocalStorageService _localStorage + + +@code { + [Parameter] + public BlogPost BlogPost { get; set; } + + [Parameter] + public EventCallback OnBlogPostLiked { get; set; } + + private bool HasLiked { get; set; } + + private string BtnClass => HasLiked ? "btn-secondary" : "btn-primary"; + + private string LikeTextButton => HasLiked ? "Unlike" : "Like"; + + private string LikeText => BlogPost.Likes == 1 ? "Like" : "Likes"; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + HasLiked = await GetHasLiked(); + StateHasChanged(); + } + } + + private async Task LikeBlogPost() + { + // Prevent multiple open sites to like / unlike multiple times + var hasLikedFromLocalStorage = await GetHasLiked(); + if (HasLiked != hasLikedFromLocalStorage) + { + return; + } + + HasLiked = !HasLiked; + await OnBlogPostLiked.InvokeAsync(HasLiked); + await _localStorage.SetItemAsync("hasLiked", HasLiked); + } + + private async Task GetHasLiked() + { + if (await _localStorage.ContainKeyAsync("hasLiked")) + { + return await _localStorage.GetItemAsync("hasLiked"); + } + + return false; + } +} \ No newline at end of file diff --git a/LinkDotNet.Blog.Web/Shared/Like.razor.css b/LinkDotNet.Blog.Web/Shared/Like.razor.css new file mode 100644 index 00000000..6f56ac59 --- /dev/null +++ b/LinkDotNet.Blog.Web/Shared/Like.razor.css @@ -0,0 +1,8 @@ +.like-container { + float: right; + margin-top: 20px; +} + +.like-container button { + margin-left: 10px; +} \ No newline at end of file diff --git a/LinkDotNet.Blog.Web/Startup.cs b/LinkDotNet.Blog.Web/Startup.cs index 33545506..c05b6565 100644 --- a/LinkDotNet.Blog.Web/Startup.cs +++ b/LinkDotNet.Blog.Web/Startup.cs @@ -1,3 +1,4 @@ +using Blazored.LocalStorage; using Blazored.Toast; using LinkDotNet.Blog.Web.Authentication.Auth0; using LinkDotNet.Blog.Web.RegistrationExtensions; @@ -39,6 +40,7 @@ public void ConfigureServices(IServiceCollection services) services.UseAuth0Authentication(Configuration); services.AddBlazoredToast(); + services.AddBlazoredLocalStorage(); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) diff --git a/LinkDotNet.Domain/BlogPost.cs b/LinkDotNet.Domain/BlogPost.cs index 96c42274..e8dd4075 100644 --- a/LinkDotNet.Domain/BlogPost.cs +++ b/LinkDotNet.Domain/BlogPost.cs @@ -26,6 +26,8 @@ private BlogPost() public bool IsPublished { get; set; } + public int Likes { get; set; } + public static BlogPost Create( string title, string shortDescription,