mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 08:08:56 +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;
|
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)
|
private static void RegisterPostgresStubs(SqliteConnection connection)
|
||||||
{
|
{
|
||||||
// hashtext(text) -> integer — returns a constant; only needed so SQL parses
|
// 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");
|
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
|
// Act
|
||||||
var act = () => _sut.BulkUpdateAsync(proposal.Id, request);
|
var act = () => _sut.BulkUpdateAsync(proposal.Id, request with { ProposalVersion = Ver(proposal.Id) });
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||||
|
|
@ -143,7 +143,7 @@ public class LineItemServiceStateGuardTests : IDisposable
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await _sut.BulkUpdateAsync(proposal.Id, request);
|
var result = await _sut.BulkUpdateAsync(proposal.Id, request with { ProposalVersion = Ver(proposal.Id) });
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Should().HaveCount(2);
|
result.Should().HaveCount(2);
|
||||||
|
|
@ -165,7 +165,7 @@ public class LineItemServiceStateGuardTests : IDisposable
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await _sut.BulkUpdateAsync(proposal.Id, request);
|
await _sut.BulkUpdateAsync(proposal.Id, request with { ProposalVersion = Ver(proposal.Id) });
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
var updated = await _db.Proposals.FindAsync(proposal.Id);
|
var updated = await _db.Proposals.FindAsync(proposal.Id);
|
||||||
|
|
@ -199,7 +199,7 @@ public class LineItemServiceStateGuardTests : IDisposable
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await _sut.BulkUpdateAsync(proposal.Id, request);
|
var result = await _sut.BulkUpdateAsync(proposal.Id, request with { ProposalVersion = Ver(proposal.Id) });
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Should().HaveCount(1);
|
result.Should().HaveCount(1);
|
||||||
|
|
@ -214,7 +214,7 @@ public class LineItemServiceStateGuardTests : IDisposable
|
||||||
public async Task BulkUpdateAsync_NonexistentProposal_ThrowsKeyNotFound()
|
public async Task BulkUpdateAsync_NonexistentProposal_ThrowsKeyNotFound()
|
||||||
{
|
{
|
||||||
var request = new BulkUpdateLineItemsRequest(new List<UpdateLineItemEntry>());
|
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>();
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -317,11 +317,9 @@ public class LineItemServiceStateGuardTests : IDisposable
|
||||||
await _sut.CreateAsync(proposal.Id, request);
|
await _sut.CreateAsync(proposal.Id, request);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await _audit.Received(1).LogAsync(
|
_audit.Received(1).Stage(AuditAction.EditLineItem,
|
||||||
AuditAction.EditLineItem,
|
|
||||||
proposal.Id,
|
proposal.Id,
|
||||||
Arg.Is<string?>(s => s != null && s.Contains(request.Description)),
|
Arg.Is<string?>(s => s != null && s.Contains(request.Description)));
|
||||||
Arg.Any<CancellationToken>());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "QA-C2: BulkUpdateAsync logs audit event on success")]
|
[Fact(DisplayName = "QA-C2: BulkUpdateAsync logs audit event on success")]
|
||||||
|
|
@ -338,14 +336,12 @@ public class LineItemServiceStateGuardTests : IDisposable
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// 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
|
// Assert — audit detail may be plain text ("Bulk update: N items") or JSON
|
||||||
await _audit.Received(1).LogAsync(
|
_audit.Received(1).Stage(AuditAction.EditLineItem,
|
||||||
AuditAction.EditLineItem,
|
|
||||||
proposal.Id,
|
proposal.Id,
|
||||||
Arg.Is<string?>(s => s != null),
|
Arg.Is<string?>(s => s != null));
|
||||||
Arg.Any<CancellationToken>());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "QA-C2: DeleteAsync logs audit event on success")]
|
[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);
|
await _sut.DeleteAsync(proposal.Id, lineItem.Id);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await _audit.Received(1).LogAsync(
|
_audit.Received(1).Stage(AuditAction.EditLineItem,
|
||||||
AuditAction.EditLineItem,
|
|
||||||
proposal.Id,
|
proposal.Id,
|
||||||
Arg.Is<string?>(s => s != null && s.Contains("Audit test item")),
|
Arg.Is<string?>(s => s != null && s.Contains("Audit test item")));
|
||||||
Arg.Any<CancellationToken>());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
@ -418,4 +412,7 @@ public class LineItemServiceStateGuardTests : IDisposable
|
||||||
Source: LineItemSource.Manual
|
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"));
|
.Throws(new InvalidOperationException("SES is down"));
|
||||||
|
|
||||||
// Act — should NOT throw
|
// 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
|
// Assert — transition completed despite email failure
|
||||||
result.Status.Should().Be(ProposalStatus.Sent);
|
result.Status.Should().Be(ProposalStatus.Sent);
|
||||||
|
|
@ -146,7 +146,7 @@ public class ProposalDeliveryTests : IDisposable
|
||||||
.Returns("https://s3.example.com/acme.pdf");
|
.Returns("https://s3.example.com/acme.pdf");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await _sut.MarkSentAsync(proposal.Id);
|
var result = await _sut.MarkSentAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Status.Should().Be(ProposalStatus.Sent);
|
result.Status.Should().Be(ProposalStatus.Sent);
|
||||||
|
|
@ -176,7 +176,7 @@ public class ProposalDeliveryTests : IDisposable
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await _sut.MarkSentAsync(proposal.Id);
|
var result = await _sut.MarkSentAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Status.Should().Be(ProposalStatus.Sent);
|
result.Status.Should().Be(ProposalStatus.Sent);
|
||||||
|
|
@ -193,7 +193,7 @@ public class ProposalDeliveryTests : IDisposable
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await _sut.MarkSentAsync(proposal.Id);
|
var result = await _sut.MarkSentAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Status.Should().Be(ProposalStatus.Sent);
|
result.Status.Should().Be(ProposalStatus.Sent);
|
||||||
|
|
@ -219,7 +219,7 @@ public class ProposalDeliveryTests : IDisposable
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await _sut.MarkSentAsync(proposal.Id);
|
var result = await _sut.MarkSentAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Status.Should().Be(ProposalStatus.Sent);
|
result.Status.Should().Be(ProposalStatus.Sent);
|
||||||
|
|
@ -248,4 +248,7 @@ public class ProposalDeliveryTests : IDisposable
|
||||||
UpdatedAt = DateTime.UtcNow.AddDays(-1),
|
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();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await _sut.ApproveAsync(proposal.Id);
|
var result = await _sut.ApproveAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Status.Should().Be(ProposalStatus.Approved);
|
result.Status.Should().Be(ProposalStatus.Approved);
|
||||||
|
|
@ -109,7 +109,7 @@ public class ProposalStateMachineTests : IDisposable
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await _sut.MarkSentAsync(proposal.Id);
|
var result = await _sut.MarkSentAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Status.Should().Be(ProposalStatus.Sent);
|
result.Status.Should().Be(ProposalStatus.Sent);
|
||||||
|
|
@ -137,7 +137,7 @@ public class ProposalStateMachineTests : IDisposable
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await _sut.ReviseAsync(proposal.Id);
|
var result = await _sut.ReviseAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Status.Should().Be(ProposalStatus.InReview);
|
result.Status.Should().Be(ProposalStatus.InReview);
|
||||||
|
|
@ -159,7 +159,7 @@ public class ProposalStateMachineTests : IDisposable
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await _sut.ApproveAsync(proposal.Id);
|
var result = await _sut.ApproveAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Status.Should().Be(ProposalStatus.Approved);
|
result.Status.Should().Be(ProposalStatus.Approved);
|
||||||
|
|
@ -174,7 +174,7 @@ public class ProposalStateMachineTests : IDisposable
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await _sut.MarkSentAsync(proposal.Id);
|
var result = await _sut.MarkSentAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Status.Should().Be(ProposalStatus.Sent);
|
result.Status.Should().Be(ProposalStatus.Sent);
|
||||||
|
|
@ -193,7 +193,7 @@ public class ProposalStateMachineTests : IDisposable
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var act = () => _sut.MarkSentAsync(proposal.Id);
|
var act = () => _sut.MarkSentAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||||
|
|
@ -209,7 +209,7 @@ public class ProposalStateMachineTests : IDisposable
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var act = () => _sut.ReviseAsync(proposal.Id);
|
var act = () => _sut.ReviseAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||||
|
|
@ -236,7 +236,7 @@ public class ProposalStateMachineTests : IDisposable
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var act = () => _sut.ApproveAsync(proposal.Id);
|
var act = () => _sut.ApproveAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||||
|
|
@ -252,7 +252,7 @@ public class ProposalStateMachineTests : IDisposable
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var act = () => _sut.ReviseAsync(proposal.Id);
|
var act = () => _sut.ReviseAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||||
|
|
@ -267,7 +267,7 @@ public class ProposalStateMachineTests : IDisposable
|
||||||
_db.Proposals.Add(proposal);
|
_db.Proposals.Add(proposal);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
var act = () => _sut.ApproveAsync(proposal.Id);
|
var act = () => _sut.ApproveAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||||
.WithMessage("*in review*");
|
.WithMessage("*in review*");
|
||||||
|
|
@ -282,7 +282,7 @@ public class ProposalStateMachineTests : IDisposable
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var act = () => _sut.ApproveAsync(proposal.Id);
|
var act = () => _sut.ApproveAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||||
|
|
@ -309,7 +309,7 @@ public class ProposalStateMachineTests : IDisposable
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var act = () => _sut.ApproveAsync(proposal.Id);
|
var act = () => _sut.ApproveAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||||
|
|
@ -321,9 +321,9 @@ public class ProposalStateMachineTests : IDisposable
|
||||||
{
|
{
|
||||||
var missingId = Guid.NewGuid();
|
var missingId = Guid.NewGuid();
|
||||||
|
|
||||||
var approveAct = () => _sut.ApproveAsync(missingId);
|
var approveAct = () => _sut.ApproveAsync(missingId, Ver(missingId));
|
||||||
var sendAct = () => _sut.MarkSentAsync(missingId);
|
var sendAct = () => _sut.MarkSentAsync(missingId, Ver(missingId));
|
||||||
var reviseAct = () => _sut.ReviseAsync(missingId);
|
var reviseAct = () => _sut.ReviseAsync(missingId, Ver(missingId));
|
||||||
|
|
||||||
await approveAct.Should().ThrowAsync<KeyNotFoundException>();
|
await approveAct.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
await sendAct.Should().ThrowAsync<KeyNotFoundException>();
|
await sendAct.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
|
|
@ -368,7 +368,7 @@ public class ProposalStateMachineTests : IDisposable
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await _sut.ReviseAsync(proposal.Id);
|
var result = await _sut.ReviseAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
var revision = await _db.Proposals.FindAsync(result.Id);
|
var revision = await _db.Proposals.FindAsync(result.Id);
|
||||||
|
|
@ -398,10 +398,10 @@ public class ProposalStateMachineTests : IDisposable
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await _sut.ApproveAsync(proposal.Id);
|
await _sut.ApproveAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// 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")]
|
[Fact(DisplayName = "QA-C2: MarkSent publishes library-ingest job")]
|
||||||
|
|
@ -413,7 +413,7 @@ public class ProposalStateMachineTests : IDisposable
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await _sut.MarkSentAsync(proposal.Id);
|
await _sut.MarkSentAsync(proposal.Id, Ver(proposal.Id));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await _jobPublisher.Received(1).PublishAsync("library-ingest", Arg.Any<object>(), Arg.Any<CancellationToken>());
|
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),
|
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