mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 11:13:14 +00:00
327 lines
11 KiB
C#
327 lines
11 KiB
C#
|
|
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;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// PR5: Tests for PricingLibraryService. Verifies CRUD behavior and that
|
||
|
|
/// create/update publish "library-ingest" SQS jobs for KB sync.
|
||
|
|
/// </summary>
|
||
|
|
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<IJobPublisher>();
|
||
|
|
|
||
|
|
_sut = new PricingLibraryService(
|
||
|
|
_db,
|
||
|
|
_jobPublisher,
|
||
|
|
Substitute.For<ILogger<PricingLibraryService>>());
|
||
|
|
}
|
||
|
|
|
||
|
|
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<object>(o => o.ToString()!.Contains(result.Id.ToString())),
|
||
|
|
Arg.Any<CancellationToken>());
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact(DisplayName = "PR5: Create succeeds even when job publish fails (no rollback)")]
|
||
|
|
public async Task CreateAsync_JobPublishFails_DoesNotRollBack()
|
||
|
|
{
|
||
|
|
_jobPublisher.PublishAsync(Arg.Any<string>(), Arg.Any<object>(), Arg.Any<CancellationToken>())
|
||
|
|
.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<object>(o => o.ToString()!.Contains(item.Id.ToString())),
|
||
|
|
Arg.Any<CancellationToken>());
|
||
|
|
}
|
||
|
|
|
||
|
|
[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<string>(), Arg.Any<object>(), Arg.Any<CancellationToken>())
|
||
|
|
.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
|
||
|
|
}
|