Skip to content

Commit cf82311

Browse files
committed
Consume the lifted JasperFx.Events ProjectionScenario
Closes #404. Deletes Polecat's seven-file copy of Marten's pre-lift harness and replaces it with one subclass of JasperFx.Events.TestSupport.ProjectionScenario<TOperations, TQuerySession> (jasperfx#616, shipped in JasperFx.Events 2.38.0), closed over Polecat's IDocumentSession / IQuerySession. Marten's equivalent adoption is marten#5133. Nearly free, as #404 predicted: all seven abstract seam members already existed as PolecatComplianceFixture bodies, because that seam was deliberately shaped to match EventStoreComplianceFixture -- including the object-id load dispatch. What Polecat gets that its own copy never had: - The 15 missing overloads exist, inherited rather than written. The one that actually bit was IEnumerable<object>: a caller holding a List<object> had to spread it at every call site. - An arrange-only scenario is no longer a silent no-op. Appends only flushed when the next step was an assertion, so a trailing append was disposed uncommitted (marten#5126). - A failed action stops the scenario instead of letting later steps run against state nobody intended; failed assertions still accumulate and report together. - A scenario can only execute once, Timeout is configurable, and the CancellationToken is honored. - DoNotDeleteExistingData -> DeleteExistingData (default true), Execute -> ExecuteAsync, and DocumentShouldExist/NotExist take object ids. Two things this change does beyond the swap: AdvancedOperations.CleanAsync gains a non-generic Type overload and the generic one now delegates to it. DeleteExistingDataAsync has to reset each projection's Options.StorageTypes, which are Types, and the alternative -- CleanAllDocumentsAsync -- would take out documents a scenario deliberately seeded first. It also removes the last ad-hoc SqlConnection in this file: the old harness cleaned projected documents on a hand-opened connection, outside StoreOptions.ResiliencePipeline. Six tests added beside the five that already existed, covering the seam rather than re-testing scripting behavior jasperfx#616 already unit-tests against a fake store: the IEnumerable overload, a string stream key through the object-id load dispatch, the trailing-append commit rule, the re-execution guard, DeleteExistingData = false, and an async-lifecycle projection that can only pass if BuildDaemonAsync really stood up a daemon and the scenario waited on it. projection_scenario_tests 11/11.
1 parent 0414f2d commit cf82311

9 files changed

Lines changed: 236 additions & 430 deletions

src/Polecat.Tests/Events/projection_scenario_tests.cs

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using JasperFx.Events;
22
using JasperFx.Events.Projections;
3+
using Polecat.Events.TestSupport;
34
using Polecat.Projections;
45
using Polecat.Tests.Harness;
56

@@ -16,6 +17,18 @@ public partial class ScenarioQuestParty
1617
public void Apply(MembersDeparted e) => Members.RemoveAll(m => e.Members.Contains(m));
1718
}
1819

