mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 06:58:57 +00:00
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:
parent
85bca54e08
commit
a4e59040d6
7 changed files with 397 additions and 42 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue