@cosmicdrift/kumiko-testing 0.367.0 → 0.369.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.
Files changed (44) hide show
  1. package/package.json +31 -6
  2. package/src/bunfig.ts +1 -1
  3. package/src/e2e/define-app-e2e-config.ts +2 -2
  4. package/src/e2e/index.ts +3 -3
  5. package/src/e2e/poll.ts +1 -1
  6. package/src/e2e/screenshots.ts +2 -2
  7. package/src/e2e/seed-route.ts +5 -4
  8. package/src/helpers/access-assertions.ts +15 -0
  9. package/src/helpers/assertions.ts +35 -0
  10. package/src/helpers/boot-validator-fixture.ts +127 -0
  11. package/src/helpers/closed-connection-error.ts +59 -0
  12. package/src/helpers/db-cleanup.ts +41 -0
  13. package/src/helpers/e2e-generator.ts +496 -0
  14. package/src/helpers/expect-error.ts +25 -0
  15. package/src/helpers/file-provider-contract.ts +123 -0
  16. package/src/helpers/http-cookies.ts +52 -0
  17. package/src/helpers/index.ts +67 -0
  18. package/src/helpers/late-bound.ts +41 -0
  19. package/src/helpers/manual-clock.ts +28 -0
  20. package/src/helpers/multipart-helper.ts +96 -0
  21. package/src/helpers/mutable-master-key-provider.ts +57 -0
  22. package/src/helpers/observability-recorder.ts +54 -0
  23. package/src/helpers/real-providers.ts +30 -0
  24. package/src/helpers/seed.ts +49 -0
  25. package/src/helpers/shared-entities.ts +65 -0
  26. package/src/helpers/utils.ts +1 -0
  27. package/src/helpers/wait-for.ts +50 -0
  28. package/src/helpers/without-ambient-temporal.ts +14 -0
  29. package/src/helpers/zip-fixture.ts +41 -0
  30. package/src/preload/real.ts +1 -1
  31. package/src/scaffold.ts +2 -2
  32. package/src/seed-tenant.ts +1 -1
  33. package/src/setup-app-test-stack.ts +1 -1
  34. package/src/stack/db.ts +94 -0
  35. package/src/stack/drain-event-consumers.ts +81 -0
  36. package/src/stack/drain-jobs.ts +188 -0
  37. package/src/stack/event-collector.ts +24 -0
  38. package/src/stack/index.ts +28 -0
  39. package/src/stack/push-entity-projection-tables.ts +102 -0
  40. package/src/stack/redis.ts +78 -0
  41. package/src/stack/request-helper.ts +298 -0
  42. package/src/stack/table-helpers.ts +47 -0
  43. package/src/stack/test-stack.ts +715 -0
  44. /package/src/{e2e/timeouts.ts → e2e-timeouts.ts} +0 -0
