diff --git a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs index 983526c..bbb1d6b 100644 --- a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs +++ b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs @@ -126,7 +126,21 @@ public sealed class MotionGovernanceService( ? $"Closed with {votesFor:0.##} for and {votesAgainst:0.##} against at a {motion.ApprovalThreshold:P0} threshold." : "Closed without decisive votes."; - await dbContext.SaveChangesAsync(cancellationToken); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException error) + { + // Status is a concurrency token: another closer won. Adopt its result and record nothing a second time. + foreach (var entry in error.Entries) + { + await entry.ReloadAsync(cancellationToken); + } + + return; + } + await auditTrailService.RecordAsync( actorUser.Id, nameof(Motion), diff --git a/tests/Bifrost.Web.Tests/StandardMotionCloseTests.cs b/tests/Bifrost.Web.Tests/StandardMotionCloseTests.cs new file mode 100644 index 0000000..21a95e9 --- /dev/null +++ b/tests/Bifrost.Web.Tests/StandardMotionCloseTests.cs @@ -0,0 +1,49 @@ +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Motions; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Tests; + +public sealed class StandardMotionCloseTests +{ + private static readonly DateTimeOffset T0 = new(2026, 11, 10, 12, 0, 0, TimeSpan.Zero); + + private sealed class Clock(DateTimeOffset start) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => start; + } + + [Fact] + public async Task A_second_simultaneous_close_adopts_the_winners_close_and_records_nothing_more() + { + var clock = new Clock(T0); + var name = $"standard-close-race-{Guid.NewGuid():N}"; + BifrostDbContext NewDb() => new(new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options); + MotionGovernanceService NewService(BifrostDbContext db) => new(db, new AuditTrailService(db, clock), clock); + + await using var seed = NewDb(); + var user = new UserAccount { DisplayName = "Mod", GitHubLogin = "mod", BifrostIdentity = "mod", NormalizedBifrostIdentity = "MOD", NormalizedGitHubLogin = "MOD" }; + user.Membership = new Membership { UserAccountId = user.Id, Status = MembershipStatus.Active, CanModerateMotions = true }; + seed.UserAccounts.Add(user); + await seed.SaveChangesAsync(); + var actor = new CurrentBifrostActor(true, user); + var motion = await NewService(seed).CreateMotionAsync( + actor, + new CreateMotionCommand(MotionScope.Management, null, MotionCategory.Features, "Race", "Two closers.", T0.AddDays(1)), + default); + + await using var first = NewDb(); + await using var second = NewDb(); + _ = await second.Motions.ToListAsync(); // second now holds a stale, still-Open copy + + await NewService(first).CloseMotionAsync(actor, motion.Id, default); + await NewService(second).CloseMotionAsync(actor, motion.Id, default); // the Status token refuses this write + + await using var check = NewDb(); + var row = await check.Motions.SingleAsync(); + Assert.Equal(MotionStatus.Closed, row.Status); + Assert.Equal(1, await check.AuditEvents.CountAsync(x => x.Action == "motion.closed")); + } +}