Voeg leesbare slug toe aan Offering naast het GUID-ID #10
@@ -24,10 +24,10 @@ public class OfferingsControllerTests
|
||||
}
|
||||
|
||||
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(
|
||||
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(
|
||||
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact");
|
||||
|
||||
@@ -152,6 +152,37 @@ public class OfferingRepositoryTests : IDisposable
|
||||
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]
|
||||
public async Task Update_PersistsChanges()
|
||||
{
|
||||
@@ -170,6 +201,7 @@ public class OfferingRepositoryTests : IDisposable
|
||||
private static Offering Offering(string title, int displayOrder) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Slug = title.ToLowerInvariant(),
|
||||
Title = title,
|
||||
Description = "Description",
|
||||
Price = "€ 100",
|
||||
|
||||
@@ -27,6 +27,7 @@ public class OfferingsServiceTests
|
||||
private static Offering ExistingOffering(bool featured = false, int displayOrder = 0) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Slug = "existing",
|
||||
Title = "Existing",
|
||||
Description = "Description",
|
||||
Price = "€ 100",
|
||||
@@ -91,6 +92,57 @@ public class OfferingsServiceTests
|
||||
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 ---
|
||||
|
||||
[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 Guid Id { get; set; }
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Description { 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.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Slug).IsRequired().HasMaxLength(130);
|
||||
entity.Property(e => e.Title).IsRequired().HasMaxLength(100);
|
||||
entity.Property(e => e.Description).IsRequired().HasMaxLength(500);
|
||||
entity.Property(e => e.Price).IsRequired().HasMaxLength(50);
|
||||
|
||||
+93
@@ -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)
|
||||
.HasColumnType("varchar(100)");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(130)
|
||||
.HasColumnType("varchar(130)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace SlpModularCms.Modules.Offerings.Models;
|
||||
[ExcludeFromCodeCoverage]
|
||||
public record OfferingAdminDto(
|
||||
string Id,
|
||||
string Slug,
|
||||
string Title,
|
||||
string Description,
|
||||
string Price,
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace SlpModularCms.Modules.Offerings.Models;
|
||||
[ExcludeFromCodeCoverage]
|
||||
public record OfferingDto(
|
||||
string Id,
|
||||
string Slug,
|
||||
string Title,
|
||||
string Description,
|
||||
string Price,
|
||||
|
||||
@@ -7,6 +7,7 @@ public interface IOfferingRepository
|
||||
{
|
||||
Task<IReadOnlyList<Offering>> GetAllAsync();
|
||||
Task<Offering?> GetByIdAsync(Guid id);
|
||||
Task<bool> ExistsBySlugAsync(string slug, Guid? excludeId = null);
|
||||
Task AddAsync(Offering offering);
|
||||
void Update(Offering offering);
|
||||
Task<int> GetMaxDisplayOrderAsync();
|
||||
|
||||
@@ -13,6 +13,9 @@ public class OfferingRepository(OfferingsDbContext context) : IOfferingRepositor
|
||||
public async Task<Offering?> GetByIdAsync(Guid 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)
|
||||
=> await context.Offerings.AddAsync(offering);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
|
||||
var offering = new Offering
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Slug = await GenerateUniqueSlugAsync(request.Title),
|
||||
Title = request.Title,
|
||||
Description = request.Description,
|
||||
Price = request.Price,
|
||||
@@ -69,6 +70,9 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
|
||||
if (offering is 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.Description = request.Description;
|
||||
offering.Price = request.Price;
|
||||
@@ -202,8 +206,24 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
|
||||
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(
|
||||
o.Id.ToString(),
|
||||
o.Slug,
|
||||
o.Title,
|
||||
o.Description,
|
||||
o.Price,
|
||||
@@ -215,6 +235,7 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
|
||||
|
||||
private static OfferingAdminDto ToAdminDto(Offering o) => new(
|
||||
o.Id.ToString(),
|
||||
o.Slug,
|
||||
o.Title,
|
||||
o.Description,
|
||||
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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user