@@ -0,0 +1,298 @@
1
+ // @runtime dev
2
+ import type { JwtHelper, SessionCreator } from "@cosmicdrift/kumiko-framework/api";
3
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { Hono } from "hono";
5
+
6
+ export type BatchCommand = { type: string; payload: unknown };
7
+
8
+ type WireErrorBody = {
9
+ readonly code?: string;
10
+ readonly details?: {
11
+ readonly causeName?: string;
12
+ readonly causeMessage?: string;
13
+ readonly causeStack?: string;
14
+ };
15
+ };
16
+
17
+ function formatFailure(kind: "write" | "query", type: string, body: unknown): string {
18
+ const parsed = body as {
19
+ isSuccess?: boolean;
20
+ error?: WireErrorBody | string;
21
+ };
22
+ const code =
23
+ (typeof parsed.error === "object" ? parsed.error?.code : undefined) ??
24
+ (typeof parsed.error === "string" ? parsed.error : "unknown");
25
+ const details =
26
+ typeof parsed.error === "object" && parsed.error?.details !== undefined
27
+ ? parsed.error.details
28
+ : undefined;
29
+ const causeMessage =
30
+ details && typeof details === "object" && "causeMessage" in details
31
+ ? String(details.causeMessage ?? "")
32
+ : "";
33
+ const causeName =
34
+ details && typeof details === "object" && "causeName" in details
35
+ ? String(details.causeName ?? "")
36
+ : "";
37
+ if (code === "internal_error" && (causeMessage || causeName)) {
38
+ return `Expected ${kind} "${type}" to succeed but got error: ${code} (${causeName}: ${causeMessage})`;
39
+ }
40
+ if (details !== undefined) {
41
+ return `Expected ${kind} "${type}" to succeed but got error: ${code} — ${JSON.stringify(details)}`;
42
+ }
43
+ return `Expected ${kind} "${type}" to succeed but got error: ${code}`;
44
+ }
45
+
46
+ export type RequestHelper = {
47
+ write: (
48
+ type: string,
49
+ payload: unknown,
50
+ user: SessionUser,
51
+ requestId?: string,
52
+ ) => Promise<Response>;
53
+ query: (type: string, payload: unknown, user: SessionUser) => Promise<Response>;
54
+ command: (type: string, payload: unknown, user: SessionUser) => Promise<Response>;
55
+ batch: (
56
+ commands: readonly BatchCommand[],
57
+ user: SessionUser,
58
+ requestId?: string,
59
+ ) => Promise<Response>;
60
+ raw: (
61
+ method: string,
62
+ path: string,
63
+ body?: unknown,
64
+ headers?: Record<string, string>,
65
+ ) => Promise<Response>;
66
+
67
+ /** write + json + assert isSuccess — returns data directly */
68
+ writeOk: <T = Record<string, unknown>>(
69
+ type: string,
70
+ payload: unknown,
71
+ user: SessionUser,
72
+ requestId?: string,
73
+ ) => Promise<T>;
74
+ /** write + json + assert isSuccess === false — returns the structured
75
+ * WriteErrorInfo with `httpStatus` filled in from the HTTP response. */
76
+ writeErr: (
77
+ type: string,
78
+ payload: unknown,
79
+ user: SessionUser,
80
+ ) => Promise<import("@cosmicdrift/kumiko-framework/errors").WriteErrorInfo>;
81
+ /** query + json — returns data directly */
82
+ queryOk: <T = unknown>(type: string, payload: unknown, user: SessionUser) => Promise<T>;
83
+ /** query + json + assert the response is an error — returns the structured
84
+ * WriteErrorInfo with `httpStatus` filled in from the HTTP response. */
85
+ queryErr: (
86
+ type: string,
87
+ payload: unknown,
88
+ user: SessionUser,
89
+ ) => Promise<import("@cosmicdrift/kumiko-framework/errors").WriteErrorInfo>;
90
+
91
+ /** write + additional HTTP headers (e.g. X-Correlation-ID). Returns the
92
+ * raw Response so callers can assert on status + headers + body as needed. */
93
+ writeWithHeaders: (
94
+ type: string,
95
+ payload: unknown,
96
+ user: SessionUser,
97
+ extraHeaders: Record<string, string>,
98
+ ) => Promise<Response>;
99
+ /** query + additional HTTP headers (e.g. X-Forwarded-For). Returns the raw
100
+ * Response so callers can assert on status + headers + body as needed. */
101
+ queryWithHeaders: (
102
+ type: string,
103
+ payload: unknown,
104
+ user: SessionUser,
105
+ extraHeaders: Record<string, string>,
106
+ ) => Promise<Response>;
107
+ };
108
+
109
+ export type RequestHelperOptions = {
110
+ // When sessionChecker is wired (sessions feature), JWTs without jti are
111
+ // rejected as no_sid. Seed/test helpers that only jwt.sign(user) need a
112
+ // live sid — create one via the same sessionCreator login uses (#1372).
113
+ readonly sessionCreator?: SessionCreator;
114
+ };
115
+
116
+ export function createRequestHelper(
117
+ app: Hono,
118
+ jwt: JwtHelper,
119
+ options: RequestHelperOptions = {},
120
+ ): RequestHelper {
121
+ // sid per (user.id, tenantId), not one mint per authHeader() call:
122
+ // sessionCreator opens a live session row, so an unmemoized call mints a
123
+ // fresh one on every request a test makes for the same user — tests
124
+ // asserting on session counts / massRevoker / revokeAllOthers behavior
125
+ // then see extra live sids that have nothing to do with what they're
126
+ // testing. Keyed on tenantId too — the same user.id can hold sessions in
127
+ // more than one tenant, and sessionCreator writes the row scoped to
128
+ // `user.tenantId` (session-callbacks.ts), so a user.id-only key would
129
+ // hand a cross-tenant test its wrong tenant's sid.
130
+ // Cache the Promise (not the settled sid) so two concurrent authHeader
131
+ // calls for the same key share one mint instead of racing on a miss.
132
+ const sidByUserKey = new Map<string, Promise<string>>();
133
+
134
+ async function authHeader(user: SessionUser): Promise<Record<string, string>> {
135
+ let forJwt = user;
136
+ if (options.sessionCreator && !user.sid) {
137
+ const key = `${user.id}:${user.tenantId}`;
138
+ let sidPromise = sidByUserKey.get(key);
139
+ if (sidPromise === undefined) {
140
+ sidPromise = options.sessionCreator(user, { ip: "test", userAgent: "request-helper" });
141
+ sidByUserKey.set(key, sidPromise);
142
+ }
143
+ const sid = await sidPromise;
144
+ forJwt = { ...user, sid };
145
+ }
146
+ const token = await jwt.sign(forJwt);
147
+ return { Authorization: `Bearer ${token}` };
148
+ }
149
+
150
+ async function req(
151
+ method: string,
152
+ path: string,
153
+ body?: unknown,
154
+ headers?: Record<string, string>,
155
+ ): Promise<Response> {
156
+ const init: RequestInit = {
157
+ method,
158
+ headers: { "Content-Type": "application/json", ...headers },
159
+ };
160
+ if (body) init.body = JSON.stringify(body);
161
+ return app.request(path, init);
162
+ }
163
+
164
+ async function writeRaw(
165
+ type: string,
166
+ payload: unknown,
167
+ user: SessionUser,
168
+ requestId?: string,
169
+ ): Promise<Response> {
170
+ const headers = await authHeader(user);
171
+ return req("POST", "/api/write", { type, payload, requestId }, headers);
172
+ }
173
+
174
+ async function queryRaw(type: string, payload: unknown, user: SessionUser): Promise<Response> {
175
+ const headers = await authHeader(user);
176
+ return req("POST", "/api/query", { type, payload }, headers);
177
+ }
178
+
179
+ return {
180
+ write: writeRaw,
181
+ query: queryRaw,
182
+
183
+ async command(type, payload, user) {
184
+ const headers = await authHeader(user);
185
+ return req("POST", "/api/command", { type, payload }, headers);
186
+ },
187
+
188
+ async batch(commands, user, requestId) {
189
+ const headers = await authHeader(user);
190
+ return req("POST", "/api/batch", { commands, requestId }, headers);
191
+ },
192
+
193
+ raw: req,
194
+
195
+ async writeOk<T = Record<string, unknown>>(
196
+ type: string,
197
+ payload: unknown,
198
+ user: SessionUser,
199
+ requestId?: string,
200
+ ): Promise<T> {
201
+ const res = await writeRaw(type, payload, user, requestId);
202
+ // Wire-body shape right after JSON.parse; the caller checks afterwards
203
+ // whether isSuccess/error/data are actually present.
204
+ const rawBody = await res.json();
205
+ const body = rawBody as {
206
+ // @cast-boundary engine-bridge
207
+ isSuccess?: boolean;
208
+ data?: unknown;
209
+ error?: { code?: string } | string;
210
+ };
211
+ // Success path still has { isSuccess: true, data }. Error responses now
212
+ // follow the error-contract shape { error: { code, i18nKey, ... } } with
213
+ // a 4xx/5xx status — no isSuccess flag. Detect either.
214
+ if (body.isSuccess !== true) {
215
+ throw new Error(formatFailure("write", type, body));
216
+ }
217
+ return body.data as T; // @cast-boundary engine-bridge
218
+ },
219
+
220
+ async writeErr(
221
+ type: string,
222
+ payload: unknown,
223
+ user: SessionUser,
224
+ ): Promise<import("@cosmicdrift/kumiko-framework/errors").WriteErrorInfo> {
225
+ const res = await writeRaw(type, payload, user);
226
+ const rawErrorBody = await res.json();
227
+ const body = rawErrorBody as {
228
+ // @cast-boundary engine-bridge
229
+ isSuccess?: boolean;
230
+ error?: Omit<import("@cosmicdrift/kumiko-framework/errors").WriteErrorInfo, "httpStatus">;
231
+ };
232
+ if (body.isSuccess === true) {
233
+ throw new Error(`Expected write "${type}" to fail but it succeeded`);
234
+ }
235
+ const wire = body.error;
236
+ if (!wire || typeof wire !== "object" || typeof wire.code !== "string") {
237
+ throw new Error(
238
+ `Expected error response for "${type}" but got unexpected shape: ${JSON.stringify(body)}`,
239
+ );
240
+ }
241
+ // The wire body doesn't carry httpStatus (it would be redundant with
242
+ // the HTTP response status). Fill it in from res.status so callers can
243
+ // assert against either code OR status without a second request round.
244
+ return { ...wire, httpStatus: res.status };
245
+ },
246
+
247
+ async queryOk<T = unknown>(type: string, payload: unknown, user: SessionUser): Promise<T> {
248
+ const res = await queryRaw(type, payload, user);
249
+ const rawBody = await res.json();
250
+ const body = rawBody as {
251
+ // @cast-boundary engine-bridge
252
+ data?: unknown;
253
+ error?: WireErrorBody | string;
254
+ };
255
+ // res.ok mirrors writeOk's isSuccess assertion — belt-and-suspenders
256
+ // in case a future non-dispatcher rejection skips the error-contract shape.
257
+ if (!res.ok || body.error !== undefined) {
258
+ throw new Error(`${formatFailure("query", type, body)} [HTTP ${res.status}]`);
259
+ }
260
+ return body.data as T; // @cast-boundary engine-bridge
261
+ },
262
+
263
+ async queryErr(
264
+ type: string,
265
+ payload: unknown,
266
+ user: SessionUser,
267
+ ): Promise<import("@cosmicdrift/kumiko-framework/errors").WriteErrorInfo> {
268
+ const res = await queryRaw(type, payload, user);
269
+ const rawErrorBody = await res.json();
270
+ const body = rawErrorBody as {
271
+ // @cast-boundary engine-bridge
272
+ error?: Omit<import("@cosmicdrift/kumiko-framework/errors").WriteErrorInfo, "httpStatus">;
273
+ };
274
+ if (res.ok) {
275
+ throw new Error(`Expected query "${type}" to fail but it succeeded`);
276
+ }
277
+ const wire = body.error;
278
+ if (!wire || typeof wire !== "object" || typeof wire.code !== "string") {
279
+ throw new Error(
280
+ `Expected error response for "${type}" but got unexpected shape: ${JSON.stringify(body)}`,
281
+ );
282
+ }
283
+ // Same rationale as writeErr: the wire body has no httpStatus (it would
284
+ // be redundant with the HTTP response status), so fill it in here.
285
+ return { ...wire, httpStatus: res.status };
286
+ },
287
+
288
+ async writeWithHeaders(type, payload, user, extraHeaders) {
289
+ const authHeaders = await authHeader(user);
290
+ return req("POST", "/api/write", { type, payload }, { ...authHeaders, ...extraHeaders });
291
+ },
292
+
293
+ async queryWithHeaders(type, payload, user, extraHeaders) {
294
+ const authHeaders = await authHeader(user);
295
+ return req("POST", "/api/query", { type, payload }, { ...authHeaders, ...extraHeaders });
296
+ },
297
+ };
298
+ }
@@ -0,0 +1,47 @@
1
+ // @runtime dev
2
+ import {
3
+ schemaTableName,
4
+ truncateTablesRestartIdentity,
5
+ } from "@cosmicdrift/kumiko-framework/internal/stack-support";
6
+ import type { EventDispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
7
+
8
+ function tableNameOf(table: unknown): string {
9
+ if (typeof table !== "object" || table === null) {
10
+ throw new Error("table-helpers: table is not a SchemaTable object");
11
+ }
12
+ const symbolName = schemaTableName(table);
13
+ if (symbolName !== undefined) return symbolName;
14
+ if ("tableName" in table && typeof table.tableName === "string") return table.tableName;
15
+ throw new Error("table-helpers: table has no name");
16
+ }
17
+
18
+ /**
19
+ * Wipes event store + framework-state + the given feature read-models in
20
+ * one TRUNCATE, then re-registers the event-consumer state rows. Used in
21
+ * test beforeEach-hooks to return the stack to a clean slate without
22
+ * rebuilding it.
23
+ */
24
+ export async function resetEventStore(
25
+ stack: { db: unknown; eventDispatcher?: EventDispatcher },
26
+ extraTables: readonly (unknown | string)[] = [],
27
+ ): Promise<void> {
28
+ const frameworkTables = [
29
+ "kumiko_events",
30
+ "kumiko_event_consumers",
31
+ "kumiko_archived_streams",
32
+ "kumiko_snapshots",
33
+ "kumiko_projections",
34
+ ];
35
+ const extraNames = extraTables.map((t) => (typeof t === "string" ? t : tableNameOf(t)));
36
+ const truncateAndReregister = async (): Promise<void> => {
37
+ await truncateTablesRestartIdentity(stack.db, [...frameworkTables, ...extraNames]);
38
+ await stack.eventDispatcher?.ensureRegistered();
39
+ };
40
+ // The dispatcher's own timer/LISTEN must not start a pass between the
41
+ // TRUNCATE and the consumer re-registration, so they are paused for the whole reset.
42
+ if (stack.eventDispatcher) {
43
+ await stack.eventDispatcher.withBackgroundPassesPaused(truncateAndReregister);
44
+ } else {
45
+ await truncateAndReregister();
46
+ }
47
+ }