diff --git a/Directory.Packages.props b/Directory.Packages.props index 964185f9..b7b6db0e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,7 @@ true + @@ -60,4 +61,4 @@ - + \ No newline at end of file diff --git a/src/LinkDotNet.Blog.Domain/BlogPostVersion.cs b/src/LinkDotNet.Blog.Domain/BlogPostVersion.cs new file mode 100644 index 00000000..74649a10 --- /dev/null +++ b/src/LinkDotNet.Blog.Domain/BlogPostVersion.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace LinkDotNet.Blog.Domain; + +public sealed class BlogPostVersion : Entity +{ + public string BlogPostId { get; private set; } = default!; + + public int VersionNumber { get; private set; } + + public DateTime CreatedAt { get; private set; } + + public string Title { get; private set; } = default!; + + public string ShortDescription { get; private set; } = default!; + + public string Content { get; private set; } = default!; + + public string PreviewImageUrl { get; private set; } = default!; + + public string? PreviewImageUrlFallback { get; private set; } + + public DateTime UpdatedDate { get; private set; } + + public IList Tags { get; private set; } = []; + + public bool IsPublished { get; private set; } + + public int ReadingTimeInMinutes { get; private set; } + + public string? AuthorName { get; private set; } + + public string TagsAsString => string.Join(",", Tags); + + public static BlogPostVersion CreateSnapshot(BlogPost post, int versionNumber) + { + ArgumentNullException.ThrowIfNull(post); + + return new BlogPostVersion + { + BlogPostId = post.Id, + VersionNumber = versionNumber, + CreatedAt = DateTime.UtcNow, + Title = post.Title, + ShortDescription = post.ShortDescription, + Content = post.Content, + PreviewImageUrl = post.PreviewImageUrl, + PreviewImageUrlFallback = post.PreviewImageUrlFallback, + UpdatedDate = post.UpdatedDate, + Tags = post.Tags.ToImmutableArray(), + IsPublished = post.IsPublished, + ReadingTimeInMinutes = post.ReadingTimeInMinutes, + AuthorName = post.AuthorName, + }; + } +} diff --git a/src/LinkDotNet.Blog.Infrastructure/Migrations/20260424073229_AddBlogPostVersioning.Designer.cs b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260424073229_AddBlogPostVersioning.Designer.cs new file mode 100644 index 00000000..79a74090 --- /dev/null +++ b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260424073229_AddBlogPostVersioning.Designer.cs @@ -0,0 +1,351 @@ +// +using System; +using LinkDotNet.Blog.Infrastructure.Persistence.Sql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LinkDotNet.Blog.Infrastructure.Migrations +{ + [DbContext(typeof(BlogDbContext))] + [Migration("20260424073229_AddBlogPostVersioning")] + partial class AddBlogPostVersioning + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("varchar(900)"); + + b.Property("AuthorName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("Likes") + .HasColumnType("int"); + + b.Property("PreviewImageUrl") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("PreviewImageUrlFallback") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ReadingTimeInMinutes") + .HasColumnType("int"); + + b.Property("ScheduledPublishDate") + .HasColumnType("datetime2"); + + b.Property("ShortDescription") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("UpdatedDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("IsPublished", "UpdatedDate") + .IsDescending(false, true) + .HasDatabaseName("IX_BlogPosts_IsPublished_UpdatedDate"); + + b.ToTable("BlogPosts"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPostRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("varchar(900)"); + + b.Property("BlogPostId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Clicks") + .HasColumnType("int"); + + b.Property("DateClicked") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.ToTable("BlogPostRecords"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPostTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("varchar(900)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ShortDescription") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.ToTable("BlogPostTemplates"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPostVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("varchar(900)"); + + b.Property("AuthorName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("BlogPostId") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("PreviewImageUrl") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("PreviewImageUrlFallback") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ReadingTimeInMinutes") + .HasColumnType("int"); + + b.Property("ShortDescription") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("UpdatedDate") + .HasColumnType("datetime2"); + + b.Property("VersionNumber") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BlogPostId", "VersionNumber") + .IsUnique() + .HasDatabaseName("IX_BlogPostVersions_BlogPostId_VersionNumber"); + + b.ToTable("BlogPostVersions"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.ProfileInformationEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("varchar(900)"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("ProfileInformationEntries"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.ShortCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("varchar(900)"); + + b.Property("MarkdownContent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.HasKey("Id"); + + b.ToTable("ShortCodes"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.SimilarBlogPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("varchar(900)"); + + b.PrimitiveCollection("SimilarBlogPostIds") + .IsRequired() + .HasMaxLength(1350) + .HasColumnType("nvarchar(1350)"); + + b.HasKey("Id"); + + b.ToTable("SimilarBlogPosts"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.Skill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("varchar(900)"); + + b.Property("Capability") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IconUrl") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProficiencyLevel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.ToTable("Skills"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.Talk", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("varchar(900)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Place") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PresentationTitle") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PublishedDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("Talks"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.UserRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("varchar(900)"); + + b.Property("DateClicked") + .HasColumnType("date"); + + b.Property("UrlClicked") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.ToTable("UserRecords"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/LinkDotNet.Blog.Infrastructure/Migrations/20260424073229_AddBlogPostVersioning.cs b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260424073229_AddBlogPostVersioning.cs new file mode 100644 index 00000000..c55c57c7 --- /dev/null +++ b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260424073229_AddBlogPostVersioning.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LinkDotNet.Blog.Infrastructure.Migrations; + +/// +public partial class AddBlogPostVersioning : Migration +{ + private static readonly string[] BlogPostVersionsColumns = ["BlogPostId", "VersionNumber"]; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.CreateTable( + name: "BlogPostVersions", + columns: table => new + { + Id = table.Column(type: "varchar(900)", unicode: false, nullable: false), + BlogPostId = table.Column(type: "varchar(256)", unicode: false, maxLength: 256, nullable: false), + VersionNumber = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + Title = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + ShortDescription = table.Column(type: "nvarchar(max)", nullable: false), + Content = table.Column(type: "nvarchar(max)", nullable: false), + PreviewImageUrl = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: false), + PreviewImageUrlFallback = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + UpdatedDate = table.Column(type: "datetime2", nullable: false), + Tags = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: false), + IsPublished = table.Column(type: "bit", nullable: false), + ReadingTimeInMinutes = table.Column(type: "int", nullable: false), + AuthorName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BlogPostVersions", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_BlogPostVersions_BlogPostId_VersionNumber", + table: "BlogPostVersions", + columns: BlogPostVersionsColumns, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.DropTable( + name: "BlogPostVersions"); + } +} diff --git a/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs b/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs index f0ee127e..08b96695 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs @@ -3,6 +3,7 @@ using LinkDotNet.Blog.Infrastructure.Persistence.Sql; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable @@ -15,60 +16,64 @@ partial class BlogDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.11"); + modelBuilder + .HasAnnotation("ProductVersion", "9.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPost", b => { b.Property("Id") .ValueGeneratedOnAdd() .IsUnicode(false) - .HasColumnType("TEXT"); + .HasColumnType("varchar(900)"); b.Property("AuthorName") .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(256)"); b.Property("Content") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(max)"); b.Property("IsPublished") - .HasColumnType("INTEGER"); + .HasColumnType("bit"); b.Property("Likes") - .HasColumnType("INTEGER"); + .HasColumnType("int"); b.Property("PreviewImageUrl") .IsRequired() .HasMaxLength(1024) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(1024)"); b.Property("PreviewImageUrlFallback") .HasMaxLength(1024) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(1024)"); b.Property("ReadingTimeInMinutes") - .HasColumnType("INTEGER"); + .HasColumnType("int"); b.Property("ScheduledPublishDate") - .HasColumnType("TEXT"); + .HasColumnType("datetime2"); b.Property("ShortDescription") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(max)"); b.PrimitiveCollection("Tags") .IsRequired() .HasMaxLength(2048) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(2048)"); b.Property("Title") .IsRequired() .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(256)"); b.Property("UpdatedDate") - .HasColumnType("TEXT"); + .HasColumnType("datetime2"); b.HasKey("Id"); @@ -84,18 +89,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .ValueGeneratedOnAdd() .IsUnicode(false) - .HasColumnType("TEXT"); + .HasColumnType("varchar(900)"); b.Property("BlogPostId") .IsRequired() .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(256)"); b.Property("Clicks") - .HasColumnType("INTEGER"); + .HasColumnType("int"); b.Property("DateClicked") - .HasColumnType("TEXT"); + .HasColumnType("date"); b.HasKey("Id"); @@ -107,45 +112,113 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .ValueGeneratedOnAdd() .IsUnicode(false) - .HasColumnType("TEXT"); + .HasColumnType("varchar(900)"); b.Property("Content") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(max)"); b.Property("Name") .IsRequired() .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(256)"); b.Property("ShortDescription") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(max)"); b.Property("Title") .IsRequired() .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(256)"); b.HasKey("Id"); b.ToTable("BlogPostTemplates"); }); + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPostVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("varchar(900)"); + + b.Property("AuthorName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("BlogPostId") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("PreviewImageUrl") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("PreviewImageUrlFallback") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ReadingTimeInMinutes") + .HasColumnType("int"); + + b.Property("ShortDescription") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("UpdatedDate") + .HasColumnType("datetime2"); + + b.Property("VersionNumber") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BlogPostId", "VersionNumber") + .IsUnique() + .HasDatabaseName("IX_BlogPostVersions_BlogPostId_VersionNumber"); + + b.ToTable("BlogPostVersions"); + }); + modelBuilder.Entity("LinkDotNet.Blog.Domain.ProfileInformationEntry", b => { b.Property("Id") .ValueGeneratedOnAdd() .IsUnicode(false) - .HasColumnType("TEXT"); + .HasColumnType("varchar(900)"); b.Property("Content") .IsRequired() .HasMaxLength(512) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(512)"); b.Property("SortOrder") - .HasColumnType("INTEGER"); + .HasColumnType("int"); b.HasKey("Id"); @@ -157,16 +230,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .ValueGeneratedOnAdd() .IsUnicode(false) - .HasColumnType("TEXT"); + .HasColumnType("varchar(900)"); b.Property("MarkdownContent") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(max)"); b.Property("Name") .IsRequired() .HasMaxLength(512) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(512)"); b.HasKey("Id"); @@ -178,12 +251,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .ValueGeneratedOnAdd() .IsUnicode(false) - .HasColumnType("TEXT"); + .HasColumnType("varchar(900)"); b.PrimitiveCollection("SimilarBlogPostIds") .IsRequired() .HasMaxLength(1350) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(1350)"); b.HasKey("Id"); @@ -195,26 +268,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .ValueGeneratedOnAdd() .IsUnicode(false) - .HasColumnType("TEXT"); + .HasColumnType("varchar(900)"); b.Property("Capability") .IsRequired() .HasMaxLength(128) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(128)"); b.Property("IconUrl") .HasMaxLength(1024) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(1024)"); b.Property("Name") .IsRequired() .HasMaxLength(128) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(128)"); b.Property("ProficiencyLevel") .IsRequired() .HasMaxLength(32) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(32)"); b.HasKey("Id"); @@ -226,24 +299,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .ValueGeneratedOnAdd() .IsUnicode(false) - .HasColumnType("TEXT"); + .HasColumnType("varchar(900)"); b.Property("Description") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(max)"); b.Property("Place") .IsRequired() .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(256)"); b.Property("PresentationTitle") .IsRequired() .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(256)"); b.Property("PublishedDate") - .HasColumnType("TEXT"); + .HasColumnType("datetime2"); b.HasKey("Id"); @@ -255,15 +328,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .ValueGeneratedOnAdd() .IsUnicode(false) - .HasColumnType("TEXT"); + .HasColumnType("varchar(900)"); b.Property("DateClicked") - .HasColumnType("TEXT"); + .HasColumnType("date"); b.Property("UrlClicked") .IsRequired() .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(256)"); b.HasKey("Id"); diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs index b8b647f6..887fa85a 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs @@ -31,6 +31,8 @@ public BlogDbContext(DbContextOptions options) public DbSet BlogPostTemplates { get; set; } + public DbSet BlogPostVersions { get; set; } + protected override void OnModelCreating(ModelBuilder modelBuilder) { ArgumentNullException.ThrowIfNull(modelBuilder); @@ -38,6 +40,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new BlogPostConfiguration()); modelBuilder.ApplyConfiguration(new BlogPostRecordConfiguration()); modelBuilder.ApplyConfiguration(new BlogPostTemplateConfiguration()); + modelBuilder.ApplyConfiguration(new BlogPostVersionConfiguration()); modelBuilder.ApplyConfiguration(new ProfileInformationEntryConfiguration()); modelBuilder.ApplyConfiguration(new ShortCodeConfiguration()); modelBuilder.ApplyConfiguration(new SimilarBlogPostConfiguration()); diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostVersionConfiguration.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostVersionConfiguration.cs new file mode 100644 index 00000000..4a34dc32 --- /dev/null +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostVersionConfiguration.cs @@ -0,0 +1,35 @@ +using LinkDotNet.Blog.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace LinkDotNet.Blog.Infrastructure.Persistence.Sql.Mapping; + +internal sealed class BlogPostVersionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(c => c.Id); + builder.Property(c => c.Id).IsUnicode(false).ValueGeneratedOnAdd(); + + builder.Property(x => x.BlogPostId).HasMaxLength(256).IsRequired().IsUnicode(false); + builder.Property(x => x.VersionNumber).IsRequired(); + builder.Property(x => x.CreatedAt).IsRequired(); + + builder.Property(x => x.Title).HasMaxLength(256).IsRequired(); + builder.Property(x => x.PreviewImageUrl).HasMaxLength(1024).IsRequired(); + builder.Property(x => x.PreviewImageUrlFallback).HasMaxLength(1024); + builder.Property(x => x.Content).IsRequired(); + builder.Property(x => x.ShortDescription).IsRequired(); + builder.Property(x => x.Tags).HasMaxLength(2048); + builder.Property(x => x.IsPublished).IsRequired(); + builder.Property(x => x.ReadingTimeInMinutes).IsRequired(); + builder.Property(x => x.AuthorName).HasMaxLength(256).IsRequired(false); + builder.Property(x => x.UpdatedDate).IsRequired(); + + builder.HasIndex(x => new { x.BlogPostId, x.VersionNumber }) + .IsUnique() + .HasDatabaseName("IX_BlogPostVersions_BlogPostId_VersionNumber"); + + builder.ToTable("BlogPostVersions"); + } +} 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 5fd30ec8..9578e40e 100644 --- a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor @@ -1,6 +1,7 @@ @using LinkDotNet.Blog.Domain @using LinkDotNet.Blog.Infrastructure @using LinkDotNet.Blog.Infrastructure.Persistence +@using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services @using LinkDotNet.Blog.Web.Features.Services @using NCronJob @inject IJSRuntime JSRuntime @@ -11,6 +12,7 @@ @inject IToastService ToastService @inject IOptions AppConfiguration @inject ICurrentUserService CurrentUserService +@inject IBlogPostVersionService BlogPostVersionService Creating new Blog Post @@ -259,6 +261,33 @@ + @if (!string.IsNullOrEmpty(BlogPostId) && versionHistory.Count > 0) + { +
+
+
Version History
+ @versionHistory.Count +
+
+
+ @foreach (var v in versionHistory) + { +
+
+
v@(v.VersionNumber)
+
@v.CreatedAt.ToString("MMM dd, yyyy HH:mm")
+
@v.Title
+
+
+ + +
+
+ } +
+
+
+ } @@ -267,6 +296,7 @@ + @@ -283,9 +313,18 @@ [Parameter] public bool ClearAfterCreated { get; set; } = true; + [Parameter] + public string? BlogPostId { get; set; } + + [Parameter] + public EventCallback OnVersionRestored { get; set; } + private FeatureInfoDialog FeatureDialog { get; set; } = default!; private ShortCodeDialog ShortCodeDialog { get; set; } = default!; private AddTemplateDialog AddTemplateDialog { get; set; } = default!; + private VersionDiffDialog DiffDialog { get; set; } = default!; + + private IReadOnlyList versionHistory = []; private CreateNewModel model = new(); @@ -312,7 +351,7 @@ } } - protected override void OnParametersSet() + protected override async Task OnParametersSetAsync() { if (BlogPost is null) { @@ -320,6 +359,11 @@ } model = CreateNewModel.FromBlogPost(BlogPost); + + if (!string.IsNullOrEmpty(BlogPostId)) + { + versionHistory = await BlogPostVersionService.GetVersionHistoryAsync(BlogPostId); + } } private string GetStatusText() @@ -373,6 +417,28 @@ } } + private void OpenDiff(BlogPostVersion version) + { + DiffDialog.Open(version, BlogPost!); + StateHasChanged(); + } + + private async Task RestoreVersionAsync(BlogPostVersion version) + { + var confirmed = await JSRuntime.InvokeAsync( + "confirm", + $"Restore version {version.VersionNumber} (\"{version.Title}\")? The current state will be saved as a new version first."); + + if (!confirmed) + { + return; + } + + await OnVersionRestored.InvokeAsync(version); + versionHistory = await BlogPostVersionService.GetVersionHistoryAsync(BlogPostId!); + StateHasChanged(); + } + private async Task PreventNavigationWhenDirty(LocationChangingContext context) { if (!model.IsDirty) diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/VersionDiffDialog.razor b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/VersionDiffDialog.razor new file mode 100644 index 00000000..34cbdfce --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/VersionDiffDialog.razor @@ -0,0 +1,398 @@ +@using DiffPlex +@using DiffPlex.DiffBuilder +@using DiffPlex.DiffBuilder.Model +@using LinkDotNet.Blog.Domain +@using System.Collections.Generic +@using System.Net +@using System.Text + + + +@if (showBackdrop) +{ + +} + +@code { + private sealed record DiffDisplayLine( + string OldNum, + string NewNum, + string Prefix, + string Text, + string CssClass, + bool IsCollapse = false, + int CollapseCount = 0); + + private enum DiffTab { Fields, Content } + + private const int ContextLines = 3; + + private string modalDisplay = "none;"; + private string modalClass = string.Empty; + private bool showBackdrop; + private DiffTab activeTab = DiffTab.Fields; + + private BlogPostVersion? version; + private BlogPost? current; + + private IReadOnlyList contentLines = []; + private int insertedLines; + private int deletedLines; + private int unchangedLines; + private int changedFieldCount; + + public void Open(BlogPostVersion v, BlogPost currentPost) + { + version = v; + current = currentPost; + activeTab = DiffTab.Fields; + Compute(); + modalDisplay = "block;"; + modalClass = "show"; + showBackdrop = true; + StateHasChanged(); + } + + public void Close() + { + modalDisplay = "none;"; + modalClass = string.Empty; + showBackdrop = false; + StateHasChanged(); + } + + private void Compute() + { + if (version is null || current is null) + { + return; + } + + changedFieldCount = CountChangedFields(); + contentLines = BuildContentDiff(version.Content, current.Content); + insertedLines = contentLines.Count(l => l.Prefix == "+"); + deletedLines = contentLines.Count(l => l.Prefix == "-"); + unchangedLines = contentLines.Count(l => l.Prefix == " " && !l.IsCollapse); + } + + private int CountChangedFields() + { + var count = 0; + if (version!.Title != current!.Title) count++; + if ((version.AuthorName ?? "") != (current.AuthorName ?? "")) count++; + if (version.ShortDescription != current.ShortDescription) count++; + if (version.PreviewImageUrl != current.PreviewImageUrl) count++; + if ((version.PreviewImageUrlFallback ?? "") != (current.PreviewImageUrlFallback ?? "")) count++; + if (version.TagsAsString != current.TagsAsString) count++; + if (version.IsPublished != current.IsPublished) count++; + if (version.UpdatedDate != current.UpdatedDate) count++; + return count; + } + + private static IReadOnlyList BuildContentDiff(string oldText, string newText) + { + var rawLines = InlineDiffBuilder.Diff(oldText, newText).Lines; + var visible = new bool[rawLines.Count]; + + for (var i = 0; i < rawLines.Count; i++) + { + if (rawLines[i].Type == ChangeType.Unchanged) + { + continue; + } + + var from = Math.Max(0, i - ContextLines); + var to = Math.Min(rawLines.Count - 1, i + ContextLines); + for (var j = from; j <= to; j++) + { + visible[j] = true; + } + } + + var result = new List(rawLines.Count); + var oldLine = 0; + var newLine = 0; + var pendingCollapse = 0; + + for (var i = 0; i < rawLines.Count; i++) + { + var piece = rawLines[i]; + + if (!visible[i] && piece.Type == ChangeType.Unchanged) + { + oldLine++; + newLine++; + pendingCollapse++; + continue; + } + + if (pendingCollapse > 0) + { + result.Add(new DiffDisplayLine("", "", " ", "", "", IsCollapse: true, CollapseCount: pendingCollapse)); + pendingCollapse = 0; + } + + string oldNum, newNum, prefix, cssClass; + switch (piece.Type) + { + case ChangeType.Deleted: + oldLine++; + oldNum = oldLine.ToString(); + newNum = ""; + prefix = "-"; + cssClass = "bg-danger-subtle"; + break; + case ChangeType.Inserted: + newLine++; + oldNum = ""; + newNum = newLine.ToString(); + prefix = "+"; + cssClass = "bg-success-subtle"; + break; + case ChangeType.Modified: + oldLine++; + newLine++; + oldNum = oldLine.ToString(); + newNum = newLine.ToString(); + prefix = "~"; + cssClass = "bg-warning-subtle"; + break; + default: + if (piece.Type == ChangeType.Unchanged) + { + oldLine++; + newLine++; + } + + oldNum = piece.Type == ChangeType.Unchanged ? oldLine.ToString() : ""; + newNum = piece.Type == ChangeType.Unchanged ? newLine.ToString() : ""; + prefix = " "; + cssClass = ""; + break; + } + + result.Add(new DiffDisplayLine(oldNum, newNum, prefix, piece.Text ?? "", cssClass)); + } + + if (pendingCollapse > 0) + { + result.Add(new DiffDisplayLine("", "", " ", "", "", IsCollapse: true, CollapseCount: pendingCollapse)); + } + + return result; + } + + private static RenderFragment FieldRow(string fieldName, string old, string @new) => builder => + { + var changed = old != @new; + + builder.OpenElement(0, "tr"); + + builder.OpenElement(1, "td"); + builder.AddAttribute(2, "class", "fw-semibold text-nowrap align-top"); + builder.AddContent(3, fieldName); + builder.CloseElement(); + + builder.OpenElement(4, "td"); + builder.AddAttribute(5, "class", changed ? "align-top bg-danger-subtle" : "align-top"); + builder.AddMarkupContent(6, BuildWordDiff(old, @new, isOldSide: true).Value); + builder.CloseElement(); + + builder.OpenElement(7, "td"); + builder.AddAttribute(8, "class", changed ? "align-top bg-success-subtle" : "align-top"); + builder.AddMarkupContent(9, BuildWordDiff(old, @new, isOldSide: false).Value); + builder.CloseElement(); + + builder.CloseElement(); + }; + + private static MarkupString BuildWordDiff(string old, string @new, bool isOldSide) + { + if (old == @new) + { + return new MarkupString(WebUtility.HtmlEncode(isOldSide ? old : @new)); + } + + var differ = new Differ(); + var diff = differ.CreateWordDiffs(old, @new, ignoreWhitespace: false, [' ']); + var pieces = isOldSide ? diff.PiecesOld : diff.PiecesNew; + + var changedIndices = new HashSet(); + foreach (var block in diff.DiffBlocks) + { + if (isOldSide) + { + for (var i = block.DeleteStartA; i < block.DeleteStartA + block.DeleteCountA; i++) + { + changedIndices.Add(i); + } + } + else + { + for (var i = block.InsertStartB; i < block.InsertStartB + block.InsertCountB; i++) + { + changedIndices.Add(i); + } + } + } + + var sb = new StringBuilder(); + for (var i = 0; i < pieces.Count; i++) + { + var encoded = WebUtility.HtmlEncode(pieces[i]); + if (changedIndices.Contains(i)) + { + var style = isOldSide + ? "background:#ffcdd2; border-radius:2px; padding:0 2px;" + : "background:#c8e6c9; border-radius:2px; padding:0 2px;"; + sb.Append($"{encoded}"); + } + else + { + sb.Append(encoded); + } + } + + return new MarkupString(sb.ToString()); + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Services/BlogPostVersionService.cs b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Services/BlogPostVersionService.cs new file mode 100644 index 00000000..57728abb --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Services/BlogPostVersionService.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.Infrastructure.Persistence; +using LinkDotNet.Blog.Infrastructure.Persistence.Sql; +using Microsoft.EntityFrameworkCore; + +namespace LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services; + +public sealed class BlogPostVersionService : IBlogPostVersionService +{ + private readonly IDbContextFactory dbContextFactory; + private readonly IRepository blogPostRepository; + + public BlogPostVersionService( + IDbContextFactory dbContextFactory, + IRepository blogPostRepository) + { + this.dbContextFactory = dbContextFactory; + this.blogPostRepository = blogPostRepository; + } + + public async ValueTask SaveNewVersionAsync(BlogPost currentBlogPost, BlogPost updatedBlogPost) + { + ArgumentNullException.ThrowIfNull(currentBlogPost); + ArgumentNullException.ThrowIfNull(updatedBlogPost); + + await StoreSnapshotAsync(currentBlogPost); + + currentBlogPost.Update(updatedBlogPost); + await blogPostRepository.StoreAsync(currentBlogPost); + } + + public async ValueTask> GetVersionHistoryAsync(string blogPostId) + { + ArgumentException.ThrowIfNullOrEmpty(blogPostId); + + await using var db = await dbContextFactory.CreateDbContextAsync(); + return await db.BlogPostVersions + .Where(v => v.BlogPostId == blogPostId) + .OrderByDescending(v => v.VersionNumber) + .AsNoTracking() + .ToListAsync(); + } + + public async ValueTask RestoreVersionAsync(BlogPost currentBlogPost, BlogPostVersion targetVersion) + { + ArgumentNullException.ThrowIfNull(currentBlogPost); + ArgumentNullException.ThrowIfNull(targetVersion); + + // Snapshot the current state before overwriting it + await StoreSnapshotAsync(currentBlogPost); + + // Reconstruct a transient BlogPost from the target version fields. + // ScheduledPublishDate is not versioned, so we preserve whatever schedule the + // current live post has — unless the version being restored is published (a + // published post cannot carry a scheduled date per the domain invariant). + var scheduledPublishDate = targetVersion.IsPublished ? null : currentBlogPost.ScheduledPublishDate; + var restored = BlogPost.Create( + targetVersion.Title, + targetVersion.ShortDescription, + targetVersion.Content, + targetVersion.PreviewImageUrl, + targetVersion.IsPublished, + targetVersion.UpdatedDate, + scheduledPublishDate, + targetVersion.Tags, + targetVersion.PreviewImageUrlFallback, + targetVersion.AuthorName); + + currentBlogPost.Update(restored); + await blogPostRepository.StoreAsync(currentBlogPost); + } + + private async ValueTask StoreSnapshotAsync(BlogPost blogPost) + { + await using var db = await dbContextFactory.CreateDbContextAsync(); + + var maxVersion = await db.BlogPostVersions + .Where(v => v.BlogPostId == blogPost.Id) + .Select(v => (int?)v.VersionNumber) + .MaxAsync() ?? 0; + + var snapshot = BlogPostVersion.CreateSnapshot(blogPost, maxVersion + 1); + await db.BlogPostVersions.AddAsync(snapshot); + await db.SaveChangesAsync(); + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Services/IBlogPostVersionService.cs b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Services/IBlogPostVersionService.cs new file mode 100644 index 00000000..5a576b1c --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Services/IBlogPostVersionService.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using LinkDotNet.Blog.Domain; + +namespace LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services; + +public interface IBlogPostVersionService +{ + /// + /// Snapshots the current state into the version history, + /// then applies fields to the existing BlogPost row. + /// + ValueTask SaveNewVersionAsync(BlogPost currentBlogPost, BlogPost updatedBlogPost); + + /// + /// Returns all versions for a blog post, ordered by VersionNumber descending. + /// + ValueTask> GetVersionHistoryAsync(string blogPostId); + + /// + /// Snapshots the current state first (as version N+1), then copies the fields from + /// back to . + /// ScheduledPublishDate is intentionally not restored. + /// + ValueTask RestoreVersionAsync(BlogPost currentBlogPost, BlogPostVersion targetVersion); +} diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/UpdateBlogPostPage.razor b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/UpdateBlogPostPage.razor index 46dff63b..babf40f5 100644 --- a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/UpdateBlogPostPage.razor +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/UpdateBlogPostPage.razor @@ -1,9 +1,11 @@ -@page "/update/{blogPostId}" +@page "/update/{blogPostId}" @attribute [Authorize] @using LinkDotNet.Blog.Domain @using LinkDotNet.Blog.Infrastructure.Persistence @using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components +@using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services @inject IRepository BlogPostRepository +@inject IBlogPostVersionService BlogPostVersionService @inject IToastService ToastService Updating: @blogPostFromDb?.Title @@ -14,7 +16,10 @@ Title="Update Blog Post" BlogPost="@blogPostFromDb" OnBlogPostCreated="@StoreBlogPostAsync" - ClearAfterCreated="false"> + OnVersionRestored="@HandleVersionRestoredAsync" + ClearAfterCreated="false" + BlogPostId="@BlogPostId"> + } else { @@ -36,10 +41,20 @@ else private async Task StoreBlogPostAsync(BlogPost blogPost) { - ArgumentNullException.ThrowIfNull(blogPostFromDb); + ArgumentNullException.ThrowIfNull(blogPostFromDb); - blogPostFromDb.Update(blogPost); - await BlogPostRepository.StoreAsync(blogPostFromDb); - ToastService.ShowInfo($"Updated BlogPost {blogPost.Title}"); + await BlogPostVersionService.SaveNewVersionAsync(blogPostFromDb, blogPost); + ToastService.ShowSuccess($"Saved new version of \"{blogPost.Title}\""); + blogPostFromDb = await BlogPostRepository.GetByIdAsync(BlogPostId); + } + + private async Task HandleVersionRestoredAsync(BlogPostVersion version) + { + ArgumentNullException.ThrowIfNull(blogPostFromDb); + + await BlogPostVersionService.RestoreVersionAsync(blogPostFromDb, version); + ToastService.ShowSuccess($"Restored version {version.VersionNumber}"); + blogPostFromDb = await BlogPostRepository.GetByIdAsync(BlogPostId); + StateHasChanged(); } } diff --git a/src/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj b/src/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj index f8c94779..6ece3cb0 100644 --- a/src/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj +++ b/src/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj @@ -1,9 +1,10 @@ - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -12,21 +13,21 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + - - + + diff --git a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs index 493204de..0864ce4a 100644 --- a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs +++ b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs @@ -26,6 +26,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddSingleton(s => s.GetRequiredService()); diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/BlogPostVersionServiceTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/BlogPostVersionServiceTests.cs new file mode 100644 index 00000000..ad8c7cc1 --- /dev/null +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/BlogPostVersionServiceTests.cs @@ -0,0 +1,261 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.TestUtilities; +using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services; +using Microsoft.EntityFrameworkCore; +using TestContext = Xunit.TestContext; + +namespace LinkDotNet.Blog.IntegrationTests.Web.Features.Admin.BlogPostEditor; + +public class BlogPostVersionServiceTests : SqlDatabaseTestBase +{ + private readonly IBlogPostVersionService sut; + + public BlogPostVersionServiceTests() + { + sut = new BlogPostVersionService(DbContextFactory, Repository); + } + + [Fact] + public async Task SaveNewVersionAsync_CreatesSnapshotOfCurrentStateBeforeUpdate() + { + var blogPost = new BlogPostBuilder().WithTitle("Original Title").WithShortDescription("Original Desc").Build(); + await Repository.StoreAsync(blogPost); + + var updated = new BlogPostBuilder().WithTitle("Updated Title").WithShortDescription("Updated Desc").Build(); + updated.Id = blogPost.Id; + + await sut.SaveNewVersionAsync(blogPost, updated); + + var versions = await DbContext.BlogPostVersions + .Where(v => v.BlogPostId == blogPost.Id) + .ToListAsync(TestContext.Current.CancellationToken); + + versions.ShouldHaveSingleItem(); + versions[0].Title.ShouldBe("Original Title"); + versions[0].ShortDescription.ShouldBe("Original Desc"); + versions[0].VersionNumber.ShouldBe(1); + } + + [Fact] + public async Task SaveNewVersionAsync_UpdatesBlogPostInDatabase() + { + var blogPost = new BlogPostBuilder().WithTitle("Original").WithShortDescription("Old Desc").Build(); + await Repository.StoreAsync(blogPost); + + var updated = new BlogPostBuilder().WithTitle("Updated").WithShortDescription("New Desc").Build(); + updated.Id = blogPost.Id; + + await sut.SaveNewVersionAsync(blogPost, updated); + + var fromDb = await DbContext.BlogPosts + .AsNoTracking() + .SingleAsync(b => b.Id == blogPost.Id, TestContext.Current.CancellationToken); + fromDb.Title.ShouldBe("Updated"); + fromDb.ShortDescription.ShouldBe("New Desc"); + } + + [Fact] + public async Task SaveNewVersionAsync_VersionNumbersAreSequential() + { + var blogPost = new BlogPostBuilder().WithTitle("V1").Build(); + await Repository.StoreAsync(blogPost); + + var v2 = new BlogPostBuilder().WithTitle("V2").Build(); + v2.Id = blogPost.Id; + await sut.SaveNewVersionAsync(blogPost, v2); + + var refreshed = await Repository.GetByIdAsync(blogPost.Id); + var v3 = new BlogPostBuilder().WithTitle("V3").Build(); + v3.Id = blogPost.Id; + await sut.SaveNewVersionAsync(refreshed!, v3); + + var versions = await DbContext.BlogPostVersions + .Where(v => v.BlogPostId == blogPost.Id) + .OrderBy(v => v.VersionNumber) + .ToListAsync(TestContext.Current.CancellationToken); + + versions.Count.ShouldBe(2); + versions[0].VersionNumber.ShouldBe(1); + versions[0].Title.ShouldBe("V1"); + versions[1].VersionNumber.ShouldBe(2); + versions[1].Title.ShouldBe("V2"); + } + + [Fact] + public async Task SaveNewVersionAsync_DoesNotChangeIdOrLikes() + { + var blogPost = new BlogPostBuilder().WithTitle("Original").Build(); + blogPost.Likes = 42; + await Repository.StoreAsync(blogPost); + var originalId = blogPost.Id; + + var updated = new BlogPostBuilder().WithTitle("Updated").Build(); + updated.Id = blogPost.Id; + await sut.SaveNewVersionAsync(blogPost, updated); + + var fromDb = await DbContext.BlogPosts + .AsNoTracking() + .SingleAsync(b => b.Id == originalId, TestContext.Current.CancellationToken); + fromDb.Id.ShouldBe(originalId); + fromDb.Likes.ShouldBe(42); + } + + [Fact] + public async Task GetVersionHistoryAsync_ReturnsVersionsOrderedByVersionNumberDescending() + { + var blogPost = new BlogPostBuilder().WithTitle("V1").Build(); + await Repository.StoreAsync(blogPost); + + var v2 = new BlogPostBuilder().WithTitle("V2").Build(); + v2.Id = blogPost.Id; + await sut.SaveNewVersionAsync(blogPost, v2); + + var refreshed = await Repository.GetByIdAsync(blogPost.Id); + var v3 = new BlogPostBuilder().WithTitle("V3").Build(); + v3.Id = blogPost.Id; + await sut.SaveNewVersionAsync(refreshed!, v3); + + var history = await sut.GetVersionHistoryAsync(blogPost.Id); + + history.Count.ShouldBe(2); + history[0].VersionNumber.ShouldBe(2); + history[1].VersionNumber.ShouldBe(1); + } + + [Fact] + public async Task GetVersionHistoryAsync_ReturnsEmptyForPostWithNoVersions() + { + var blogPost = new BlogPostBuilder().Build(); + await Repository.StoreAsync(blogPost); + + var history = await sut.GetVersionHistoryAsync(blogPost.Id); + + history.ShouldBeEmpty(); + } + + [Fact] + public async Task RestoreVersionAsync_CreatesPreRestoreSnapshotBeforeOverwriting() + { + var blogPost = new BlogPostBuilder().WithTitle("V1").Build(); + await Repository.StoreAsync(blogPost); + var v2 = new BlogPostBuilder().WithTitle("V2").Build(); + v2.Id = blogPost.Id; + await sut.SaveNewVersionAsync(blogPost, v2); + + var refreshed = await Repository.GetByIdAsync(blogPost.Id); + var history = await sut.GetVersionHistoryAsync(blogPost.Id); + var v1Snapshot = history.Single(v => v.VersionNumber == 1); + + await sut.RestoreVersionAsync(refreshed!, v1Snapshot); + + var allVersions = await DbContext.BlogPostVersions + .Where(v => v.BlogPostId == blogPost.Id) + .OrderBy(v => v.VersionNumber) + .ToListAsync(TestContext.Current.CancellationToken); + + allVersions.Count.ShouldBe(2); + allVersions[1].Title.ShouldBe("V2"); // v2 = pre-restore snapshot of current "V2" state + } + + [Fact] + public async Task RestoreVersionAsync_AppliesTargetVersionFieldsToBlogPost() + { + var blogPost = new BlogPostBuilder().WithTitle("Original").WithShortDescription("Original Desc").Build(); + await Repository.StoreAsync(blogPost); + var updated = new BlogPostBuilder().WithTitle("Updated").WithShortDescription("Updated Desc").Build(); + updated.Id = blogPost.Id; + await sut.SaveNewVersionAsync(blogPost, updated); + + var refreshed = await Repository.GetByIdAsync(blogPost.Id); + var history = await sut.GetVersionHistoryAsync(blogPost.Id); + var v1 = history.Single(v => v.VersionNumber == 1); + + await sut.RestoreVersionAsync(refreshed!, v1); + + var fromDb = await DbContext.BlogPosts + .AsNoTracking() + .SingleAsync(b => b.Id == blogPost.Id, TestContext.Current.CancellationToken); + fromDb.Title.ShouldBe("Original"); + fromDb.ShortDescription.ShouldBe("Original Desc"); + } + + [Fact] + public async Task RestoreVersionAsync_DoesNotRestoreScheduledPublishDate() + { + var scheduledDate = DateTime.UtcNow.AddDays(7); + var blogPost = new BlogPostBuilder() + .WithTitle("Scheduled Post") + .WithScheduledPublishDate(scheduledDate) + .IsPublished(false) + .Build(); + await Repository.StoreAsync(blogPost); + + // Save a version (snapshot of the scheduled post) + var published = new BlogPostBuilder().WithTitle("Now Published").IsPublished(true).Build(); + published.Id = blogPost.Id; + await sut.SaveNewVersionAsync(blogPost, published); + + // Restore v1 which had a scheduled date — the date must NOT come back + var refreshed = await Repository.GetByIdAsync(blogPost.Id); + var history = await sut.GetVersionHistoryAsync(blogPost.Id); + var v1 = history.Single(v => v.VersionNumber == 1); + + await sut.RestoreVersionAsync(refreshed!, v1); + + var fromDb = await DbContext.BlogPosts + .AsNoTracking() + .SingleAsync(b => b.Id == blogPost.Id, TestContext.Current.CancellationToken); + fromDb.ScheduledPublishDate.ShouldBeNull(); + } + + [Fact] + public async Task RestoreVersionAsync_UpdatedDateIsRestoredFromVersion() + { + var originalDate = new DateTime(2025, 3, 10, 12, 0, 0, DateTimeKind.Utc); + var blogPost = new BlogPostBuilder() + .WithTitle("Post") + .WithUpdatedDate(originalDate) + .Build(); + await Repository.StoreAsync(blogPost); + + var updated = new BlogPostBuilder() + .WithTitle("Post Updated") + .WithUpdatedDate(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)) + .Build(); + updated.Id = blogPost.Id; + await sut.SaveNewVersionAsync(blogPost, updated); + + var refreshed = await Repository.GetByIdAsync(blogPost.Id); + var history = await sut.GetVersionHistoryAsync(blogPost.Id); + var v1 = history.Single(v => v.VersionNumber == 1); + + await sut.RestoreVersionAsync(refreshed!, v1); + + var fromDb = await DbContext.BlogPosts + .AsNoTracking() + .SingleAsync(b => b.Id == blogPost.Id, TestContext.Current.CancellationToken); + fromDb.UpdatedDate.ShouldBe(originalDate); + } + + [Fact] + public async Task SaveNewVersionAsync_ThrowsForNullCurrentBlogPost() + { + Func act = () => sut.SaveNewVersionAsync(null!, new BlogPostBuilder().Build()).AsTask(); + + await act.ShouldThrowAsync(); + } + + [Fact] + public async Task SaveNewVersionAsync_ThrowsForNullUpdatedBlogPost() + { + var blogPost = new BlogPostBuilder().Build(); + await Repository.StoreAsync(blogPost); + + Func act = () => sut.SaveNewVersionAsync(blogPost, null!).AsTask(); + + await act.ShouldThrowAsync(); + } +} 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 db02007e..e9e77cff 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/CreateNewBlogPostPageTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/CreateNewBlogPostPageTests.cs @@ -37,6 +37,7 @@ public async Task ShouldSaveBlogPostOnSave() ctx.Services.AddScoped(_ => Substitute.For()); ctx.Services.AddScoped(_ => instantRegistry); ctx.Services.AddScoped(_ => Substitute.For()); + ctx.Services.AddScoped(_ => Substitute.For()); var shortCodeRepository = Substitute.For>(); shortCodeRepository.GetAllAsync().Returns(PagedList.Empty); ctx.Services.AddScoped(_ => shortCodeRepository); @@ -89,6 +90,7 @@ public async Task ShouldSaveAuthorNameAsNullWhenMultiAuthorModeIsDisabled() ctx.Services.AddScoped(_ => Substitute.For()); ctx.Services.AddScoped(_ => instantRegistry); ctx.Services.AddScoped(_ => Substitute.For()); + ctx.Services.AddScoped(_ => Substitute.For()); var shortCodeRepository = Substitute.For>(); shortCodeRepository.GetAllAsync().Returns(PagedList.Empty); ctx.Services.AddScoped(_ => shortCodeRepository); @@ -136,6 +138,7 @@ public async Task ShouldLoadTemplate() ctx.Services.AddScoped(_ => Substitute.For()); ctx.Services.AddScoped(_ => Substitute.For()); ctx.Services.AddScoped(_ => Substitute.For()); + ctx.Services.AddScoped(_ => Substitute.For()); var shortCodeRepository = Substitute.For>(); shortCodeRepository.GetAllAsync().Returns(PagedList.Empty); ctx.Services.AddScoped(_ => shortCodeRepository); 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 1c86c280..e190b4b2 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/UpdateBlogPostPageTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/UpdateBlogPostPageTests.cs @@ -11,6 +11,7 @@ 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 LinkDotNet.Blog.Web.Features.Services; using Microsoft.AspNetCore.Components; @@ -39,6 +40,7 @@ public async Task ShouldSaveBlogPostOnSave() ctx.Services.AddScoped(_ => Repository); ctx.Services.AddScoped(_ => toastService); ctx.Services.AddScoped(_ => instantRegistry); + ctx.Services.AddScoped(_ => new BlogPostVersionService(DbContextFactory, Repository)); var shortCodeRepository = Substitute.For>(); shortCodeRepository.GetAllAsync().Returns(PagedList.Empty); ctx.Services.AddScoped(_ => shortCodeRepository); @@ -74,7 +76,7 @@ public async Task ShouldSaveBlogPostOnSave() blogPostFromDb.ShortDescription.ShouldBe("My new Description"); blogPostFromDb.AuthorName.ShouldBe("Test Author"); - toastService.Received(1).ShowInfo("Updated BlogPost Title", null); + toastService.Received(1).ShowSuccess($"Saved new version of \"{blogPost.Title}\"", null); instantRegistry.Received(1).RunInstantJob(Arg.Any(), Arg.Any()); } @@ -93,6 +95,7 @@ public async Task ShouldSaveAuthorNameAsNullWhenMultiAuthorModeIsDisabled() ctx.Services.AddScoped(_ => Repository); ctx.Services.AddScoped(_ => toastService); ctx.Services.AddScoped(_ => instantRegistry); + ctx.Services.AddScoped(_ => new BlogPostVersionService(DbContextFactory, Repository)); var shortCodeRepository = Substitute.For>(); shortCodeRepository.GetAllAsync().Returns(PagedList.Empty); ctx.Services.AddScoped(_ => shortCodeRepository); @@ -137,6 +140,7 @@ public void ShouldThrowWhenNoIdProvided() ctx.Services.AddScoped(_ => Repository); ctx.Services.AddScoped(_ => Substitute.For()); ctx.Services.AddScoped(_ => Substitute.For()); + ctx.Services.AddScoped(_ => new BlogPostVersionService(DbContextFactory, Repository)); var currentUserService = Substitute.For(); currentUserService.GetDisplayNameAsync().Returns("Test Author"); diff --git a/tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostVersionTests.cs b/tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostVersionTests.cs new file mode 100644 index 00000000..7644c835 --- /dev/null +++ b/tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostVersionTests.cs @@ -0,0 +1,109 @@ +using System; +using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.TestUtilities; + +namespace LinkDotNet.Blog.UnitTests.Domain; + +public class BlogPostVersionTests +{ + [Fact] + public void CreateSnapshot_CopiesAllVersionableFields() + { + var updatedDate = new DateTime(2026, 1, 15, 10, 0, 0, DateTimeKind.Utc); + var blogPost = new BlogPostBuilder() + .WithTitle("My Title") + .WithShortDescription("My Short Description") + .WithContent("My Content") + .WithPreviewImageUrl("https://example.com/img.webp") + .WithPreviewImageUrlFallback("https://example.com/img.jpg") + .WithTags("tag1", "tag2") + .WithAuthorName("John Doe") + .WithUpdatedDate(updatedDate) + .IsPublished(true) + .Build(); + blogPost.Id = "post-123"; + + var snapshot = BlogPostVersion.CreateSnapshot(blogPost, 3); + + snapshot.BlogPostId.ShouldBe("post-123"); + snapshot.VersionNumber.ShouldBe(3); + snapshot.Title.ShouldBe("My Title"); + snapshot.ShortDescription.ShouldBe("My Short Description"); + snapshot.Content.ShouldBe("My Content"); + snapshot.PreviewImageUrl.ShouldBe("https://example.com/img.webp"); + snapshot.PreviewImageUrlFallback.ShouldBe("https://example.com/img.jpg"); + snapshot.Tags.ShouldBe(blogPost.Tags, ignoreOrder: false); + snapshot.AuthorName.ShouldBe("John Doe"); + snapshot.UpdatedDate.ShouldBe(updatedDate); + snapshot.IsPublished.ShouldBeTrue(); + snapshot.ReadingTimeInMinutes.ShouldBe(blogPost.ReadingTimeInMinutes); + } + + [Fact] + public void CreateSnapshot_SetsCreatedAtToApproximatelyNow() + { + var before = DateTime.UtcNow; + var blogPost = new BlogPostBuilder().Build(); + blogPost.Id = "post-1"; + + var snapshot = BlogPostVersion.CreateSnapshot(blogPost, 1); + + snapshot.CreatedAt.ShouldBeGreaterThanOrEqualTo(before); + snapshot.CreatedAt.ShouldBeLessThanOrEqualTo(DateTime.UtcNow.AddSeconds(1)); + } + + [Fact] + public void CreateSnapshot_DoesNotIncludeLikes() + { + // Likes are on BlogPost but must never appear on BlogPostVersion + typeof(BlogPostVersion).GetProperty("Likes").ShouldBeNull(); + } + + [Fact] + public void CreateSnapshot_DoesNotIncludeScheduledPublishDate() + { + // ScheduledPublishDate is editorial state, not part of content history + typeof(BlogPostVersion).GetProperty("ScheduledPublishDate").ShouldBeNull(); + } + + [Fact] + public void CreateSnapshot_ThrowsForNullBlogPost() + { + Action act = () => BlogPostVersion.CreateSnapshot(null!, 1); + + act.ShouldThrow(); + } + + [Fact] + public void CreateSnapshot_TagsAsStringJoinsWithComma() + { + var blogPost = new BlogPostBuilder().WithTags("csharp", "dotnet", "blazor").Build(); + blogPost.Id = "post-1"; + + var snapshot = BlogPostVersion.CreateSnapshot(blogPost, 1); + + snapshot.TagsAsString.ShouldBe("csharp,dotnet,blazor"); + } + + [Fact] + public void CreateSnapshot_WithNullAuthorName_PreservesNull() + { + var blogPost = new BlogPostBuilder().Build(); + blogPost.Id = "post-1"; + + var snapshot = BlogPostVersion.CreateSnapshot(blogPost, 1); + + snapshot.AuthorName.ShouldBeNull(); + } + + [Fact] + public void CreateSnapshot_WithNullFallbackImageUrl_PreservesNull() + { + var blogPost = new BlogPostBuilder().WithPreviewImageUrlFallback(null).Build(); + blogPost.Id = "post-1"; + + var snapshot = BlogPostVersion.CreateSnapshot(blogPost, 1); + + snapshot.PreviewImageUrlFallback.ShouldBeNull(); + } +} 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 9a6eb97e..b637bb8c 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 @@ -9,6 +9,7 @@ using LinkDotNet.Blog.TestUtilities.Fakes; using LinkDotNet.Blog.Web; using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components; +using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services; using LinkDotNet.Blog.Web.Features.Components; using LinkDotNet.Blog.Web.Features.Services; using Microsoft.AspNetCore.Components.Routing; @@ -53,6 +54,10 @@ public CreateNewBlogPostTests() var currentUserService = Substitute.For(); currentUserService.GetDisplayNameAsync().Returns("Test Author"); Services.AddScoped(_ => currentUserService); + + var versionService = Substitute.For(); + versionService.GetVersionHistoryAsync(Arg.Any()).Returns([]); + Services.AddScoped(_ => versionService); } [Fact]