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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user