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

250 lines
9.8 KiB
C#
Raw Permalink Normal View History

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));
}
}