test(api): concurrency, codec, 409 wire-shape, and audit-atomicity coverage

- ProposalConcurrencyTests (shared-connection SQLite, two competing
  writers): stale-token pre-check with embedded currentState, DB-level
  lost race reloading the winner's values, 422 token codes, version
  bump on success, bulk-update proposal-token guard, line-item create
  bumping the aggregate, audit rows never persisted on a lost race.
- GlobalExceptionHandlerTests: pin both SHOC 409 envelopes verbatim
  (guarded { message, currentState } incl. null state; fallback
  { status, message, code }) and assert shape exclusivity.
- RowVersionCodecTests: round-trip, SHOC-style token literal,
  malformed/wrong-length rejection.
- Existing suites threaded with live version tokens (Ver helper);
  audit assertions moved from LogAsync to Stage.

187 xUnit green (was 166).
This commit is contained in:
Adam Moussa 2026-07-13 20:48:28 -04:00
parent 85bca54e08
commit a4e59040d6
No known key found for this signature in database
7 changed files with 397 additions and 42 deletions

View file

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

View file

@ -37,6 +37,29 @@ public static class SqliteDbContextFactory
return context;
}
/// <summary>
/// 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.
/// </summary>
public static (SqliteConnection Connection, Func<ProposalDbContext> ContextFactory) CreateShared()
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
RegisterPostgresStubs(connection);
var options = new DbContextOptionsBuilder<ProposalDbContext>()
.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

View file

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

View file

@ -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<InvalidOperationException>()
@ -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<UpdateLineItemEntry>());
var act = () => _sut.BulkUpdateAsync(Guid.NewGuid(), request);
var act = () => _sut.BulkUpdateAsync(Guid.NewGuid(), request with { ProposalVersion = Ver(Guid.NewGuid()) });
await act.Should().ThrowAsync<KeyNotFoundException>();
}
@ -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<string?>(s => s != null && s.Contains(request.Description)),
Arg.Any<CancellationToken>());
Arg.Is<string?>(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<string?>(s => s != null),
Arg.Any<CancellationToken>());
Arg.Is<string?>(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<string?>(s => s != null && s.Contains("Audit test item")),
Arg.Any<CancellationToken>());
Arg.Is<string?>(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);
}

View file

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

View file

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

View file

@ -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<InvalidOperationException>()
@ -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<InvalidOperationException>()
@ -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<InvalidOperationException>()
@ -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<InvalidOperationException>()
@ -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<InvalidOperationException>()
.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<InvalidOperationException>()
@ -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<InvalidOperationException>()
@ -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<KeyNotFoundException>();
await sendAct.Should().ThrowAsync<KeyNotFoundException>();
@ -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<string?>(), Arg.Any<CancellationToken>());
_audit.Received(1).Stage(AuditAction.Approve, proposal.Id, Arg.Any<string?>());
}
[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<object>(), Arg.Any<CancellationToken>());
@ -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);
}