@kairos-es/testing 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.md +13 -0
- package/dist/cjs/index.js +17 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/specification.js +159 -0
- package/dist/cjs/specification.js.map +1 -0
- package/dist/dts/index.d.ts +28 -0
- package/dist/dts/index.d.ts.map +1 -0
- package/dist/dts/specification.d.ts +76 -0
- package/dist/dts/specification.d.ts.map +1 -0
- package/dist/esm/index.js +28 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/package.json +4 -0
- package/dist/esm/specification.js +150 -0
- package/dist/esm/specification.js.map +1 -0
- package/package.json +33 -0
- package/src/index.ts +27 -0
- package/src/specification.ts +294 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Neverbland
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @kairos-es/testing
|
|
2
|
+
|
|
3
|
+
Given/When/Then decision-model testing surface for kairos-es.
|
|
4
|
+
|
|
5
|
+
A `DeciderSpecification`-equivalent over the codec's `buildDecisionModel`, running
|
|
6
|
+
the real read → decode → decide → append cycle against the in-memory store rather
|
|
7
|
+
than a mock, on `@effect/vitest`. `@kairos-es/core`, `@kairos-es/codec`, `effect`
|
|
8
|
+
and `@effect/vitest` are peer dependencies.
|
|
9
|
+
|
|
10
|
+
The `Decider` a scenario exercises and the `runDecider` that runs it are
|
|
11
|
+
production symbols and belong to [`@kairos-es/codec`](../codec); this package
|
|
12
|
+
imports them and deliberately does not re-export them, so typing a command
|
|
13
|
+
handler never means naming a package that peers a test framework.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
var _specification = require("./specification.js");
|
|
7
|
+
Object.keys(_specification).forEach(function (key) {
|
|
8
|
+
if (key === "default" || key === "__esModule") return;
|
|
9
|
+
if (key in exports && exports[key] === _specification[key]) return;
|
|
10
|
+
Object.defineProperty(exports, key, {
|
|
11
|
+
enumerable: true,
|
|
12
|
+
get: function () {
|
|
13
|
+
return _specification[key];
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["_specification","require","Object","keys","forEach","key","exports","defineProperty","enumerable","get"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":";;;;;AA0BA,IAAAA,cAAA,GAAAC,OAAA;AAAAC,MAAA,CAAAC,IAAA,CAAAH,cAAA,EAAAI,OAAA,WAAAC,GAAA;EAAA,IAAAA,GAAA,kBAAAA,GAAA;EAAA,IAAAA,GAAA,IAAAC,OAAA,IAAAA,OAAA,CAAAD,GAAA,MAAAL,cAAA,CAAAK,GAAA;EAAAH,MAAA,CAAAK,cAAA,CAAAD,OAAA,EAAAD,GAAA;IAAAG,UAAA;IAAAC,GAAA,WAAAA,CAAA;MAAA,OAAAT,cAAA,CAAAK,GAAA;IAAA;EAAA;AAAA","ignoreList":[]}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.specificationFor = exports.scenario = void 0;
|
|
7
|
+
var _vitest = require("@effect/vitest");
|
|
8
|
+
var _codec = require("@kairos-es/codec");
|
|
9
|
+
var _core = require("@kairos-es/core");
|
|
10
|
+
var _effect = require("effect");
|
|
11
|
+
/**
|
|
12
|
+
* The Given/When/Then surface: a `DeciderSpecification`-equivalent over the real
|
|
13
|
+
* `@kairos-es/codec` `buildDecisionModel` fold and a `DcbEventStore` (the
|
|
14
|
+
* in-memory layer in practice). It is shaped so an Event Modeling
|
|
15
|
+
* `Specification` (given events, when a command, then events / thenError a tag)
|
|
16
|
+
* compiles onto it 1:1 — prior art: dcb-events `TestRunner` and Emmett's
|
|
17
|
+
* `DeciderSpecification`.
|
|
18
|
+
*
|
|
19
|
+
* specificationFor(decider)
|
|
20
|
+
* .given(...pastEventProducers)
|
|
21
|
+
* .when(command)
|
|
22
|
+
* .then(...expectedNewEventProducers) // success branch
|
|
23
|
+
*
|
|
24
|
+
* specificationFor(decider)
|
|
25
|
+
* .given(...pastEventProducers)
|
|
26
|
+
* .when(command)
|
|
27
|
+
* .thenError('AppendConditionFailed') // failure branch (domain error or conflict)
|
|
28
|
+
*
|
|
29
|
+
* Events are supplied *effectfully*: a given/expected event is the producer an
|
|
30
|
+
* application authors via `toEvent` — an `Effect<DcbEvent, CodecError,
|
|
31
|
+
* Serializer | Uuid | R>` — so both sides materialise (encode -> serialise)
|
|
32
|
+
* under the same layers the cycle runs against, and their `data` bytes match.
|
|
33
|
+
*
|
|
34
|
+
* Each terminal (`then` / `thenError`) returns an `Effect` requiring
|
|
35
|
+
* `DcbEventStore | Serializer | Uuid | R`, so a test provides the store,
|
|
36
|
+
* serialiser, and uuid layers (fresh store per case for isolation).
|
|
37
|
+
* `scenario(...)` wraps the whole thing in `@effect/vitest`'s `it.effect`
|
|
38
|
+
* against a fresh in-memory store plus `SerializerDefault` and `UuidTest`, so
|
|
39
|
+
* example specs read as one line.
|
|
40
|
+
*
|
|
41
|
+
* `then` compares the *appended* events (those written past the given seed) to
|
|
42
|
+
* the expected batch structurally — by `type`, `tags`, and `data` — ignoring
|
|
43
|
+
* the store-assigned `position` and the producer-supplied `uuid`/`occurredAt`,
|
|
44
|
+
* which are not part of the decision under test. JSON serialisation is
|
|
45
|
+
* deterministic, so equal payloads produce equal `data` bytes.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
const shapeOf = event => ({
|
|
49
|
+
type: event.type,
|
|
50
|
+
tags: event.tags,
|
|
51
|
+
data: event.data
|
|
52
|
+
});
|
|
53
|
+
/** Collect a stream of sequenced events into a plain array. */
|
|
54
|
+
const collect = events => _effect.Stream.runCollect(events).pipe(_effect.Effect.map(_effect.Chunk.toReadonlyArray));
|
|
55
|
+
/**
|
|
56
|
+
* Seed the given past-event producers into the store: materialise each producer
|
|
57
|
+
* (encode -> serialise), then one append each (log order). Returns the position
|
|
58
|
+
* the LAST seeded append was written at, or `ORIGIN` when nothing was seeded —
|
|
59
|
+
* this is the boundary `then` reads strictly after.
|
|
60
|
+
*
|
|
61
|
+
* We track the boundary by position rather than by count because positions are
|
|
62
|
+
* gappy on a real store (Postgres `bigserial` skips values on rollbacks and
|
|
63
|
+
* across concurrent writers), so "everything past N rows" is wrong; "everything
|
|
64
|
+
* strictly after this position" is exact regardless of gaps.
|
|
65
|
+
*/
|
|
66
|
+
const seed = producers => _effect.Effect.gen(function* () {
|
|
67
|
+
const store = yield* _core.DcbEventStore;
|
|
68
|
+
let last = _core.ORIGIN;
|
|
69
|
+
for (const producer of producers) {
|
|
70
|
+
const event = yield* producer;
|
|
71
|
+
// Unconditional appends never fail; a failure here would be a defect in
|
|
72
|
+
// the store, not a domain outcome, so turn it into one.
|
|
73
|
+
last = yield* store.append([event]).pipe(_effect.Effect.orDie);
|
|
74
|
+
}
|
|
75
|
+
return last;
|
|
76
|
+
});
|
|
77
|
+
/**
|
|
78
|
+
* The events appended past the seed boundary, so `then` can read back exactly
|
|
79
|
+
* the events the decider appended. We read all matching events strictly after
|
|
80
|
+
* `after` (the last seeded position), which is correct on a gappy store where
|
|
81
|
+
* dropping a fixed row count would not be — the in-memory store gives the
|
|
82
|
+
* identical result because its positions are gapless.
|
|
83
|
+
*/
|
|
84
|
+
const appendedAfter = after => _effect.Effect.gen(function* () {
|
|
85
|
+
const store = yield* _core.DcbEventStore;
|
|
86
|
+
const {
|
|
87
|
+
events
|
|
88
|
+
} = yield* store.read(_core.query.all(), {
|
|
89
|
+
after
|
|
90
|
+
});
|
|
91
|
+
return yield* collect(events);
|
|
92
|
+
});
|
|
93
|
+
/**
|
|
94
|
+
* Begin a specification for `decider`. The returned `given(...)` starts the
|
|
95
|
+
* Given/When/Then chain; the terminal step yields an `Effect` requiring
|
|
96
|
+
* `DcbEventStore | Serializer | Uuid | R | RequirementOf<P>`.
|
|
97
|
+
*/
|
|
98
|
+
const specificationFor = decider => ({
|
|
99
|
+
given: (...pastEvents) => ({
|
|
100
|
+
when: command => {
|
|
101
|
+
// Run the seed + decider cycle, exposing the outcome as an Exit so both
|
|
102
|
+
// branches (then / thenError) can inspect success or failure uniformly. The
|
|
103
|
+
// seed is inside the Exit too, so a decode failure while materialising a
|
|
104
|
+
// given producer is captured as a failure rather than escaping the channel.
|
|
105
|
+
// The success value pairs the seed boundary (the last seeded position) with
|
|
106
|
+
// the decider's outcome, so `then` reads strictly after that boundary
|
|
107
|
+
// rather than dropping a fixed row count (correct on a gappy store).
|
|
108
|
+
const runToExit = seed(pastEvents).pipe(_effect.Effect.zip((0, _codec.runDecider)(decider, command)), _effect.Effect.exit);
|
|
109
|
+
return {
|
|
110
|
+
// biome-ignore lint/suspicious/noThenProperty: deliberate Given/When/Then terminal name (see WhenSpec.then); never awaited as a thenable.
|
|
111
|
+
then: (...expected) => _effect.Effect.gen(function* () {
|
|
112
|
+
const exit = yield* runToExit;
|
|
113
|
+
if (_effect.Exit.isFailure(exit)) {
|
|
114
|
+
// Surface the real failure to the caller's error channel; a
|
|
115
|
+
// `.then` on a failing cycle is a test authoring mistake, so make
|
|
116
|
+
// it loud rather than silently passing.
|
|
117
|
+
return yield* _effect.Effect.failCause(exit.cause);
|
|
118
|
+
}
|
|
119
|
+
const [seedBoundary] = exit.value;
|
|
120
|
+
const appended = yield* appendedAfter(seedBoundary);
|
|
121
|
+
const expectedEvents = yield* _effect.Effect.all(expected);
|
|
122
|
+
(0, _vitest.expect)(appended.map(s => shapeOf(s.event))).toEqual(expectedEvents.map(shapeOf));
|
|
123
|
+
}),
|
|
124
|
+
thenError: tag => _effect.Effect.gen(function* () {
|
|
125
|
+
const exit = yield* runToExit;
|
|
126
|
+
(0, _vitest.expect)(_effect.Exit.isFailure(exit)).toBe(true);
|
|
127
|
+
if (_effect.Exit.isFailure(exit)) {
|
|
128
|
+
const failure = _effect.Cause.failureOption(exit.cause);
|
|
129
|
+
const error = failure._tag === 'Some' ? failure.value : undefined;
|
|
130
|
+
const actualTag = typeof error === 'object' && error !== null && '_tag' in error ? error._tag : undefined;
|
|
131
|
+
(0, _vitest.expect)(actualTag).toBe(tag);
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
});
|
|
138
|
+
/**
|
|
139
|
+
* `scenario(description, spec => spec.given(...).when(...).then(...))` — register
|
|
140
|
+
* an `@effect/vitest` `it.effect` case running `build` against a fresh in-memory
|
|
141
|
+
* `DcbEventStore` plus the default serialiser and a fresh deterministic uuid
|
|
142
|
+
* layer. The callback receives `specificationFor(decider)` so example suites
|
|
143
|
+
* read as a single Given/When/Then line per case.
|
|
144
|
+
*
|
|
145
|
+
* `scenario` provides exactly the store, serialiser, and uuid, so `build` must
|
|
146
|
+
* return an effect requiring only those three. For a plain decider (`R` and
|
|
147
|
+
* `RequirementOf<P>` both `never`) that is automatic; a decider carrying an
|
|
148
|
+
* encryption requirement provides its cipher layer inside `build` before
|
|
149
|
+
* returning.
|
|
150
|
+
*/
|
|
151
|
+
exports.specificationFor = specificationFor;
|
|
152
|
+
const scenario = (description, decider, build) => {
|
|
153
|
+
_vitest.it.effect(description, () => build(specificationFor(decider)).pipe(
|
|
154
|
+
// A fresh store + uuid layer per case: both are stateful, so isolation is
|
|
155
|
+
// required (mirrors the contract suite's per-case layer thunk).
|
|
156
|
+
_effect.Effect.provide(_effect.Layer.mergeAll(_effect.Layer.fresh(_core.DcbEventStoreInMemory), _codec.SerializerDefault, _effect.Layer.fresh(_codec.UuidTest)))));
|
|
157
|
+
};
|
|
158
|
+
exports.scenario = scenario;
|
|
159
|
+
//# sourceMappingURL=specification.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"specification.js","names":["_vitest","require","_codec","_core","_effect","shapeOf","event","type","tags","data","collect","events","Stream","runCollect","pipe","Effect","map","Chunk","toReadonlyArray","seed","producers","gen","store","DcbEventStore","last","ORIGIN","producer","append","orDie","appendedAfter","after","read","Q","all","specificationFor","decider","given","pastEvents","when","command","runToExit","zip","runDecider","exit","then","expected","Exit","isFailure","failCause","cause","seedBoundary","value","appended","expectedEvents","expect","s","toEqual","thenError","tag","toBe","failure","Cause","failureOption","error","_tag","undefined","actualTag","exports","scenario","description","build","it","effect","provide","Layer","mergeAll","fresh","DcbEventStoreInMemory","SerializerDefault","UuidTest"],"sources":["../../src/specification.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kairos-es/testing — the Given/When/Then decision-model testing surface.
|
|
3
|
+
*
|
|
4
|
+
* A `DeciderSpecification`-equivalent over the real `@kairos-es/codec`
|
|
5
|
+
* `buildDecisionModel` fold and a `DcbEventStore` (in practice the in-memory
|
|
6
|
+
* layer). Shaped so an Event Modeling `Specification` (given events / when a
|
|
7
|
+
* command / then events / thenError a tag) compiles onto it 1:1.
|
|
8
|
+
*
|
|
9
|
+
* scenario('subscribe a student', subscribeDecider, (spec) =>
|
|
10
|
+
* spec
|
|
11
|
+
* .given(courseDefined('c1', 10))
|
|
12
|
+
* .when({ studentId: 's1', courseId: 'c1' })
|
|
13
|
+
* .then(studentSubscribed('s1', 'c1')),
|
|
14
|
+
* )
|
|
15
|
+
*
|
|
16
|
+
* The unit it exercises — `Decider`, pairing the schema-backed projections a
|
|
17
|
+
* command reads with an effectful `decide` step producing events via `toEvent`,
|
|
18
|
+
* and `runDecider`, running the real read -> decode -> decide -> append cycle —
|
|
19
|
+
* belongs to `@kairos-es/codec` and is imported from there, so the surface
|
|
20
|
+
* exercises the genuine production cycle and not a test-only restatement of it.
|
|
21
|
+
* It is deliberately NOT re-exported here: this package peers `@effect/vitest`,
|
|
22
|
+
* and a re-export would let a production command handler reach a test framework
|
|
23
|
+
* through the type it names. Given/expected events are effectful producers
|
|
24
|
+
* (`toEvent`), materialised under the same serialiser + uuid layers `scenario`
|
|
25
|
+
* provides.
|
|
26
|
+
*/
|
|
27
|
+
export * from './specification.js';
|
|
28
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,cAAc,iBAAiB,CAAA"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { CodecError, Decider, RequirementOf, SliceProjection } from '@kairos-es/codec';
|
|
2
|
+
import { type Serializer, type Uuid } from '@kairos-es/codec';
|
|
3
|
+
import { type AppendConditionFailed, type DcbEvent, DcbEventStore } from '@kairos-es/core';
|
|
4
|
+
import { Effect } from 'effect';
|
|
5
|
+
/** An effectful producer of a wire event (the `toEvent` result an app authors). */
|
|
6
|
+
export type EventProducer<R> = Effect.Effect<DcbEvent, CodecError, Serializer | Uuid | R>;
|
|
7
|
+
/**
|
|
8
|
+
* The entry point of the Given/When/Then chain: what `specificationFor` returns
|
|
9
|
+
* and what `scenario` hands its `build` callback. It is NAMED rather than
|
|
10
|
+
* written inline at those two sites because it was the one step of this chain
|
|
11
|
+
* with no name — the two statements of it were free to drift — and it is the
|
|
12
|
+
* step a caller most needs to name, since factoring a `build` callback out of a
|
|
13
|
+
* `scenario` call means writing its parameter's type. The sibling fluent
|
|
14
|
+
* builder in `@kairos-es/codec` names its own entry point (`ProjectionBuilder`)
|
|
15
|
+
* for the same reason.
|
|
16
|
+
*
|
|
17
|
+
* It introduces no type parameter of its own: all four are `GivenSpec`'s below,
|
|
18
|
+
* passed straight through, because seeding past events changes nothing about
|
|
19
|
+
* the command, the domain error, or either requirement — it only fixes the
|
|
20
|
+
* state the decision is folded from.
|
|
21
|
+
*/
|
|
22
|
+
export interface Specification<Command, E, R, PR> {
|
|
23
|
+
/**
|
|
24
|
+
* Seed the past events, in log order (one append each). They are effectful
|
|
25
|
+
* producers, materialised under the same layers the decision runs against.
|
|
26
|
+
*/
|
|
27
|
+
readonly given: (...pastEvents: ReadonlyArray<EventProducer<R>>) => GivenSpec<Command, E, R, PR>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A spec bound to a decider and a set of given events, awaiting `.when`. `PR` is
|
|
31
|
+
* the slices' decode requirement (`RequirementOf<P>`) that also flows through
|
|
32
|
+
* the terminals' requirement channel alongside the produce requirement `R`.
|
|
33
|
+
*/
|
|
34
|
+
export interface GivenSpec<Command, E, R, PR> {
|
|
35
|
+
/** Supply the command to decide. */
|
|
36
|
+
readonly when: (command: Command) => WhenSpec<E, R, PR>;
|
|
37
|
+
}
|
|
38
|
+
/** A spec that has a command and awaits a `then`/`thenError` assertion. */
|
|
39
|
+
export interface WhenSpec<E, R, PR> {
|
|
40
|
+
/**
|
|
41
|
+
* Assert the success branch: the decider appended exactly these events
|
|
42
|
+
* (compared structurally by type/tags/data, in order). The expected events are
|
|
43
|
+
* effectful producers, materialised under the provided layers. The error
|
|
44
|
+
* channel keeps `E | CodecError | AppendConditionFailed` because a genuinely
|
|
45
|
+
* failing cycle re-raises its cause (a `.then` on a failing decision is a
|
|
46
|
+
* test-authoring mistake, made loud rather than silently green).
|
|
47
|
+
*/
|
|
48
|
+
readonly then: (...expected: ReadonlyArray<EventProducer<R>>) => Effect.Effect<void, E | CodecError | AppendConditionFailed, DcbEventStore | Serializer | Uuid | R | PR>;
|
|
49
|
+
/**
|
|
50
|
+
* Assert the failure branch: the cycle failed with a tagged error whose
|
|
51
|
+
* `_tag` equals `tag` (a domain error from `decide`, a `CodecError` from
|
|
52
|
+
* decode, or `AppendConditionFailed` from a moved consistency boundary).
|
|
53
|
+
*/
|
|
54
|
+
readonly thenError: (tag: string) => Effect.Effect<void, never, DcbEventStore | Serializer | Uuid | R | PR>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Begin a specification for `decider`. The returned `given(...)` starts the
|
|
58
|
+
* Given/When/Then chain; the terminal step yields an `Effect` requiring
|
|
59
|
+
* `DcbEventStore | Serializer | Uuid | R | RequirementOf<P>`.
|
|
60
|
+
*/
|
|
61
|
+
export declare const specificationFor: <P extends Record<string, SliceProjection>, Command, E, R>(decider: Decider<P, Command, E, R>) => Specification<Command, E, R, RequirementOf<P>>;
|
|
62
|
+
/**
|
|
63
|
+
* `scenario(description, spec => spec.given(...).when(...).then(...))` — register
|
|
64
|
+
* an `@effect/vitest` `it.effect` case running `build` against a fresh in-memory
|
|
65
|
+
* `DcbEventStore` plus the default serialiser and a fresh deterministic uuid
|
|
66
|
+
* layer. The callback receives `specificationFor(decider)` so example suites
|
|
67
|
+
* read as a single Given/When/Then line per case.
|
|
68
|
+
*
|
|
69
|
+
* `scenario` provides exactly the store, serialiser, and uuid, so `build` must
|
|
70
|
+
* return an effect requiring only those three. For a plain decider (`R` and
|
|
71
|
+
* `RequirementOf<P>` both `never`) that is automatic; a decider carrying an
|
|
72
|
+
* encryption requirement provides its cipher layer inside `build` before
|
|
73
|
+
* returning.
|
|
74
|
+
*/
|
|
75
|
+
export declare const scenario: <P extends Record<string, SliceProjection>, Command, E>(description: string, decider: Decider<P, Command, E>, build: (spec: Specification<Command, E, never, RequirementOf<P>>) => Effect.Effect<void, E | CodecError | AppendConditionFailed, DcbEventStore | Serializer | Uuid>) => void;
|
|
76
|
+
//# sourceMappingURL=specification.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"specification.d.ts","sourceRoot":"","sources":["../../src/specification.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EACV,UAAU,EACV,OAAO,EACP,aAAa,EACb,eAAe,EAChB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAEL,KAAK,UAAU,EAEf,KAAK,IAAI,EAEV,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,QAAQ,EACb,aAAa,EAMd,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAgB,MAAM,EAAuB,MAAM,QAAQ,CAAA;AAElE,mFAAmF;AACnF,MAAM,MAAM,aAAa,CAAC,CAAC,IAAI,MAAM,CAAC,MAAM,CAC1C,QAAQ,EACR,UAAU,EACV,UAAU,GAAG,IAAI,GAAG,CAAC,CACtB,CAAA;AA+DD;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,aAAa,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE;IAC9C;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,CACd,GAAG,UAAU,EAAE,aAAa,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,KAC3C,SAAS,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAA;CAClC;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE;IAC1C,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAA;CACxD;AAED,2EAA2E;AAC3E,MAAM,WAAW,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;IAChC;;;;;;;OAOG;IACH,QAAQ,CAAC,IAAI,EAAE,CACb,GAAG,QAAQ,EAAE,aAAa,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,KACzC,MAAM,CAAC,MAAM,CAChB,IAAI,EACJ,CAAC,GAAG,UAAU,GAAG,qBAAqB,EACtC,aAAa,GAAG,UAAU,GAAG,IAAI,GAAG,CAAC,GAAG,EAAE,CAC3C,CAAA;IACD;;;;OAIG;IACH,QAAQ,CAAC,SAAS,EAAE,CAClB,GAAG,EAAE,MAAM,KACR,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,aAAa,GAAG,UAAU,GAAG,IAAI,GAAG,CAAC,GAAG,EAAE,CAAC,CAAA;CAC5E;AAED;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,GAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,EACzC,OAAO,EACP,CAAC,EACD,CAAC,EAED,SAAS,OAAO,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC,KACjC,aAAa,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,aAAa,CAAC,CAAC,CAAC,CAkD9C,CAAA;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,QAAQ,GAAI,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,EAAE,OAAO,EAAE,CAAC,EAC5E,aAAa,MAAM,EACnB,SAAS,OAAO,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,EAC/B,OAAO,CACL,IAAI,EAAE,aAAa,CAAC,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,CAAC,KACrD,MAAM,CAAC,MAAM,CAChB,IAAI,EACJ,CAAC,GAAG,UAAU,GAAG,qBAAqB,EACtC,aAAa,GAAG,UAAU,GAAG,IAAI,CAClC,KACA,IAcF,CAAA"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kairos-es/testing — the Given/When/Then decision-model testing surface.
|
|
3
|
+
*
|
|
4
|
+
* A `DeciderSpecification`-equivalent over the real `@kairos-es/codec`
|
|
5
|
+
* `buildDecisionModel` fold and a `DcbEventStore` (in practice the in-memory
|
|
6
|
+
* layer). Shaped so an Event Modeling `Specification` (given events / when a
|
|
7
|
+
* command / then events / thenError a tag) compiles onto it 1:1.
|
|
8
|
+
*
|
|
9
|
+
* scenario('subscribe a student', subscribeDecider, (spec) =>
|
|
10
|
+
* spec
|
|
11
|
+
* .given(courseDefined('c1', 10))
|
|
12
|
+
* .when({ studentId: 's1', courseId: 'c1' })
|
|
13
|
+
* .then(studentSubscribed('s1', 'c1')),
|
|
14
|
+
* )
|
|
15
|
+
*
|
|
16
|
+
* The unit it exercises — `Decider`, pairing the schema-backed projections a
|
|
17
|
+
* command reads with an effectful `decide` step producing events via `toEvent`,
|
|
18
|
+
* and `runDecider`, running the real read -> decode -> decide -> append cycle —
|
|
19
|
+
* belongs to `@kairos-es/codec` and is imported from there, so the surface
|
|
20
|
+
* exercises the genuine production cycle and not a test-only restatement of it.
|
|
21
|
+
* It is deliberately NOT re-exported here: this package peers `@effect/vitest`,
|
|
22
|
+
* and a re-export would let a production command handler reach a test framework
|
|
23
|
+
* through the type it names. Given/expected events are effectful producers
|
|
24
|
+
* (`toEvent`), materialised under the same serialiser + uuid layers `scenario`
|
|
25
|
+
* provides.
|
|
26
|
+
*/
|
|
27
|
+
export * from "./specification.js";
|
|
28
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,cAAc,oBAAiB","ignoreList":[]}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Given/When/Then surface: a `DeciderSpecification`-equivalent over the real
|
|
3
|
+
* `@kairos-es/codec` `buildDecisionModel` fold and a `DcbEventStore` (the
|
|
4
|
+
* in-memory layer in practice). It is shaped so an Event Modeling
|
|
5
|
+
* `Specification` (given events, when a command, then events / thenError a tag)
|
|
6
|
+
* compiles onto it 1:1 — prior art: dcb-events `TestRunner` and Emmett's
|
|
7
|
+
* `DeciderSpecification`.
|
|
8
|
+
*
|
|
9
|
+
* specificationFor(decider)
|
|
10
|
+
* .given(...pastEventProducers)
|
|
11
|
+
* .when(command)
|
|
12
|
+
* .then(...expectedNewEventProducers) // success branch
|
|
13
|
+
*
|
|
14
|
+
* specificationFor(decider)
|
|
15
|
+
* .given(...pastEventProducers)
|
|
16
|
+
* .when(command)
|
|
17
|
+
* .thenError('AppendConditionFailed') // failure branch (domain error or conflict)
|
|
18
|
+
*
|
|
19
|
+
* Events are supplied *effectfully*: a given/expected event is the producer an
|
|
20
|
+
* application authors via `toEvent` — an `Effect<DcbEvent, CodecError,
|
|
21
|
+
* Serializer | Uuid | R>` — so both sides materialise (encode -> serialise)
|
|
22
|
+
* under the same layers the cycle runs against, and their `data` bytes match.
|
|
23
|
+
*
|
|
24
|
+
* Each terminal (`then` / `thenError`) returns an `Effect` requiring
|
|
25
|
+
* `DcbEventStore | Serializer | Uuid | R`, so a test provides the store,
|
|
26
|
+
* serialiser, and uuid layers (fresh store per case for isolation).
|
|
27
|
+
* `scenario(...)` wraps the whole thing in `@effect/vitest`'s `it.effect`
|
|
28
|
+
* against a fresh in-memory store plus `SerializerDefault` and `UuidTest`, so
|
|
29
|
+
* example specs read as one line.
|
|
30
|
+
*
|
|
31
|
+
* `then` compares the *appended* events (those written past the given seed) to
|
|
32
|
+
* the expected batch structurally — by `type`, `tags`, and `data` — ignoring
|
|
33
|
+
* the store-assigned `position` and the producer-supplied `uuid`/`occurredAt`,
|
|
34
|
+
* which are not part of the decision under test. JSON serialisation is
|
|
35
|
+
* deterministic, so equal payloads produce equal `data` bytes.
|
|
36
|
+
*/
|
|
37
|
+
import { expect, it } from '@effect/vitest';
|
|
38
|
+
import { runDecider, SerializerDefault, UuidTest } from '@kairos-es/codec';
|
|
39
|
+
import { DcbEventStore, DcbEventStoreInMemory, ORIGIN, query as Q } from '@kairos-es/core';
|
|
40
|
+
import { Cause, Chunk, Effect, Exit, Layer, Stream } from 'effect';
|
|
41
|
+
const shapeOf = event => ({
|
|
42
|
+
type: event.type,
|
|
43
|
+
tags: event.tags,
|
|
44
|
+
data: event.data
|
|
45
|
+
});
|
|
46
|
+
/** Collect a stream of sequenced events into a plain array. */
|
|
47
|
+
const collect = events => Stream.runCollect(events).pipe(Effect.map(Chunk.toReadonlyArray));
|
|
48
|
+
/**
|
|
49
|
+
* Seed the given past-event producers into the store: materialise each producer
|
|
50
|
+
* (encode -> serialise), then one append each (log order). Returns the position
|
|
51
|
+
* the LAST seeded append was written at, or `ORIGIN` when nothing was seeded —
|
|
52
|
+
* this is the boundary `then` reads strictly after.
|
|
53
|
+
*
|
|
54
|
+
* We track the boundary by position rather than by count because positions are
|
|
55
|
+
* gappy on a real store (Postgres `bigserial` skips values on rollbacks and
|
|
56
|
+
* across concurrent writers), so "everything past N rows" is wrong; "everything
|
|
57
|
+
* strictly after this position" is exact regardless of gaps.
|
|
58
|
+
*/
|
|
59
|
+
const seed = producers => Effect.gen(function* () {
|
|
60
|
+
const store = yield* DcbEventStore;
|
|
61
|
+
let last = ORIGIN;
|
|
62
|
+
for (const producer of producers) {
|
|
63
|
+
const event = yield* producer;
|
|
64
|
+
// Unconditional appends never fail; a failure here would be a defect in
|
|
65
|
+
// the store, not a domain outcome, so turn it into one.
|
|
66
|
+
last = yield* store.append([event]).pipe(Effect.orDie);
|
|
67
|
+
}
|
|
68
|
+
return last;
|
|
69
|
+
});
|
|
70
|
+
/**
|
|
71
|
+
* The events appended past the seed boundary, so `then` can read back exactly
|
|
72
|
+
* the events the decider appended. We read all matching events strictly after
|
|
73
|
+
* `after` (the last seeded position), which is correct on a gappy store where
|
|
74
|
+
* dropping a fixed row count would not be — the in-memory store gives the
|
|
75
|
+
* identical result because its positions are gapless.
|
|
76
|
+
*/
|
|
77
|
+
const appendedAfter = after => Effect.gen(function* () {
|
|
78
|
+
const store = yield* DcbEventStore;
|
|
79
|
+
const {
|
|
80
|
+
events
|
|
81
|
+
} = yield* store.read(Q.all(), {
|
|
82
|
+
after
|
|
83
|
+
});
|
|
84
|
+
return yield* collect(events);
|
|
85
|
+
});
|
|
86
|
+
/**
|
|
87
|
+
* Begin a specification for `decider`. The returned `given(...)` starts the
|
|
88
|
+
* Given/When/Then chain; the terminal step yields an `Effect` requiring
|
|
89
|
+
* `DcbEventStore | Serializer | Uuid | R | RequirementOf<P>`.
|
|
90
|
+
*/
|
|
91
|
+
export const specificationFor = decider => ({
|
|
92
|
+
given: (...pastEvents) => ({
|
|
93
|
+
when: command => {
|
|
94
|
+
// Run the seed + decider cycle, exposing the outcome as an Exit so both
|
|
95
|
+
// branches (then / thenError) can inspect success or failure uniformly. The
|
|
96
|
+
// seed is inside the Exit too, so a decode failure while materialising a
|
|
97
|
+
// given producer is captured as a failure rather than escaping the channel.
|
|
98
|
+
// The success value pairs the seed boundary (the last seeded position) with
|
|
99
|
+
// the decider's outcome, so `then` reads strictly after that boundary
|
|
100
|
+
// rather than dropping a fixed row count (correct on a gappy store).
|
|
101
|
+
const runToExit = seed(pastEvents).pipe(Effect.zip(runDecider(decider, command)), Effect.exit);
|
|
102
|
+
return {
|
|
103
|
+
// biome-ignore lint/suspicious/noThenProperty: deliberate Given/When/Then terminal name (see WhenSpec.then); never awaited as a thenable.
|
|
104
|
+
then: (...expected) => Effect.gen(function* () {
|
|
105
|
+
const exit = yield* runToExit;
|
|
106
|
+
if (Exit.isFailure(exit)) {
|
|
107
|
+
// Surface the real failure to the caller's error channel; a
|
|
108
|
+
// `.then` on a failing cycle is a test authoring mistake, so make
|
|
109
|
+
// it loud rather than silently passing.
|
|
110
|
+
return yield* Effect.failCause(exit.cause);
|
|
111
|
+
}
|
|
112
|
+
const [seedBoundary] = exit.value;
|
|
113
|
+
const appended = yield* appendedAfter(seedBoundary);
|
|
114
|
+
const expectedEvents = yield* Effect.all(expected);
|
|
115
|
+
expect(appended.map(s => shapeOf(s.event))).toEqual(expectedEvents.map(shapeOf));
|
|
116
|
+
}),
|
|
117
|
+
thenError: tag => Effect.gen(function* () {
|
|
118
|
+
const exit = yield* runToExit;
|
|
119
|
+
expect(Exit.isFailure(exit)).toBe(true);
|
|
120
|
+
if (Exit.isFailure(exit)) {
|
|
121
|
+
const failure = Cause.failureOption(exit.cause);
|
|
122
|
+
const error = failure._tag === 'Some' ? failure.value : undefined;
|
|
123
|
+
const actualTag = typeof error === 'object' && error !== null && '_tag' in error ? error._tag : undefined;
|
|
124
|
+
expect(actualTag).toBe(tag);
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
});
|
|
131
|
+
/**
|
|
132
|
+
* `scenario(description, spec => spec.given(...).when(...).then(...))` — register
|
|
133
|
+
* an `@effect/vitest` `it.effect` case running `build` against a fresh in-memory
|
|
134
|
+
* `DcbEventStore` plus the default serialiser and a fresh deterministic uuid
|
|
135
|
+
* layer. The callback receives `specificationFor(decider)` so example suites
|
|
136
|
+
* read as a single Given/When/Then line per case.
|
|
137
|
+
*
|
|
138
|
+
* `scenario` provides exactly the store, serialiser, and uuid, so `build` must
|
|
139
|
+
* return an effect requiring only those three. For a plain decider (`R` and
|
|
140
|
+
* `RequirementOf<P>` both `never`) that is automatic; a decider carrying an
|
|
141
|
+
* encryption requirement provides its cipher layer inside `build` before
|
|
142
|
+
* returning.
|
|
143
|
+
*/
|
|
144
|
+
export const scenario = (description, decider, build) => {
|
|
145
|
+
it.effect(description, () => build(specificationFor(decider)).pipe(
|
|
146
|
+
// A fresh store + uuid layer per case: both are stateful, so isolation is
|
|
147
|
+
// required (mirrors the contract suite's per-case layer thunk).
|
|
148
|
+
Effect.provide(Layer.mergeAll(Layer.fresh(DcbEventStoreInMemory), SerializerDefault, Layer.fresh(UuidTest)))));
|
|
149
|
+
};
|
|
150
|
+
//# sourceMappingURL=specification.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"specification.js","names":["expect","it","runDecider","SerializerDefault","UuidTest","DcbEventStore","DcbEventStoreInMemory","ORIGIN","query","Q","Cause","Chunk","Effect","Exit","Layer","Stream","shapeOf","event","type","tags","data","collect","events","runCollect","pipe","map","toReadonlyArray","seed","producers","gen","store","last","producer","append","orDie","appendedAfter","after","read","all","specificationFor","decider","given","pastEvents","when","command","runToExit","zip","exit","then","expected","isFailure","failCause","cause","seedBoundary","value","appended","expectedEvents","s","toEqual","thenError","tag","toBe","failure","failureOption","error","_tag","undefined","actualTag","scenario","description","build","effect","provide","mergeAll","fresh"],"sources":["../../src/specification.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kairos-es/testing",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Given/When/Then decision-model testing surface for kairos-es",
|
|
5
|
+
"license": "BSD-3-Clause",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/neverbland/kairos-es.git",
|
|
9
|
+
"directory": "packages/testing"
|
|
10
|
+
},
|
|
11
|
+
"sideEffects": [],
|
|
12
|
+
"homepage": "https://github.com/neverbland/kairos-es",
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"@effect/vitest": "^0.30.0",
|
|
15
|
+
"effect": "^3.22.0",
|
|
16
|
+
"@kairos-es/codec": "^0.0.0",
|
|
17
|
+
"@kairos-es/core": "^0.0.0"
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/cjs/index.js",
|
|
20
|
+
"module": "./dist/esm/index.js",
|
|
21
|
+
"types": "./dist/dts/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/dts/index.d.ts",
|
|
25
|
+
"import": "./dist/esm/index.js",
|
|
26
|
+
"default": "./dist/cjs/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"typesVersions": {
|
|
31
|
+
"*": {}
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kairos-es/testing — the Given/When/Then decision-model testing surface.
|
|
3
|
+
*
|
|
4
|
+
* A `DeciderSpecification`-equivalent over the real `@kairos-es/codec`
|
|
5
|
+
* `buildDecisionModel` fold and a `DcbEventStore` (in practice the in-memory
|
|
6
|
+
* layer). Shaped so an Event Modeling `Specification` (given events / when a
|
|
7
|
+
* command / then events / thenError a tag) compiles onto it 1:1.
|
|
8
|
+
*
|
|
9
|
+
* scenario('subscribe a student', subscribeDecider, (spec) =>
|
|
10
|
+
* spec
|
|
11
|
+
* .given(courseDefined('c1', 10))
|
|
12
|
+
* .when({ studentId: 's1', courseId: 'c1' })
|
|
13
|
+
* .then(studentSubscribed('s1', 'c1')),
|
|
14
|
+
* )
|
|
15
|
+
*
|
|
16
|
+
* The unit it exercises — `Decider`, pairing the schema-backed projections a
|
|
17
|
+
* command reads with an effectful `decide` step producing events via `toEvent`,
|
|
18
|
+
* and `runDecider`, running the real read -> decode -> decide -> append cycle —
|
|
19
|
+
* belongs to `@kairos-es/codec` and is imported from there, so the surface
|
|
20
|
+
* exercises the genuine production cycle and not a test-only restatement of it.
|
|
21
|
+
* It is deliberately NOT re-exported here: this package peers `@effect/vitest`,
|
|
22
|
+
* and a re-export would let a production command handler reach a test framework
|
|
23
|
+
* through the type it names. Given/expected events are effectful producers
|
|
24
|
+
* (`toEvent`), materialised under the same serialiser + uuid layers `scenario`
|
|
25
|
+
* provides.
|
|
26
|
+
*/
|
|
27
|
+
export * from './specification'
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Given/When/Then surface: a `DeciderSpecification`-equivalent over the real
|
|
3
|
+
* `@kairos-es/codec` `buildDecisionModel` fold and a `DcbEventStore` (the
|
|
4
|
+
* in-memory layer in practice). It is shaped so an Event Modeling
|
|
5
|
+
* `Specification` (given events, when a command, then events / thenError a tag)
|
|
6
|
+
* compiles onto it 1:1 — prior art: dcb-events `TestRunner` and Emmett's
|
|
7
|
+
* `DeciderSpecification`.
|
|
8
|
+
*
|
|
9
|
+
* specificationFor(decider)
|
|
10
|
+
* .given(...pastEventProducers)
|
|
11
|
+
* .when(command)
|
|
12
|
+
* .then(...expectedNewEventProducers) // success branch
|
|
13
|
+
*
|
|
14
|
+
* specificationFor(decider)
|
|
15
|
+
* .given(...pastEventProducers)
|
|
16
|
+
* .when(command)
|
|
17
|
+
* .thenError('AppendConditionFailed') // failure branch (domain error or conflict)
|
|
18
|
+
*
|
|
19
|
+
* Events are supplied *effectfully*: a given/expected event is the producer an
|
|
20
|
+
* application authors via `toEvent` — an `Effect<DcbEvent, CodecError,
|
|
21
|
+
* Serializer | Uuid | R>` — so both sides materialise (encode -> serialise)
|
|
22
|
+
* under the same layers the cycle runs against, and their `data` bytes match.
|
|
23
|
+
*
|
|
24
|
+
* Each terminal (`then` / `thenError`) returns an `Effect` requiring
|
|
25
|
+
* `DcbEventStore | Serializer | Uuid | R`, so a test provides the store,
|
|
26
|
+
* serialiser, and uuid layers (fresh store per case for isolation).
|
|
27
|
+
* `scenario(...)` wraps the whole thing in `@effect/vitest`'s `it.effect`
|
|
28
|
+
* against a fresh in-memory store plus `SerializerDefault` and `UuidTest`, so
|
|
29
|
+
* example specs read as one line.
|
|
30
|
+
*
|
|
31
|
+
* `then` compares the *appended* events (those written past the given seed) to
|
|
32
|
+
* the expected batch structurally — by `type`, `tags`, and `data` — ignoring
|
|
33
|
+
* the store-assigned `position` and the producer-supplied `uuid`/`occurredAt`,
|
|
34
|
+
* which are not part of the decision under test. JSON serialisation is
|
|
35
|
+
* deterministic, so equal payloads produce equal `data` bytes.
|
|
36
|
+
*/
|
|
37
|
+
import { expect, it } from '@effect/vitest'
|
|
38
|
+
import type {
|
|
39
|
+
CodecError,
|
|
40
|
+
Decider,
|
|
41
|
+
RequirementOf,
|
|
42
|
+
SliceProjection,
|
|
43
|
+
} from '@kairos-es/codec'
|
|
44
|
+
import {
|
|
45
|
+
runDecider,
|
|
46
|
+
type Serializer,
|
|
47
|
+
SerializerDefault,
|
|
48
|
+
type Uuid,
|
|
49
|
+
UuidTest,
|
|
50
|
+
} from '@kairos-es/codec'
|
|
51
|
+
import {
|
|
52
|
+
type AppendConditionFailed,
|
|
53
|
+
type DcbEvent,
|
|
54
|
+
DcbEventStore,
|
|
55
|
+
DcbEventStoreInMemory,
|
|
56
|
+
ORIGIN,
|
|
57
|
+
type Position,
|
|
58
|
+
query as Q,
|
|
59
|
+
type SequencedEvent,
|
|
60
|
+
} from '@kairos-es/core'
|
|
61
|
+
import { Cause, Chunk, Effect, Exit, Layer, Stream } from 'effect'
|
|
62
|
+
|
|
63
|
+
/** An effectful producer of a wire event (the `toEvent` result an app authors). */
|
|
64
|
+
export type EventProducer<R> = Effect.Effect<
|
|
65
|
+
DcbEvent,
|
|
66
|
+
CodecError,
|
|
67
|
+
Serializer | Uuid | R
|
|
68
|
+
>
|
|
69
|
+
|
|
70
|
+
/** The structural shape `then` compares on (position/uuid/occurredAt excluded). */
|
|
71
|
+
interface EventShape {
|
|
72
|
+
readonly type: string
|
|
73
|
+
readonly tags: ReadonlyArray<string>
|
|
74
|
+
readonly data: Uint8Array
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const shapeOf = (event: DcbEvent): EventShape => ({
|
|
78
|
+
type: event.type,
|
|
79
|
+
tags: event.tags,
|
|
80
|
+
data: event.data,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
/** Collect a stream of sequenced events into a plain array. */
|
|
84
|
+
const collect = (
|
|
85
|
+
events: Stream.Stream<SequencedEvent>,
|
|
86
|
+
): Effect.Effect<ReadonlyArray<SequencedEvent>> =>
|
|
87
|
+
Stream.runCollect(events).pipe(Effect.map(Chunk.toReadonlyArray))
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Seed the given past-event producers into the store: materialise each producer
|
|
91
|
+
* (encode -> serialise), then one append each (log order). Returns the position
|
|
92
|
+
* the LAST seeded append was written at, or `ORIGIN` when nothing was seeded —
|
|
93
|
+
* this is the boundary `then` reads strictly after.
|
|
94
|
+
*
|
|
95
|
+
* We track the boundary by position rather than by count because positions are
|
|
96
|
+
* gappy on a real store (Postgres `bigserial` skips values on rollbacks and
|
|
97
|
+
* across concurrent writers), so "everything past N rows" is wrong; "everything
|
|
98
|
+
* strictly after this position" is exact regardless of gaps.
|
|
99
|
+
*/
|
|
100
|
+
const seed = <R>(
|
|
101
|
+
producers: ReadonlyArray<EventProducer<R>>,
|
|
102
|
+
): Effect.Effect<Position, CodecError, DcbEventStore | Serializer | Uuid | R> =>
|
|
103
|
+
Effect.gen(function* () {
|
|
104
|
+
const store = yield* DcbEventStore
|
|
105
|
+
let last: Position = ORIGIN
|
|
106
|
+
for (const producer of producers) {
|
|
107
|
+
const event = yield* producer
|
|
108
|
+
// Unconditional appends never fail; a failure here would be a defect in
|
|
109
|
+
// the store, not a domain outcome, so turn it into one.
|
|
110
|
+
last = yield* store.append([event]).pipe(Effect.orDie)
|
|
111
|
+
}
|
|
112
|
+
return last
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The events appended past the seed boundary, so `then` can read back exactly
|
|
117
|
+
* the events the decider appended. We read all matching events strictly after
|
|
118
|
+
* `after` (the last seeded position), which is correct on a gappy store where
|
|
119
|
+
* dropping a fixed row count would not be — the in-memory store gives the
|
|
120
|
+
* identical result because its positions are gapless.
|
|
121
|
+
*/
|
|
122
|
+
const appendedAfter = (
|
|
123
|
+
after: Position,
|
|
124
|
+
): Effect.Effect<ReadonlyArray<SequencedEvent>, never, DcbEventStore> =>
|
|
125
|
+
Effect.gen(function* () {
|
|
126
|
+
const store = yield* DcbEventStore
|
|
127
|
+
const { events } = yield* store.read(Q.all(), { after })
|
|
128
|
+
return yield* collect(events)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The entry point of the Given/When/Then chain: what `specificationFor` returns
|
|
133
|
+
* and what `scenario` hands its `build` callback. It is NAMED rather than
|
|
134
|
+
* written inline at those two sites because it was the one step of this chain
|
|
135
|
+
* with no name — the two statements of it were free to drift — and it is the
|
|
136
|
+
* step a caller most needs to name, since factoring a `build` callback out of a
|
|
137
|
+
* `scenario` call means writing its parameter's type. The sibling fluent
|
|
138
|
+
* builder in `@kairos-es/codec` names its own entry point (`ProjectionBuilder`)
|
|
139
|
+
* for the same reason.
|
|
140
|
+
*
|
|
141
|
+
* It introduces no type parameter of its own: all four are `GivenSpec`'s below,
|
|
142
|
+
* passed straight through, because seeding past events changes nothing about
|
|
143
|
+
* the command, the domain error, or either requirement — it only fixes the
|
|
144
|
+
* state the decision is folded from.
|
|
145
|
+
*/
|
|
146
|
+
export interface Specification<Command, E, R, PR> {
|
|
147
|
+
/**
|
|
148
|
+
* Seed the past events, in log order (one append each). They are effectful
|
|
149
|
+
* producers, materialised under the same layers the decision runs against.
|
|
150
|
+
*/
|
|
151
|
+
readonly given: (
|
|
152
|
+
...pastEvents: ReadonlyArray<EventProducer<R>>
|
|
153
|
+
) => GivenSpec<Command, E, R, PR>
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A spec bound to a decider and a set of given events, awaiting `.when`. `PR` is
|
|
158
|
+
* the slices' decode requirement (`RequirementOf<P>`) that also flows through
|
|
159
|
+
* the terminals' requirement channel alongside the produce requirement `R`.
|
|
160
|
+
*/
|
|
161
|
+
export interface GivenSpec<Command, E, R, PR> {
|
|
162
|
+
/** Supply the command to decide. */
|
|
163
|
+
readonly when: (command: Command) => WhenSpec<E, R, PR>
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A spec that has a command and awaits a `then`/`thenError` assertion. */
|
|
167
|
+
export interface WhenSpec<E, R, PR> {
|
|
168
|
+
/**
|
|
169
|
+
* Assert the success branch: the decider appended exactly these events
|
|
170
|
+
* (compared structurally by type/tags/data, in order). The expected events are
|
|
171
|
+
* effectful producers, materialised under the provided layers. The error
|
|
172
|
+
* channel keeps `E | CodecError | AppendConditionFailed` because a genuinely
|
|
173
|
+
* failing cycle re-raises its cause (a `.then` on a failing decision is a
|
|
174
|
+
* test-authoring mistake, made loud rather than silently green).
|
|
175
|
+
*/
|
|
176
|
+
readonly then: (
|
|
177
|
+
...expected: ReadonlyArray<EventProducer<R>>
|
|
178
|
+
) => Effect.Effect<
|
|
179
|
+
void,
|
|
180
|
+
E | CodecError | AppendConditionFailed,
|
|
181
|
+
DcbEventStore | Serializer | Uuid | R | PR
|
|
182
|
+
>
|
|
183
|
+
/**
|
|
184
|
+
* Assert the failure branch: the cycle failed with a tagged error whose
|
|
185
|
+
* `_tag` equals `tag` (a domain error from `decide`, a `CodecError` from
|
|
186
|
+
* decode, or `AppendConditionFailed` from a moved consistency boundary).
|
|
187
|
+
*/
|
|
188
|
+
readonly thenError: (
|
|
189
|
+
tag: string,
|
|
190
|
+
) => Effect.Effect<void, never, DcbEventStore | Serializer | Uuid | R | PR>
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Begin a specification for `decider`. The returned `given(...)` starts the
|
|
195
|
+
* Given/When/Then chain; the terminal step yields an `Effect` requiring
|
|
196
|
+
* `DcbEventStore | Serializer | Uuid | R | RequirementOf<P>`.
|
|
197
|
+
*/
|
|
198
|
+
export const specificationFor = <
|
|
199
|
+
P extends Record<string, SliceProjection>,
|
|
200
|
+
Command,
|
|
201
|
+
E,
|
|
202
|
+
R,
|
|
203
|
+
>(
|
|
204
|
+
decider: Decider<P, Command, E, R>,
|
|
205
|
+
): Specification<Command, E, R, RequirementOf<P>> => ({
|
|
206
|
+
given: (...pastEvents) => ({
|
|
207
|
+
when: (command) => {
|
|
208
|
+
// Run the seed + decider cycle, exposing the outcome as an Exit so both
|
|
209
|
+
// branches (then / thenError) can inspect success or failure uniformly. The
|
|
210
|
+
// seed is inside the Exit too, so a decode failure while materialising a
|
|
211
|
+
// given producer is captured as a failure rather than escaping the channel.
|
|
212
|
+
// The success value pairs the seed boundary (the last seeded position) with
|
|
213
|
+
// the decider's outcome, so `then` reads strictly after that boundary
|
|
214
|
+
// rather than dropping a fixed row count (correct on a gappy store).
|
|
215
|
+
const runToExit = seed(pastEvents).pipe(
|
|
216
|
+
Effect.zip(runDecider(decider, command)),
|
|
217
|
+
Effect.exit,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
// biome-ignore lint/suspicious/noThenProperty: deliberate Given/When/Then terminal name (see WhenSpec.then); never awaited as a thenable.
|
|
222
|
+
then: (...expected) =>
|
|
223
|
+
Effect.gen(function* () {
|
|
224
|
+
const exit = yield* runToExit
|
|
225
|
+
if (Exit.isFailure(exit)) {
|
|
226
|
+
// Surface the real failure to the caller's error channel; a
|
|
227
|
+
// `.then` on a failing cycle is a test authoring mistake, so make
|
|
228
|
+
// it loud rather than silently passing.
|
|
229
|
+
return yield* Effect.failCause(exit.cause)
|
|
230
|
+
}
|
|
231
|
+
const [seedBoundary] = exit.value
|
|
232
|
+
const appended = yield* appendedAfter(seedBoundary)
|
|
233
|
+
const expectedEvents = yield* Effect.all(expected)
|
|
234
|
+
expect(appended.map((s) => shapeOf(s.event))).toEqual(
|
|
235
|
+
expectedEvents.map(shapeOf),
|
|
236
|
+
)
|
|
237
|
+
}),
|
|
238
|
+
thenError: (tag) =>
|
|
239
|
+
Effect.gen(function* () {
|
|
240
|
+
const exit = yield* runToExit
|
|
241
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
242
|
+
if (Exit.isFailure(exit)) {
|
|
243
|
+
const failure = Cause.failureOption(exit.cause)
|
|
244
|
+
const error = failure._tag === 'Some' ? failure.value : undefined
|
|
245
|
+
const actualTag =
|
|
246
|
+
typeof error === 'object' && error !== null && '_tag' in error
|
|
247
|
+
? (error as { readonly _tag: unknown })._tag
|
|
248
|
+
: undefined
|
|
249
|
+
expect(actualTag).toBe(tag)
|
|
250
|
+
}
|
|
251
|
+
}),
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
}),
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* `scenario(description, spec => spec.given(...).when(...).then(...))` — register
|
|
259
|
+
* an `@effect/vitest` `it.effect` case running `build` against a fresh in-memory
|
|
260
|
+
* `DcbEventStore` plus the default serialiser and a fresh deterministic uuid
|
|
261
|
+
* layer. The callback receives `specificationFor(decider)` so example suites
|
|
262
|
+
* read as a single Given/When/Then line per case.
|
|
263
|
+
*
|
|
264
|
+
* `scenario` provides exactly the store, serialiser, and uuid, so `build` must
|
|
265
|
+
* return an effect requiring only those three. For a plain decider (`R` and
|
|
266
|
+
* `RequirementOf<P>` both `never`) that is automatic; a decider carrying an
|
|
267
|
+
* encryption requirement provides its cipher layer inside `build` before
|
|
268
|
+
* returning.
|
|
269
|
+
*/
|
|
270
|
+
export const scenario = <P extends Record<string, SliceProjection>, Command, E>(
|
|
271
|
+
description: string,
|
|
272
|
+
decider: Decider<P, Command, E>,
|
|
273
|
+
build: (
|
|
274
|
+
spec: Specification<Command, E, never, RequirementOf<P>>,
|
|
275
|
+
) => Effect.Effect<
|
|
276
|
+
void,
|
|
277
|
+
E | CodecError | AppendConditionFailed,
|
|
278
|
+
DcbEventStore | Serializer | Uuid
|
|
279
|
+
>,
|
|
280
|
+
): void => {
|
|
281
|
+
it.effect(description, () =>
|
|
282
|
+
build(specificationFor(decider)).pipe(
|
|
283
|
+
// A fresh store + uuid layer per case: both are stateful, so isolation is
|
|
284
|
+
// required (mirrors the contract suite's per-case layer thunk).
|
|
285
|
+
Effect.provide(
|
|
286
|
+
Layer.mergeAll(
|
|
287
|
+
Layer.fresh(DcbEventStoreInMemory),
|
|
288
|
+
SerializerDefault,
|
|
289
|
+
Layer.fresh(UuidTest),
|
|
290
|
+
),
|
|
291
|
+
),
|
|
292
|
+
),
|
|
293
|
+
)
|
|
294
|
+
}
|