De CMS-admin toonde het nieuwe Slug-veld nog niet — dat bestond alleen in de API-respons. Voegt een read-only slugweergave onder het titelveld toe met een potlood-icoon om 'm inline te bewerken. Backend: Create/UpdateOfferingRequest accepteren nu een optionele Slug-override. Zonder expliciete waarde blijft het bestaande gedrag (auto-genereren uit titel, regenereren bij titelwijziging). Met een expliciete waarde wordt die genormaliseerd en op uniekheid gecontroleerd; een botsing geeft 409 Conflict (RFC 9457 ProblemDetails, naar het patroon van MasterControlledAvailabilityException). Frontend: de slug wordt alleen meegestuurd als de admin 'm daadwerkelijk bewerkt heeft (dirtyFields.slug), zodat een ongemoeide slug bij een titelwijziging gewoon blijft auto-regenereren. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
363 lines
12 KiB
C#
363 lines
12 KiB
C#
using FluentAssertions;
|
|
using Microsoft.EntityFrameworkCore.Storage;
|
|
using Microsoft.Extensions.Logging;
|
|
using NSubstitute;
|
|
using SlpModularCms.Modules.Offerings.Data.Entities;
|
|
using SlpModularCms.Modules.Offerings.Models;
|
|
using SlpModularCms.Modules.Offerings.Repositories;
|
|
using SlpModularCms.Modules.Offerings.Services;
|
|
|
|
namespace SlpModularCms.Modules.Offerings.Tests.Services;
|
|
|
|
public class OfferingsServiceTests
|
|
{
|
|
private readonly IOfferingRepository _repo = Substitute.For<IOfferingRepository>();
|
|
private readonly ILogger<OfferingsService> _logger = Substitute.For<ILogger<OfferingsService>>();
|
|
|
|
public OfferingsServiceTests()
|
|
{
|
|
_repo.BeginTransactionAsync().Returns(Substitute.For<IDbContextTransaction>());
|
|
}
|
|
|
|
private OfferingsService CreateSut() => new(_repo, _logger);
|
|
|
|
private static CreateOfferingRequest CreateRequest(bool featured = false) => new(
|
|
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", featured);
|
|
|
|
private static Offering ExistingOffering(bool featured = false, int displayOrder = 0) => new()
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Slug = "existing",
|
|
Title = "Existing",
|
|
Description = "Description",
|
|
Price = "€ 100",
|
|
PriceNote = "per month",
|
|
Features = ["Feature 1"],
|
|
CtaLabel = "Contact",
|
|
Featured = featured,
|
|
DisplayOrder = displayOrder,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow,
|
|
LastModifiedByUserId = Guid.NewGuid(),
|
|
};
|
|
|
|
// --- CreateAsync / BR-OFF-01 featured exclusivity ---
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_AppendsToEndOfDisplayOrder()
|
|
{
|
|
_repo.GetMaxDisplayOrderAsync().Returns(4);
|
|
var callerId = Guid.NewGuid();
|
|
|
|
var result = await CreateSut().CreateAsync(CreateRequest(), callerId);
|
|
|
|
result.DisplayOrder.Should().Be(5);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_SetsLastModifiedByUserId()
|
|
{
|
|
_repo.GetMaxDisplayOrderAsync().Returns(-1);
|
|
var callerId = Guid.NewGuid();
|
|
|
|
var dto = await CreateSut().CreateAsync(CreateRequest(), callerId);
|
|
|
|
// The DTO doesn't expose LastModifiedByUserId (never shown to any caller) —
|
|
// verify via the entity actually persisted through the repository instead.
|
|
await _repo.Received(1).AddAsync(Arg.Is<Offering>(o => o.LastModifiedByUserId == callerId));
|
|
dto.Should().NotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_UnfeaturesCurrentlyFeaturedOffering_WhenNewOneIsFeatured()
|
|
{
|
|
var current = ExistingOffering(featured: true);
|
|
_repo.GetFeaturedAsync().Returns(current);
|
|
_repo.GetMaxDisplayOrderAsync().Returns(-1);
|
|
|
|
await CreateSut().CreateAsync(CreateRequest(featured: true), Guid.NewGuid());
|
|
|
|
current.Featured.Should().BeFalse();
|
|
await _repo.Received(1).BeginTransactionAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_DoesNotTouchFeatured_WhenNewOfferingNotFeatured()
|
|
{
|
|
_repo.GetMaxDisplayOrderAsync().Returns(-1);
|
|
|
|
await CreateSut().CreateAsync(CreateRequest(featured: false), Guid.NewGuid());
|
|
|
|
await _repo.DidNotReceive().GetFeaturedAsync();
|
|
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?>());
|
|
}
|
|
|
|
// --- Explicit slug override (admin-supplied via CMS) ---
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_NormalizesExplicitSlug()
|
|
{
|
|
_repo.GetMaxDisplayOrderAsync().Returns(-1);
|
|
_repo.ExistsBySlugAsync("my-custom-slug", null).Returns(false);
|
|
|
|
var request = new CreateOfferingRequest(
|
|
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", Slug: " My Custom Slug!! ");
|
|
var dto = await CreateSut().CreateAsync(request, Guid.NewGuid());
|
|
|
|
dto.Slug.Should().Be("my-custom-slug");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_Throws_WhenExplicitSlugAlreadyTaken()
|
|
{
|
|
_repo.GetMaxDisplayOrderAsync().Returns(-1);
|
|
_repo.ExistsBySlugAsync("taken", null).Returns(true);
|
|
|
|
var request = new CreateOfferingRequest(
|
|
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", Slug: "taken");
|
|
|
|
var act = () => CreateSut().CreateAsync(request, Guid.NewGuid());
|
|
|
|
await act.Should().ThrowAsync<OfferingSlugConflictException>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAsync_UsesExplicitSlug_EvenWhenTitleUnchanged()
|
|
{
|
|
var target = ExistingOffering();
|
|
_repo.GetByIdAsync(target.Id).Returns(target);
|
|
_repo.ExistsBySlugAsync("manually-set", target.Id).Returns(false);
|
|
|
|
var request = new UpdateOfferingRequest(
|
|
target.Title, "Description", "€ 100", "per month", ["Feature 1"], "Contact", Slug: "manually-set");
|
|
var dto = await CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
|
|
|
|
dto!.Slug.Should().Be("manually-set");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAsync_Throws_WhenExplicitSlugTakenByAnotherOffering()
|
|
{
|
|
var target = ExistingOffering();
|
|
_repo.GetByIdAsync(target.Id).Returns(target);
|
|
_repo.ExistsBySlugAsync("taken", target.Id).Returns(true);
|
|
|
|
var request = new UpdateOfferingRequest(
|
|
target.Title, "Description", "€ 100", "per month", ["Feature 1"], "Contact", Slug: "taken");
|
|
|
|
var act = () => CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
|
|
|
|
await act.Should().ThrowAsync<OfferingSlugConflictException>();
|
|
}
|
|
|
|
// --- UpdateAsync ---
|
|
|
|
[Fact]
|
|
public async Task UpdateAsync_ReturnsNull_WhenOfferingNotFound()
|
|
{
|
|
_repo.GetByIdAsync(Arg.Any<Guid>()).Returns((Offering?)null);
|
|
|
|
var request = new UpdateOfferingRequest("Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact");
|
|
var result = await CreateSut().UpdateAsync(Guid.NewGuid(), request, Guid.NewGuid());
|
|
|
|
result.Should().BeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAsync_UnfeaturesOtherOffering_ExceptItself()
|
|
{
|
|
var target = ExistingOffering(featured: false);
|
|
var other = ExistingOffering(featured: true);
|
|
_repo.GetByIdAsync(target.Id).Returns(target);
|
|
_repo.GetFeaturedAsync().Returns(other);
|
|
|
|
var request = new UpdateOfferingRequest("Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", true);
|
|
await CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
|
|
|
|
target.Featured.Should().BeTrue();
|
|
other.Featured.Should().BeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAsync_DoesNotUnfeatureItself_WhenAlreadyFeatured()
|
|
{
|
|
var target = ExistingOffering(featured: true);
|
|
_repo.GetByIdAsync(target.Id).Returns(target);
|
|
_repo.GetFeaturedAsync().Returns(target);
|
|
|
|
var request = new UpdateOfferingRequest("Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", true);
|
|
await CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
|
|
|
|
target.Featured.Should().BeTrue();
|
|
}
|
|
|
|
// --- DeleteAsync / BR-OFF-02 ---
|
|
|
|
[Fact]
|
|
public async Task DeleteAsync_ReturnsFalse_WhenNotFound()
|
|
{
|
|
_repo.GetByIdAsync(Arg.Any<Guid>()).Returns((Offering?)null);
|
|
|
|
var result = await CreateSut().DeleteAsync(Guid.NewGuid(), Guid.NewGuid());
|
|
|
|
result.Should().BeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteAsync_SoftDeletes_EvenForLastRemainingOffering()
|
|
{
|
|
var offering = ExistingOffering();
|
|
_repo.GetByIdAsync(offering.Id).Returns(offering);
|
|
|
|
var result = await CreateSut().DeleteAsync(offering.Id, Guid.NewGuid());
|
|
|
|
result.Should().BeTrue();
|
|
offering.IsDeleted.Should().BeTrue();
|
|
offering.DeletedAt.Should().NotBeNull();
|
|
}
|
|
|
|
// --- ReorderAsync / BR-OFF-03 ---
|
|
|
|
[Fact]
|
|
public async Task ReorderAsync_ReassignsDisplayOrderSequentially()
|
|
{
|
|
var first = ExistingOffering(displayOrder: 5);
|
|
var second = ExistingOffering(displayOrder: 2);
|
|
_repo.GetByIdAsync(first.Id).Returns(first);
|
|
_repo.GetByIdAsync(second.Id).Returns(second);
|
|
|
|
await CreateSut().ReorderAsync([first.Id, second.Id], Guid.NewGuid());
|
|
|
|
first.DisplayOrder.Should().Be(0);
|
|
second.DisplayOrder.Should().Be(1);
|
|
}
|
|
|
|
// --- MoveUpAsync / MoveDownAsync / BR-OFF-03 boundaries ---
|
|
|
|
[Fact]
|
|
public async Task MoveUpAsync_IsNoOp_WhenAlreadyFirst()
|
|
{
|
|
var offering = ExistingOffering(displayOrder: 0);
|
|
_repo.GetByIdAsync(offering.Id).Returns(offering);
|
|
_repo.GetPreviousAsync(0).Returns((Offering?)null);
|
|
|
|
await CreateSut().MoveUpAsync(offering.Id, Guid.NewGuid());
|
|
|
|
await _repo.DidNotReceive().BeginTransactionAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MoveUpAsync_SwapsDisplayOrder_WithPrevious()
|
|
{
|
|
var offering = ExistingOffering(displayOrder: 1);
|
|
var previous = ExistingOffering(displayOrder: 0);
|
|
_repo.GetByIdAsync(offering.Id).Returns(offering);
|
|
_repo.GetPreviousAsync(1).Returns(previous);
|
|
|
|
await CreateSut().MoveUpAsync(offering.Id, Guid.NewGuid());
|
|
|
|
offering.DisplayOrder.Should().Be(0);
|
|
previous.DisplayOrder.Should().Be(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MoveDownAsync_IsNoOp_WhenAlreadyLast()
|
|
{
|
|
var offering = ExistingOffering(displayOrder: 0);
|
|
_repo.GetByIdAsync(offering.Id).Returns(offering);
|
|
_repo.GetNextAsync(0).Returns((Offering?)null);
|
|
|
|
await CreateSut().MoveDownAsync(offering.Id, Guid.NewGuid());
|
|
|
|
await _repo.DidNotReceive().BeginTransactionAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MoveDownAsync_SwapsDisplayOrder_WithNext()
|
|
{
|
|
var offering = ExistingOffering(displayOrder: 0);
|
|
var next = ExistingOffering(displayOrder: 1);
|
|
_repo.GetByIdAsync(offering.Id).Returns(offering);
|
|
_repo.GetNextAsync(0).Returns(next);
|
|
|
|
await CreateSut().MoveDownAsync(offering.Id, Guid.NewGuid());
|
|
|
|
offering.DisplayOrder.Should().Be(1);
|
|
next.DisplayOrder.Should().Be(0);
|
|
}
|
|
|
|
// --- GetPublicOfferingsAsync / GetAllForAdminAsync ---
|
|
|
|
[Fact]
|
|
public async Task GetPublicOfferingsAsync_MapsToPublicDto()
|
|
{
|
|
_repo.GetAllAsync().Returns([ExistingOffering()]);
|
|
|
|
var result = await CreateSut().GetPublicOfferingsAsync();
|
|
|
|
result.Should().ContainSingle();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetAllForAdminAsync_IncludesDisplayOrder()
|
|
{
|
|
var offering = ExistingOffering(displayOrder: 3);
|
|
_repo.GetAllAsync().Returns([offering]);
|
|
|
|
var result = await CreateSut().GetAllForAdminAsync();
|
|
|
|
result[0].DisplayOrder.Should().Be(3);
|
|
}
|
|
}
|