using FluentAssertions; using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ExceptionExtensions; using ProposalSystem.Application.DTOs; using ProposalSystem.Application.Interfaces; using ProposalSystem.Domain.Entities; using ProposalSystem.Infrastructure.Services; using ProposalSystem.Tests.Helpers; using Xunit; namespace ProposalSystem.Tests.Services; /// /// PR5: Tests for PricingLibraryService. Verifies CRUD behavior and that /// create/update publish "library-ingest" SQS jobs for KB sync. /// public class PricingLibraryServiceTests : IDisposable { private readonly Infrastructure.Data.ProposalDbContext _db; private readonly IJobPublisher _jobPublisher; private readonly PricingLibraryService _sut; public PricingLibraryServiceTests() { _db = DbContextFactory.Create(); _jobPublisher = Substitute.For(); _sut = new PricingLibraryService( _db, _jobPublisher, Substitute.For>()); } public void Dispose() { _db.Dispose(); } #region Create [Fact(DisplayName = "PR5: Create returns item with generated Id and timestamps")] public async Task CreateAsync_ReturnsItemWithIdAndTimestamps() { var request = new CreatePricingLibraryItemRequest( Title: "HVAC Filter Replacement", Description: "Standard filter replacement service", ServiceCategory: ServiceCategory.HVAC, Unit: "ea", UnitPrice: 45.00m, Keywords: "filter HVAC maintenance", Source: "Manual" ); var result = await _sut.CreateAsync(request); result.Id.Should().NotBe(Guid.Empty); result.Title.Should().Be("HVAC Filter Replacement"); result.Description.Should().Be("Standard filter replacement service"); result.ServiceCategory.Should().Be(ServiceCategory.HVAC); result.Unit.Should().Be("ea"); result.UnitPrice.Should().Be(45.00m); result.Keywords.Should().Be("filter HVAC maintenance"); result.Source.Should().Be("Manual"); result.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); result.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); } [Fact(DisplayName = "PR5: Create defaults Source to 'Manual' when null")] public async Task CreateAsync_DefaultsSourceToManual() { var request = new CreatePricingLibraryItemRequest( Title: "Basic Item", Description: null, ServiceCategory: ServiceCategory.General, Unit: null, UnitPrice: null, Keywords: null, Source: null ); var result = await _sut.CreateAsync(request); result.Source.Should().Be("Manual"); } [Fact(DisplayName = "PR5: Create publishes library-ingest job with pricingLibraryItemId")] public async Task CreateAsync_PublishesLibraryIngestJob() { var request = new CreatePricingLibraryItemRequest( Title: "Test Item", Description: null, ServiceCategory: ServiceCategory.General, Unit: null, UnitPrice: null, Keywords: null, Source: null ); var result = await _sut.CreateAsync(request); await _jobPublisher.Received(1).PublishAsync( "library-ingest", Arg.Is(o => o.ToString()!.Contains(result.Id.ToString())), Arg.Any()); } [Fact(DisplayName = "PR5: Create succeeds even when job publish fails (no rollback)")] public async Task CreateAsync_JobPublishFails_DoesNotRollBack() { _jobPublisher.PublishAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new InvalidOperationException("SQS is down")); var request = new CreatePricingLibraryItemRequest( Title: "Resilient Item", Description: null, ServiceCategory: ServiceCategory.General, Unit: null, UnitPrice: null, Keywords: null, Source: null ); var result = await _sut.CreateAsync(request); result.Title.Should().Be("Resilient Item"); // Verify item was persisted despite job publish failure var dbItem = await _db.PricingLibraryItems.FindAsync(result.Id); dbItem.Should().NotBeNull(); } #endregion #region Update [Fact(DisplayName = "PR5: Update modifies provided fields and publishes ingest job")] public async Task UpdateAsync_ModifiesFieldsAndPublishesJob() { // Seed an item var originalUpdatedAt = DateTime.UtcNow.AddDays(-1); var item = new PricingLibraryItem { Id = Guid.NewGuid(), Title = "Original Title", Description = "Original", ServiceCategory = ServiceCategory.General, Unit = "ea", UnitPrice = 10.00m, Keywords = "original", Source = "Manual", CreatedAt = DateTime.UtcNow.AddDays(-1), UpdatedAt = originalUpdatedAt, }; _db.PricingLibraryItems.Add(item); await _db.SaveChangesAsync(); var request = new UpdatePricingLibraryItemRequest( Title: "Updated Title", Description: null, ServiceCategory: ServiceCategory.Electrical, Unit: null, UnitPrice: 99.99m, Keywords: null ); var result = await _sut.UpdateAsync(item.Id, request); result.Should().NotBeNull(); result!.Title.Should().Be("Updated Title"); result.ServiceCategory.Should().Be(ServiceCategory.Electrical); result.UnitPrice.Should().Be(99.99m); result.UpdatedAt.Should().BeAfter(originalUpdatedAt); await _jobPublisher.Received(1).PublishAsync( "library-ingest", Arg.Is(o => o.ToString()!.Contains(item.Id.ToString())), Arg.Any()); } [Fact(DisplayName = "PR5: Update returns null for non-existent item")] public async Task UpdateAsync_NonExistentItem_ReturnsNull() { var request = new UpdatePricingLibraryItemRequest( Title: "Does Not Exist", Description: null, ServiceCategory: null, Unit: null, UnitPrice: null, Keywords: null ); var result = await _sut.UpdateAsync(Guid.NewGuid(), request); result.Should().BeNull(); } [Fact(DisplayName = "PR5: Update succeeds even when job publish fails (no rollback)")] public async Task UpdateAsync_JobPublishFails_DoesNotRollBack() { var item = new PricingLibraryItem { Id = Guid.NewGuid(), Title = "Will Be Updated", ServiceCategory = ServiceCategory.General, Source = "Manual", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; _db.PricingLibraryItems.Add(item); await _db.SaveChangesAsync(); _jobPublisher.PublishAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new InvalidOperationException("SQS is down")); var request = new UpdatePricingLibraryItemRequest( Title: "Updated Despite SQS Failure", Description: null, ServiceCategory: null, Unit: null, UnitPrice: null, Keywords: null ); var result = await _sut.UpdateAsync(item.Id, request); result.Should().NotBeNull(); result!.Title.Should().Be("Updated Despite SQS Failure"); } #endregion #region GetById [Fact(DisplayName = "PR5: GetById returns item when it exists")] public async Task GetByIdAsync_ExistingItem_ReturnsItem() { var item = new PricingLibraryItem { Id = Guid.NewGuid(), Title = "Findable Item", ServiceCategory = ServiceCategory.Plumbing, Source = "Manual", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; _db.PricingLibraryItems.Add(item); await _db.SaveChangesAsync(); var result = await _sut.GetByIdAsync(item.Id); result.Should().NotBeNull(); result!.Id.Should().Be(item.Id); result.Title.Should().Be("Findable Item"); } [Fact(DisplayName = "PR5: GetById returns null for non-existent item")] public async Task GetByIdAsync_NonExistentItem_ReturnsNull() { var result = await _sut.GetByIdAsync(Guid.NewGuid()); result.Should().BeNull(); } #endregion #region Delete [Fact(DisplayName = "PR5: Delete removes item and returns true")] public async Task DeleteAsync_ExistingItem_RemovesAndReturnsTrue() { var item = new PricingLibraryItem { Id = Guid.NewGuid(), Title = "To Be Deleted", ServiceCategory = ServiceCategory.General, Source = "Manual", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; _db.PricingLibraryItems.Add(item); await _db.SaveChangesAsync(); var result = await _sut.DeleteAsync(item.Id); result.Should().BeTrue(); var dbItem = await _db.PricingLibraryItems.FindAsync(item.Id); dbItem.Should().BeNull(); } [Fact(DisplayName = "PR5: Delete returns false for non-existent item")] public async Task DeleteAsync_NonExistentItem_ReturnsFalse() { var result = await _sut.DeleteAsync(Guid.NewGuid()); result.Should().BeFalse(); } #endregion #region List [Fact(DisplayName = "PR5: List returns paginated results ordered by title")] public async Task ListAsync_ReturnsPaginatedResults() { // Seed 3 items var items = new[] { new PricingLibraryItem { Id = Guid.NewGuid(), Title = "Charlie", ServiceCategory = ServiceCategory.General, Source = "Manual", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }, new PricingLibraryItem { Id = Guid.NewGuid(), Title = "Alpha", ServiceCategory = ServiceCategory.HVAC, Source = "Manual", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }, new PricingLibraryItem { Id = Guid.NewGuid(), Title = "Bravo", ServiceCategory = ServiceCategory.Plumbing, Source = "Manual", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }, }; _db.PricingLibraryItems.AddRange(items); await _db.SaveChangesAsync(); var result = await _sut.ListAsync(1, 2); result.TotalCount.Should().Be(3); result.Page.Should().Be(1); result.PageSize.Should().Be(2); result.Items.Should().HaveCount(2); result.Items[0].Title.Should().Be("Alpha"); result.Items[1].Title.Should().Be("Bravo"); } #endregion }