diff --git a/src/DaemonTests/EventProjections/scenario_trailing_action_tests.cs b/src/DaemonTests/EventProjections/scenario_trailing_action_tests.cs new file mode 100644 index 0000000000..0c72bd6ee6 --- /dev/null +++ b/src/DaemonTests/EventProjections/scenario_trailing_action_tests.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading.Tasks; +using JasperFx.Events.Projections; +using Marten; +using Marten.Testing.Documents; +using Marten.Testing.Harness; +using Shouldly; +using Xunit; + +namespace DaemonTests.EventProjections; + +// #5126: a ScenarioAction only flushed when the NEXT queued step was an assertion, so anything a +// trailing action appended was still in the session when Execute's finally disposed it. The events +// were silently dropped, and an arrange-only scenario was a no-op that passed. +public class scenario_trailing_action_tests: OneOffConfigurationsContext +{ + [Fact] + public async Task trailing_append_is_committed() + { + StoreOptions(opts => opts.Projections.Add(new UserProjection(), ProjectionLifecycle.Inline)); + + var flushed = Guid.NewGuid(); + var trailing = Guid.NewGuid(); + + await theStore.Advanced.EventProjectionScenario(scenario => + { + // An assertion follows this one, so it flushes through the pre-existing path. + scenario.Append(Guid.NewGuid(), new CreateUser { UserId = flushed, UserName = "Flushed" }); + scenario.DocumentShouldExist(flushed); + + // Nothing follows this one. + scenario.Append(Guid.NewGuid(), new CreateUser { UserId = trailing, UserName = "Trailing" }); + }); + + await using var query = theStore.QuerySession(); + (await query.LoadAsync(trailing)).ShouldNotBeNull(); + } + + [Fact] + public async Task arrange_only_scenario_actually_writes() + { + StoreOptions(opts => opts.Projections.Add(new UserProjection(), ProjectionLifecycle.Inline)); + + var id = Guid.NewGuid(); + + // No assertions at all -- every step is an action, so nothing used to be committed. + await theStore.Advanced.EventProjectionScenario(scenario => + { + scenario.Append(Guid.NewGuid(), new CreateUser { UserId = id, UserName = "Arranged" }); + }); + + await using var query = theStore.QuerySession(); + var user = await query.LoadAsync(id); + user.ShouldNotBeNull(); + user.UserName.ShouldBe("Arranged"); + } +} diff --git a/src/Marten/Events/TestSupport/ProjectionScenario.cs b/src/Marten/Events/TestSupport/ProjectionScenario.cs index a38c07441b..afb6a0e202 100644 --- a/src/Marten/Events/TestSupport/ProjectionScenario.cs +++ b/src/Marten/Events/TestSupport/ProjectionScenario.cs @@ -107,6 +107,25 @@ internal async Task Execute(CancellationToken ct = default) } } + // A ScenarioAction only flushes when the step AFTER it is an assertion, so whatever a + // trailing action queued is still sitting in the session -- and the finally below disposes + // that session without committing. An append with no assertion after it is still an append, + // and an arrange-only scenario should not be a silent no-op that passes. See #5126. + // + // Unconditional on purpose: SaveChangesAsync returns immediately when the unit of work is + // empty, and WaitForNonStaleData is already a no-op when no daemon is running. + try + { + await Session.SaveChangesAsync(ct).ConfigureAwait(false); + await WaitForNonStaleData().ConfigureAwait(false); + } + catch (Exception e) + { + descriptions.Add($"FAILED: committing the events queued by the final step"); + descriptions.Add(e.ToString()); + exceptions.Add(e); + } + if (exceptions.Any()) { throw new ProjectionScenarioException(descriptions, exceptions);