From 165595fd76cc4bd7ac66aae7792c8799041cd120 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sun, 23 Jun 2024 17:17:29 +0200 Subject: [PATCH] feat: Show Similiar blog posts --- .editorconfig | 2 + LinkDotNet.Blog.sln | 1 + MIGRATION.md | 26 ++++++ docs/Setup/Configuration.md | 4 +- src/LinkDotNet.Blog.Domain/SimilarBlogPost.cs | 8 ++ .../Persistence/Sql/BlogDbContext.cs | 2 + .../Mapping/SimilarBlogPostConfiguration.cs | 17 ++++ .../ApplicationConfiguration.cs | 3 + .../Components/CreateNewBlogPost.razor | 3 + .../Features/BlogPostPublisher.cs | 9 +- .../Similiarity/SimiliarityCalculator.cs | 30 +++++++ .../Services/Similiarity/TextProcessor.cs | 26 ++++++ .../Services/Similiarity/TfIdfVectorizer.cs | 57 +++++++++++++ .../Components/BlogPostAdminActions.razor | 11 ++- .../Components/SimilarBlogPostSection.razor | 49 +++++++++++ .../ShowBlogPost/ShowBlogPostPage.razor | 10 +-- .../Features/SimilarBlogPostJob.cs | 84 +++++++++++++++++++ .../LinkDotNet.Blog.Web.csproj | 6 +- ...BackgroundServiceRegistrationExtensions.cs | 5 +- src/LinkDotNet.Blog.Web/appsettings.json | 3 +- .../CreateNewBlogPostPageTests.cs | 8 +- .../BlogPostEditor/UpdateBlogPostPageTests.cs | 6 ++ .../Components/SimilarBlogPostSectionTests.cs | 43 ++++++++++ .../ShowBlogPost/ShowBlogPostPageTests.cs | 2 + .../Web/Features/SimilarBlogPostJobTests.cs | 77 +++++++++++++++++ .../Shared/Admin/BlogPostAdminActionsTests.cs | 36 ++++---- .../Components/CreateNewBlogPostTests.cs | 3 + .../ShowBlogPost/ShowBlogPostPageTests.cs | 34 ++++---- 28 files changed, 512 insertions(+), 53 deletions(-) create mode 100644 MIGRATION.md create mode 100644 src/LinkDotNet.Blog.Domain/SimilarBlogPost.cs create mode 100644 src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/SimilarBlogPostConfiguration.cs create mode 100644 src/LinkDotNet.Blog.Web/Features/Services/Similiarity/SimiliarityCalculator.cs create mode 100644 src/LinkDotNet.Blog.Web/Features/Services/Similiarity/TextProcessor.cs create mode 100644 src/LinkDotNet.Blog.Web/Features/Services/Similiarity/TfIdfVectorizer.cs create mode 100644 src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/SimilarBlogPostSection.razor create mode 100644 src/LinkDotNet.Blog.Web/Features/SimilarBlogPostJob.cs create mode 100644 tests/LinkDotNet.Blog.IntegrationTests/Web/Features/ShowBlogPost/Components/SimilarBlogPostSectionTests.cs create mode 100644 tests/LinkDotNet.Blog.IntegrationTests/Web/Features/SimilarBlogPostJobTests.cs diff --git a/.editorconfig b/.editorconfig index 9672ddfc..0a6487a7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -419,6 +419,7 @@ dotnet_diagnostic.S3875.severity = none # Remove this overload of 'operato dotnet_diagnostic.IDE0005.severity = none # IDE0005: Using directive is unnecessary dotnet_diagnostic.IDE0021.severity = suggestion # IDE0021: Use expression body for constructor dotnet_diagnostic.IDE0022.severity = suggestion # IDE0022: Use expression body for method +dotnet_diagnostic.IDE0055.severity = none # IDE0055: Fix formatting dotnet_diagnostic.IDE0058.severity = none # IDE0058: Expression value is never used dotnet_diagnostic.IDE0079.severity = warning # IDE0079: Remove unnecessary suppression dotnet_diagnostic.IDE0290.severity = none # IDE0290: Use primary constructor @@ -443,6 +444,7 @@ dotnet_diagnostic.CA1055.severity = none # CA1055: Uri return values should not dotnet_diagnostic.CA1056.severity = none # CA1056: Uri properties should not be strings dotnet_diagnostic.CA1812.severity = none # CA1812: Avoid uninstantiated internal classes dotnet_diagnostic.CA2201.severity = suggestion # CA2201: Do not raise reserved exception types +dotnet_diagnostic.CA2227.severity = suggestion # CA2227: Collection properties should be read only # SonarAnalyzer.CSharp # https://rules.sonarsource.com/csharp diff --git a/LinkDotNet.Blog.sln b/LinkDotNet.Blog.sln index 826b0f83..517f52de 100644 --- a/LinkDotNet.Blog.sln +++ b/LinkDotNet.Blog.sln @@ -19,6 +19,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ProjectSection(SolutionItems) = preProject Readme.md = Readme.md .editorconfig = .editorconfig + MIGRATION.md = MIGRATION.md EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{86FD0EB5-13F9-4F1C-ADA1-072EEFEFF1E9}" diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 00000000..ce51b99d --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,26 @@ +# Migration Guide +This document describes the changes that need to be made to migrate from one version of the blog to another. + +## 8.0 to 9.0 +A new `SimilarBlogPost` table is introduced to store similar blog posts. + +```sql +CREATE TABLE SimilarBlogPosts +( + Id [NVARCHAR](450) NOT NULL, + SimilarBlogPostIds NVARCHAR(1350) NOT NULL, +) + +ALTER TABLE SimilarBlogPosts +ADD CONSTRAINT PK_SimilarBlogPosts PRIMARY KEY (Id) +``` + +Add the following to the `appsettings.json`: + +```json +{ + "SimilarBlogPosts": true +} +``` + +Or `false` if you don't want to use this feature. diff --git a/docs/Setup/Configuration.md b/docs/Setup/Configuration.md index 42430794..34beb114 100644 --- a/docs/Setup/Configuration.md +++ b/docs/Setup/Configuration.md @@ -48,7 +48,8 @@ The appsettings.json file has a lot of options to customize the content of the b "KofiToken": "ABC123", "GithubSponsorName": "your-tag-here", "ShowReadingIndicator": true, - "PatreonName": "your-tag-here" + "PatreonName": "your-tag-here", + "SimlarBlogPosts": "true" } ``` @@ -85,3 +86,4 @@ The appsettings.json file has a lot of options to customize the content of the b | GithubSponsorName | string | Enables the "Github Sponsor" button which redirects to GitHub. Only pass in the user name instead of the url. | | ShowReadingIndicator | boolean | If set to `true` (default) a circle indicates the progress when a user reads a blog post (without comments). | | PatreonName | string | Enables the "Become a patreon" button that redirects to patreon.com. Only pass the user name (public profile) as user name. | +| SimilarBlogPosts | boolean | If set to `true` (default) similar blog posts are shown at the end of a blog post. | \ No newline at end of file diff --git a/src/LinkDotNet.Blog.Domain/SimilarBlogPost.cs b/src/LinkDotNet.Blog.Domain/SimilarBlogPost.cs new file mode 100644 index 00000000..de2e0bf4 --- /dev/null +++ b/src/LinkDotNet.Blog.Domain/SimilarBlogPost.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace LinkDotNet.Blog.Domain; + +public class SimilarBlogPost : Entity +{ + public IList SimilarBlogPostIds { get; set; } = []; +} diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs index 150e5e67..424d2359 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs @@ -24,6 +24,8 @@ public BlogDbContext(DbContextOptions options) public DbSet BlogPostRecords { get; set; } + public DbSet SimilarBlogPosts { get; set; } + protected override void OnModelCreating(ModelBuilder modelBuilder) { ArgumentNullException.ThrowIfNull(modelBuilder); diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/SimilarBlogPostConfiguration.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/SimilarBlogPostConfiguration.cs new file mode 100644 index 00000000..d96aea96 --- /dev/null +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/SimilarBlogPostConfiguration.cs @@ -0,0 +1,17 @@ +using LinkDotNet.Blog.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace LinkDotNet.Blog.Infrastructure.Persistence.Sql.Mapping; + +internal sealed class SimilarBlogPostConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(b => b.Id); + builder.Property(b => b.Id) + .IsUnicode(false) + .ValueGeneratedOnAdd(); + builder.Property(b => b.SimilarBlogPostIds).HasMaxLength(450 * 3).IsRequired(); + } +} diff --git a/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs b/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs index f5f195fe..6794eee2 100644 --- a/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs +++ b/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs @@ -17,6 +17,7 @@ public sealed record ApplicationConfiguration public bool IsAboutMeEnabled { get; set; } public bool IsGiscusEnabled { get; set; } + public bool IsDisqusEnabled { get; set; } public string KofiToken { get; init; } @@ -32,4 +33,6 @@ public sealed record ApplicationConfiguration public string PatreonName { get; init; } public bool IsPatreonEnabled => !string.IsNullOrEmpty(PatreonName); + + public bool ShowSimilarPosts { get; init; } } diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor index d726f7b0..d3409452 100644 --- a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor @@ -1,5 +1,7 @@ @using LinkDotNet.Blog.Domain +@using NCronJob @inject IJSRuntime JSRuntime +@inject IInstantJobRegistry InstantJobRegistry

