using FluentAssertions; using Microsoft.Extensions.Logging; using NSubstitute; 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; /// /// LineItemService state guard tests. /// Verifies that line items cannot be created, bulk-updated, or deleted on /// proposals in Approved or Sent status. Line item operations on InReview /// and Revised proposals must succeed. /// /// Related findings: QA-C2 (state machine enforcement), API-H3 (status field exposure). /// public class LineItemServiceStateGuardTests : IDisposable { private readonly Infrastructure.Data.ProposalDbContext _db; private readonly IAuditService _audit; private readonly LineItemService _sut; private readonly Guid _userId; public LineItemServiceStateGuardTests() { _db = DbContextFactory.Create(); _audit = Substitute.For(); _userId = Guid.NewGuid(); _db.Users.Add(new User { Id = _userId, CognitoSub = $"sub-{_userId}", Email = "admin@test.com", DisplayName = "Test Admin", Role = UserRole.Admin, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }); _db.SaveChanges(); _sut = new LineItemService(_db, _audit, Substitute.For>()); } public void Dispose() { _db.Dispose(); } #region CreateAsync Guards [Theory(DisplayName = "QA-C2: CreateAsync throws on Approved and Sent proposals")] [InlineData(ProposalStatus.Approved)] [InlineData(ProposalStatus.Sent)] public async Task CreateAsync_OnLockedStatus_ThrowsInvalidOperation(ProposalStatus status) { // Arrange var proposal = CreateProposal(status); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); var request = CreateLineItemRequest(); // Act var act = () => _sut.CreateAsync(proposal.Id, request); // Assert await act.Should().ThrowAsync() .WithMessage("*approved/sent*"); } [Theory(DisplayName = "QA-C2: CreateAsync succeeds on InReview and Revised proposals")] [InlineData(ProposalStatus.InReview)] [InlineData(ProposalStatus.Revised)] public async Task CreateAsync_OnEditableStatus_Succeeds(ProposalStatus status) { // Arrange var proposal = CreateProposal(status); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); var request = CreateLineItemRequest(); // Act var result = await _sut.CreateAsync(proposal.Id, request); // Assert result.Should().NotBeNull(); result.Description.Should().Be(request.Description); result.ProposalId.Should().Be(proposal.Id); } [Fact(DisplayName = "QA-C2: CreateAsync throws KeyNotFoundException for nonexistent proposal")] public async Task CreateAsync_NonexistentProposal_ThrowsKeyNotFound() { var act = () => _sut.CreateAsync(Guid.NewGuid(), CreateLineItemRequest()); await act.Should().ThrowAsync(); } #endregion #region BulkUpdateAsync Guards [Theory(DisplayName = "QA-C2: BulkUpdateAsync throws on Approved and Sent proposals")] [InlineData(ProposalStatus.Approved)] [InlineData(ProposalStatus.Sent)] public async Task BulkUpdateAsync_OnLockedStatus_ThrowsInvalidOperation(ProposalStatus status) { // Arrange var proposal = CreateProposal(status); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); var request = new BulkUpdateLineItemsRequest(new List { new(null, "Updated item", 1, "each", null, 100m, PricingMode.TotalPrice, 1, LineItemSource.Manual), }); // Act var act = () => _sut.BulkUpdateAsync(proposal.Id, request); // Assert await act.Should().ThrowAsync() .WithMessage("*approved/sent*"); } [Fact(DisplayName = "QA-C2: BulkUpdateAsync succeeds on InReview proposal")] public async Task BulkUpdateAsync_OnInReview_Succeeds() { // Arrange var proposal = CreateProposal(ProposalStatus.InReview); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); var request = new BulkUpdateLineItemsRequest(new List { new(null, "Item A", 2, "hours", 50m, 100m, PricingMode.UnitPrice, 1, LineItemSource.Manual), new(null, "Item B", 1, "each", null, 200m, PricingMode.TotalPrice, 2, LineItemSource.AI), }); // Act var result = await _sut.BulkUpdateAsync(proposal.Id, request); // Assert result.Should().HaveCount(2); result.Select(r => r.Description).Should().BeEquivalentTo("Item A", "Item B"); } [Fact(DisplayName = "QA-C2: BulkUpdateAsync updates proposal TotalBidAmount")] public async Task BulkUpdateAsync_UpdatesTotalBidAmount() { // Arrange var proposal = CreateProposal(ProposalStatus.InReview); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); var request = new BulkUpdateLineItemsRequest(new List { new(null, "Item A", 1, "each", null, 500m, PricingMode.TotalPrice, 1, LineItemSource.Manual), new(null, "Item B", 1, "each", null, 300m, PricingMode.TotalPrice, 2, LineItemSource.Manual), }); // Act await _sut.BulkUpdateAsync(proposal.Id, request); // Assert var updated = await _db.Proposals.FindAsync(proposal.Id); updated!.TotalBidAmount.Should().Be(800m); } [Fact(DisplayName = "QA-C2: BulkUpdateAsync replaces all existing line items")] public async Task BulkUpdateAsync_ReplacesExistingItems() { // Arrange var proposal = CreateProposal(ProposalStatus.InReview); proposal.LineItems.Add(new LineItem { Id = Guid.NewGuid(), ProposalId = proposal.Id, Description = "Old item", Quantity = 1, Unit = "each", TotalPrice = 999m, PricingMode = PricingMode.TotalPrice, Source = LineItemSource.Manual, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); var request = new BulkUpdateLineItemsRequest(new List { new(null, "New item", 1, "each", null, 100m, PricingMode.TotalPrice, 1, LineItemSource.Manual), }); // Act var result = await _sut.BulkUpdateAsync(proposal.Id, request); // Assert result.Should().HaveCount(1); result[0].Description.Should().Be("New item"); var dbItems = _db.LineItems.Where(li => li.ProposalId == proposal.Id).ToList(); dbItems.Should().HaveCount(1); dbItems[0].Description.Should().Be("New item"); } [Fact(DisplayName = "QA-C2: BulkUpdateAsync throws KeyNotFoundException for nonexistent proposal")] public async Task BulkUpdateAsync_NonexistentProposal_ThrowsKeyNotFound() { var request = new BulkUpdateLineItemsRequest(new List()); var act = () => _sut.BulkUpdateAsync(Guid.NewGuid(), request); await act.Should().ThrowAsync(); } #endregion #region DeleteAsync Guards [Theory(DisplayName = "QA-C2: DeleteAsync throws on Approved and Sent proposals")] [InlineData(ProposalStatus.Approved)] [InlineData(ProposalStatus.Sent)] public async Task DeleteAsync_OnLockedStatus_ThrowsInvalidOperation(ProposalStatus status) { // Arrange var proposal = CreateProposal(status); var lineItem = new LineItem { Id = Guid.NewGuid(), ProposalId = proposal.Id, Description = "Item to delete", Quantity = 1, Unit = "each", TotalPrice = 100m, PricingMode = PricingMode.TotalPrice, Source = LineItemSource.Manual, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; proposal.LineItems.Add(lineItem); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act var act = () => _sut.DeleteAsync(proposal.Id, lineItem.Id); // Assert await act.Should().ThrowAsync() .WithMessage("*approved/sent*"); } [Fact(DisplayName = "QA-C2: DeleteAsync succeeds on InReview proposal")] public async Task DeleteAsync_OnInReview_Succeeds() { // Arrange var proposal = CreateProposal(ProposalStatus.InReview); var lineItem = new LineItem { Id = Guid.NewGuid(), ProposalId = proposal.Id, Description = "Item to delete", Quantity = 1, Unit = "each", TotalPrice = 100m, PricingMode = PricingMode.TotalPrice, Source = LineItemSource.Manual, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; proposal.LineItems.Add(lineItem); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act await _sut.DeleteAsync(proposal.Id, lineItem.Id); // Assert var deleted = await _db.LineItems.FindAsync(lineItem.Id); deleted.Should().BeNull(); } [Fact(DisplayName = "QA-C2: DeleteAsync throws KeyNotFoundException for nonexistent line item")] public async Task DeleteAsync_NonexistentLineItem_ThrowsKeyNotFound() { // Arrange var proposal = CreateProposal(ProposalStatus.InReview); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act var act = () => _sut.DeleteAsync(proposal.Id, Guid.NewGuid()); // Assert await act.Should().ThrowAsync(); } #endregion #region Audit Logging [Fact(DisplayName = "QA-C2: CreateAsync logs audit event on success")] public async Task CreateAsync_LogsAuditEvent() { // Arrange var proposal = CreateProposal(ProposalStatus.InReview); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); var request = CreateLineItemRequest(); // Act await _sut.CreateAsync(proposal.Id, request); // Assert await _audit.Received(1).LogAsync( AuditAction.EditLineItem, proposal.Id, Arg.Is(s => s != null && s.Contains(request.Description)), Arg.Any()); } [Fact(DisplayName = "QA-C2: BulkUpdateAsync logs audit event on success")] public async Task BulkUpdateAsync_LogsAuditEvent() { // Arrange var proposal = CreateProposal(ProposalStatus.InReview); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); var request = new BulkUpdateLineItemsRequest(new List { new(null, "Item", 1, "each", null, 100m, PricingMode.TotalPrice, 1, LineItemSource.Manual), }); // Act await _sut.BulkUpdateAsync(proposal.Id, request); // Assert — audit detail may be plain text ("Bulk update: N items") or JSON await _audit.Received(1).LogAsync( AuditAction.EditLineItem, proposal.Id, Arg.Is(s => s != null), Arg.Any()); } [Fact(DisplayName = "QA-C2: DeleteAsync logs audit event on success")] public async Task DeleteAsync_LogsAuditEvent() { // Arrange var proposal = CreateProposal(ProposalStatus.InReview); var lineItem = new LineItem { Id = Guid.NewGuid(), ProposalId = proposal.Id, Description = "Audit test item", Quantity = 1, Unit = "each", TotalPrice = 100m, PricingMode = PricingMode.TotalPrice, Source = LineItemSource.Manual, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; proposal.LineItems.Add(lineItem); _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); // Act await _sut.DeleteAsync(proposal.Id, lineItem.Id); // Assert await _audit.Received(1).LogAsync( AuditAction.EditLineItem, proposal.Id, Arg.Is(s => s != null && s.Contains("Audit test item")), Arg.Any()); } #endregion private Proposal CreateProposal(ProposalStatus status) { return new Proposal { Id = Guid.NewGuid(), ProposalNumber = $"SHI-2026-{Guid.NewGuid():N}"[..16], WorkOrderNumber = "WO-TEST", CustomerName = "Test Customer", CustomerAddress = "123 Test St", ScopeOfWork = "Test scope of work", ServiceCategory = ServiceCategory.General, Priority = Priority.Standard, Status = status, Notes = "", SubmittedById = _userId, SubmittedAt = DateTime.UtcNow, CurrentRevision = 1, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; } private static CreateLineItemRequest CreateLineItemRequest() { return new CreateLineItemRequest( Description: "HVAC duct replacement", Quantity: 2, Unit: "each", UnitPrice: 250m, TotalPrice: 500m, PricingMode: PricingMode.UnitPrice, SortOrder: 1, Source: LineItemSource.Manual ); } }