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