mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 03:03:13 +00:00
418 lines
14 KiB
C#
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);
|
|
|
|
}
|