diff --git a/api/tests/ProposalSystem.Tests/Common/RowVersionCodecTests.cs b/api/tests/ProposalSystem.Tests/Common/RowVersionCodecTests.cs new file mode 100644 index 0000000..808b08d --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Common/RowVersionCodecTests.cs @@ -0,0 +1,37 @@ +using FluentAssertions; +using ProposalSystem.Application.Common; +using Xunit; + +namespace ProposalSystem.Tests.Common; + +public class RowVersionCodecTests +{ + [Theory(DisplayName = "Encode/TryDecode round-trips")] + [InlineData(1L)] + [InlineData(2L)] + [InlineData(long.MaxValue)] + public void RoundTrips(long version) + { + var token = RowVersionCodec.Encode(version); + + RowVersionCodec.TryDecode(token, out var decoded).Should().BeTrue(); + decoded.Should().Be(version); + } + + [Fact(DisplayName = "Version 1 encodes to the SHOC-style opaque token")] + public void EncodesOpaqueBase64() + { + RowVersionCodec.Encode(1).Should().Be("AAAAAAAAAAE="); + } + + [Theory(DisplayName = "TryDecode rejects missing/malformed/wrong-length tokens")] + [InlineData(null)] + [InlineData("")] + [InlineData("not base64!!")] + [InlineData("AAA=")] // valid base64 but not 8 bytes + [InlineData("AAAAAAAAAAAAAAAAAAAAAA==")] // 16 bytes + public void RejectsInvalid(string? token) + { + RowVersionCodec.TryDecode(token, out _).Should().BeFalse(); + } +} diff --git a/api/tests/ProposalSystem.Tests/Helpers/SqliteDbContextFactory.cs b/api/tests/ProposalSystem.Tests/Helpers/SqliteDbContextFactory.cs index 6177b97..edeaa8f 100644 --- a/api/tests/ProposalSystem.Tests/Helpers/SqliteDbContextFactory.cs +++ b/api/tests/ProposalSystem.Tests/Helpers/SqliteDbContextFactory.cs @@ -37,6 +37,29 @@ public static class SqliteDbContextFactory return context; } + /// + /// Opens a shared in-memory database and returns the connection plus a factory + /// for additional contexts over the SAME database — needed by optimistic- + /// concurrency tests that simulate two competing writers. Callers dispose the + /// connection after the contexts. + /// + public static (SqliteConnection Connection, Func ContextFactory) CreateShared() + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + RegisterPostgresStubs(connection); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + var first = new ProposalDbContext(options); + first.Database.EnsureCreated(); + first.Dispose(); + + return (connection, () => new ProposalDbContext(options)); + } + private static void RegisterPostgresStubs(SqliteConnection connection) { // hashtext(text) -> integer — returns a constant; only needed so SQL parses diff --git a/api/tests/ProposalSystem.Tests/Middleware/GlobalExceptionHandlerTests.cs b/api/tests/ProposalSystem.Tests/Middleware/GlobalExceptionHandlerTests.cs index 78c5c04..33ea6c1 100644 --- a/api/tests/ProposalSystem.Tests/Middleware/GlobalExceptionHandlerTests.cs +++ b/api/tests/ProposalSystem.Tests/Middleware/GlobalExceptionHandlerTests.cs @@ -93,4 +93,47 @@ public class GlobalExceptionHandlerTests context.Response.ContentType.Should().Be("application/problem+json"); } + + // ── Concurrency 409 envelopes (ADR 0004) ───────────────────────────────── + // These are SHOC's shapes verbatim, NOT ProblemDetails. Both bodies are wire + // contract: web/mobile branch on message + currentState / status + code. + + [Fact(DisplayName = "ProposalConcurrencyException maps to 409 { message, currentState } (SHOC envelope)")] + public async Task ConcurrencyException_Maps409WithCurrentState() + { + var (status, body) = await InvokeWith( + new ProposalConcurrencyException(new { Id = "p1", Notes = "winner" })); + + status.Should().Be(409); + body.GetProperty("message").GetString() + .Should().Be("The record was modified by another user. Refresh and retry."); + body.GetProperty("currentState").GetProperty("id").GetString().Should().Be("p1"); + body.GetProperty("currentState").GetProperty("notes").GetString().Should().Be("winner"); + // The guarded envelope is NOT the fallback shape. + body.TryGetProperty("status", out _).Should().BeFalse(); + body.TryGetProperty("code", out _).Should().BeFalse(); + } + + [Fact(DisplayName = "ProposalConcurrencyException with no reloadable state carries currentState: null")] + public async Task ConcurrencyException_NullState_SerializesNull() + { + var (status, body) = await InvokeWith(new ProposalConcurrencyException(null)); + + status.Should().Be(409); + body.GetProperty("currentState").ValueKind.Should().Be(JsonValueKind.Null); + } + + [Fact(DisplayName = "Bare DbUpdateConcurrencyException maps to the 409 fallback { status, message, code }")] + public async Task DbUpdateConcurrencyException_Maps409Fallback() + { + var (status, body) = await InvokeWith( + new Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException("boom")); + + status.Should().Be(409); + body.GetProperty("status").GetString().Should().Be("Conflict"); + body.GetProperty("message").GetString() + .Should().Be("The record was modified by another user. Refresh and retry."); + body.GetProperty("code").GetInt32().Should().Be(409); + body.TryGetProperty("currentState", out _).Should().BeFalse(); + } } diff --git a/api/tests/ProposalSystem.Tests/Services/LineItemServiceStateGuardTests.cs b/api/tests/ProposalSystem.Tests/Services/LineItemServiceStateGuardTests.cs index c3e068d..715eb30 100644 --- a/api/tests/ProposalSystem.Tests/Services/LineItemServiceStateGuardTests.cs +++ b/api/tests/ProposalSystem.Tests/Services/LineItemServiceStateGuardTests.cs @@ -121,7 +121,7 @@ public class LineItemServiceStateGuardTests : IDisposable }); // Act - var act = () => _sut.BulkUpdateAsync(proposal.Id, request); + var act = () => _sut.BulkUpdateAsync(proposal.Id, request with { ProposalVersion = Ver(proposal.Id) }); // Assert await act.Should().ThrowAsync() @@ -143,7 +143,7 @@ public class LineItemServiceStateGuardTests : IDisposable }); // Act - var result = await _sut.BulkUpdateAsync(proposal.Id, request); + var result = await _sut.BulkUpdateAsync(proposal.Id, request with { ProposalVersion = Ver(proposal.Id) }); // Assert result.Should().HaveCount(2); @@ -165,7 +165,7 @@ public class LineItemServiceStateGuardTests : IDisposable }); // Act - await _sut.BulkUpdateAsync(proposal.Id, request); + await _sut.BulkUpdateAsync(proposal.Id, request with { ProposalVersion = Ver(proposal.Id) }); // Assert var updated = await _db.Proposals.FindAsync(proposal.Id); @@ -199,7 +199,7 @@ public class LineItemServiceStateGuardTests : IDisposable }); // Act - var result = await _sut.BulkUpdateAsync(proposal.Id, request); + var result = await _sut.BulkUpdateAsync(proposal.Id, request with { ProposalVersion = Ver(proposal.Id) }); // Assert result.Should().HaveCount(1); @@ -214,7 +214,7 @@ public class LineItemServiceStateGuardTests : IDisposable public async Task BulkUpdateAsync_NonexistentProposal_ThrowsKeyNotFound() { var request = new BulkUpdateLineItemsRequest(new List()); - var act = () => _sut.BulkUpdateAsync(Guid.NewGuid(), request); + var act = () => _sut.BulkUpdateAsync(Guid.NewGuid(), request with { ProposalVersion = Ver(Guid.NewGuid()) }); await act.Should().ThrowAsync(); } @@ -317,11 +317,9 @@ public class LineItemServiceStateGuardTests : IDisposable await _sut.CreateAsync(proposal.Id, request); // Assert - await _audit.Received(1).LogAsync( - AuditAction.EditLineItem, + _audit.Received(1).Stage(AuditAction.EditLineItem, proposal.Id, - Arg.Is(s => s != null && s.Contains(request.Description)), - Arg.Any()); + Arg.Is(s => s != null && s.Contains(request.Description))); } [Fact(DisplayName = "QA-C2: BulkUpdateAsync logs audit event on success")] @@ -338,14 +336,12 @@ public class LineItemServiceStateGuardTests : IDisposable }); // Act - await _sut.BulkUpdateAsync(proposal.Id, request); + await _sut.BulkUpdateAsync(proposal.Id, request with { ProposalVersion = Ver(proposal.Id) }); // Assert — audit detail may be plain text ("Bulk update: N items") or JSON - await _audit.Received(1).LogAsync( - AuditAction.EditLineItem, + _audit.Received(1).Stage(AuditAction.EditLineItem, proposal.Id, - Arg.Is(s => s != null), - Arg.Any()); + Arg.Is(s => s != null)); } [Fact(DisplayName = "QA-C2: DeleteAsync logs audit event on success")] @@ -374,11 +370,9 @@ public class LineItemServiceStateGuardTests : IDisposable await _sut.DeleteAsync(proposal.Id, lineItem.Id); // Assert - await _audit.Received(1).LogAsync( - AuditAction.EditLineItem, + _audit.Received(1).Stage(AuditAction.EditLineItem, proposal.Id, - Arg.Is(s => s != null && s.Contains("Audit test item")), - Arg.Any()); + Arg.Is(s => s != null && s.Contains("Audit test item"))); } #endregion @@ -418,4 +412,7 @@ public class LineItemServiceStateGuardTests : IDisposable Source: LineItemSource.Manual ); } + private string Ver(Guid id) => + ProposalSystem.Application.Common.RowVersionCodec.Encode(_db.Proposals.Find(id)?.Version ?? 1); + } diff --git a/api/tests/ProposalSystem.Tests/Services/ProposalConcurrencyTests.cs b/api/tests/ProposalSystem.Tests/Services/ProposalConcurrencyTests.cs new file mode 100644 index 0000000..39a92fd --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Services/ProposalConcurrencyTests.cs @@ -0,0 +1,249 @@ +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)); + } +} diff --git a/api/tests/ProposalSystem.Tests/Services/ProposalDeliveryTests.cs b/api/tests/ProposalSystem.Tests/Services/ProposalDeliveryTests.cs index 157dac2..bd23b61 100644 --- a/api/tests/ProposalSystem.Tests/Services/ProposalDeliveryTests.cs +++ b/api/tests/ProposalSystem.Tests/Services/ProposalDeliveryTests.cs @@ -108,7 +108,7 @@ public class ProposalDeliveryTests : IDisposable .Throws(new InvalidOperationException("SES is down")); // Act — should NOT throw - var result = await _sut.MarkSentAsync(proposal.Id); + var result = await _sut.MarkSentAsync(proposal.Id, Ver(proposal.Id)); // Assert — transition completed despite email failure result.Status.Should().Be(ProposalStatus.Sent); @@ -146,7 +146,7 @@ public class ProposalDeliveryTests : IDisposable .Returns("https://s3.example.com/acme.pdf"); // Act - var result = await _sut.MarkSentAsync(proposal.Id); + var result = await _sut.MarkSentAsync(proposal.Id, Ver(proposal.Id)); // Assert result.Status.Should().Be(ProposalStatus.Sent); @@ -176,7 +176,7 @@ public class ProposalDeliveryTests : IDisposable await _db.SaveChangesAsync(); // Act - var result = await _sut.MarkSentAsync(proposal.Id); + var result = await _sut.MarkSentAsync(proposal.Id, Ver(proposal.Id)); // Assert result.Status.Should().Be(ProposalStatus.Sent); @@ -193,7 +193,7 @@ public class ProposalDeliveryTests : IDisposable await _db.SaveChangesAsync(); // Act - var result = await _sut.MarkSentAsync(proposal.Id); + var result = await _sut.MarkSentAsync(proposal.Id, Ver(proposal.Id)); // Assert result.Status.Should().Be(ProposalStatus.Sent); @@ -219,7 +219,7 @@ public class ProposalDeliveryTests : IDisposable await _db.SaveChangesAsync(); // Act - var result = await _sut.MarkSentAsync(proposal.Id); + var result = await _sut.MarkSentAsync(proposal.Id, Ver(proposal.Id)); // Assert result.Status.Should().Be(ProposalStatus.Sent); @@ -248,4 +248,7 @@ public class ProposalDeliveryTests : IDisposable UpdatedAt = DateTime.UtcNow.AddDays(-1), }; } + private string Ver(Guid id) => + ProposalSystem.Application.Common.RowVersionCodec.Encode(_db.Proposals.Find(id)?.Version ?? 1); + } diff --git a/api/tests/ProposalSystem.Tests/Services/ProposalStateMachineTests.cs b/api/tests/ProposalSystem.Tests/Services/ProposalStateMachineTests.cs index feea3f8..1a3b4c3 100644 --- a/api/tests/ProposalSystem.Tests/Services/ProposalStateMachineTests.cs +++ b/api/tests/ProposalSystem.Tests/Services/ProposalStateMachineTests.cs @@ -92,7 +92,7 @@ public class ProposalStateMachineTests : IDisposable await _db.SaveChangesAsync(); // Act - var result = await _sut.ApproveAsync(proposal.Id); + var result = await _sut.ApproveAsync(proposal.Id, Ver(proposal.Id)); // Assert result.Status.Should().Be(ProposalStatus.Approved); @@ -109,7 +109,7 @@ public class ProposalStateMachineTests : IDisposable await _db.SaveChangesAsync(); // Act - var result = await _sut.MarkSentAsync(proposal.Id); + var result = await _sut.MarkSentAsync(proposal.Id, Ver(proposal.Id)); // Assert result.Status.Should().Be(ProposalStatus.Sent); @@ -137,7 +137,7 @@ public class ProposalStateMachineTests : IDisposable await _db.SaveChangesAsync(); // Act - var result = await _sut.ReviseAsync(proposal.Id); + var result = await _sut.ReviseAsync(proposal.Id, Ver(proposal.Id)); // Assert result.Status.Should().Be(ProposalStatus.InReview); @@ -159,7 +159,7 @@ public class ProposalStateMachineTests : IDisposable await _db.SaveChangesAsync(); // Act - var result = await _sut.ApproveAsync(proposal.Id); + var result = await _sut.ApproveAsync(proposal.Id, Ver(proposal.Id)); // Assert result.Status.Should().Be(ProposalStatus.Approved); @@ -174,7 +174,7 @@ public class ProposalStateMachineTests : IDisposable await _db.SaveChangesAsync(); // Act - var result = await _sut.MarkSentAsync(proposal.Id); + var result = await _sut.MarkSentAsync(proposal.Id, Ver(proposal.Id)); // Assert result.Status.Should().Be(ProposalStatus.Sent); @@ -193,7 +193,7 @@ public class ProposalStateMachineTests : IDisposable await _db.SaveChangesAsync(); // Act - var act = () => _sut.MarkSentAsync(proposal.Id); + var act = () => _sut.MarkSentAsync(proposal.Id, Ver(proposal.Id)); // Assert await act.Should().ThrowAsync() @@ -209,7 +209,7 @@ public class ProposalStateMachineTests : IDisposable await _db.SaveChangesAsync(); // Act - var act = () => _sut.ReviseAsync(proposal.Id); + var act = () => _sut.ReviseAsync(proposal.Id, Ver(proposal.Id)); // Assert await act.Should().ThrowAsync() @@ -236,7 +236,7 @@ public class ProposalStateMachineTests : IDisposable await _db.SaveChangesAsync(); // Act - var act = () => _sut.ApproveAsync(proposal.Id); + var act = () => _sut.ApproveAsync(proposal.Id, Ver(proposal.Id)); // Assert await act.Should().ThrowAsync() @@ -252,7 +252,7 @@ public class ProposalStateMachineTests : IDisposable await _db.SaveChangesAsync(); // Act - var act = () => _sut.ReviseAsync(proposal.Id); + var act = () => _sut.ReviseAsync(proposal.Id, Ver(proposal.Id)); // Assert await act.Should().ThrowAsync() @@ -267,7 +267,7 @@ public class ProposalStateMachineTests : IDisposable _db.Proposals.Add(proposal); await _db.SaveChangesAsync(); - var act = () => _sut.ApproveAsync(proposal.Id); + var act = () => _sut.ApproveAsync(proposal.Id, Ver(proposal.Id)); await act.Should().ThrowAsync() .WithMessage("*in review*"); @@ -282,7 +282,7 @@ public class ProposalStateMachineTests : IDisposable await _db.SaveChangesAsync(); // Act - var act = () => _sut.ApproveAsync(proposal.Id); + var act = () => _sut.ApproveAsync(proposal.Id, Ver(proposal.Id)); // Assert await act.Should().ThrowAsync() @@ -309,7 +309,7 @@ public class ProposalStateMachineTests : IDisposable await _db.SaveChangesAsync(); // Act - var act = () => _sut.ApproveAsync(proposal.Id); + var act = () => _sut.ApproveAsync(proposal.Id, Ver(proposal.Id)); // Assert await act.Should().ThrowAsync() @@ -321,9 +321,9 @@ public class ProposalStateMachineTests : IDisposable { var missingId = Guid.NewGuid(); - var approveAct = () => _sut.ApproveAsync(missingId); - var sendAct = () => _sut.MarkSentAsync(missingId); - var reviseAct = () => _sut.ReviseAsync(missingId); + var approveAct = () => _sut.ApproveAsync(missingId, Ver(missingId)); + var sendAct = () => _sut.MarkSentAsync(missingId, Ver(missingId)); + var reviseAct = () => _sut.ReviseAsync(missingId, Ver(missingId)); await approveAct.Should().ThrowAsync(); await sendAct.Should().ThrowAsync(); @@ -368,7 +368,7 @@ public class ProposalStateMachineTests : IDisposable await _db.SaveChangesAsync(); // Act - var result = await _sut.ReviseAsync(proposal.Id); + var result = await _sut.ReviseAsync(proposal.Id, Ver(proposal.Id)); // Assert var revision = await _db.Proposals.FindAsync(result.Id); @@ -398,10 +398,10 @@ public class ProposalStateMachineTests : IDisposable await _db.SaveChangesAsync(); // Act - await _sut.ApproveAsync(proposal.Id); + await _sut.ApproveAsync(proposal.Id, Ver(proposal.Id)); // Assert - await _audit.Received(1).LogAsync(AuditAction.Approve, proposal.Id, Arg.Any(), Arg.Any()); + _audit.Received(1).Stage(AuditAction.Approve, proposal.Id, Arg.Any()); } [Fact(DisplayName = "QA-C2: MarkSent publishes library-ingest job")] @@ -413,7 +413,7 @@ public class ProposalStateMachineTests : IDisposable await _db.SaveChangesAsync(); // Act - await _sut.MarkSentAsync(proposal.Id); + await _sut.MarkSentAsync(proposal.Id, Ver(proposal.Id)); // Assert await _jobPublisher.Received(1).PublishAsync("library-ingest", Arg.Any(), Arg.Any()); @@ -442,4 +442,7 @@ public class ProposalStateMachineTests : IDisposable UpdatedAt = DateTime.UtcNow.AddDays(-1), }; } + private string Ver(Guid id) => + ProposalSystem.Application.Common.RowVersionCodec.Encode(_db.Proposals.Find(id)?.Version ?? 1); + }