20+
/// <summary>
21+
/// String-keyed twin of <see cref="ScenarioQuestParty" />, so the scenario's object-id load
22+
/// dispatch gets exercised with something other than a Guid.
23+
/// </summary>
24+
public partial class ScenarioStringQuestParty
25+
{
26+
public string Id { get; set; } = string.Empty;
27+
public string Name { get; set; } = string.Empty;
28+
29+
public void Apply(QuestStarted e) => Name = e.Name;
30+
}
31+
1932
[Collection("integration")]
2033
public class projection_scenario_tests : IntegrationContext
2134
{
@@ -140,7 +153,7 @@ await StoreOptions(opts =>
140153

141154
var missingId = Guid.NewGuid();
142155

143-
await Should.ThrowAsync<Polecat.Events.TestSupport.ProjectionScenarioException>(async () =>
156+
await Should.ThrowAsync<JasperFx.Events.TestSupport.ProjectionScenarioException>(async () =>
144157
{
145158
await theStore.Advanced.EventProjectionScenario(scenario =>
146159
{
@@ -149,4 +162,166 @@ await theStore.Advanced.EventProjectionScenario(scenario =>
149162
});
150163
});
151164
}
165+
166+
// The tests below cover what #404 bought: the harness is now
167+
// JasperFx.Events.TestSupport.ProjectionScenario<,> and Polecat supplies only the seam. They
168+
// exercise the seam through surface Polecat's own copy never had, rather than re-testing
169+
// scripting behavior that jasperfx#616 already unit-tests against a fake store.
170+
171+
[Fact]
172+
public async Task append_accepts_an_enumerable_of_events()
173+
{
174+
await StoreOptions(opts =>
175+
{
176+
opts.DatabaseSchemaName = "scenario_enumerable";
177+
opts.Projections.Add<SingleStreamProjection<ScenarioQuestParty, Guid>>(ProjectionLifecycle.Inline);
178+
});
179+
180+
var questId = Guid.NewGuid();
181+
182+
// The headline gap in the original #404: a caller holding a List<object> had to spread it
183+
// at every call site, because Polecat's copy was params-only.
184+
var events = new List<object>
185+
{
186+
new QuestStarted("Enumerable Quest"),
187+
new MembersJoined(1, "Bree", ["Barliman"])
188+
};
189+
190+
await theStore.Advanced.EventProjectionScenario(scenario =>
191+
{
192+
scenario.StartStream(questId, events);
193+
194+
scenario.DocumentShouldExist<ScenarioQuestParty>(questId, doc =>
195+
{
196+
doc.Name.ShouldBe("Enumerable Quest");
197+
doc.Members.ShouldContain("Barliman");
198+
});
199+
}, TestContext.Current.CancellationToken);
200+
}
201+
202+
[Fact]
203+
public async Task string_stream_key_flows_through_the_object_id_load_dispatch()
204+
{
205+
await StoreOptions(opts =>
206+
{
207+
opts.DatabaseSchemaName = "scenario_stringkey";
208+
opts.Events.StreamIdentity = StreamIdentity.AsString;
209+
opts.Projections.Add<SingleStreamProjection<ScenarioStringQuestParty, string>>(
210+
ProjectionLifecycle.Inline);
211+
});
212+
213+
var key = "quest-" + Guid.NewGuid();
214+
215+
// DocumentShouldExist takes object now rather than one overload per identity type, so the
216+
// seam's LoadDocumentAsync has to dispatch on the runtime type. That is the part only a
217+
// real store can prove.
218+
await theStore.Advanced.EventProjectionScenario(scenario =>
219+
{
220+
scenario.StartStream(key, new QuestStarted("Keyed Quest"));
221+
scenario.DocumentShouldExist<ScenarioStringQuestParty>(key, doc => doc.Name.ShouldBe("Keyed Quest"));
222+
}, TestContext.Current.CancellationToken);
223+
}
224+
225+
[Fact]
226+
public async Task a_trailing_append_with_no_assertion_after_it_is_still_committed()
227+
{
228+
await StoreOptions(opts =>
229+
{
230+
opts.DatabaseSchemaName = "scenario_trailing";
231+
opts.Projections.Add<SingleStreamProjection<ScenarioQuestParty, Guid>>(ProjectionLifecycle.Inline);
232+
});
233+
234+
var questId = Guid.NewGuid();
235+
236+
// An arrange-only scenario used to be a silent no-op that passed: appends only flushed when
237+
// the next step was an assertion, and the trailing one was disposed uncommitted (marten#5126).
238+
await theStore.Advanced.EventProjectionScenario(scenario =>
239+
{
240+
scenario.Append(questId, new QuestStarted("Trailing Quest"));
241+
}, TestContext.Current.CancellationToken);
242+
243+
await using var query = theStore.QuerySession();
244+
var party = await query.LoadAsync<ScenarioQuestParty>(questId, TestContext.Current.CancellationToken);
245+
246+
party.ShouldNotBeNull();
247+
party.Name.ShouldBe("Trailing Quest");
248+
}
249+
250+
[Fact]
251+
public async Task a_scenario_cannot_be_executed_twice()
252+
{
253+
await StoreOptions(opts =>
254+
{
255+
opts.DatabaseSchemaName = "scenario_once";
256+
opts.Projections.Add<SingleStreamProjection<ScenarioQuestParty, Guid>>(ProjectionLifecycle.Inline);
257+
});
258+
259+
var scenario = new ProjectionScenario(theStore);
260+
scenario.Append(Guid.NewGuid(), new QuestStarted("Once"));
261+
262+
await scenario.ExecuteAsync(TestContext.Current.CancellationToken);
263+
264+
// The steps were consumed by the first run, so a second run would be a silent no-op. It
265+
// should be a loud failure instead.
266+
await Should.ThrowAsync<InvalidOperationException>(async () =>
267+
{
268+
await scenario.ExecuteAsync(TestContext.Current.CancellationToken);
269+
});
270+
}
271+
272+
[Fact]
273+
public async Task delete_existing_data_can_be_turned_off()
274+
{
275+
await StoreOptions(opts =>
276+
{
277+
opts.DatabaseSchemaName = "scenario_keepdata";
278+
opts.Projections.Add<SingleStreamProjection<ScenarioQuestParty, Guid>>(ProjectionLifecycle.Inline);
279+
});
280+
281+
var first = Guid.NewGuid();
282+
var second = Guid.NewGuid();
283+
284+
await theStore.Advanced.EventProjectionScenario(scenario =>
285+
{
286+
scenario.Append(first, new QuestStarted("Survivor"));
287+
}, TestContext.Current.CancellationToken);
288+
289+
// DeleteExistingData replaced the double-negative DoNotDeleteExistingData, and defaults to
290+
// true -- so without this the first quest would be wiped by the second run.
291+
await theStore.Advanced.EventProjectionScenario(scenario =>
292+
{
293+
scenario.DeleteExistingData = false;
294+
scenario.Append(second, new QuestStarted("Newcomer"));
295+
296+
scenario.DocumentShouldExist<ScenarioQuestParty>(first, doc => doc.Name.ShouldBe("Survivor"));
297+
scenario.DocumentShouldExist<ScenarioQuestParty>(second, doc => doc.Name.ShouldBe("Newcomer"));
298+
}, TestContext.Current.CancellationToken);
299+
}
300+
301+
[Fact]
302+
public async Task scenario_stands_up_a_daemon_for_an_async_projection()
303+
{
304+
await StoreOptions(opts =>
305+
{
306+
opts.DatabaseSchemaName = "scenario_async";
307+
opts.Projections.Add<SingleStreamProjection<ScenarioQuestParty, Guid>>(ProjectionLifecycle.Async);
308+
});
309+
310+
var questId = Guid.NewGuid();
311+
312+
// Exercises the BuildDaemonAsync seam and the non-stale wait: with an async lifecycle the
313+
// assertion can only pass if the scenario actually ran a daemon and waited for it.
314+
await theStore.Advanced.EventProjectionScenario(scenario =>
315+
{
316+
scenario.Append(questId,
317+
new QuestStarted("Async Quest"),
318+
new MembersJoined(1, "Rivendell", ["Elrond"]));
319+
320+
scenario.DocumentShouldExist<ScenarioQuestParty>(questId, doc =>
321+
{
322+
doc.Name.ShouldBe("Async Quest");
323+
doc.Members.ShouldContain("Elrond");
324+
});
325+
}, TestContext.Current.CancellationToken);
326+
}
152327
}

src/Polecat/AdvancedOperations.cs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -492,9 +492,20 @@ private static async Task DeleteFlatTablesAsync(SqlConnection conn, string[] qua
492492
/// <summary>
493493
/// Delete all rows from the document table for type T.
494494
/// </summary>
495-
public async Task CleanAsync<T>(CancellationToken token = default)
495+
public Task CleanAsync<T>(CancellationToken token = default) => CleanAsync(typeof(T), token);
496+
497+
/// <summary>
498+
/// Delete all rows from the document table for <paramref name="documentType" />.
499+
/// </summary>
500+
/// <remarks>
501+
/// The non-generic twin of <see cref="CleanAsync{T}" />, for callers holding a
502+
/// <see cref="Type" /> rather than a generic parameter — a projection's
503+
/// <c>Options.StorageTypes</c>, for one, which is how <see cref="ProjectionScenario" />
504+
/// resets exactly the documents its projections own instead of every table in the schema.
505+
/// </remarks>
506+
public async Task CleanAsync(Type documentType, CancellationToken token = default)
496507
{
497-
var provider = _store.GetProvider(typeof(T));
508+
var provider = _store.GetProvider(documentType);
498509
var tableName = provider.Mapping.QualifiedTableName;
499510
var connStr = _store.Options.ConnectionString;
500511
await _resilience.ExecuteAsync(static async (state, ct) =>
@@ -683,7 +694,7 @@ public Task EventProjectionScenario(Action<ProjectionScenario> configuration, Ca
683694
{
684695
var scenario = new ProjectionScenario(_store);
685696
configuration(scenario);
686-
return scenario.Execute(ct);
697+
return scenario.ExecuteAsync(ct);
687698
}
688699

689700
// ---- runtime tenant onboarding under managed per-tenant partitioning (#335) ----

src/Polecat/Events/TestSupport/ProjectionScenario.Assertions.cs

Lines changed: 0 additions & 120 deletions
This file was deleted.

0 commit comments

Comments
 (0)