A reusable conformance case for Statifier.Invoke.Handler implementations
the second member of the
Statifier.Testingfamily ADR-0053 opened, and the mechanical pin for the behaviour contract ADR-0051 decision 4 anddocs/extending.mdstate in prose. A host applicationuses this module in a test alongsideuse ExUnit.Caseand gets the contract checks as generated tests against its own handler:defmodule MyApp.EnrichHandlerConformanceTest do
use ExUnit.Case, async: false use Statifier.Testing.HandlerCase, handler: MyApp.EnrichHandler, type: "myapp:enrich"end
What the generated tests verify, each a clause of the documented contract:
start/2,cancel/2, andforward/3are pure planning callbacks - deterministic for fixed inputs, observably effect-free, returning the documented shapes (Statifier.Invoke.Handler's moduledoc, point 1).perform/2is idempotent oninvoke_id: performing the handler's own planned instructions twice must not duplicate observable effects (point 2 there, anddocs/extending.md's "At-least-once" section). Idempotency needs an observation point only the implementor can name - see "The observation point" below.cancel/2afterstart/2plans without raising, andcancel/2for an invoke_id the handler never saw is a no-op, never a raise - the library plans cancels off the live invocation table, but a crash-recovering host may replay a cancel whose start it never durably recorded.{:error, _}fromstart/2surfaces aserror.executionin the driving chart (ADR-0051 decision 1's classification table), pinned by driving a minimal chart fixture with a case-internal always-failing probe handler registered under the implementor's own type string.- Handler exceptions propagate - the library never rescues them into an
event (
docs/extending.md, "Where the library will not help") - pinned with a case-internal raising probe the same way.
Checks 4 and 5 pin the library's half of the contract with probe
handlers rather than the implementation under test, on purpose: a
conforming handler has no reason to expose a start that fails or raises on
demand, and the point of running the pins downstream is that the contract
an implementor builds against stays enforced by tests where the
implementations live, not by this repository's suite alone. A handler
whose start/2 has a real failure path asserts its own half with
assert_erroring_start/3.
Every check is also a public function on this module, so a suite that
wants different fixtures - or only one of the checks - calls them directly
instead of use-ing the whole case.
Options
:handler(required) - theStatifier.Invoke.Handlerimplementation module under test.:type(required) - the<invoke type>string the handler serves.
The fixtures, and overriding them
The generated tests plan against a synthetic %Statifier.Effect.Invoke{},
a plan context, and an autoforwarded event, built by build_invoke/2,
build_ctx/3, and build_event/1. Each is reachable through an
overridable function in the use-ing module, so a handler that reads
params, src, or content overrides the one fixture it cares about:
conformance_invoke/0- the%Statifier.Effect.Invoke{}handed tostart/2.conformance_ctx/0- the plan context handed to every callback.conformance_event/0- the%Statifier.Event{}handed toforward/3.observed_effects/1- the observation point, below.
The observation point
The case cannot see your job queue, your database, or your test inbox, so
idempotency (and planning purity) are judged against a function you
declare: override observed_effects/1 to return the observable effects
attributable to the given invoke_id at the moment of the call - a list
of enqueued jobs, a set of rows, whatever your perform/2 writes. The
default returns :unobserved, which skips the purity comparisons; the
idempotency test flunks under that default whenever the handler
actually routes instructions to perform/2, naming the override to
write - a handler with real performed effects never gets a silent pass
(ADR-0053's fail-not-skip discipline). A handler that routes nothing to
perform/2 needs no observation point, and its idempotency test asserts
exactly that instead.
Summary
Types
The observation point: returns the observable effects attributable to an
invoke_id, or :unobserved when the use-ing module has not declared
one - see the moduledoc's "The observation point" section.
Functions
Generates the conformance tests into the use-ing module, which must
use ExUnit.Case first (the generated tests are ordinary test blocks).
Options and the overridable fixture functions are described in the
moduledoc.
Contract check 3 for cancel/2: after start/2 has planned, cancel/2
for the same invoke_id deterministically plans {:ok, instructions};
for an invoke_id the handler never saw it is a no-op that returns
{:ok, instructions} rather than raising; and neither call moves
observe's reading.
Contract check 4, the library's half: an {:error, _} from start/2 is
planned as error.execution (ADR-0051 decision 1 - a pure planning
failure, no communication ever attempted). Drives a minimal chart whose
initial state invokes type, registered to a case-internal probe handler
whose start/2 always returns {:error, _}, and asserts the chart
transitions on error.execution - the observation a chart author
actually has.
The implementor's half of contract check 4, for a handler whose start/2
has a real failure path: asserts start/2 returns {:error, _} for
invoke, deterministically. Not generated by use - a conforming handler
need not expose a failing start at all - so a suite whose handler has one
calls this itself with the failing fixture.
Contract check 1 for forward/3: called twice with fixed inputs it
returns the same {:ok, instructions}, and leaves observe's reading
unchanged.
Contract check 5: an exception raised from a handler callback propagates
and crashes the session - the library never rescues it into an event
(docs/extending.md, "Where the library will not help"). Drives the same
minimal chart with a case-internal probe whose start/2 raises, and
asserts the session process exits with that very exception.
Contract check 2: performing the handler's own planned {:handler, module, payload} instructions twice - the crash-and-replay shape
docs/extending.md's "At-least-once" section describes - must not
duplicate the effects observe reads for the invoke's id, and each
perform/2 call must return :ok or {:error, term}.
Contract check 1 for start/2: called twice with fixed inputs it returns
the same value, of the documented shape ({:ok, instructions} or
{:error, term}), and leaves observe's reading for the invoke's own id
unchanged - planning is deciding, never doing.
A plan context (Statifier.Invoke.Handler.t:ctx/0) declaring type
registered and dispatching it to handler - the same three keys
Statifier.Session.Effects.plan/2 hands every planning callback.
overrides merges over the defaults, since ctx is a plain map by
contract.
A synthetic external event for forward/3 (spec 6.4.2's autoforward):
name "conformance.ping", no data. overrides replaces any field.
A synthetic %Statifier.Effect.Invoke{} for planning against: type as
given, invoke_id "inv_1" (the shape a generated platformid takes,
ADR-0008 as amended), empty params, and zeroed counters. overrides
replaces any field.
Types
Functions
Generates the conformance tests into the use-ing module, which must
use ExUnit.Case first (the generated tests are ordinary test blocks).
Options and the overridable fixture functions are described in the
moduledoc.
@spec assert_cancel_contract( handler :: module(), invoke :: Statifier.Effect.Invoke.t(), ctx :: Statifier.Invoke.Handler.ctx(), observe :: observe() ) :: :ok
Contract check 3 for cancel/2: after start/2 has planned, cancel/2
for the same invoke_id deterministically plans {:ok, instructions};
for an invoke_id the handler never saw it is a no-op that returns
{:ok, instructions} rather than raising; and neither call moves
observe's reading.
@spec assert_error_start_raises_error_execution(type :: String.t()) :: :ok
Contract check 4, the library's half: an {:error, _} from start/2 is
planned as error.execution (ADR-0051 decision 1 - a pure planning
failure, no communication ever attempted). Drives a minimal chart whose
initial state invokes type, registered to a case-internal probe handler
whose start/2 always returns {:error, _}, and asserts the chart
transitions on error.execution - the observation a chart author
actually has.
@spec assert_erroring_start( handler :: module(), invoke :: Statifier.Effect.Invoke.t(), ctx :: Statifier.Invoke.Handler.ctx() ) :: :ok
The implementor's half of contract check 4, for a handler whose start/2
has a real failure path: asserts start/2 returns {:error, _} for
invoke, deterministically. Not generated by use - a conforming handler
need not expose a failing start at all - so a suite whose handler has one
calls this itself with the failing fixture.
@spec assert_forward_contract( handler :: module(), invoke :: Statifier.Effect.Invoke.t(), ctx :: Statifier.Invoke.Handler.ctx(), event :: Statifier.Event.t(), observe :: observe() ) :: :ok
Contract check 1 for forward/3: called twice with fixed inputs it
returns the same {:ok, instructions}, and leaves observe's reading
unchanged.
@spec assert_handler_exceptions_propagate(type :: String.t()) :: :ok
Contract check 5: an exception raised from a handler callback propagates
and crashes the session - the library never rescues it into an event
(docs/extending.md, "Where the library will not help"). Drives the same
minimal chart with a case-internal probe whose start/2 raises, and
asserts the session process exits with that very exception.
@spec assert_perform_idempotent( handler :: module(), invoke :: Statifier.Effect.Invoke.t(), ctx :: Statifier.Invoke.Handler.ctx(), event :: Statifier.Event.t(), observe :: observe() ) :: :ok
Contract check 2: performing the handler's own planned {:handler, module, payload} instructions twice - the crash-and-replay shape
docs/extending.md's "At-least-once" section describes - must not
duplicate the effects observe reads for the invoke's id, and each
perform/2 call must return :ok or {:error, term}.
When the handler routes instructions to perform/2 and observe still
answers :unobserved, this flunks naming the observed_effects/1
override to write - never a silent pass. When the handler routes nothing
to perform/2 (the built-in scxml handler's own shape), it asserts
exactly that and no observation point is needed.
@spec assert_start_contract( handler :: module(), invoke :: Statifier.Effect.Invoke.t(), ctx :: Statifier.Invoke.Handler.ctx(), observe :: observe() ) :: :ok
Contract check 1 for start/2: called twice with fixed inputs it returns
the same value, of the documented shape ({:ok, instructions} or
{:error, term}), and leaves observe's reading for the invoke's own id
unchanged - planning is deciding, never doing.
@spec build_ctx(type :: String.t(), handler :: module(), overrides :: keyword()) :: Statifier.Invoke.Handler.ctx()
A plan context (Statifier.Invoke.Handler.t:ctx/0) declaring type
registered and dispatching it to handler - the same three keys
Statifier.Session.Effects.plan/2 hands every planning callback.
overrides merges over the defaults, since ctx is a plain map by
contract.
@spec build_event(overrides :: keyword()) :: Statifier.Event.t()
A synthetic external event for forward/3 (spec 6.4.2's autoforward):
name "conformance.ping", no data. overrides replaces any field.
@spec build_invoke(type :: String.t(), overrides :: keyword()) :: Statifier.Effect.Invoke.t()
A synthetic %Statifier.Effect.Invoke{} for planning against: type as
given, invoke_id "inv_1" (the shape a generated platformid takes,
ADR-0008 as amended), empty params, and zeroed counters. overrides
replaces any field.