@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.
- package/package.json +31 -6
- package/src/bunfig.ts +1 -1
- package/src/e2e/define-app-e2e-config.ts +2 -2
- package/src/e2e/index.ts +3 -3
- package/src/e2e/poll.ts +1 -1
- package/src/e2e/screenshots.ts +2 -2
- package/src/e2e/seed-route.ts +5 -4
- package/src/helpers/access-assertions.ts +15 -0
- package/src/helpers/assertions.ts +35 -0
- package/src/helpers/boot-validator-fixture.ts +127 -0
- package/src/helpers/closed-connection-error.ts +59 -0
- package/src/helpers/db-cleanup.ts +41 -0
- package/src/helpers/e2e-generator.ts +496 -0
- package/src/helpers/expect-error.ts +25 -0
- package/src/helpers/file-provider-contract.ts +123 -0
- package/src/helpers/http-cookies.ts +52 -0
- package/src/helpers/index.ts +67 -0
- package/src/helpers/late-bound.ts +41 -0
- package/src/helpers/manual-clock.ts +28 -0
- package/src/helpers/multipart-helper.ts +96 -0
- package/src/helpers/mutable-master-key-provider.ts +57 -0
- package/src/helpers/observability-recorder.ts +54 -0
- package/src/helpers/real-providers.ts +30 -0
- package/src/helpers/seed.ts +49 -0
- package/src/helpers/shared-entities.ts +65 -0
- package/src/helpers/utils.ts +1 -0
- package/src/helpers/wait-for.ts +50 -0
- package/src/helpers/without-ambient-temporal.ts +14 -0
- package/src/helpers/zip-fixture.ts +41 -0
- package/src/preload/real.ts +1 -1
- package/src/scaffold.ts +2 -2
- package/src/seed-tenant.ts +1 -1
- package/src/setup-app-test-stack.ts +1 -1
- package/src/stack/db.ts +94 -0
- package/src/stack/drain-event-consumers.ts +81 -0
- package/src/stack/drain-jobs.ts +188 -0
- package/src/stack/event-collector.ts +24 -0
- package/src/stack/index.ts +28 -0
- package/src/stack/push-entity-projection-tables.ts +102 -0
- package/src/stack/redis.ts +78 -0
- package/src/stack/request-helper.ts +298 -0
- package/src/stack/table-helpers.ts +47 -0
- package/src/stack/test-stack.ts +715 -0
- /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
|
+
}
|