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(); private readonly ILogger _logger = Substitute.For>(); public OfferingsServiceTests() { _repo.BeginTransactionAsync().Returns(Substitute.For()); } 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(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(), Arg.Any()); } // --- 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(); } [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(); } // --- UpdateAsync --- [Fact] public async Task UpdateAsync_ReturnsNull_WhenOfferingNotFound() { _repo.GetByIdAsync(Arg.Any()).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()).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); } }