proposal-system/api/tests/ProposalSystem.Tests/Services/PricingLibraryServiceTests.cs
Adam Moussa 5d84399a0d
Some checks are pending
Deploy / Deploy to AWS (push) Waiting to run
feat: pricing library — curated priced items feed the RAG corpus (#127)
Adds a managed pricing library so admins can seed/curate reference priced items
directly, instead of the corpus being populated only by ingesting Sent proposals.
v1 PR5.

API:
- PricingLibraryItem entity + migration; /api/pricing-library CRUD (admin), with
  GET {id} reachable by internal Lambda callers (admins role via internal key).
- Create/update publish an ADDITIVE library-ingest SQS job {pricingLibraryItemId},
  wrapped so a publish failure never rolls back the save.

Lambda (library-ingest):
- Additive event-shape branch: pricingLibraryItemId -> fetch item, format markdown,
  upload to pricing-library/{category}/{id}.md, trigger KB sync. The existing
  proposalId path is byte-for-byte unchanged. Explicit error when neither id present;
  warns when both present.

Web:
- Pricing Library management page (/admin/pricing-library): list / create / edit / delete.

GPT-4.1 cross-review on the event-shape change: no BLOCK (neither/both-id handling
applied). Verified: api 159 tests; web tsc + 26 tests; lambdas ruff + 37 pytest.
2026-06-18 12:49:47 -04:00

326 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
}