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; /// /// 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). /// public class ProposalConcurrencyTests : IDisposable { private readonly SqliteConnection _connection; private readonly Func _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(); _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 { { "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(), audit, Substitute.For(), Substitute.For(), Substitute.For(), config, Substitute.For>()); _lineItems = new LineItemService(_db, audit, Substitute.For>()); } 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(); 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(); 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(); var state = ex.Which.CurrentState.Should().BeOfType().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(); var state = ex.Which.CurrentState.Should().BeOfType().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(); 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 { 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(); } [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)); } }