@Title

@@ -119,6 +121,7 @@ { canSubmit = false; await OnBlogPostCreated.InvokeAsync(model.ToBlogPost()); + InstantJobRegistry.RunInstantJob(parameter: true); ClearModel(); canSubmit = true; } diff --git a/src/LinkDotNet.Blog.Web/Features/BlogPostPublisher.cs b/src/LinkDotNet.Blog.Web/Features/BlogPostPublisher.cs index d38ed851..3f6582f8 100644 --- a/src/LinkDotNet.Blog.Web/Features/BlogPostPublisher.cs +++ b/src/LinkDotNet.Blog.Web/Features/BlogPostPublisher.cs @@ -25,12 +25,15 @@ public BlogPostPublisher(IRepository repository, ICacheInvalidator cac public async Task RunAsync(JobExecutionContext context, CancellationToken token) { + ArgumentNullException.ThrowIfNull(context); + LogPublishStarting(); - await PublishScheduledBlogPostsAsync(); + var publishedPosts = await PublishScheduledBlogPostsAsync(); + context.Output = publishedPosts; LogPublishStopping(); } - private async Task PublishScheduledBlogPostsAsync() + private async Task PublishScheduledBlogPostsAsync() { LogCheckingForScheduledBlogPosts(); @@ -46,6 +49,8 @@ private async Task PublishScheduledBlogPostsAsync() { cacheInvalidator.Cancel(); } + + return blogPostsToPublish.Count; } private async Task> GetScheduledBlogPostsAsync() diff --git a/src/LinkDotNet.Blog.Web/Features/Services/Similiarity/SimiliarityCalculator.cs b/src/LinkDotNet.Blog.Web/Features/Services/Similiarity/SimiliarityCalculator.cs new file mode 100644 index 00000000..b213ca35 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Services/Similiarity/SimiliarityCalculator.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace LinkDotNet.Blog.Web.Features.Services.Similiarity; + +public static class SimilarityCalculator +{ + public static double CosineSimilarity(Dictionary vectorA, Dictionary vectorB) + { + ArgumentNullException.ThrowIfNull(vectorA); + ArgumentNullException.ThrowIfNull(vectorB); + + var dotProduct = 0d; + var magnitudeA = 0d; + + foreach (var term in vectorA.Keys) + { + if (vectorB.TryGetValue(term, out var value)) + { + dotProduct += vectorA[term] * value; + } + magnitudeA += Math.Pow(vectorA[term], 2); + } + + var magnitudeB = vectorB.Values.Sum(value => Math.Pow(value, 2)); + + return dotProduct / (Math.Sqrt(magnitudeA) * Math.Sqrt(magnitudeB)); + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/Services/Similiarity/TextProcessor.cs b/src/LinkDotNet.Blog.Web/Features/Services/Similiarity/TextProcessor.cs new file mode 100644 index 00000000..647846a4 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Services/Similiarity/TextProcessor.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace LinkDotNet.Blog.Web.Features.Services.Similiarity; + +public static partial class TextProcessor +{ + private static readonly char[] Separator = [' ']; + + public static IReadOnlyCollection TokenizeAndNormalize(IEnumerable texts) + => texts.SelectMany(TokenizeAndNormalize).ToList(); + + private static IReadOnlyCollection TokenizeAndNormalize(string text) + { + ArgumentNullException.ThrowIfNull(text); + + text = text.ToUpperInvariant(); + text = TokenRegex().Replace(text, " "); + return [..text.Split(Separator, StringSplitOptions.RemoveEmptyEntries)]; + } + + [GeneratedRegex(@"[^a-z0-9\s]")] + private static partial Regex TokenRegex(); +} diff --git a/src/LinkDotNet.Blog.Web/Features/Services/Similiarity/TfIdfVectorizer.cs b/src/LinkDotNet.Blog.Web/Features/Services/Similiarity/TfIdfVectorizer.cs new file mode 100644 index 00000000..32817f80 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Services/Similiarity/TfIdfVectorizer.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace LinkDotNet.Blog.Web.Features.Services.Similiarity; + +public class TfIdfVectorizer +{ + private readonly IReadOnlyCollection> documents; + private readonly Dictionary idfScores; + + public TfIdfVectorizer(IReadOnlyCollection> documents) + { + this.documents = documents; + idfScores = CalculateIdfScores(); + } + + public Dictionary ComputeTfIdfVector(IReadOnlyCollection targetDocument) + { + ArgumentNullException.ThrowIfNull(targetDocument); + + var termFrequency = targetDocument.GroupBy(t => t).ToDictionary(g => g.Key, g => g.Count()); + var tfidfVector = new Dictionary(); + + foreach (var term in termFrequency.Keys) + { + var tf = termFrequency[term] / (double)targetDocument.Count; + var idf = idfScores.TryGetValue(term, out var score) ? score : 0; + tfidfVector[term] = tf * idf; + } + + return tfidfVector; + } + + private Dictionary CalculateIdfScores() + { + var termDocumentFrequency = new Dictionary(); + var scores = new Dictionary(); + + foreach (var term in documents.Select(document => document.Distinct()).SelectMany(terms => terms)) + { + if (!termDocumentFrequency.TryGetValue(term, out var value)) + { + value = 0; + termDocumentFrequency[term] = value; + } + termDocumentFrequency[term] = ++value; + } + + foreach (var term in termDocumentFrequency.Keys) + { + scores[term] = Math.Log(documents.Count / (double)termDocumentFrequency[term]); + } + + return scores; + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/BlogPostAdminActions.razor b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/BlogPostAdminActions.razor index 2d8c187e..199f16a6 100644 --- a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/BlogPostAdminActions.razor +++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/BlogPostAdminActions.razor @@ -1,14 +1,16 @@ @using LinkDotNet.Blog.Domain @using LinkDotNet.Blog.Infrastructure.Persistence +@using NCronJob @inject NavigationManager NavigationManager @inject IToastService ToastService @inject IRepository BlogPostRepository +@inject IInstantJobRegistry InstantJobRegistry
-
@@ -18,16 +20,17 @@ @code { [Parameter] public string BlogPostId { get; set; } - + private ConfirmDialog ConfirmDialog { get; set; } private async Task DeleteBlogPostAsync() { await BlogPostRepository.DeleteAsync(BlogPostId); + InstantJobRegistry.RunInstantJob(true); ToastService.ShowSuccess("The Blog Post was successfully deleted"); NavigationManager.NavigateTo("/"); } - + private void ShowConfirmDialog() { ConfirmDialog.Open(); @@ -37,4 +40,4 @@ { NavigationManager.NavigateTo($"update/{BlogPostId}"); } -} \ No newline at end of file +} diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/SimilarBlogPostSection.razor b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/SimilarBlogPostSection.razor new file mode 100644 index 00000000..64cdbb64 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/SimilarBlogPostSection.razor @@ -0,0 +1,49 @@ +@using LinkDotNet.Blog.Domain +@using LinkDotNet.Blog.Infrastructure.Persistence˘ +@inject IRepository BlogPostRepository +@inject IRepository SimilarBlogPostJobRepository + +@if (similarBlogPosts.Count > 0) +{ +
+
+

+ +

+
+
+ @foreach (var relatedBlogPost in similarBlogPosts) + { +
+
+
+
@relatedBlogPost.Title
+

@MarkdownConverter.ToMarkupString(relatedBlogPost.ShortDescription)

+
+ +
+
+ } +
+
+
+
+} + +@code { + [Parameter] public BlogPost BlogPost { get; set; } + + private IReadOnlyCollection similarBlogPosts = []; + + protected override async Task OnParametersSetAsync() + { + var similarBlogPostIds = await SimilarBlogPostJobRepository.GetByIdAsync(BlogPost.Id); + if (similarBlogPostIds is not null) + { + similarBlogPosts = await BlogPostRepository.GetAllAsync( + b => similarBlogPostIds.SimilarBlogPostIds.Contains(b.Id)); + } + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor index 697dff4d..c81d0d8e 100644 --- a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor +++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor @@ -75,6 +75,10 @@ else
+ @if (AppConfiguration.Value.ShowSimilarPosts) + { + + } @@ -109,11 +113,7 @@ else protected override async Task OnAfterRenderAsync(bool firstRender) { await JsRuntime.InvokeVoidAsync("hljs.highlightAll"); - - if (firstRender) - { - _ = UserRecordService.StoreUserRecordAsync(); - } + _ = UserRecordService.StoreUserRecordAsync(); } private async Task UpdateLikes(bool hasLiked) diff --git a/src/LinkDotNet.Blog.Web/Features/SimilarBlogPostJob.cs b/src/LinkDotNet.Blog.Web/Features/SimilarBlogPostJob.cs new file mode 100644 index 00000000..9b7296cb --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/SimilarBlogPostJob.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NCronJob; +using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.Infrastructure.Persistence; +using LinkDotNet.Blog.Web.Features.Services.Similiarity; +using Microsoft.Extensions.Options; + +namespace LinkDotNet.Blog.Web.Features; + +public class SimilarBlogPostJob : IJob +{ + private readonly IRepository blogPostRepository; + private readonly IRepository similarBlogPostRepository; + private readonly bool showSimilarPosts; + + public SimilarBlogPostJob( + IRepository blogPostRepository, + IRepository similarBlogPostRepository, + IOptions applicationConfiguration) + { + ArgumentNullException.ThrowIfNull(applicationConfiguration); + + this.blogPostRepository = blogPostRepository; + this.similarBlogPostRepository = similarBlogPostRepository; + showSimilarPosts = applicationConfiguration.Value.ShowSimilarPosts; + } + + public async Task RunAsync(JobExecutionContext context, CancellationToken token) + { + ArgumentNullException.ThrowIfNull(context); + + if (!showSimilarPosts) + { + return; + } + + var isInstantJobTriggered = context.Parameter is not null; + var noJobPublished = context.ParentOutput is null or 0; + if (noJobPublished && !isInstantJobTriggered) + { + return; + } + + var blogPosts = await blogPostRepository.GetAllByProjectionAsync(bp => new BlogPostSimilarity(bp.Id, bp.Title, bp.Tags, bp.ShortDescription)); + var documents = blogPosts.Select(bp => TextProcessor.TokenizeAndNormalize(new[] { bp.Title, bp.ShortDescription }.Concat(bp.Tags))).ToList(); + + var similarities = blogPosts.Select(bp => GetSimilarityForBlogPost(bp, documents, blogPosts)).ToArray(); + var ids = await similarBlogPostRepository.GetAllByProjectionAsync(s => s.Id); + await similarBlogPostRepository.DeleteBulkAsync(ids); + await similarBlogPostRepository.StoreBulkAsync(similarities); + + } + + private static SimilarBlogPost GetSimilarityForBlogPost( + BlogPostSimilarity blogPost, + List> documents, + IReadOnlyCollection blogPosts) + { + var target = TextProcessor.TokenizeAndNormalize(new[] { blogPost.Title, blogPost.ShortDescription }.Concat(blogPost.Tags)); + + var vectorizer = new TfIdfVectorizer(documents); + var targetVector = vectorizer.ComputeTfIdfVector(target); + + var similarBlogPosts = blogPosts + .Select((bp, index) => new + { + BlogPost = bp, + Similarity = SimilarityCalculator.CosineSimilarity(targetVector, vectorizer.ComputeTfIdfVector(documents[index])) + }) + .Where(s => s.BlogPost.Id != blogPost.Id) + .OrderByDescending(x => x.Similarity) + .Take(3) + .Select(s => s.BlogPost.Id) + .ToArray(); + + return new SimilarBlogPost { Id = blogPost.Id, SimilarBlogPostIds = similarBlogPosts }; + } + + private sealed record BlogPostSimilarity(string Id, string Title, IList Tags, string ShortDescription); +} diff --git a/src/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj b/src/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj index 58900c23..1441b20f 100644 --- a/src/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj +++ b/src/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj @@ -10,7 +10,7 @@ - + @@ -21,4 +21,8 @@ + + + + diff --git a/src/LinkDotNet.Blog.Web/RegistrationExtensions/BackgroundServiceRegistrationExtensions.cs b/src/LinkDotNet.Blog.Web/RegistrationExtensions/BackgroundServiceRegistrationExtensions.cs index fb3ba1a2..9eb3d787 100644 --- a/src/LinkDotNet.Blog.Web/RegistrationExtensions/BackgroundServiceRegistrationExtensions.cs +++ b/src/LinkDotNet.Blog.Web/RegistrationExtensions/BackgroundServiceRegistrationExtensions.cs @@ -17,7 +17,10 @@ public static void AddBackgroundServices(this IServiceCollection services) services.AddNCronJob(options => { - options.AddJob(p => p.WithCronExpression("* * * * *")); + options + .AddJob(p => p.WithCronExpression("* * * * *")) + .ExecuteWhen(s => s.RunJob()); + options.AddJob(p => p.WithCronExpression("0/10 * * * *")); }); } diff --git a/src/LinkDotNet.Blog.Web/appsettings.json b/src/LinkDotNet.Blog.Web/appsettings.json index 10f48832..45f92662 100644 --- a/src/LinkDotNet.Blog.Web/appsettings.json +++ b/src/LinkDotNet.Blog.Web/appsettings.json @@ -35,5 +35,6 @@ "Heading": "Software Engineer", "ProfilePictureUrl": "assets/profile-picture.webp" }, - "ShowReadingIndicator": true + "ShowReadingIndicator": true, + "ShowSimilarPosts": true } diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/CreateNewBlogPostPageTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/CreateNewBlogPostPageTests.cs index 1c67cbcd..451e4a3e 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/CreateNewBlogPostPageTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/CreateNewBlogPostPageTests.cs @@ -1,13 +1,16 @@ -using System.Threading.Tasks; +using System.Threading; +using System.Threading.Tasks; using Blazored.Toast.Services; using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.TestUtilities.Fakes; +using LinkDotNet.Blog.Web.Features; using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor; using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components; using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services; using LinkDotNet.Blog.Web.Features.Components; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using NCronJob; namespace LinkDotNet.Blog.IntegrationTests.Web.Features.Admin.BlogPostEditor; @@ -19,11 +22,13 @@ public async Task ShouldSaveBlogPostOnSave() await using var ctx = new BunitContext(); ctx.ComponentFactories.Add(); var toastService = Substitute.For(); + var instantRegistry = Substitute.For(); ctx.JSInterop.SetupVoid("hljs.highlightAll"); ctx.AddAuthorization().SetAuthorized("some username"); ctx.Services.AddScoped(_ => Repository); ctx.Services.AddScoped(_ => toastService); ctx.Services.AddScoped(_ => Substitute.For()); + ctx.Services.AddScoped(_ => instantRegistry); using var cut = ctx.Render(); var newBlogPost = cut.FindComponent(); @@ -33,6 +38,7 @@ public async Task ShouldSaveBlogPostOnSave() blogPostFromDb.Should().NotBeNull(); blogPostFromDb.ShortDescription.Should().Be("My short Description"); toastService.Received(1).ShowInfo("Created BlogPost My Title", null); + instantRegistry.Received(1).RunInstantJob(Arg.Any(), Arg.Any()); } private static void TriggerNewBlogPost(RenderedComponent cut) diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/UpdateBlogPostPageTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/UpdateBlogPostPageTests.cs index 91f517e3..ff005b74 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/UpdateBlogPostPageTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/UpdateBlogPostPageTests.cs @@ -1,14 +1,17 @@ using System; +using System.Threading; using System.Threading.Tasks; using Blazored.Toast.Services; using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.TestUtilities; using LinkDotNet.Blog.TestUtilities.Fakes; +using LinkDotNet.Blog.Web.Features; using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor; using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components; using LinkDotNet.Blog.Web.Features.Components; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using NCronJob; namespace LinkDotNet.Blog.IntegrationTests.Web.Features.Admin.BlogPostEditor; @@ -21,11 +24,13 @@ public async Task ShouldSaveBlogPostOnSave() ctx.ComponentFactories.Add(); ctx.JSInterop.SetupVoid("hljs.highlightAll"); var toastService = Substitute.For(); + var instantRegistry = Substitute.For(); var blogPost = new BlogPostBuilder().WithTitle("Title").WithShortDescription("Sub").Build(); await Repository.StoreAsync(blogPost); ctx.AddAuthorization().SetAuthorized("some username"); ctx.Services.AddScoped(_ => Repository); ctx.Services.AddScoped(_ => toastService); + ctx.Services.AddScoped(_ => instantRegistry); using var cut = ctx.Render( p => p.Add(s => s.BlogPostId, blogPost.Id)); var newBlogPost = cut.FindComponent(); @@ -36,6 +41,7 @@ public async Task ShouldSaveBlogPostOnSave() blogPostFromDb.Should().NotBeNull(); blogPostFromDb.ShortDescription.Should().Be("My new Description"); toastService.Received(1).ShowInfo("Updated BlogPost Title", null); + instantRegistry.Received(1).RunInstantJob(Arg.Any(), Arg.Any()); } [Fact] diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/ShowBlogPost/Components/SimilarBlogPostSectionTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/ShowBlogPost/Components/SimilarBlogPostSectionTests.cs new file mode 100644 index 00000000..641875ab --- /dev/null +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/ShowBlogPost/Components/SimilarBlogPostSectionTests.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.Infrastructure.Persistence; +using LinkDotNet.Blog.Infrastructure.Persistence.Sql; +using LinkDotNet.Blog.TestUtilities; +using LinkDotNet.Blog.Web.Features.ShowBlogPost.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace LinkDotNet.Blog.IntegrationTests.Web.Features.ShowBlogPost.Components; + +public class SimilarBlogPostSectionTests : SqlDatabaseTestBase +{ + [Fact] + public async Task ShouldShowSimilarBlogPosts() + { + var blogPost1 = new BlogPostBuilder().WithTitle("Title 1").Build(); + var blogPost2 = new BlogPostBuilder().WithTitle("Title 2").Build(); + var blogPost3 = new BlogPostBuilder().WithTitle("Title 3").Build(); + await Repository.StoreAsync(blogPost1); + await Repository.StoreAsync(blogPost2); + await Repository.StoreAsync(blogPost3); + var similarBlogPost1 = new SimilarBlogPost + { + Id = blogPost1.Id, + SimilarBlogPostIds = [blogPost2.Id, blogPost3.Id] + }; + await DbContext.SimilarBlogPosts.AddAsync(similarBlogPost1); + await DbContext.SaveChangesAsync(); + await using var context = new BunitContext(); + context.Services.AddScoped>(_ => + new Repository(DbContextFactory, Substitute.For>>())); + context.Services.AddScoped(_ => Repository); + + var cut = context.Render(p => p.Add(s => s.BlogPost, blogPost1)); + + var elements = cut.WaitForElements(".card-title"); + elements.Should().HaveCount(2); + elements.Should().Contain(p => p.TextContent == "Title 2"); + elements.Should().Contain(p => p.TextContent == "Title 3"); + } +} diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs index 0bd38c2a..6b3af0b6 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs @@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using NCronJob; namespace LinkDotNet.Blog.IntegrationTests.Web.Features.ShowBlogPost; @@ -121,5 +122,6 @@ private void RegisterComponents(BunitContext ctx, ILocalStorageService localStor ctx.Services.AddScoped(_ => Substitute.For()); ctx.Services.AddScoped(_ => Substitute.For()); ctx.Services.AddScoped(_ => Options.Create(new ApplicationConfiguration())); + ctx.Services.AddScoped(_ => Substitute.For()); } } \ No newline at end of file diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/SimilarBlogPostJobTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/SimilarBlogPostJobTests.cs new file mode 100644 index 00000000..5ca4aecf --- /dev/null +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/SimilarBlogPostJobTests.cs @@ -0,0 +1,77 @@ +using System.Threading; +using System.Threading.Tasks; +using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.Infrastructure.Persistence.Sql; +using LinkDotNet.Blog.TestUtilities; +using LinkDotNet.Blog.Web; +using LinkDotNet.Blog.Web.Features; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NCronJob; + +namespace LinkDotNet.Blog.IntegrationTests.Web.Features; + +public class SimilarBlogPostJobTests : SqlDatabaseTestBase +{ + private readonly Repository similarBlogPostRepository; + + public SimilarBlogPostJobTests() + { + similarBlogPostRepository = + new Repository(DbContextFactory, Substitute.For>>()); + } + + [Fact] + public async Task ShouldCalculateSimilarBlogPosts() + { + var blogPost1 = new BlogPostBuilder().WithTitle("Title 1").Build(); + var blogPost2 = new BlogPostBuilder().WithTitle("Title 2").Build(); + var blogPost3 = new BlogPostBuilder().WithTitle("Title 3").Build(); + await Repository.StoreAsync(blogPost1); + await Repository.StoreAsync(blogPost2); + await Repository.StoreAsync(blogPost3); + var config = Options.Create(new ApplicationConfiguration { ShowSimilarPosts = true }); + + var job = new SimilarBlogPostJob(Repository, similarBlogPostRepository, config); + await job.RunAsync(new JobExecutionContext(typeof(SimilarBlogPostJob), true), CancellationToken.None); + + var similarBlogPosts = await similarBlogPostRepository.GetAllAsync(); + similarBlogPosts.Should().HaveCount(3); + } + + [Fact] + public async Task ShouldNotCalculateWhenDisabledInApplicationConfiguration() + { + var blogPost1 = new BlogPostBuilder().WithTitle("Title 1").Build(); + var blogPost2 = new BlogPostBuilder().WithTitle("Title 2").Build(); + var blogPost3 = new BlogPostBuilder().WithTitle("Title 3").Build(); + await Repository.StoreAsync(blogPost1); + await Repository.StoreAsync(blogPost2); + await Repository.StoreAsync(blogPost3); + var config = Options.Create(new ApplicationConfiguration { ShowSimilarPosts = false }); + + var job = new SimilarBlogPostJob(Repository, similarBlogPostRepository, config); + await job.RunAsync(new JobExecutionContext(typeof(SimilarBlogPostJob), true), CancellationToken.None); + + var similarBlogPosts = await similarBlogPostRepository.GetAllAsync(); + similarBlogPosts.Should().BeEmpty(); + } + + [Fact] + public async Task ShouldNotCalculateWhenNotTriggeredAsInstantJob() + { + var blogPost1 = new BlogPostBuilder().WithTitle("Title 1").Build(); + var blogPost2 = new BlogPostBuilder().WithTitle("Title 2").Build(); + var blogPost3 = new BlogPostBuilder().WithTitle("Title 3").Build(); + await Repository.StoreAsync(blogPost1); + await Repository.StoreAsync(blogPost2); + await Repository.StoreAsync(blogPost3); + var config = Options.Create(new ApplicationConfiguration { ShowSimilarPosts = true }); + + var job = new SimilarBlogPostJob(Repository, similarBlogPostRepository, config); + await job.RunAsync(new JobExecutionContext(typeof(SimilarBlogPostJob), null), CancellationToken.None); + + var similarBlogPosts = await similarBlogPostRepository.GetAllAsync(); + similarBlogPosts.Should().BeEmpty(); + } +} diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/Admin/BlogPostAdminActionsTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/Admin/BlogPostAdminActionsTests.cs index 50983c38..62d54429 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/Admin/BlogPostAdminActionsTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/Admin/BlogPostAdminActionsTests.cs @@ -5,21 +5,28 @@ using LinkDotNet.Blog.Web.Features.ShowBlogPost.Components; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; +using NCronJob; namespace LinkDotNet.Blog.IntegrationTests.Web.Shared.Admin; -public class BlogPostAdminActionsTests +public class BlogPostAdminActionsTests : BunitContext { + public BlogPostAdminActionsTests() + { + Services.AddSingleton(Substitute.For>()); + Services.AddSingleton(Substitute.For()); + Services.AddSingleton(Substitute.For()); + AddAuthorization().SetAuthorized("s"); + } + [Fact] public async Task ShouldDeleteBlogPostWhenOkClicked() { const string blogPostId = "2"; var repositoryMock = Substitute.For>(); - using var ctx = new BunitContext(); - ctx.AddAuthorization().SetAuthorized("s"); - ctx.Services.AddSingleton(repositoryMock); - ctx.Services.AddSingleton(Substitute.For()); - var cut = ctx.Render(s => s.Add(p => p.BlogPostId, blogPostId)); + Services.AddSingleton(repositoryMock); + + var cut = Render(s => s.Add(p => p.BlogPostId, blogPostId)); cut.Find("#delete-blogpost").Click(); cut.Find("#ok").Click(); @@ -32,11 +39,9 @@ public async Task ShouldNotDeleteBlogPostWhenCancelClicked() { const string blogPostId = "2"; var repositoryMock = Substitute.For>(); - using var ctx = new BunitContext(); - ctx.AddAuthorization().SetAuthorized("s"); - ctx.Services.AddSingleton(repositoryMock); - ctx.Services.AddSingleton(Substitute.For()); - var cut = ctx.Render(s => s.Add(p => p.BlogPostId, blogPostId)); + + Services.AddSingleton(repositoryMock); + var cut = Render(s => s.Add(p => p.BlogPostId, blogPostId)); cut.Find("#delete-blogpost").Click(); cut.Find("#cancel").Click(); @@ -48,13 +53,8 @@ public async Task ShouldNotDeleteBlogPostWhenCancelClicked() public void ShouldGoToEditPageOnEditClick() { const string blogPostId = "2"; - var repositoryMock = Substitute.For>(); - using var ctx = new BunitContext(); - ctx.AddAuthorization().SetAuthorized("s"); - ctx.Services.AddSingleton(repositoryMock); - ctx.Services.AddSingleton(Substitute.For()); - var navigationManager = ctx.Services.GetRequiredService(); - var cut = ctx.Render(s => s.Add(p => p.BlogPostId, blogPostId)); + var navigationManager = Services.GetRequiredService(); + var cut = Render(s => s.Add(p => p.BlogPostId, blogPostId)); cut.Find("#edit-blogpost").Click(); diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPostTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPostTests.cs index c6736891..e6c1f67f 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPostTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPostTests.cs @@ -6,8 +6,10 @@ using LinkDotNet.Blog.TestUtilities.Fakes; using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components; using LinkDotNet.Blog.Web.Features.Components; +using LinkDotNet.Blog.Web.Features.ShowBlogPost.Components; using Microsoft.AspNetCore.Components.Routing; using Microsoft.Extensions.DependencyInjection; +using NCronJob; namespace LinkDotNet.Blog.UnitTests.Web.Features.Admin.BlogPostEditor.Components; @@ -17,6 +19,7 @@ public CreateNewBlogPostTests() { JSInterop.SetupVoid("hljs.highlightAll"); ComponentFactories.Add(); + Services.AddScoped(_ => Substitute.For()); } [Fact] diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs index 09601d11..6575e428 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs @@ -12,17 +12,31 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using NCronJob; namespace LinkDotNet.Blog.UnitTests.Web.Features.ShowBlogPost; public class ShowBlogPostPageTests : BunitContext { + public ShowBlogPostPageTests() + { + ComponentFactories.AddStub(); + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddScoped(_ => Substitute.For()); + Services.AddScoped(_ => Substitute.For()); + Services.AddScoped(_ => Substitute.For()); + Services.AddScoped(_ => Options.Create(new ApplicationConfiguration())); + AddAuthorization(); + ComponentFactories.AddStub(); + ComponentFactories.AddStub(); + ComponentFactories.AddStub(); + } + [Fact] public void ShouldShowLoadingAnimation() { const string blogPostId = "2"; var repositoryMock = Substitute.For>(); - SetupMocks(); Services.AddScoped(_ => repositoryMock); repositoryMock.GetByIdAsync(blogPostId) .Returns(new ValueTask(Task.Run(async () => @@ -45,7 +59,6 @@ public void ShouldSetTitleToTag() var blogPost = new BlogPostBuilder().WithTitle("Title").Build(); repositoryMock.GetByIdAsync("1").Returns(blogPost); Services.AddScoped(_ => repositoryMock); - SetupMocks(); var cut = Render( p => p.Add(s => s.BlogPostId, "1")); @@ -67,7 +80,6 @@ public void ShouldUseFallbackAsOgDataIfAvailable(string preview, string fallback .Build(); repositoryMock.GetByIdAsync("1").Returns(blogPost); Services.AddScoped(_ => repositoryMock); - SetupMocks(); var cut = Render( p => p.Add(s => s.BlogPostId, "1")); @@ -84,7 +96,6 @@ public void ShowTagWithLinksWhenAvailable() .Build(); repositoryMock.GetByIdAsync("1").Returns(blogPost); Services.AddScoped(_ => repositoryMock); - SetupMocks(); var cut = Render( p => p.Add(s => s.BlogPostId, "1")); @@ -102,7 +113,6 @@ public void ShowNotShowTagsWhenNotSet() .Build(); repositoryMock.GetByIdAsync("1").Returns(blogPost); Services.AddScoped(_ => repositoryMock); - SetupMocks(); var cut = Render( p => p.Add(s => s.BlogPostId, "1")); @@ -124,7 +134,6 @@ public void ShowReadingIndicatorWhenEnabled(bool isEnabled) .Build(); repositoryMock.GetByIdAsync("1").Returns(blogPost); Services.AddScoped(_ => repositoryMock); - SetupMocks(); Services.AddScoped(_ => Options.Create(appConfiguration)); var cut = Render( @@ -143,23 +152,10 @@ public void ShouldSetCanoncialUrlOfOgDataWithoutSlug() blogPost.Id = "1"; repositoryMock.GetByIdAsync("1").Returns(blogPost); Services.AddScoped(_ => repositoryMock); - SetupMocks(); var cut = Render( p => p.Add(s => s.BlogPostId, "1")); cut.FindComponent().Instance.CanonicalRelativeUrl.Should().Be("blogPost/1"); } - - private void SetupMocks() - { - JSInterop.Mode = JSRuntimeMode.Loose; - Services.AddScoped(_ => Substitute.For()); - Services.AddScoped(_ => Substitute.For()); - Services.AddScoped(_ => Options.Create(new ApplicationConfiguration())); - this.AddAuthorization(); - ComponentFactories.AddStub(); - ComponentFactories.AddStub(); - ComponentFactories.AddStub(); - } } \ No newline at end of file