Voeg leesbare slug toe aan Offering naast het GUID-ID #10

Merged
Sluijsens merged 4 commits from feature/offering-slug into master 2026-08-04 16:38:54 +02:00
15 changed files with 316 additions and 2 deletions
Showing only changes of commit cc09b46cfb - Show all commits
@@ -24,10 +24,10 @@ public class OfferingsControllerTests
} }
private static OfferingDto PublicDto() => new( private static OfferingDto PublicDto() => new(
Guid.NewGuid().ToString(), "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false); Guid.NewGuid().ToString(), "title", "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false);
private static OfferingAdminDto AdminDto(int displayOrder = 0) => new( private static OfferingAdminDto AdminDto(int displayOrder = 0) => new(
Guid.NewGuid().ToString(), "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false, displayOrder); Guid.NewGuid().ToString(), "title", "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false, displayOrder);
private static CreateOfferingRequest CreateRequest() => new( private static CreateOfferingRequest CreateRequest() => new(
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact"); "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact");
@@ -152,6 +152,37 @@ public class OfferingRepositoryTests : IDisposable
result.Should().BeNull(); result.Should().BeNull();
} }
[Fact]
public async Task ExistsBySlugAsync_ReturnsTrue_WhenSlugTaken()
{
await _sut.AddAsync(Offering("Website", displayOrder: 0));
await _sut.SaveChangesAsync();
var result = await _sut.ExistsBySlugAsync("website");
result.Should().BeTrue();
}
[Fact]
public async Task ExistsBySlugAsync_ReturnsFalse_WhenSlugFree()
{
var result = await _sut.ExistsBySlugAsync("unused-slug");
result.Should().BeFalse();
}
[Fact]
public async Task ExistsBySlugAsync_ExcludesGivenId()
{
var offering = Offering("Website", displayOrder: 0);
await _sut.AddAsync(offering);
await _sut.SaveChangesAsync();
var result = await _sut.ExistsBySlugAsync("website", excludeId: offering.Id);
result.Should().BeFalse();
}
[Fact] [Fact]
public async Task Update_PersistsChanges() public async Task Update_PersistsChanges()
{ {
@@ -170,6 +201,7 @@ public class OfferingRepositoryTests : IDisposable
private static Offering Offering(string title, int displayOrder) => new() private static Offering Offering(string title, int displayOrder) => new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Slug = title.ToLowerInvariant(),
Title = title, Title = title,
Description = "Description", Description = "Description",
Price = "€ 100", Price = "€ 100",
@@ -27,6 +27,7 @@ public class OfferingsServiceTests
private static Offering ExistingOffering(bool featured = false, int displayOrder = 0) => new() private static Offering ExistingOffering(bool featured = false, int displayOrder = 0) => new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Slug = "existing",
Title = "Existing", Title = "Existing",
Description = "Description", Description = "Description",
Price = "€ 100", Price = "€ 100",
@@ -91,6 +92,57 @@ public class OfferingsServiceTests
await _repo.DidNotReceive().BeginTransactionAsync(); await _repo.DidNotReceive().BeginTransactionAsync();
} }
// --- Slug generation ---
[Fact]
public async Task CreateAsync_GeneratesSlugFromTitle()
{
_repo.GetMaxDisplayOrderAsync().Returns(-1);
var dto = await CreateSut().CreateAsync(CreateRequest(), Guid.NewGuid());
dto.Slug.Should().Be("title");
}
[Fact]
public async Task CreateAsync_AppendsSuffix_WhenSlugAlreadyTaken()
{
_repo.GetMaxDisplayOrderAsync().Returns(-1);
_repo.ExistsBySlugAsync("title", null).Returns(true);
_repo.ExistsBySlugAsync("title-2", null).Returns(true);
_repo.ExistsBySlugAsync("title-3", null).Returns(false);
var dto = await CreateSut().CreateAsync(CreateRequest(), Guid.NewGuid());
dto.Slug.Should().Be("title-3");
}
[Fact]
public async Task UpdateAsync_RegeneratesSlug_WhenTitleChanges()
{
var target = ExistingOffering();
_repo.GetByIdAsync(target.Id).Returns(target);
_repo.ExistsBySlugAsync("new-title", target.Id).Returns(false);
var request = new UpdateOfferingRequest("New Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact");
var dto = await CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
dto!.Slug.Should().Be("new-title");
}
[Fact]
public async Task UpdateAsync_KeepsExistingSlug_WhenTitleUnchanged()
{
var target = ExistingOffering();
_repo.GetByIdAsync(target.Id).Returns(target);
var request = new UpdateOfferingRequest(target.Title, "Description", "€ 100", "per month", ["Feature 1"], "Contact");
var dto = await CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
dto!.Slug.Should().Be("existing");
await _repo.DidNotReceive().ExistsBySlugAsync(Arg.Any<string>(), Arg.Any<Guid?>());
}
// --- UpdateAsync --- // --- UpdateAsync ---
[Fact] [Fact]
@@ -0,0 +1,24 @@
using FluentAssertions;
using SlpModularCms.Modules.Offerings.Services;
namespace SlpModularCms.Modules.Offerings.Tests.Services;
public class SlugifierTests
{
[Theory]
[InlineData("Landingspagina", "landingspagina")]
[InlineData("Website", "website")]
[InlineData("Grote Website & Maatwerk", "grote-website-maatwerk")]
[InlineData(" Trimmed Title ", "trimmed-title")]
[InlineData("Café Ontwerp", "cafe-ontwerp")]
public void Slugify_ProducesReadableSlug(string title, string expected)
{
Slugifier.Slugify(title).Should().Be(expected);
}
[Fact]
public void Slugify_FallsBackToPakket_WhenTitleHasNoAlphanumerics()
{
Slugifier.Slugify("---").Should().Be("pakket");
}
}
@@ -3,6 +3,7 @@ namespace SlpModularCms.Modules.Offerings.Data.Entities;
public class Offering public class Offering
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public string Slug { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty; public string Description { get; set; } = string.Empty;
public string Price { get; set; } = string.Empty; public string Price { get; set; } = string.Empty;
@@ -17,6 +17,7 @@ public class OfferingsDbContext(DbContextOptions<OfferingsDbContext> options) :
{ {
entity.ToTable("OfferingsOfferings"); entity.ToTable("OfferingsOfferings");
entity.HasKey(e => e.Id); entity.HasKey(e => e.Id);
entity.Property(e => e.Slug).IsRequired().HasMaxLength(130);
entity.Property(e => e.Title).IsRequired().HasMaxLength(100); entity.Property(e => e.Title).IsRequired().HasMaxLength(100);
entity.Property(e => e.Description).IsRequired().HasMaxLength(500); entity.Property(e => e.Description).IsRequired().HasMaxLength(500);
entity.Property(e => e.Price).IsRequired().HasMaxLength(50); entity.Property(e => e.Price).IsRequired().HasMaxLength(50);
@@ -0,0 +1,93 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using SlpModularCms.Modules.Offerings.Data;
#nullable disable
namespace SlpModularCms.Modules.Offerings.Migrations
{
[DbContext(typeof(OfferingsDbContext))]
[Migration("20260804113000_AddOfferingSlug")]
partial class AddOfferingSlug
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("SlpModularCms.Modules.Offerings.Data.Entities.Offering", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetime");
b.Property<string>("CtaLabel")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetime");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("DisplayOrder")
.HasColumnType("int");
b.Property<bool>("Featured")
.HasColumnType("tinyint(1)");
b.Property<string>("Features")
.IsRequired()
.HasColumnType("longtext");
b.Property<bool>("IsDeleted")
.HasColumnType("tinyint(1)");
b.Property<Guid>("LastModifiedByUserId")
.HasColumnType("char(36)");
b.Property<string>("Price")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("PriceNote")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(130)
.HasColumnType("varchar(130)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetime");
b.HasKey("Id");
b.ToTable("OfferingsOfferings", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SlpModularCms.Modules.Offerings.Migrations
{
/// <inheritdoc />
public partial class AddOfferingSlug : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Slug",
table: "OfferingsOfferings",
type: "varchar(130)",
maxLength: 130,
nullable: false,
defaultValue: "");
// Backfill existing rows with a title-derived slug so the NOT NULL column
// never holds an empty string for pre-existing data. New/edited rows get
// their slug from OfferingsService.GenerateUniqueSlugAsync instead, which
// also handles collisions this best-effort SQL backfill doesn't.
migrationBuilder.Sql(
"UPDATE OfferingsOfferings " +
"SET Slug = LOWER(TRIM(BOTH '-' FROM REGEXP_REPLACE(TRIM(Title), '[^a-zA-Z0-9]+', '-'))) " +
"WHERE Slug = '';");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Slug",
table: "OfferingsOfferings");
}
}
}
@@ -67,6 +67,11 @@ namespace SlpModularCms.Modules.Offerings.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("varchar(100)"); .HasColumnType("varchar(100)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(130)
.HasColumnType("varchar(130)");
b.Property<string>("Title") b.Property<string>("Title")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@@ -5,6 +5,7 @@ namespace SlpModularCms.Modules.Offerings.Models;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public record OfferingAdminDto( public record OfferingAdminDto(
string Id, string Id,
string Slug,
string Title, string Title,
string Description, string Description,
string Price, string Price,
@@ -5,6 +5,7 @@ namespace SlpModularCms.Modules.Offerings.Models;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public record OfferingDto( public record OfferingDto(
string Id, string Id,
string Slug,
string Title, string Title,
string Description, string Description,
string Price, string Price,
@@ -7,6 +7,7 @@ public interface IOfferingRepository
{ {
Task<IReadOnlyList<Offering>> GetAllAsync(); Task<IReadOnlyList<Offering>> GetAllAsync();
Task<Offering?> GetByIdAsync(Guid id); Task<Offering?> GetByIdAsync(Guid id);
Task<bool> ExistsBySlugAsync(string slug, Guid? excludeId = null);
Task AddAsync(Offering offering); Task AddAsync(Offering offering);
void Update(Offering offering); void Update(Offering offering);
Task<int> GetMaxDisplayOrderAsync(); Task<int> GetMaxDisplayOrderAsync();
@@ -13,6 +13,9 @@ public class OfferingRepository(OfferingsDbContext context) : IOfferingRepositor
public async Task<Offering?> GetByIdAsync(Guid id) public async Task<Offering?> GetByIdAsync(Guid id)
=> await context.Offerings.FirstOrDefaultAsync(o => o.Id == id); => await context.Offerings.FirstOrDefaultAsync(o => o.Id == id);
public async Task<bool> ExistsBySlugAsync(string slug, Guid? excludeId = null)
=> await context.Offerings.AnyAsync(o => o.Slug == slug && o.Id != (excludeId ?? Guid.Empty));
public async Task AddAsync(Offering offering) public async Task AddAsync(Offering offering)
=> await context.Offerings.AddAsync(offering); => await context.Offerings.AddAsync(offering);
@@ -25,6 +25,7 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
var offering = new Offering var offering = new Offering
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Slug = await GenerateUniqueSlugAsync(request.Title),
Title = request.Title, Title = request.Title,
Description = request.Description, Description = request.Description,
Price = request.Price, Price = request.Price,
@@ -69,6 +70,9 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
if (offering is null) if (offering is null)
return null; return null;
if (!string.Equals(offering.Title, request.Title, StringComparison.Ordinal))
offering.Slug = await GenerateUniqueSlugAsync(request.Title, excludeId: offering.Id);
offering.Title = request.Title; offering.Title = request.Title;
offering.Description = request.Description; offering.Description = request.Description;
offering.Price = request.Price; offering.Price = request.Price;
@@ -202,8 +206,24 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
repository.Update(current); repository.Update(current);
} }
private async Task<string> GenerateUniqueSlugAsync(string title, Guid? excludeId = null)
{
var baseSlug = Slugifier.Slugify(title);
var slug = baseSlug;
var suffix = 2;
while (await repository.ExistsBySlugAsync(slug, excludeId))
{
slug = $"{baseSlug}-{suffix}";
suffix++;
}
return slug;
}
private static OfferingDto ToPublicDto(Offering o) => new( private static OfferingDto ToPublicDto(Offering o) => new(
o.Id.ToString(), o.Id.ToString(),
o.Slug,
o.Title, o.Title,
o.Description, o.Description,
o.Price, o.Price,
@@ -215,6 +235,7 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
private static OfferingAdminDto ToAdminDto(Offering o) => new( private static OfferingAdminDto ToAdminDto(Offering o) => new(
o.Id.ToString(), o.Id.ToString(),
o.Slug,
o.Title, o.Title,
o.Description, o.Description,
o.Price, o.Price,
@@ -0,0 +1,40 @@
using System.Text;
namespace SlpModularCms.Modules.Offerings.Services;
/// <summary>
/// Turns a title into a URL/display-safe slug (lowercase, hyphen-separated, no diacritics).
/// Pure string transform — uniqueness is handled by the caller.
/// </summary>
public static class Slugifier
{
public static string Slugify(string value)
{
var normalized = value.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(normalized.Length);
var lastWasHyphen = false;
foreach (var c in normalized)
{
var category = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c);
if (category == System.Globalization.UnicodeCategory.NonSpacingMark)
continue;
if (char.IsLetterOrDigit(c))
{
builder.Append(char.ToLowerInvariant(c));
lastWasHyphen = false;
}
else if (!lastWasHyphen && builder.Length > 0)
{
builder.Append('-');
lastWasHyphen = true;
}
}
if (lastWasHyphen)
builder.Length--;
return builder.Length > 0 ? builder.ToString() : "pakket";
}
}