proposal-system/api/tests/ProposalSystem.Tests/Services/LineItemServiceStateGuardTests.cs

418 lines
14 KiB
C#

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;
/// <summary>
/// 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).
/// </summary>
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<IAuditService>();
_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<ILogger<LineItemService>>());
}
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<InvalidOperationException>()
.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<KeyNotFoundException>();
}
#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<UpdateLineItemEntry>
{
new(null, "Updated item", 1, "each", null, 100m, PricingMode.TotalPrice, 1, LineItemSource.Manual),
});
// Act
var act = () => _sut.BulkUpdateAsync(proposal.Id, request with { ProposalVersion = Ver(proposal.Id) });
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.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<UpdateLineItemEntry>
{
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 with { ProposalVersion = Ver(proposal.Id) });
// 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<UpdateLineItemEntry>
{
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 with { ProposalVersion = Ver(proposal.Id) });
// 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<UpdateLineItemEntry>
{
new(null, "New item", 1, "each", null, 100m, PricingMode.TotalPrice, 1, LineItemSource.Manual),
});
// Act
var result = await _sut.BulkUpdateAsync(proposal.Id, request with { ProposalVersion = Ver(proposal.Id) });
// 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<UpdateLineItemEntry>());
var act = () => _sut.BulkUpdateAsync(Guid.NewGuid(), request with { ProposalVersion = Ver(Guid.NewGuid()) });
await act.Should().ThrowAsync<KeyNotFoundException>();
}
#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<InvalidOperationException>()
.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<KeyNotFoundException>();
}
#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
_audit.Received(1).Stage(AuditAction.EditLineItem,
proposal.Id,
Arg.Is<string?>(s => s != null && s.Contains(request.Description)));
}
[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<UpdateLineItemEntry>
{
new(null, "Item", 1, "each", null, 100m, PricingMode.TotalPrice, 1, LineItemSource.Manual),
});
// Act
await _sut.BulkUpdateAsync(proposal.Id, request with { ProposalVersion = Ver(proposal.Id) });
// Assert — audit detail may be plain text ("Bulk update: N items") or JSON
_audit.Received(1).Stage(AuditAction.EditLineItem,
proposal.Id,
Arg.Is<string?>(s => s != null));
}
[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
_audit.Received(1).Stage(AuditAction.EditLineItem,
proposal.Id,
Arg.Is<string?>(s => s != null && s.Contains("Audit test item")));
}
#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
);
}
private string Ver(Guid id) =>
ProposalSystem.Application.Common.RowVersionCodec.Encode(_db.Proposals.Find(id)?.Version ?? 1);
}