Files
slp-modular-cms/src/SlpModularCms.Modules.Offerings.Tests/Services/OfferingsServiceTests.cs
T
SluijsensandClaude Sonnet 5 1b974696bf Maak slug in CMS zichtbaar en inline aanpasbaar
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>
2026-08-04 15:31:10 +02:00

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);
}
}