mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 20:33:14 +00:00
249 lines
9.8 KiB
C#
249 lines
9.8 KiB
C#
using FluentAssertions;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging;
|
|
using NSubstitute;
|
|
using ProposalSystem.Application.Common;
|
|
using ProposalSystem.Application.DTOs;
|
|
using ProposalSystem.Application.Interfaces;
|
|
using ProposalSystem.Domain.Entities;
|
|
using ProposalSystem.Infrastructure.Data;
|
|
using ProposalSystem.Infrastructure.Services;
|
|
using ProposalSystem.Tests.Helpers;
|
|
using Xunit;
|
|
|
|
namespace ProposalSystem.Tests.Services;
|
|
|
|
/// <summary>
|
|
/// Optimistic-concurrency tests for the proposal aggregate (ADR 0004, SHOC
|
|
/// double-guard). Covers the pre-check path, both 422 token codes, the
|
|
/// DB-level race window (two writers over one SQLite database), version
|
|
/// bumping, and audit atomicity (staged rows must not survive a failed save).
|
|
/// </summary>
|
|
public class ProposalConcurrencyTests : IDisposable
|
|
{
|
|
private readonly SqliteConnection _connection;
|
|
private readonly Func<ProposalDbContext> _contextFactory;
|
|
private readonly ProposalDbContext _db;
|
|
private readonly ICurrentUserService _currentUser;
|
|
private readonly ProposalService _sut;
|
|
private readonly LineItemService _lineItems;
|
|
private readonly Guid _userId = Guid.NewGuid();
|
|
|
|
public ProposalConcurrencyTests()
|
|
{
|
|
(_connection, _contextFactory) = SqliteDbContextFactory.CreateShared();
|
|
_db = _contextFactory();
|
|
|
|
_currentUser = Substitute.For<ICurrentUserService>();
|
|
_currentUser.UserId.Returns(_userId);
|
|
_currentUser.Role.Returns(UserRole.Admin);
|
|
|
|
_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();
|
|
|
|
var config = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?> { { "GENERATED_BUCKET", "test-bucket" } })
|
|
.Build();
|
|
|
|
// Real AuditService on the SAME context so staged-audit atomicity is exercised.
|
|
var audit = new AuditService(_db, _currentUser);
|
|
_sut = new ProposalService(_db, _currentUser, Substitute.For<IProposalNumberGenerator>(), audit,
|
|
Substitute.For<IJobPublisher>(), Substitute.For<IEmailService>(), Substitute.For<IS3Service>(),
|
|
config, Substitute.For<ILogger<ProposalService>>());
|
|
_lineItems = new LineItemService(_db, audit, Substitute.For<ILogger<LineItemService>>());
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_db.Dispose();
|
|
_connection.Dispose();
|
|
}
|
|
|
|
private Proposal SeedProposal(ProposalStatus status = ProposalStatus.InReview)
|
|
{
|
|
var proposal = new Proposal
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ProposalNumber = $"P-{Guid.NewGuid():N}"[..12],
|
|
WorkOrderNumber = "WO-001",
|
|
CustomerName = "Test Customer",
|
|
CustomerAddress = "123 Test St",
|
|
ScopeOfWork = "Test scope of work",
|
|
ServiceCategory = ServiceCategory.HVAC,
|
|
Priority = Priority.Standard,
|
|
Status = status,
|
|
Notes = "",
|
|
SubmittedById = _userId,
|
|
SubmittedAt = DateTime.UtcNow.AddDays(-1),
|
|
CurrentRevision = 1,
|
|
CreatedAt = DateTime.UtcNow.AddDays(-1),
|
|
UpdatedAt = DateTime.UtcNow.AddDays(-1),
|
|
};
|
|
_db.Proposals.Add(proposal);
|
|
_db.SaveChanges();
|
|
return proposal;
|
|
}
|
|
|
|
private static UpdateProposalRequest UpdateNotes(string notes, string? version) =>
|
|
new(null, notes, null, null, null, version);
|
|
|
|
[Fact(DisplayName = "Missing proposalVersion -> 422 ProposalVersionRequired")]
|
|
public async Task MissingVersion_ThrowsVersionRequired()
|
|
{
|
|
var proposal = SeedProposal();
|
|
|
|
var act = () => _sut.UpdateAsync(proposal.Id, UpdateNotes("x", null));
|
|
|
|
var ex = await act.Should().ThrowAsync<BusinessRuleException>();
|
|
ex.Which.Code.Should().Be("ProposalVersionRequired");
|
|
}
|
|
|
|
[Fact(DisplayName = "Malformed proposalVersion -> 422 InvalidRowVersion")]
|
|
public async Task MalformedVersion_ThrowsInvalidRowVersion()
|
|
{
|
|
var proposal = SeedProposal();
|
|
|
|
var act = () => _sut.UpdateAsync(proposal.Id, UpdateNotes("x", "not-a-token!"));
|
|
|
|
var ex = await act.Should().ThrowAsync<BusinessRuleException>();
|
|
ex.Which.Code.Should().Be("InvalidRowVersion");
|
|
}
|
|
|
|
[Fact(DisplayName = "Stale token fails the pre-check and carries currentState")]
|
|
public async Task StaleToken_PreCheck_ThrowsWithCurrentState()
|
|
{
|
|
var proposal = SeedProposal();
|
|
|
|
var act = () => _sut.UpdateAsync(proposal.Id, UpdateNotes("x", RowVersionCodec.Encode(99)));
|
|
|
|
var ex = await act.Should().ThrowAsync<ProposalConcurrencyException>();
|
|
var state = ex.Which.CurrentState.Should().BeOfType<ProposalResponse>().Subject;
|
|
state.Id.Should().Be(proposal.Id);
|
|
state.RowVersion.Should().Be(RowVersionCodec.Encode(1));
|
|
}
|
|
|
|
[Fact(DisplayName = "DB-level race: concurrent writer wins, loser gets 409 state with the winner's values")]
|
|
public async Task LostRace_ReloadsCurrentState()
|
|
{
|
|
var proposal = SeedProposal();
|
|
var token = RowVersionCodec.Encode(1);
|
|
|
|
// Load into the service context first (pre-check will pass against version 1)...
|
|
var tracked = await _db.Proposals.FirstAsync(p => p.Id == proposal.Id);
|
|
tracked.Should().NotBeNull();
|
|
|
|
// ...then a second writer commits between the pre-check read and our save.
|
|
await using (var other = _contextFactory())
|
|
{
|
|
var competing = await other.Proposals.FirstAsync(p => p.Id == proposal.Id);
|
|
other.Entry(competing).Property(p => p.Version).OriginalValue = 1L;
|
|
competing.Notes = "the winner's notes";
|
|
competing.Version = 2;
|
|
await other.SaveChangesAsync();
|
|
}
|
|
|
|
var act = () => _sut.UpdateAsync(proposal.Id, UpdateNotes("the loser's notes", token));
|
|
|
|
var ex = await act.Should().ThrowAsync<ProposalConcurrencyException>();
|
|
var state = ex.Which.CurrentState.Should().BeOfType<ProposalResponse>().Subject;
|
|
state.Notes.Should().Be("the winner's notes");
|
|
state.RowVersion.Should().Be(RowVersionCodec.Encode(2));
|
|
}
|
|
|
|
[Fact(DisplayName = "Audit atomicity: a save lost to a concurrent writer persists no audit row")]
|
|
public async Task LostRace_PersistsNoAuditRow()
|
|
{
|
|
var proposal = SeedProposal();
|
|
await _db.Proposals.FirstAsync(p => p.Id == proposal.Id);
|
|
|
|
await using (var other = _contextFactory())
|
|
{
|
|
var competing = await other.Proposals.FirstAsync(p => p.Id == proposal.Id);
|
|
competing.Notes = "winner";
|
|
competing.Version = 2;
|
|
await other.SaveChangesAsync();
|
|
}
|
|
|
|
var act = () => _sut.UpdateAsync(proposal.Id, UpdateNotes("loser", RowVersionCodec.Encode(1)));
|
|
await act.Should().ThrowAsync<ProposalConcurrencyException>();
|
|
|
|
await using var check = _contextFactory();
|
|
(await check.AuditLogs.CountAsync()).Should().Be(0);
|
|
}
|
|
|
|
[Fact(DisplayName = "Successful update bumps the version and commits exactly one audit row atomically")]
|
|
public async Task SuccessfulUpdate_BumpsVersionAndCommitsAudit()
|
|
{
|
|
var proposal = SeedProposal();
|
|
|
|
var result = await _sut.UpdateAsync(proposal.Id, UpdateNotes("new notes", RowVersionCodec.Encode(1)));
|
|
|
|
result.RowVersion.Should().Be(RowVersionCodec.Encode(2));
|
|
await using var check = _contextFactory();
|
|
(await check.AuditLogs.CountAsync(a => a.ProposalId == proposal.Id)).Should().Be(1);
|
|
(await check.Proposals.SingleAsync(p => p.Id == proposal.Id)).Version.Should().Be(2);
|
|
}
|
|
|
|
[Fact(DisplayName = "Bulk line-item update is guarded by the proposal token")]
|
|
public async Task BulkUpdate_StaleProposalToken_Conflicts()
|
|
{
|
|
var proposal = SeedProposal();
|
|
var request = new BulkUpdateLineItemsRequest(
|
|
new List<UpdateLineItemEntry>
|
|
{
|
|
new(null, "Item", 1, "each", null, 100m, PricingMode.TotalPrice, 0, LineItemSource.Manual),
|
|
},
|
|
RowVersionCodec.Encode(42));
|
|
|
|
var act = () => _lineItems.BulkUpdateAsync(proposal.Id, request);
|
|
|
|
await act.Should().ThrowAsync<ProposalConcurrencyException>();
|
|
}
|
|
|
|
[Fact(DisplayName = "Line-item create bumps the proposal aggregate version")]
|
|
public async Task LineItemCreate_BumpsProposalVersion()
|
|
{
|
|
var proposal = SeedProposal();
|
|
|
|
var created = await _lineItems.CreateAsync(proposal.Id,
|
|
new CreateLineItemRequest("Item", 1, "each", null, 50m, PricingMode.TotalPrice, 0, LineItemSource.Manual));
|
|
|
|
created.RowVersion.Should().Be(RowVersionCodec.Encode(1));
|
|
await using var check = _contextFactory();
|
|
(await check.Proposals.SingleAsync(p => p.Id == proposal.Id)).Version.Should().Be(2);
|
|
}
|
|
|
|
[Fact(DisplayName = "Approve with the current token succeeds and returns the bumped token")]
|
|
public async Task Approve_WithCurrentToken_Succeeds()
|
|
{
|
|
var proposal = SeedProposal();
|
|
_db.LineItems.Add(new LineItem
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ProposalId = proposal.Id,
|
|
Description = "Priced item",
|
|
Quantity = 1,
|
|
Unit = "each",
|
|
TotalPrice = 500m,
|
|
PricingMode = PricingMode.TotalPrice,
|
|
Source = LineItemSource.Manual,
|
|
});
|
|
_db.SaveChanges();
|
|
|
|
var result = await _sut.ApproveAsync(proposal.Id, RowVersionCodec.Encode(1));
|
|
|
|
result.Status.Should().Be(ProposalStatus.Approved);
|
|
result.RowVersion.Should().Be(RowVersionCodec.Encode(2));
|
|
}
|
|
}
|