Adds the Offerings module and retargets the CI/CD pipeline to Api.SlpSoftware
Continuous Integration / config (pull_request) Successful in 12s
Continuous Integration / changes (pull_request) Successful in 22s
Continuous Integration / backend-build (pull_request) Successful in 5m53s
Continuous Integration / vulnerability-scan (pull_request) Successful in 5m46s
Continuous Integration / frontend-prepare (pull_request) Successful in 1m54s
Continuous Integration / backend-test (pull_request) Successful in 7m37s
Continuous Integration / frontend-build (pull_request) Successful in 2m14s
Continuous Integration / frontend-test (pull_request) Successful in 4m59s
Continuous Integration / frontend-lint (pull_request) Successful in 2m2s
Continuous Integration / publish-production (pull_request) Skipped
Continuous Integration / deploy-production (pull_request) Skipped
Continuous Integration / publish-test (pull_request) Successful in 7m34s
Continuous Integration / deploy-test (pull_request) Skipped

Implements Unit 2 "Offerings" (backend module, admin CRUD UI with
drag-and-drop reordering, public GET /api/v1/offerings endpoint) and
executes the feature's D-15 CI/CD cutover, switching the deploy
pipeline's build/publish target from SlpModularCms.Api to
SlpModularCms.Api.SlpSoftware.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWyStNL2ZsjrS7FLd7xvvN
This commit is contained in:
2026-08-02 16:23:09 +02:00
co-authored by Claude Sonnet 5
parent b6e9c07c06
commit cfb06b28b6
79 changed files with 4069 additions and 94 deletions
@@ -0,0 +1,252 @@
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(),
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();
}
// --- 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);
}
}