evoq_cmd_case (evoq_testkit v0.1.0)
View SourcePersistence-level CMD test harness — Layer B.
Layer A (evoq_aggregate_spec) proves command LOGIC purely: the right events for the right state. It is blind to whether those events actually PERSIST when dispatched for real — the half where a malformed stream id, a swallowed dispatch error, or a mis-wired adapter hides. Layer B closes that gap by replaying a command sequence through the REAL dispatch path (evoq_dispatcher) against the in-memory mem_evoq adapter, then reading the stream back to prove the events landed.
mem-evoq is a real evoq_event_store adapter with no Khepri / Ra / disk, so this runs at unit-test speed. evoq_aggregate persists via the globally configured adapter (evoq_event_store:get_adapter/0), so with_mem_store/1 swaps that to mem_evoq_adapter for the duration of the test and restores it after. The aggregate's handle_call appends synchronously before replying {ok, Version, Events}, so a read-back right after a successful dispatch sees the persisted events.
Usage
evoq_cmd_case:with_mem_store(fun(StoreId) ->
Sid = my_aggregate:stream_id(<<"thing-1">>),
Scenario = [{open, #{...}}, {close, #{...}}],
ok = evoq_cmd_case:dispatch_all(my_aggregate, Sid, Scenario, StoreId),
evoq_cmd_case:assert_stream(StoreId, Sid,
[<<"opened">>, <<"closed">>])
end).A scenario step is {CmdType, Payload} OR the Layer-A 4-tuple ({CmdType, Payload, _Expect, _Pred}), so the SAME scenario list drives both layers; Layer B ignores the pure-assertion slots.
Summary
Functions
Assert the persisted stream holds EXACTLY these event types, in order. This is the assertion that distinguishes "dispatch returned ok" from "the events actually landed".
Assert that StreamId is a valid reckon-db stream id. reckon-db rejects ids not matching ^[a-z]{1,32}-[a-f0-9]{32}$ at append time, so an aggregate that uses (say) a human id as its stream id fails on the very first real dispatch. Calling this in a test catches that explicitly — mem-evoq itself does NOT enforce the format, so this guard is what makes Layer B faithful to reckon-db on that point.
Dispatch every command in the scenario through the real dispatch path, asserting each one PERSISTS ({ok, _Version, _Events}). A non-ok dispatch raises — the swallowed catch dispatch that hides "nothing got stored" bugs is structurally impossible here. Validates the stream id up front (reckon-db rejects malformed ids at append, so an invalid id would make every dispatch fail at the store boundary).
Dispatch a single command and assert it persisted.
The event types persisted under a stream, oldest first.
Start a fresh in-memory store, point evoq at the mem-evoq adapter, run Fun(StoreId), then tear down (stop the store, restore the previous adapter). Returns whatever Fun returns. The StoreId is unique per call.
As with_mem_store/1 with store options (e.g. #{integrity => ...}), passed through to mem_evoq:start_store/2.
Functions
Assert the persisted stream holds EXACTLY these event types, in order. This is the assertion that distinguishes "dispatch returned ok" from "the events actually landed".
-spec assert_valid_stream_id(binary()) -> ok.
Assert that StreamId is a valid reckon-db stream id. reckon-db rejects ids not matching ^[a-z]{1,32}-[a-f0-9]{32}$ at append time, so an aggregate that uses (say) a human id as its stream id fails on the very first real dispatch. Calling this in a test catches that explicitly — mem-evoq itself does NOT enforce the format, so this guard is what makes Layer B faithful to reckon-db on that point.
Dispatch every command in the scenario through the real dispatch path, asserting each one PERSISTS ({ok, _Version, _Events}). A non-ok dispatch raises — the swallowed catch dispatch that hides "nothing got stored" bugs is structurally impossible here. Validates the stream id up front (reckon-db rejects malformed ids at append, so an invalid id would make every dispatch fail at the store boundary).
Dispatch a single command and assert it persisted.
The event types persisted under a stream, oldest first.
-spec with_mem_store(fun((atom()) -> Result)) -> Result.
Start a fresh in-memory store, point evoq at the mem-evoq adapter, run Fun(StoreId), then tear down (stop the store, restore the previous adapter). Returns whatever Fun returns. The StoreId is unique per call.
As with_mem_store/1 with store options (e.g. #{integrity => ...}), passed through to mem_evoq:start_store/2.