@kronos-ts/test 0.1.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/dist/fixture.d.ts +78 -0
- package/dist/fixture.d.ts.map +1 -0
- package/dist/fixture.js +424 -0
- package/dist/fixture.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/recording-enhancer.d.ts +40 -0
- package/dist/recording-enhancer.d.ts.map +1 -0
- package/dist/recording-enhancer.js +82 -0
- package/dist/recording-enhancer.js.map +1 -0
- package/package.json +57 -0
- package/src/fixture.ts +535 -0
- package/src/index.ts +18 -0
- package/src/recording-enhancer.ts +113 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create a fresh Recordings handle. Pass it into {@link testRecordingExtension}
|
|
3
|
+
* so the fixture and the decorators share the same backing arrays.
|
|
4
|
+
*
|
|
5
|
+
* Replaces the legacy "register testRecordings as a component, retrieve via
|
|
6
|
+
* configuration.getComponent" pattern (removed in Phase 8).
|
|
7
|
+
*/
|
|
8
|
+
export function createRecordings() {
|
|
9
|
+
const recordedEvents = [];
|
|
10
|
+
const recordedCommands = [];
|
|
11
|
+
const internal = {
|
|
12
|
+
events() {
|
|
13
|
+
return [...recordedEvents];
|
|
14
|
+
},
|
|
15
|
+
commands() {
|
|
16
|
+
return [...recordedCommands];
|
|
17
|
+
},
|
|
18
|
+
reset() {
|
|
19
|
+
recordedEvents.length = 0;
|
|
20
|
+
recordedCommands.length = 0;
|
|
21
|
+
},
|
|
22
|
+
_push: {
|
|
23
|
+
event: (e) => {
|
|
24
|
+
recordedEvents.push(e);
|
|
25
|
+
},
|
|
26
|
+
command: (c) => {
|
|
27
|
+
recordedCommands.push(c);
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
return internal;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Native Extension that decorates the eventStore and commandBus with
|
|
35
|
+
* recording wrappers.
|
|
36
|
+
*
|
|
37
|
+
* **Decoration order** (Phase 6 D-62): user decorators registered AFTER this
|
|
38
|
+
* extension's `app.use(...)` wrap OUTSIDE the recording decorators. To land
|
|
39
|
+
* the recording decorators at the INNERMOST position (capturing messages
|
|
40
|
+
* AFTER all interceptors have enriched them), call
|
|
41
|
+
* `app.use(testRecordingExtension(recordings))` BEFORE applying any user
|
|
42
|
+
* decorators / `configureFn(app)`.
|
|
43
|
+
*
|
|
44
|
+
* The legacy enhancer used `Number.MIN_SAFE_INTEGER` numeric priority for the
|
|
45
|
+
* same effect; Phase 6 dropped numeric priorities — innermost = first
|
|
46
|
+
* registered.
|
|
47
|
+
*/
|
|
48
|
+
export function testRecordingExtension(recordings) {
|
|
49
|
+
const push = recordings._push;
|
|
50
|
+
if (!push) {
|
|
51
|
+
throw new Error("[testRecordingExtension] Recordings handle missing internal writers — pass an instance from createRecordings().");
|
|
52
|
+
}
|
|
53
|
+
return (app) => {
|
|
54
|
+
// EventStore append wrapper — records events after a successful append.
|
|
55
|
+
app.decorate("eventStore", (inner) => {
|
|
56
|
+
const originalAppend = inner.append.bind(inner);
|
|
57
|
+
return {
|
|
58
|
+
...inner,
|
|
59
|
+
async append(events, condition) {
|
|
60
|
+
const result = await originalAppend(events, condition);
|
|
61
|
+
for (const e of events)
|
|
62
|
+
push.event(e);
|
|
63
|
+
return result;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
// CommandBus dispatch wrapper — records commands BEFORE dispatch (legacy
|
|
68
|
+
// semantics: legacy fixture inspected the raw dispatched command, not
|
|
69
|
+
// the post-handler outcome).
|
|
70
|
+
app.decorate("commandBus", (inner) => {
|
|
71
|
+
const originalDispatch = inner.dispatch.bind(inner);
|
|
72
|
+
return {
|
|
73
|
+
...inner,
|
|
74
|
+
async dispatch(message) {
|
|
75
|
+
push.command(message);
|
|
76
|
+
return originalDispatch(message);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=recording-enhancer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recording-enhancer.js","sourceRoot":"","sources":["../src/recording-enhancer.ts"],"names":[],"mappings":"AA6BA;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB;IAC9B,MAAM,cAAc,GAAmB,EAAE,CAAA;IACzC,MAAM,gBAAgB,GAAqB,EAAE,CAAA;IAC7C,MAAM,QAAQ,GAAuB;QACnC,MAAM;YACJ,OAAO,CAAC,GAAG,cAAc,CAAC,CAAA;QAC5B,CAAC;QACD,QAAQ;YACN,OAAO,CAAC,GAAG,gBAAgB,CAAC,CAAA;QAC9B,CAAC;QACD,KAAK;YACH,cAAc,CAAC,MAAM,GAAG,CAAC,CAAA;YACzB,gBAAgB,CAAC,MAAM,GAAG,CAAC,CAAA;QAC7B,CAAC;QACD,KAAK,EAAE;YACL,KAAK,EAAE,CAAC,CAAC,EAAE,EAAE;gBACX,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;YACxB,CAAC;YACD,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE;gBACb,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;YAC1B,CAAC;SACF;KACF,CAAA;IACD,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,sBAAsB,CAAC,UAAsB;IAC3D,MAAM,IAAI,GAAI,UAAiC,CAAC,KAAK,CAAA;IACrD,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,MAAM,IAAI,KAAK,CACb,iHAAiH,CAClH,CAAA;IACH,CAAC;IACD,OAAO,CAAC,GAAQ,EAAE,EAAE;QAClB,wEAAwE;QACxE,GAAG,CAAC,QAAQ,CAAC,YAAY,EAAE,CAAC,KAAK,EAAE,EAAE;YACnC,MAAM,cAAc,GAAG,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;YAC/C,OAAO;gBACL,GAAG,KAAK;gBACR,KAAK,CAAC,MAAM,CAAC,MAAmC,EAAE,SAAe;oBAC/D,MAAM,MAAM,GAAG,MAAM,cAAc,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;oBACtD,KAAK,MAAM,CAAC,IAAI,MAAM;wBAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;oBACrC,OAAO,MAAM,CAAA;gBACf,CAAC;aACF,CAAA;QACH,CAAC,CAAC,CAAA;QAEF,yEAAyE;QACzE,sEAAsE;QACtE,6BAA6B;QAC7B,GAAG,CAAC,QAAQ,CAAC,YAAY,EAAE,CAAC,KAAK,EAAE,EAAE;YACnC,MAAM,gBAAgB,GAAG,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;YACnD,OAAO;gBACL,GAAG,KAAK;gBACR,KAAK,CAAC,QAAQ,CAAC,OAAuB;oBACpC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAA;oBACrB,OAAO,gBAAgB,CAAC,OAAO,CAAC,CAAA;gBAClC,CAAC;aACY,CAAA;QACjB,CAAC,CAAC,CAAA;IACJ,CAAC,CAAA;AACH,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kronos-ts/test",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Testing toolkit for Kronos — given/when/then fixtures for event-sourced slices.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"author": "Theo Emanuelsson",
|
|
8
|
+
"homepage": "https://github.com/KronosDB/kronos-ts/tree/main/packages/test#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/KronosDB/kronos-ts.git",
|
|
12
|
+
"directory": "packages/test"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/KronosDB/kronos-ts/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"kronos",
|
|
19
|
+
"event-sourcing",
|
|
20
|
+
"cqrs",
|
|
21
|
+
"dcb",
|
|
22
|
+
"typescript"
|
|
23
|
+
],
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"main": "src/index.ts",
|
|
26
|
+
"types": "src/index.ts",
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"src",
|
|
30
|
+
"!src/**/__tests__",
|
|
31
|
+
"!src/**/*.test.ts",
|
|
32
|
+
"!src/**/*.bench.ts"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc -p tsconfig.json",
|
|
36
|
+
"clean": "rm -rf dist *.tsbuildinfo"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public",
|
|
40
|
+
"main": "./dist/index.js",
|
|
41
|
+
"types": "./dist/index.d.ts",
|
|
42
|
+
"exports": {
|
|
43
|
+
".": {
|
|
44
|
+
"types": "./dist/index.d.ts",
|
|
45
|
+
"default": "./dist/index.js"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@kronos-ts/common": "workspace:*",
|
|
51
|
+
"@kronos-ts/app": "workspace:*",
|
|
52
|
+
"@kronos-ts/messaging": "workspace:*",
|
|
53
|
+
"@kronos-ts/modelling": "workspace:*",
|
|
54
|
+
"@kronos-ts/eventsourcing": "workspace:*",
|
|
55
|
+
"zod": "^4.3.6"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/fixture.ts
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import {
|
|
2
|
+
generateIdentifier,
|
|
3
|
+
emptyMetadata,
|
|
4
|
+
qualifiedNameToString,
|
|
5
|
+
type Metadata,
|
|
6
|
+
} from "@kronos-ts/common"
|
|
7
|
+
import type {
|
|
8
|
+
CommandDescriptor,
|
|
9
|
+
EventDescriptor,
|
|
10
|
+
EventMessage,
|
|
11
|
+
CommandMessage,
|
|
12
|
+
} from "@kronos-ts/messaging"
|
|
13
|
+
import { runInNewUoW } from "@kronos-ts/messaging"
|
|
14
|
+
import { kronos, type App, type RunningApp } from "@kronos-ts/app"
|
|
15
|
+
import type { EventStore } from "@kronos-ts/eventsourcing"
|
|
16
|
+
import type { z } from "zod"
|
|
17
|
+
import {
|
|
18
|
+
createRecordings,
|
|
19
|
+
testRecordingExtension,
|
|
20
|
+
type Recordings,
|
|
21
|
+
} from "./recording-enhancer.js"
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Public types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
type EventPair = [EventDescriptor<any>, unknown]
|
|
28
|
+
type CommandPair = [CommandDescriptor<any>, unknown]
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Fixture entry point
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates a BDD test fixture that runs your REAL application code
|
|
36
|
+
* against an in-memory event store with recording decorators.
|
|
37
|
+
*
|
|
38
|
+
* Pass a configuration function that sets up the same domain
|
|
39
|
+
* as your production app via the kronos() App fluent surface.
|
|
40
|
+
*
|
|
41
|
+
* ```typescript
|
|
42
|
+
* const fixture = await createTestFixture((app) => {
|
|
43
|
+
* app.states(Course)
|
|
44
|
+
* app.commands(createCourse, subscribeStudent)
|
|
45
|
+
* app.queries(getCourseView, getAllCourses)
|
|
46
|
+
* })
|
|
47
|
+
*
|
|
48
|
+
* await fixture
|
|
49
|
+
* .given()
|
|
50
|
+
* .events([CourseCreated, { courseId: "cs-101", name: "Intro", capacity: 30 }])
|
|
51
|
+
* .when()
|
|
52
|
+
* .command(SubscribeStudent, { courseId: "cs-101", studentId: "stu-001" })
|
|
53
|
+
* .then()
|
|
54
|
+
* .expectSuccess()
|
|
55
|
+
* .expectEvents([StudentSubscribed, { courseId: "cs-101", studentId: "stu-001" }])
|
|
56
|
+
*
|
|
57
|
+
* await fixture.stop()
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export async function createTestFixture(
|
|
61
|
+
configureFn: (app: App) => void,
|
|
62
|
+
): Promise<TestFixture> {
|
|
63
|
+
const recordings = createRecordings()
|
|
64
|
+
const app = kronos({ quiet: true })
|
|
65
|
+
|
|
66
|
+
// Apply testRecordingExtension synchronously BEFORE configureFn so its
|
|
67
|
+
// decorators land FIRST in the registration order — Phase 6 D-62: first
|
|
68
|
+
// registered = innermost wrap. Recording must be innermost so it captures
|
|
69
|
+
// messages AFTER all interceptors have enriched them.
|
|
70
|
+
testRecordingExtension(recordings)(app)
|
|
71
|
+
|
|
72
|
+
// Capture the eventStore reference via an identity decorator so the BDD
|
|
73
|
+
// given/when paths can append events directly. The decorator runs as
|
|
74
|
+
// part of the user-decorator pass at .start(), so it sees whatever
|
|
75
|
+
// (recording-wrapped) store the framework resolved.
|
|
76
|
+
let capturedEventStore: EventStore | undefined
|
|
77
|
+
app.decorate("eventStore", (inner) => {
|
|
78
|
+
capturedEventStore = inner
|
|
79
|
+
return inner
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
configureFn(app)
|
|
83
|
+
|
|
84
|
+
const running = await app.start()
|
|
85
|
+
|
|
86
|
+
if (!capturedEventStore) {
|
|
87
|
+
throw new Error("Test fixture: eventStore capture failed (start() did not run probe decorator).")
|
|
88
|
+
}
|
|
89
|
+
const eventStore = capturedEventStore
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
given() {
|
|
93
|
+
return new GivenPhaseImpl(running, recordings, eventStore)
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async stop() {
|
|
97
|
+
await running.stop()
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface TestFixture {
|
|
103
|
+
given(): GivenPhase
|
|
104
|
+
stop(): Promise<void>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Given phase
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
export interface GivenPhase {
|
|
112
|
+
events(...pairs: EventPair[]): GivenPhase
|
|
113
|
+
commands(...pairs: CommandPair[]): GivenPhase
|
|
114
|
+
execute(fn: (app: RunningApp) => void | Promise<void>): GivenPhase
|
|
115
|
+
noPriorActivity(): GivenPhase
|
|
116
|
+
when(): WhenPhase
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
class GivenPhaseImpl implements GivenPhase {
|
|
120
|
+
private readonly givenEvents: EventPair[] = []
|
|
121
|
+
private readonly givenCommands: CommandPair[] = []
|
|
122
|
+
private readonly givenSetupFns: Array<(app: RunningApp) => void | Promise<void>> = []
|
|
123
|
+
_prerequisite: Promise<void> | undefined
|
|
124
|
+
|
|
125
|
+
constructor(
|
|
126
|
+
private readonly app: RunningApp,
|
|
127
|
+
private readonly recordings: Recordings,
|
|
128
|
+
private readonly eventStore: EventStore,
|
|
129
|
+
) {}
|
|
130
|
+
|
|
131
|
+
events(...pairs: EventPair[]): GivenPhase {
|
|
132
|
+
this.givenEvents.push(...pairs)
|
|
133
|
+
return this
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
commands(...pairs: CommandPair[]): GivenPhase {
|
|
137
|
+
this.givenCommands.push(...pairs)
|
|
138
|
+
return this
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
execute(fn: (app: RunningApp) => void | Promise<void>): GivenPhase {
|
|
142
|
+
this.givenSetupFns.push(fn)
|
|
143
|
+
return this
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
noPriorActivity(): GivenPhase {
|
|
147
|
+
return this
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
when(): WhenPhase {
|
|
151
|
+
return new WhenPhaseImpl(
|
|
152
|
+
this.app, this.recordings, this.eventStore,
|
|
153
|
+
this.givenEvents, this.givenCommands, this.givenSetupFns,
|
|
154
|
+
this._prerequisite,
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// When phase
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
export interface WhenPhase {
|
|
164
|
+
command<P extends z.ZodType>(descriptor: CommandDescriptor<P>, payload: z.infer<P>, metadata?: Metadata): WhenResult
|
|
165
|
+
event<P extends z.ZodType>(descriptor: EventDescriptor<P>, payload: z.infer<P>, metadata?: Metadata): WhenResult
|
|
166
|
+
nothing(): WhenResult
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface WhenResult {
|
|
170
|
+
then(): ThenPhase
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
class WhenPhaseImpl implements WhenPhase {
|
|
174
|
+
constructor(
|
|
175
|
+
private readonly app: RunningApp,
|
|
176
|
+
private readonly recordings: Recordings,
|
|
177
|
+
private readonly eventStore: EventStore,
|
|
178
|
+
private readonly givenEvents: EventPair[],
|
|
179
|
+
private readonly givenCommands: CommandPair[],
|
|
180
|
+
private readonly givenSetupFns: Array<(app: RunningApp) => void | Promise<void>>,
|
|
181
|
+
private readonly prerequisite?: Promise<void>,
|
|
182
|
+
) {}
|
|
183
|
+
|
|
184
|
+
command<P extends z.ZodType>(descriptor: CommandDescriptor<P>, payload: z.infer<P>, metadata?: Metadata): WhenResult {
|
|
185
|
+
const thenPhase = new ThenPhaseImpl(
|
|
186
|
+
this.app, this.recordings, this.eventStore,
|
|
187
|
+
this.givenEvents, this.givenCommands, this.givenSetupFns,
|
|
188
|
+
{ kind: "command", descriptor, payload, metadata },
|
|
189
|
+
this.prerequisite,
|
|
190
|
+
)
|
|
191
|
+
return { then: () => thenPhase }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
event<P extends z.ZodType>(descriptor: EventDescriptor<P>, payload: z.infer<P>, metadata?: Metadata): WhenResult {
|
|
195
|
+
const thenPhase = new ThenPhaseImpl(
|
|
196
|
+
this.app, this.recordings, this.eventStore,
|
|
197
|
+
this.givenEvents, this.givenCommands, this.givenSetupFns,
|
|
198
|
+
{ kind: "event", descriptor, payload, metadata },
|
|
199
|
+
this.prerequisite,
|
|
200
|
+
)
|
|
201
|
+
return { then: () => thenPhase }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
nothing(): WhenResult {
|
|
205
|
+
const thenPhase = new ThenPhaseImpl(
|
|
206
|
+
this.app, this.recordings, this.eventStore,
|
|
207
|
+
this.givenEvents, this.givenCommands, this.givenSetupFns,
|
|
208
|
+
{ kind: "nothing" },
|
|
209
|
+
this.prerequisite,
|
|
210
|
+
)
|
|
211
|
+
return { then: () => thenPhase }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Then phase
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
type WhenAction =
|
|
220
|
+
| { kind: "command"; descriptor: CommandDescriptor<any>; payload: unknown; metadata?: Metadata }
|
|
221
|
+
| { kind: "event"; descriptor: EventDescriptor<any>; payload: unknown; metadata?: Metadata }
|
|
222
|
+
| { kind: "nothing" }
|
|
223
|
+
|
|
224
|
+
export interface ThenPhase extends PromiseLike<void> {
|
|
225
|
+
expectEvents(...pairs: EventPair[]): ThenPhase
|
|
226
|
+
expectNoEvents(): ThenPhase
|
|
227
|
+
expectSuccess(): ThenPhase
|
|
228
|
+
expectResult(expected: unknown): ThenPhase
|
|
229
|
+
expectResultSatisfying(fn: (result: unknown) => void): ThenPhase
|
|
230
|
+
expectResultPayloadSatisfying<T>(fn: (payload: T) => void): ThenPhase
|
|
231
|
+
expectException(messageSubstring: string): ThenPhase
|
|
232
|
+
expectExceptionType(errorName: string): ThenPhase
|
|
233
|
+
expectExceptionSatisfying(fn: (error: unknown) => void): ThenPhase
|
|
234
|
+
expectEventsSatisfying(fn: (events: ReadonlyArray<EventMessage>) => void): ThenPhase
|
|
235
|
+
expectCommands(...pairs: CommandPair[]): ThenPhase
|
|
236
|
+
expectNoCommands(): ThenPhase
|
|
237
|
+
expectCommandsSatisfying(fn: (commands: ReadonlyArray<CommandMessage>) => void): ThenPhase
|
|
238
|
+
expect(fn: (app: RunningApp) => void | Promise<void>): ThenPhase
|
|
239
|
+
await(assertion: (app: RunningApp) => void | Promise<void>, timeoutMs?: number, intervalMs?: number): ThenPhase
|
|
240
|
+
and(): TestFixture
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
class ThenPhaseImpl implements ThenPhase {
|
|
244
|
+
private readonly assertions: Array<(result: unknown, error: unknown, events: ReadonlyArray<EventMessage>) => void | Promise<void>> = []
|
|
245
|
+
private executionPromise: Promise<void> | null = null
|
|
246
|
+
|
|
247
|
+
constructor(
|
|
248
|
+
private readonly app: RunningApp,
|
|
249
|
+
private readonly recordings: Recordings,
|
|
250
|
+
private readonly eventStore: EventStore,
|
|
251
|
+
private readonly givenEvents: EventPair[],
|
|
252
|
+
private readonly givenCommands: CommandPair[],
|
|
253
|
+
private readonly givenSetupFns: Array<(app: RunningApp) => void | Promise<void>>,
|
|
254
|
+
private readonly whenAction: WhenAction,
|
|
255
|
+
private readonly prerequisite?: Promise<void>,
|
|
256
|
+
) {}
|
|
257
|
+
|
|
258
|
+
expectEvents(...pairs: EventPair[]): ThenPhase {
|
|
259
|
+
this.assertions.push((_result, _error, events) => {
|
|
260
|
+
if (events.length !== pairs.length) {
|
|
261
|
+
throw new FixtureAssertionError(
|
|
262
|
+
`Expected ${pairs.length} event(s) but got ${events.length}.\n` +
|
|
263
|
+
` Expected: [${pairs.map(([d]) => qualifiedNameToString(d.name)).join(", ")}]\n` +
|
|
264
|
+
` Actual: [${events.map((e) => qualifiedNameToString(e.name)).join(", ")}]`,
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
268
|
+
const [desc, payload] = pairs[i]!
|
|
269
|
+
const actual = events[i]!
|
|
270
|
+
const expectedName = qualifiedNameToString(desc.name)
|
|
271
|
+
const actualName = qualifiedNameToString(actual.name)
|
|
272
|
+
if (actualName !== expectedName) {
|
|
273
|
+
throw new FixtureAssertionError(`Event ${i}: expected "${expectedName}" but got "${actualName}"`)
|
|
274
|
+
}
|
|
275
|
+
assertDeepEqual(payload, actual.payload, `Event ${i} (${expectedName}) payload`)
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
return this
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
expectNoEvents(): ThenPhase {
|
|
282
|
+
this.assertions.push((_result, _error, events) => {
|
|
283
|
+
if (events.length !== 0) {
|
|
284
|
+
throw new FixtureAssertionError(
|
|
285
|
+
`Expected no events but got ${events.length}: ` + events.map((e) => qualifiedNameToString(e.name)).join(", "),
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
return this
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
expectSuccess(): ThenPhase {
|
|
293
|
+
this.assertions.push((_result, error) => {
|
|
294
|
+
if (error) throw new FixtureAssertionError(`Expected success but command failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
295
|
+
})
|
|
296
|
+
return this
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
expectResult(expected: unknown): ThenPhase {
|
|
300
|
+
this.assertions.push((result, error) => {
|
|
301
|
+
if (error) throw new FixtureAssertionError(`Expected result but command failed: ${error}`)
|
|
302
|
+
assertDeepEqual(expected, result, "Command result")
|
|
303
|
+
})
|
|
304
|
+
return this
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
expectResultSatisfying(fn: (result: unknown) => void): ThenPhase {
|
|
308
|
+
this.assertions.push((result, error) => {
|
|
309
|
+
if (error) throw new FixtureAssertionError(`Expected result but command failed: ${error}`)
|
|
310
|
+
fn(result)
|
|
311
|
+
})
|
|
312
|
+
return this
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
expectResultPayloadSatisfying<T>(fn: (payload: T) => void): ThenPhase {
|
|
316
|
+
this.assertions.push((result, error) => {
|
|
317
|
+
if (error) throw new FixtureAssertionError(`Expected result but command failed: ${error}`)
|
|
318
|
+
fn(result as T)
|
|
319
|
+
})
|
|
320
|
+
return this
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
expectException(messageSubstring: string): ThenPhase {
|
|
324
|
+
this.assertions.push((_result, error) => {
|
|
325
|
+
if (!error) throw new FixtureAssertionError(`Expected exception containing "${messageSubstring}" but command succeeded`)
|
|
326
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
327
|
+
if (!msg.includes(messageSubstring)) {
|
|
328
|
+
throw new FixtureAssertionError(`Expected exception containing "${messageSubstring}" but got: "${msg}"`)
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
return this
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
expectExceptionType(errorName: string): ThenPhase {
|
|
335
|
+
this.assertions.push((_result, error) => {
|
|
336
|
+
if (!error) throw new FixtureAssertionError(`Expected ${errorName} but command succeeded`)
|
|
337
|
+
const actualName = error instanceof Error ? error.name : "Error"
|
|
338
|
+
if (actualName !== errorName) {
|
|
339
|
+
throw new FixtureAssertionError(`Expected ${errorName} but got ${actualName}: ${error instanceof Error ? error.message : error}`)
|
|
340
|
+
}
|
|
341
|
+
})
|
|
342
|
+
return this
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
expectExceptionSatisfying(fn: (error: unknown) => void): ThenPhase {
|
|
346
|
+
this.assertions.push((_result, error) => {
|
|
347
|
+
if (!error) throw new FixtureAssertionError("Expected exception but command succeeded")
|
|
348
|
+
fn(error)
|
|
349
|
+
})
|
|
350
|
+
return this
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
expectEventsSatisfying(fn: (events: ReadonlyArray<EventMessage>) => void): ThenPhase {
|
|
354
|
+
this.assertions.push((_result, _error, events) => { fn(events) })
|
|
355
|
+
return this
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
expectCommands(...pairs: CommandPair[]): ThenPhase {
|
|
359
|
+
this.assertions.push(() => {
|
|
360
|
+
const actual = this.recordings.commands()
|
|
361
|
+
const handlerCommands = actual.slice(1)
|
|
362
|
+
if (handlerCommands.length !== pairs.length) {
|
|
363
|
+
throw new FixtureAssertionError(
|
|
364
|
+
`Expected ${pairs.length} dispatched command(s) but got ${handlerCommands.length}.`)
|
|
365
|
+
}
|
|
366
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
367
|
+
const [desc, payload] = pairs[i]!
|
|
368
|
+
const actualCmd = handlerCommands[i]!
|
|
369
|
+
const expectedName = qualifiedNameToString(desc.name)
|
|
370
|
+
const actualName = qualifiedNameToString(actualCmd.name)
|
|
371
|
+
if (actualName !== expectedName) throw new FixtureAssertionError(`Command ${i}: expected "${expectedName}" but got "${actualName}"`)
|
|
372
|
+
assertDeepEqual(payload, actualCmd.payload, `Command ${i} (${expectedName}) payload`)
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
return this
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
expectNoCommands(): ThenPhase {
|
|
379
|
+
this.assertions.push(() => {
|
|
380
|
+
const handlerCommands = this.recordings.commands().slice(1)
|
|
381
|
+
if (handlerCommands.length !== 0) {
|
|
382
|
+
throw new FixtureAssertionError(`Expected no dispatched commands but got ${handlerCommands.length}`)
|
|
383
|
+
}
|
|
384
|
+
})
|
|
385
|
+
return this
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
expectCommandsSatisfying(fn: (commands: ReadonlyArray<CommandMessage>) => void): ThenPhase {
|
|
389
|
+
this.assertions.push(() => { fn(this.recordings.commands().slice(1)) })
|
|
390
|
+
return this
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
expect(fn: (app: RunningApp) => void | Promise<void>): ThenPhase {
|
|
394
|
+
this.assertions.push(async () => { await fn(this.app) })
|
|
395
|
+
return this
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
await(assertion: (app: RunningApp) => void | Promise<void>, timeoutMs: number = 5000, intervalMs: number = 50): ThenPhase {
|
|
399
|
+
this.assertions.push(async () => {
|
|
400
|
+
const start = Date.now()
|
|
401
|
+
let lastError: unknown
|
|
402
|
+
while (Date.now() - start < timeoutMs) {
|
|
403
|
+
try { await assertion(this.app); return } catch (err) { lastError = err; await new Promise((r) => setTimeout(r, intervalMs)) }
|
|
404
|
+
}
|
|
405
|
+
throw new FixtureAssertionError(`Assertion did not pass within ${timeoutMs}ms. Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`)
|
|
406
|
+
})
|
|
407
|
+
return this
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
and(): TestFixture {
|
|
411
|
+
const prerequisite = this.getExecutionPromise()
|
|
412
|
+
return {
|
|
413
|
+
given: () => {
|
|
414
|
+
const given = new GivenPhaseImpl(this.app, this.recordings, this.eventStore)
|
|
415
|
+
given._prerequisite = prerequisite
|
|
416
|
+
return given
|
|
417
|
+
},
|
|
418
|
+
stop: async () => { await this.app.stop() },
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
then<TResult1 = void, TResult2 = never>(
|
|
423
|
+
onfulfilled?: ((value: void) => TResult1 | PromiseLike<TResult1>) | null,
|
|
424
|
+
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
|
|
425
|
+
): PromiseLike<TResult1 | TResult2> {
|
|
426
|
+
return this.getExecutionPromise().then(onfulfilled, onrejected)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private getExecutionPromise(): Promise<void> {
|
|
430
|
+
if (!this.executionPromise) this.executionPromise = this.execute()
|
|
431
|
+
return this.executionPromise
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private async execute(): Promise<void> {
|
|
435
|
+
if (this.prerequisite) await this.prerequisite
|
|
436
|
+
|
|
437
|
+
const eventStore = this.eventStore
|
|
438
|
+
|
|
439
|
+
// 1. Given: publish events within a UnitOfWork
|
|
440
|
+
if (this.givenEvents.length > 0) {
|
|
441
|
+
await runInNewUoW(emptyMetadata(), async () => {
|
|
442
|
+
const events: EventMessage[] = this.givenEvents.map(([desc, payload]) => {
|
|
443
|
+
const tags = desc.tags ? desc.tags(payload) : []
|
|
444
|
+
return { identifier: generateIdentifier(), name: desc.name, version: desc.version, payload, metadata: emptyMetadata(), timestamp: Date.now(), tags }
|
|
445
|
+
})
|
|
446
|
+
await eventStore.append(events)
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// 1b. Given: run custom setup
|
|
451
|
+
for (const fn of this.givenSetupFns) { await fn(this.app) }
|
|
452
|
+
|
|
453
|
+
// 1c. Given: dispatch commands via the gateway (matches user-facing semantics).
|
|
454
|
+
// The recording decorator on the bus captures messages whether they
|
|
455
|
+
// arrive via gateway or direct bus dispatch.
|
|
456
|
+
for (const [desc, payload] of this.givenCommands) {
|
|
457
|
+
await this.app.commandGateway.send(desc, payload, emptyMetadata())
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// 2. Reset recordings
|
|
461
|
+
this.recordings.reset()
|
|
462
|
+
|
|
463
|
+
// 3. When
|
|
464
|
+
let result: unknown
|
|
465
|
+
let error: unknown
|
|
466
|
+
|
|
467
|
+
if (this.whenAction.kind === "command") {
|
|
468
|
+
try {
|
|
469
|
+
result = await this.app.commandGateway.send(
|
|
470
|
+
this.whenAction.descriptor,
|
|
471
|
+
this.whenAction.payload,
|
|
472
|
+
this.whenAction.metadata ?? emptyMetadata(),
|
|
473
|
+
)
|
|
474
|
+
} catch (err) { error = err }
|
|
475
|
+
} else if (this.whenAction.kind === "event") {
|
|
476
|
+
const desc = this.whenAction.descriptor
|
|
477
|
+
const payload = this.whenAction.payload
|
|
478
|
+
const tags = desc.tags ? desc.tags(payload) : []
|
|
479
|
+
try {
|
|
480
|
+
await eventStore.append([{ identifier: generateIdentifier(), name: desc.name, version: desc.version, payload, metadata: this.whenAction.metadata ?? emptyMetadata(), timestamp: Date.now(), tags }])
|
|
481
|
+
} catch (err) { error = err }
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// 4. Then
|
|
485
|
+
const recordedEvents = this.recordings.events()
|
|
486
|
+
for (const assertion of this.assertions) { await assertion(result, error, recordedEvents) }
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// Helpers
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
export class FixtureAssertionError extends Error {
|
|
495
|
+
constructor(message: string) { super(message); this.name = "FixtureAssertionError" }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export type FieldFilter = (fieldName: string, owner: unknown) => boolean
|
|
499
|
+
export const allFieldsFilter: FieldFilter = () => true
|
|
500
|
+
export function ignoreFields(...fieldNames: string[]): FieldFilter {
|
|
501
|
+
const ignored = new Set(fieldNames)
|
|
502
|
+
return (name) => !ignored.has(name)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function assertDeepEqual(expected: unknown, actual: unknown, label: string, fieldFilter: FieldFilter = allFieldsFilter): void {
|
|
506
|
+
const differences = deepCompare(expected, actual, "", fieldFilter)
|
|
507
|
+
if (differences.length > 0) throw new FixtureAssertionError(`${label} mismatch:\n${differences.map((d) => ` ${d}`).join("\n")}`)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function deepCompare(expected: unknown, actual: unknown, path: string, fieldFilter: FieldFilter): string[] {
|
|
511
|
+
if (expected === actual) return []
|
|
512
|
+
if (expected === null || actual === null) return [`${path || "root"}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`]
|
|
513
|
+
if (typeof expected !== typeof actual) return [`${path || "root"}: expected type ${typeof expected}, got type ${typeof actual}`]
|
|
514
|
+
if (typeof expected !== "object") return [`${path || "root"}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`]
|
|
515
|
+
if (Array.isArray(expected) !== Array.isArray(actual)) return [`${path || "root"}: expected ${Array.isArray(expected) ? "array" : "object"}, got ${Array.isArray(actual) ? "array" : "object"}`]
|
|
516
|
+
if (Array.isArray(expected) && Array.isArray(actual)) {
|
|
517
|
+
const diffs: string[] = []
|
|
518
|
+
for (let i = 0; i < Math.max(expected.length, actual.length); i++) {
|
|
519
|
+
if (i >= expected.length) diffs.push(`${path}[${i}]: unexpected extra element`)
|
|
520
|
+
else if (i >= actual.length) diffs.push(`${path}[${i}]: missing expected element`)
|
|
521
|
+
else diffs.push(...deepCompare(expected[i], actual[i], `${path}[${i}]`, fieldFilter))
|
|
522
|
+
}
|
|
523
|
+
return diffs
|
|
524
|
+
}
|
|
525
|
+
const diffs: string[] = []
|
|
526
|
+
const allKeys = new Set([...Object.keys(expected as any), ...Object.keys(actual as any)])
|
|
527
|
+
for (const key of allKeys) {
|
|
528
|
+
if (!fieldFilter(key, expected)) continue
|
|
529
|
+
const fieldPath = path ? `${path}.${key}` : key
|
|
530
|
+
if (!(key in (expected as any))) diffs.push(`${fieldPath}: unexpected field`)
|
|
531
|
+
else if (!(key in (actual as any))) diffs.push(`${fieldPath}: missing expected value`)
|
|
532
|
+
else diffs.push(...deepCompare((expected as any)[key], (actual as any)[key], fieldPath, fieldFilter))
|
|
533
|
+
}
|
|
534
|
+
return diffs
|
|
535
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type TestFixture,
|
|
3
|
+
type GivenPhase,
|
|
4
|
+
type WhenPhase,
|
|
5
|
+
type WhenResult,
|
|
6
|
+
type ThenPhase,
|
|
7
|
+
type FieldFilter,
|
|
8
|
+
allFieldsFilter,
|
|
9
|
+
ignoreFields,
|
|
10
|
+
createTestFixture,
|
|
11
|
+
FixtureAssertionError,
|
|
12
|
+
} from "./fixture.js"
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
type Recordings,
|
|
16
|
+
createRecordings,
|
|
17
|
+
testRecordingExtension,
|
|
18
|
+
} from "./recording-enhancer.js"
|