@velajs/testing 0.4.0 → 0.5.1
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/CHANGELOG.md +7 -2
- package/dist/index.d.ts +484 -3
- package/dist/index.js +866 -3
- package/dist/index.js.map +1 -0
- package/dist/test-ws-connection-BHlEKwQz.js +166 -0
- package/dist/test-ws-connection-BHlEKwQz.js.map +1 -0
- package/dist/websocket-node/index.d.ts +1 -0
- package/dist/websocket-node/index.js +74 -0
- package/dist/websocket-node/index.js.map +1 -0
- package/package.json +71 -40
- package/dist/test.d.ts +0 -5
- package/dist/test.js +0 -6
- package/dist/testing-module.builder.d.ts +0 -25
- package/dist/testing-module.builder.js +0 -160
- package/dist/testing-module.d.ts +0 -10
- package/dist/testing-module.js +0 -18
package/dist/index.js
CHANGED
|
@@ -1,3 +1,866 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { i as registerWsConnector, n as TestWsRequest, r as getWsConnector, t as TestWsConnection } from "./test-ws-connection-BHlEKwQz.js";
|
|
2
|
+
import { DiscoveryService, REQUEST_CONTEXT, Scope } from "@velajs/vela";
|
|
3
|
+
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, APP_PIPE, Container, MetadataRegistry, ModuleLoader, ModuleRef, RouteManager, VelaApplication as VelaApplication$1, bindAppProviders } from "@velajs/vela/internal";
|
|
4
|
+
import { Context } from "hono";
|
|
5
|
+
import { SeederRegistry } from "@velajs/vela/seeder";
|
|
6
|
+
import { expect } from "vitest";
|
|
7
|
+
//#region src/http/path-utils.ts
|
|
8
|
+
/**
|
|
9
|
+
* Read the value at a dot-notation path (e.g. `data.user.id`).
|
|
10
|
+
* Returns `undefined` when any segment along the way is null/undefined.
|
|
11
|
+
*/
|
|
12
|
+
function getValueAtPath(obj, path) {
|
|
13
|
+
const parts = path.split(".");
|
|
14
|
+
let current = obj;
|
|
15
|
+
for (const part of parts) {
|
|
16
|
+
if (current === null || current === void 0) return;
|
|
17
|
+
current = current[part];
|
|
18
|
+
}
|
|
19
|
+
return current;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether a dot-notation path exists on the object, even when the value at the
|
|
23
|
+
* path is `null`/`undefined`. Distinguishes "key present but null" from "key
|
|
24
|
+
* absent".
|
|
25
|
+
*/
|
|
26
|
+
function hasValueAtPath(obj, path) {
|
|
27
|
+
const parts = path.split(".");
|
|
28
|
+
let current = obj;
|
|
29
|
+
for (const part of parts) {
|
|
30
|
+
if (current === null || current === void 0) return false;
|
|
31
|
+
if (typeof current !== "object") return false;
|
|
32
|
+
const record = current;
|
|
33
|
+
if (!(part in record)) return false;
|
|
34
|
+
current = record[part];
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/http/test-response.ts
|
|
40
|
+
/**
|
|
41
|
+
* TestResponse
|
|
42
|
+
*
|
|
43
|
+
* Wraps a `Response` with fluent, chainable assertions. Synchronous status /
|
|
44
|
+
* header assertions return `this`; JSON assertions (which must read the body)
|
|
45
|
+
* return `Promise<this>`.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* const res = await module.http.get('/users/1').send();
|
|
50
|
+
* res.assertOk();
|
|
51
|
+
* await res.assertJsonPath('data.id', 1);
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
var TestResponse = class {
|
|
55
|
+
response;
|
|
56
|
+
jsonData = null;
|
|
57
|
+
textData = null;
|
|
58
|
+
constructor(response) {
|
|
59
|
+
this.response = response;
|
|
60
|
+
}
|
|
61
|
+
/** The raw `Response`. */
|
|
62
|
+
get raw() {
|
|
63
|
+
return this.response;
|
|
64
|
+
}
|
|
65
|
+
/** The response status code. */
|
|
66
|
+
get status() {
|
|
67
|
+
return this.response.status;
|
|
68
|
+
}
|
|
69
|
+
/** The response headers. */
|
|
70
|
+
get headers() {
|
|
71
|
+
return this.response.headers;
|
|
72
|
+
}
|
|
73
|
+
/** Parse (and cache) the response body as JSON. */
|
|
74
|
+
async json() {
|
|
75
|
+
if (this.jsonData === null) this.jsonData = await this.response.clone().json();
|
|
76
|
+
return this.jsonData;
|
|
77
|
+
}
|
|
78
|
+
/** Read (and cache) the response body as text. */
|
|
79
|
+
async text() {
|
|
80
|
+
this.textData ??= await this.response.clone().text();
|
|
81
|
+
return this.textData;
|
|
82
|
+
}
|
|
83
|
+
/** Assert status is 200 OK. */
|
|
84
|
+
assertOk() {
|
|
85
|
+
return this.assertStatus(200);
|
|
86
|
+
}
|
|
87
|
+
/** Assert status is 201 Created. */
|
|
88
|
+
assertCreated() {
|
|
89
|
+
return this.assertStatus(201);
|
|
90
|
+
}
|
|
91
|
+
/** Assert status is 204 No Content. */
|
|
92
|
+
assertNoContent() {
|
|
93
|
+
return this.assertStatus(204);
|
|
94
|
+
}
|
|
95
|
+
/** Assert status is 400 Bad Request. */
|
|
96
|
+
assertBadRequest() {
|
|
97
|
+
return this.assertStatus(400);
|
|
98
|
+
}
|
|
99
|
+
/** Assert status is 401 Unauthorized. */
|
|
100
|
+
assertUnauthorized() {
|
|
101
|
+
return this.assertStatus(401);
|
|
102
|
+
}
|
|
103
|
+
/** Assert status is 403 Forbidden. */
|
|
104
|
+
assertForbidden() {
|
|
105
|
+
return this.assertStatus(403);
|
|
106
|
+
}
|
|
107
|
+
/** Assert status is 404 Not Found. */
|
|
108
|
+
assertNotFound() {
|
|
109
|
+
return this.assertStatus(404);
|
|
110
|
+
}
|
|
111
|
+
/** Assert status is 422 Unprocessable Entity. */
|
|
112
|
+
assertUnprocessable() {
|
|
113
|
+
return this.assertStatus(422);
|
|
114
|
+
}
|
|
115
|
+
/** Assert status is 500 Internal Server Error. */
|
|
116
|
+
assertServerError() {
|
|
117
|
+
return this.assertStatus(500);
|
|
118
|
+
}
|
|
119
|
+
/** Assert the response has the given status code. */
|
|
120
|
+
assertStatus(expected) {
|
|
121
|
+
expect(this.response.status, `Expected status ${expected}, got ${this.response.status}`).toBe(expected);
|
|
122
|
+
return this;
|
|
123
|
+
}
|
|
124
|
+
/** Assert the status is in the 2xx range. */
|
|
125
|
+
assertSuccessful() {
|
|
126
|
+
expect(this.response.status >= 200 && this.response.status < 300, `Expected successful status (2xx), got ${this.response.status}`).toBe(true);
|
|
127
|
+
return this;
|
|
128
|
+
}
|
|
129
|
+
/** Assert each key in `expected` equals the corresponding top-level value. */
|
|
130
|
+
async assertJson(expected) {
|
|
131
|
+
const actual = await this.json();
|
|
132
|
+
for (const [key, value] of Object.entries(expected)) expect(actual[key], `Expected JSON key "${key}" to be ${JSON.stringify(value)}, got ${JSON.stringify(actual[key])}`).toStrictEqual(value);
|
|
133
|
+
return this;
|
|
134
|
+
}
|
|
135
|
+
/** Assert the value at a dot-notation path equals `expected`. */
|
|
136
|
+
async assertJsonPath(path, expected) {
|
|
137
|
+
const actual = getValueAtPath(await this.json(), path);
|
|
138
|
+
expect(actual, `Expected JSON path "${path}" to be ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`).toStrictEqual(expected);
|
|
139
|
+
return this;
|
|
140
|
+
}
|
|
141
|
+
/** Assert every path/value pair in `expectations` matches (batch assert). */
|
|
142
|
+
async assertJsonPaths(expectations) {
|
|
143
|
+
const json = await this.json();
|
|
144
|
+
for (const [path, expected] of Object.entries(expectations)) {
|
|
145
|
+
const actual = getValueAtPath(json, path);
|
|
146
|
+
expect(actual, `Expected JSON path "${path}" to be ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`).toStrictEqual(expected);
|
|
147
|
+
}
|
|
148
|
+
return this;
|
|
149
|
+
}
|
|
150
|
+
/** Assert the top-level JSON object has every key in `structure`. */
|
|
151
|
+
async assertJsonStructure(structure) {
|
|
152
|
+
const json = await this.json();
|
|
153
|
+
for (const key of structure) expect(key in json, `Expected JSON to have key "${key}", got keys: ${JSON.stringify(Object.keys(json))}`).toBe(true);
|
|
154
|
+
return this;
|
|
155
|
+
}
|
|
156
|
+
/** Assert a path exists (value may be anything, including `null`). */
|
|
157
|
+
async assertJsonPathExists(path) {
|
|
158
|
+
expect(hasValueAtPath(await this.json(), path), `Expected JSON path "${path}" to exist`).toBe(true);
|
|
159
|
+
return this;
|
|
160
|
+
}
|
|
161
|
+
/** Assert a path does not exist. */
|
|
162
|
+
async assertJsonPathMissing(path) {
|
|
163
|
+
expect(hasValueAtPath(await this.json(), path), `Expected JSON path "${path}" to not exist`).toBe(false);
|
|
164
|
+
return this;
|
|
165
|
+
}
|
|
166
|
+
/** Assert the value at a path satisfies a predicate. */
|
|
167
|
+
async assertJsonPathMatches(path, matcher) {
|
|
168
|
+
const value = getValueAtPath(await this.json(), path);
|
|
169
|
+
expect(matcher(value), `Expected JSON path "${path}" to match predicate, got ${JSON.stringify(value)}`).toBe(true);
|
|
170
|
+
return this;
|
|
171
|
+
}
|
|
172
|
+
/** Assert the string value at a path contains `substring`. */
|
|
173
|
+
async assertJsonPathContains(path, substring) {
|
|
174
|
+
const value = getValueAtPath(await this.json(), path);
|
|
175
|
+
expect(typeof value === "string", `Expected JSON path "${path}" to be a string, got ${typeof value}`).toBe(true);
|
|
176
|
+
expect(value.includes(substring), `Expected JSON path "${path}" to contain "${substring}", got "${String(value)}"`).toBe(true);
|
|
177
|
+
return this;
|
|
178
|
+
}
|
|
179
|
+
/** Assert the array value at a path includes `item`. */
|
|
180
|
+
async assertJsonPathIncludes(path, item) {
|
|
181
|
+
const value = getValueAtPath(await this.json(), path);
|
|
182
|
+
expect(Array.isArray(value), `Expected JSON path "${path}" to be an array, got ${typeof value}`).toBe(true);
|
|
183
|
+
expect(value.includes(item), `Expected JSON path "${path}" to include ${JSON.stringify(item)}`).toBe(true);
|
|
184
|
+
return this;
|
|
185
|
+
}
|
|
186
|
+
/** Assert the array value at a path has `count` items. */
|
|
187
|
+
async assertJsonPathCount(path, count) {
|
|
188
|
+
const value = getValueAtPath(await this.json(), path);
|
|
189
|
+
expect(Array.isArray(value), `Expected JSON path "${path}" to be an array, got ${typeof value}`).toBe(true);
|
|
190
|
+
expect(value.length, `Expected JSON path "${path}" to have ${count} items, got ${value.length}`).toBe(count);
|
|
191
|
+
return this;
|
|
192
|
+
}
|
|
193
|
+
/** Assert a header is present, optionally equal to `expected`. */
|
|
194
|
+
assertHeader(name, expected) {
|
|
195
|
+
const actual = this.response.headers.get(name);
|
|
196
|
+
expect(actual !== null, `Expected header "${name}" to be present`).toBe(true);
|
|
197
|
+
if (expected !== void 0) expect(actual, `Expected header "${name}" to be "${expected}", got "${actual}"`).toBe(expected);
|
|
198
|
+
return this;
|
|
199
|
+
}
|
|
200
|
+
/** Assert a header is absent. */
|
|
201
|
+
assertHeaderMissing(name) {
|
|
202
|
+
const actual = this.response.headers.get(name);
|
|
203
|
+
expect(actual, `Expected header "${name}" to be absent, but got "${actual}"`).toBeNull();
|
|
204
|
+
return this;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/http/test-http-request.ts
|
|
209
|
+
/**
|
|
210
|
+
* TestHttpRequest
|
|
211
|
+
*
|
|
212
|
+
* Fluent builder for a single test HTTP request. `send()` builds a `Request`
|
|
213
|
+
* and drives it through `module.fetch()` (the full Hono pipeline).
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* ```ts
|
|
217
|
+
* const res = await module.http
|
|
218
|
+
* .post('/users')
|
|
219
|
+
* .withBody({ name: 'A' })
|
|
220
|
+
* .withHeaders({ 'X-Trace': '1' })
|
|
221
|
+
* .send();
|
|
222
|
+
* res.assertCreated();
|
|
223
|
+
* ```
|
|
224
|
+
*/
|
|
225
|
+
var TestHttpRequest = class {
|
|
226
|
+
method;
|
|
227
|
+
path;
|
|
228
|
+
module;
|
|
229
|
+
host;
|
|
230
|
+
body = void 0;
|
|
231
|
+
requestHeaders;
|
|
232
|
+
principal = null;
|
|
233
|
+
resolver = null;
|
|
234
|
+
constructor(method, path, headers, module, host = null) {
|
|
235
|
+
this.method = method;
|
|
236
|
+
this.path = path;
|
|
237
|
+
this.module = module;
|
|
238
|
+
this.host = host;
|
|
239
|
+
this.requestHeaders = new Headers(headers);
|
|
240
|
+
}
|
|
241
|
+
/** Set the request body (JSON-serialized on send). */
|
|
242
|
+
withBody(data) {
|
|
243
|
+
this.body = data;
|
|
244
|
+
return this;
|
|
245
|
+
}
|
|
246
|
+
/** Merge additional headers. */
|
|
247
|
+
withHeaders(headers) {
|
|
248
|
+
for (const [key, value] of Object.entries(headers)) this.requestHeaders.set(key, value);
|
|
249
|
+
return this;
|
|
250
|
+
}
|
|
251
|
+
/** Set `Content-Type: application/json`. */
|
|
252
|
+
asJson() {
|
|
253
|
+
this.requestHeaders.set("Content-Type", "application/json");
|
|
254
|
+
return this;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Authenticate the request as `principal`. The `resolver` (or a default one
|
|
258
|
+
* registered via `module.setAuthResolver`) turns the principal into request
|
|
259
|
+
* headers. The resolver signature `(module, principal) => Promise<Headers>`
|
|
260
|
+
* is the cross-package contract sibling packages (e.g. `@velajs/better-auth`)
|
|
261
|
+
* build against.
|
|
262
|
+
*/
|
|
263
|
+
actingAs(principal, resolver) {
|
|
264
|
+
this.principal = principal;
|
|
265
|
+
this.resolver = resolver ?? null;
|
|
266
|
+
return this;
|
|
267
|
+
}
|
|
268
|
+
/** Build the `Request` and send it through `module.fetch()`. */
|
|
269
|
+
async send() {
|
|
270
|
+
await this.applyAuthentication();
|
|
271
|
+
const hasBody = this.body !== void 0 && this.body !== null;
|
|
272
|
+
if (hasBody && !this.requestHeaders.has("Content-Type")) this.requestHeaders.set("Content-Type", "application/json");
|
|
273
|
+
const url = new URL(this.path, `http://${this.host ?? "localhost"}`);
|
|
274
|
+
const request = new Request(url.toString(), {
|
|
275
|
+
method: this.method,
|
|
276
|
+
headers: this.requestHeaders,
|
|
277
|
+
body: hasBody ? JSON.stringify(this.body) : null
|
|
278
|
+
});
|
|
279
|
+
return new TestResponse(await this.module.fetch(request));
|
|
280
|
+
}
|
|
281
|
+
async applyAuthentication() {
|
|
282
|
+
if (!this.principal) return;
|
|
283
|
+
const resolver = this.resolver ?? this.module.getAuthResolver();
|
|
284
|
+
if (!resolver) throw new Error("actingAs() requires an auth resolver. Pass one explicitly — actingAs(principal, resolver) — or register a default with module.setAuthResolver(resolver). For better-auth: import { actingAs } from \"@velajs/better-auth/testing\".");
|
|
285
|
+
const headers = await resolver(this.module, this.principal);
|
|
286
|
+
for (const [key, value] of headers.entries()) this.requestHeaders.set(key, value);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
//#endregion
|
|
290
|
+
//#region src/http/test-http-client.ts
|
|
291
|
+
/**
|
|
292
|
+
* TestHttpClient
|
|
293
|
+
*
|
|
294
|
+
* Fluent entry point for test HTTP requests. `forHost`/`withHeaders` return a
|
|
295
|
+
* new immutable client; the verb methods start a {@link TestHttpRequest}.
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* ```ts
|
|
299
|
+
* const res = await module.http
|
|
300
|
+
* .forHost('example.com')
|
|
301
|
+
* .post('/users')
|
|
302
|
+
* .withBody({ name: 'A' })
|
|
303
|
+
* .send();
|
|
304
|
+
* res.assertCreated();
|
|
305
|
+
* ```
|
|
306
|
+
*/
|
|
307
|
+
var TestHttpClient = class TestHttpClient {
|
|
308
|
+
module;
|
|
309
|
+
host;
|
|
310
|
+
defaultHeaders;
|
|
311
|
+
constructor(module, host = null, defaultHeaders = new Headers()) {
|
|
312
|
+
this.module = module;
|
|
313
|
+
this.host = host;
|
|
314
|
+
this.defaultHeaders = defaultHeaders;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Return a new client bound to `host`. Also sets the `Host` header so domain
|
|
318
|
+
* routing works even when the runtime reads the header rather than the URL.
|
|
319
|
+
*/
|
|
320
|
+
forHost(host) {
|
|
321
|
+
const headers = new Headers(this.defaultHeaders);
|
|
322
|
+
headers.set("Host", host);
|
|
323
|
+
return new TestHttpClient(this.module, host, headers);
|
|
324
|
+
}
|
|
325
|
+
/** Return a new client with additional default headers on every request. */
|
|
326
|
+
withHeaders(headers) {
|
|
327
|
+
const next = new Headers(this.defaultHeaders);
|
|
328
|
+
for (const [key, value] of Object.entries(headers)) next.set(key, value);
|
|
329
|
+
return new TestHttpClient(this.module, this.host, next);
|
|
330
|
+
}
|
|
331
|
+
get(path) {
|
|
332
|
+
return this.createRequest("GET", path);
|
|
333
|
+
}
|
|
334
|
+
post(path) {
|
|
335
|
+
return this.createRequest("POST", path);
|
|
336
|
+
}
|
|
337
|
+
put(path) {
|
|
338
|
+
return this.createRequest("PUT", path);
|
|
339
|
+
}
|
|
340
|
+
patch(path) {
|
|
341
|
+
return this.createRequest("PATCH", path);
|
|
342
|
+
}
|
|
343
|
+
delete(path) {
|
|
344
|
+
return this.createRequest("DELETE", path);
|
|
345
|
+
}
|
|
346
|
+
createRequest(method, path) {
|
|
347
|
+
return new TestHttpRequest(method, path, this.defaultHeaders, this.module, this.host);
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
//#endregion
|
|
351
|
+
//#region src/sse/test-sse-connection.ts
|
|
352
|
+
/**
|
|
353
|
+
* TestSseConnection
|
|
354
|
+
*
|
|
355
|
+
* Reads a streaming `text/event-stream` response body and exposes queue-based
|
|
356
|
+
* wait/assert helpers over the parsed events.
|
|
357
|
+
*
|
|
358
|
+
* @example
|
|
359
|
+
* ```ts
|
|
360
|
+
* const sse = await module.sse('/stream/events').connect();
|
|
361
|
+
* await sse.assertEventData('ping');
|
|
362
|
+
* await sse.waitForEnd();
|
|
363
|
+
* ```
|
|
364
|
+
*/
|
|
365
|
+
var TestSseConnection = class {
|
|
366
|
+
response;
|
|
367
|
+
eventQueue = [];
|
|
368
|
+
eventWaiters = [];
|
|
369
|
+
streamEnded = false;
|
|
370
|
+
endWaiters = [];
|
|
371
|
+
constructor(response) {
|
|
372
|
+
this.response = response;
|
|
373
|
+
this.startReading();
|
|
374
|
+
}
|
|
375
|
+
/** The raw `Response`. */
|
|
376
|
+
get raw() {
|
|
377
|
+
return this.response;
|
|
378
|
+
}
|
|
379
|
+
/** Wait for the next event (rejects after `timeout` ms). */
|
|
380
|
+
async waitForEvent(timeout = 5e3) {
|
|
381
|
+
if (this.eventQueue.length > 0) return this.eventQueue.shift();
|
|
382
|
+
if (this.streamEnded) throw new Error("SSE: stream has ended, no more events");
|
|
383
|
+
return new Promise((resolve, reject) => {
|
|
384
|
+
const waiter = (event) => {
|
|
385
|
+
clearTimeout(timer);
|
|
386
|
+
resolve(event);
|
|
387
|
+
};
|
|
388
|
+
const timer = setTimeout(() => {
|
|
389
|
+
const index = this.eventWaiters.indexOf(waiter);
|
|
390
|
+
if (index !== -1) this.eventWaiters.splice(index, 1);
|
|
391
|
+
reject(/* @__PURE__ */ new Error(`SSE: no event received within ${timeout}ms`));
|
|
392
|
+
}, timeout);
|
|
393
|
+
this.eventWaiters.push(waiter);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
/** Wait for the stream to end (rejects after `timeout` ms). */
|
|
397
|
+
async waitForEnd(timeout = 5e3) {
|
|
398
|
+
if (this.streamEnded) return;
|
|
399
|
+
return new Promise((resolve, reject) => {
|
|
400
|
+
const waiter = () => {
|
|
401
|
+
clearTimeout(timer);
|
|
402
|
+
resolve();
|
|
403
|
+
};
|
|
404
|
+
const timer = setTimeout(() => {
|
|
405
|
+
const index = this.endWaiters.indexOf(waiter);
|
|
406
|
+
if (index !== -1) this.endWaiters.splice(index, 1);
|
|
407
|
+
reject(/* @__PURE__ */ new Error(`SSE: stream did not end within ${timeout}ms`));
|
|
408
|
+
}, timeout);
|
|
409
|
+
this.endWaiters.push(waiter);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
/** Collect all remaining events until the stream ends. */
|
|
413
|
+
async collectEvents(timeout = 5e3) {
|
|
414
|
+
const events = [];
|
|
415
|
+
if (this.streamEnded) return [...this.eventQueue.splice(0)];
|
|
416
|
+
return new Promise((resolve, reject) => {
|
|
417
|
+
const originalDispatch = this.dispatchEvent.bind(this);
|
|
418
|
+
this.dispatchEvent = (event) => {
|
|
419
|
+
events.push(event);
|
|
420
|
+
originalDispatch(event);
|
|
421
|
+
};
|
|
422
|
+
const endWaiter = () => {
|
|
423
|
+
clearTimeout(timer);
|
|
424
|
+
this.dispatchEvent = originalDispatch;
|
|
425
|
+
resolve(events);
|
|
426
|
+
};
|
|
427
|
+
const timer = setTimeout(() => {
|
|
428
|
+
this.dispatchEvent = originalDispatch;
|
|
429
|
+
const index = this.endWaiters.indexOf(endWaiter);
|
|
430
|
+
if (index !== -1) this.endWaiters.splice(index, 1);
|
|
431
|
+
reject(/* @__PURE__ */ new Error(`SSE: stream did not end within ${timeout}ms`));
|
|
432
|
+
}, timeout);
|
|
433
|
+
events.push(...this.eventQueue.splice(0));
|
|
434
|
+
this.endWaiters.push(endWaiter);
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
/** Assert the next event matches the expected partial shape. */
|
|
438
|
+
async assertEvent(expected, timeout = 5e3) {
|
|
439
|
+
expect(await this.waitForEvent(timeout)).toMatchObject(expected);
|
|
440
|
+
}
|
|
441
|
+
/** Assert the next event's `data` equals `expected`. */
|
|
442
|
+
async assertEventData(expected, timeout = 5e3) {
|
|
443
|
+
const event = await this.waitForEvent(timeout);
|
|
444
|
+
expect(event.data, `Expected SSE data "${expected}", got "${event.data}"`).toBe(expected);
|
|
445
|
+
}
|
|
446
|
+
/** Assert the next event's `data` is JSON equal to `expected`. */
|
|
447
|
+
async assertJsonEventData(expected, timeout = 5e3) {
|
|
448
|
+
const event = await this.waitForEvent(timeout);
|
|
449
|
+
expect(JSON.parse(event.data)).toEqual(expected);
|
|
450
|
+
}
|
|
451
|
+
startReading() {
|
|
452
|
+
const body = this.response.body;
|
|
453
|
+
if (!body) {
|
|
454
|
+
this.streamEnded = true;
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const reader = body.getReader();
|
|
458
|
+
const decoder = new TextDecoder();
|
|
459
|
+
let buffer = "";
|
|
460
|
+
const read = async () => {
|
|
461
|
+
try {
|
|
462
|
+
for (;;) {
|
|
463
|
+
const { done, value } = await reader.read();
|
|
464
|
+
if (done) {
|
|
465
|
+
if (buffer.trim()) {
|
|
466
|
+
const event = this.parseEvent(buffer);
|
|
467
|
+
if (event) this.dispatchEvent(event);
|
|
468
|
+
}
|
|
469
|
+
this.endStream();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
buffer += decoder.decode(value, { stream: true });
|
|
473
|
+
const parts = buffer.split("\n\n");
|
|
474
|
+
buffer = parts.pop();
|
|
475
|
+
for (const part of parts) {
|
|
476
|
+
if (!part.trim()) continue;
|
|
477
|
+
const event = this.parseEvent(part);
|
|
478
|
+
if (event) this.dispatchEvent(event);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
} catch {
|
|
482
|
+
this.endStream();
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
read();
|
|
486
|
+
}
|
|
487
|
+
endStream() {
|
|
488
|
+
this.streamEnded = true;
|
|
489
|
+
for (const waiter of this.endWaiters) waiter();
|
|
490
|
+
this.endWaiters = [];
|
|
491
|
+
}
|
|
492
|
+
parseEvent(raw) {
|
|
493
|
+
const lines = raw.split("\n");
|
|
494
|
+
const dataLines = [];
|
|
495
|
+
let event;
|
|
496
|
+
let id;
|
|
497
|
+
let retry;
|
|
498
|
+
for (const line of lines) {
|
|
499
|
+
if (line.startsWith(":")) continue;
|
|
500
|
+
const colonIndex = line.indexOf(":");
|
|
501
|
+
if (colonIndex === -1) continue;
|
|
502
|
+
const field = line.slice(0, colonIndex);
|
|
503
|
+
const value = line[colonIndex + 1] === " " ? line.slice(colonIndex + 2) : line.slice(colonIndex + 1);
|
|
504
|
+
switch (field) {
|
|
505
|
+
case "data":
|
|
506
|
+
dataLines.push(value);
|
|
507
|
+
break;
|
|
508
|
+
case "event":
|
|
509
|
+
event = value;
|
|
510
|
+
break;
|
|
511
|
+
case "id":
|
|
512
|
+
id = value;
|
|
513
|
+
break;
|
|
514
|
+
case "retry": {
|
|
515
|
+
const parsed = parseInt(value, 10);
|
|
516
|
+
if (!Number.isNaN(parsed)) retry = parsed;
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (dataLines.length === 0) return null;
|
|
522
|
+
const result = { data: dataLines.join("\n") };
|
|
523
|
+
if (event !== void 0) result.event = event;
|
|
524
|
+
if (id !== void 0) result.id = id;
|
|
525
|
+
if (retry !== void 0) result.retry = retry;
|
|
526
|
+
return result;
|
|
527
|
+
}
|
|
528
|
+
dispatchEvent(event) {
|
|
529
|
+
if (this.eventWaiters.length > 0) this.eventWaiters.shift()(event);
|
|
530
|
+
else this.eventQueue.push(event);
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
//#endregion
|
|
534
|
+
//#region src/sse/test-sse-request.ts
|
|
535
|
+
/**
|
|
536
|
+
* TestSseRequest
|
|
537
|
+
*
|
|
538
|
+
* Builder for a Server-Sent Events connection. `connect()` issues a GET through
|
|
539
|
+
* `module.fetch()`, asserts a `text/event-stream` 200, and wraps the streaming
|
|
540
|
+
* body in a {@link TestSseConnection}.
|
|
541
|
+
*
|
|
542
|
+
* @example
|
|
543
|
+
* ```ts
|
|
544
|
+
* const sse = await module.sse('/stream/events').connect();
|
|
545
|
+
* await sse.assertEvent({ event: 'message', data: 'hello' });
|
|
546
|
+
* ```
|
|
547
|
+
*/
|
|
548
|
+
var TestSseRequest = class {
|
|
549
|
+
path;
|
|
550
|
+
module;
|
|
551
|
+
requestHeaders = new Headers();
|
|
552
|
+
principal = null;
|
|
553
|
+
resolver = null;
|
|
554
|
+
constructor(path, module) {
|
|
555
|
+
this.path = path;
|
|
556
|
+
this.module = module;
|
|
557
|
+
}
|
|
558
|
+
/** Merge additional headers onto the SSE request. */
|
|
559
|
+
withHeaders(headers) {
|
|
560
|
+
for (const [key, value] of Object.entries(headers)) this.requestHeaders.set(key, value);
|
|
561
|
+
return this;
|
|
562
|
+
}
|
|
563
|
+
/** Authenticate the connection (see {@link TestHttpRequest.actingAs}). */
|
|
564
|
+
actingAs(principal, resolver) {
|
|
565
|
+
this.principal = principal;
|
|
566
|
+
this.resolver = resolver ?? null;
|
|
567
|
+
return this;
|
|
568
|
+
}
|
|
569
|
+
/** Open the stream and return a live {@link TestSseConnection}. */
|
|
570
|
+
async connect() {
|
|
571
|
+
await this.applyAuthentication();
|
|
572
|
+
this.requestHeaders.set("Accept", "text/event-stream");
|
|
573
|
+
const url = new URL(this.path, "http://localhost");
|
|
574
|
+
const request = new Request(url.toString(), { headers: this.requestHeaders });
|
|
575
|
+
const response = await this.module.fetch(request);
|
|
576
|
+
expect(response.status, `Expected status 200, got ${response.status}`).toBe(200);
|
|
577
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
578
|
+
expect(contentType.includes("text/event-stream"), `Expected content-type "text/event-stream", got "${contentType}"`).toBe(true);
|
|
579
|
+
return new TestSseConnection(response);
|
|
580
|
+
}
|
|
581
|
+
async applyAuthentication() {
|
|
582
|
+
if (!this.principal) return;
|
|
583
|
+
const resolver = this.resolver ?? this.module.getAuthResolver();
|
|
584
|
+
if (!resolver) throw new Error("actingAs() requires an auth resolver. Pass one explicitly or register a default with module.setAuthResolver(resolver).");
|
|
585
|
+
const headers = await resolver(this.module, this.principal);
|
|
586
|
+
for (const [key, value] of headers.entries()) this.requestHeaders.set(key, value);
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
//#endregion
|
|
590
|
+
//#region src/testing-module.ts
|
|
591
|
+
/**
|
|
592
|
+
* TestingModule
|
|
593
|
+
*
|
|
594
|
+
* The compiled test harness. Beyond `get`/`createApplication`/`close`, it adds
|
|
595
|
+
* Laravel-flavored ergonomics: a fluent HTTP client, SSE/WS builders, request-
|
|
596
|
+
* scope execution, seeding, and database assertion wrappers.
|
|
597
|
+
*
|
|
598
|
+
* @example
|
|
599
|
+
* ```ts
|
|
600
|
+
* const module = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
|
601
|
+
* await module.http.post('/users').withBody({ name: 'A' }).send()
|
|
602
|
+
* .then((r) => r.assertCreated());
|
|
603
|
+
* ```
|
|
604
|
+
*/
|
|
605
|
+
var TestingModule = class {
|
|
606
|
+
app;
|
|
607
|
+
container;
|
|
608
|
+
_http = null;
|
|
609
|
+
honoApp = null;
|
|
610
|
+
authResolver = null;
|
|
611
|
+
constructor(app, container) {
|
|
612
|
+
this.app = app;
|
|
613
|
+
this.container = container;
|
|
614
|
+
}
|
|
615
|
+
/** Resolve a provider from the root container. */
|
|
616
|
+
get(token) {
|
|
617
|
+
return this.container.resolve(token);
|
|
618
|
+
}
|
|
619
|
+
/** Build (once) and return the underlying application. */
|
|
620
|
+
async createApplication() {
|
|
621
|
+
await this.app.initRoutes();
|
|
622
|
+
return this.app;
|
|
623
|
+
}
|
|
624
|
+
/** Lazy fluent HTTP client bound to this module. */
|
|
625
|
+
get http() {
|
|
626
|
+
this._http ??= new TestHttpClient(this);
|
|
627
|
+
return this._http;
|
|
628
|
+
}
|
|
629
|
+
/** Start an SSE connection builder for `path`. */
|
|
630
|
+
sse(path) {
|
|
631
|
+
return new TestSseRequest(path, this);
|
|
632
|
+
}
|
|
633
|
+
/** Start a WebSocket connection builder for `path` (needs a transport adapter). */
|
|
634
|
+
ws(path) {
|
|
635
|
+
return new TestWsRequest(path, this);
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Drive a `Request` through the full Hono pipeline. The Hono app is built
|
|
639
|
+
* once and reused across requests.
|
|
640
|
+
*/
|
|
641
|
+
async fetch(request, env, ctx) {
|
|
642
|
+
return (await this.ensureHono()).fetch(request, env, ctx);
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Register a default auth resolver used by `actingAs(principal)` when no
|
|
646
|
+
* resolver is passed explicitly.
|
|
647
|
+
*/
|
|
648
|
+
setAuthResolver(resolver) {
|
|
649
|
+
this.authResolver = resolver;
|
|
650
|
+
return this;
|
|
651
|
+
}
|
|
652
|
+
/** The default auth resolver, if one was registered. */
|
|
653
|
+
getAuthResolver() {
|
|
654
|
+
return this.authResolver;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Run `callback` inside a request-scoped child container seeded with a mock
|
|
658
|
+
* {@link RequestContext}, so REQUEST-scoped providers (and anything injecting
|
|
659
|
+
* `REQUEST_CONTEXT`) resolve. The child is disposed afterwards.
|
|
660
|
+
*/
|
|
661
|
+
async runInRequestScope(callback) {
|
|
662
|
+
const child = this.container.createChild();
|
|
663
|
+
child.setRequestInstance(REQUEST_CONTEXT, this.createMockRequestContext());
|
|
664
|
+
try {
|
|
665
|
+
return await callback(child);
|
|
666
|
+
} finally {
|
|
667
|
+
await child.dispose();
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Run the given `@Seeder()` classes, each in its own request scope. Throws if
|
|
672
|
+
* a class is not a registered seeder. Requires `SeederModule` (or the seeders
|
|
673
|
+
* themselves) to be present in the module graph.
|
|
674
|
+
*/
|
|
675
|
+
async seed(...SeederClasses) {
|
|
676
|
+
const registry = this.container.resolve(SeederRegistry);
|
|
677
|
+
const known = new Set(registry.list().map((s) => s.target));
|
|
678
|
+
for (const SeederClass of SeederClasses) {
|
|
679
|
+
if (!known.has(SeederClass)) throw new Error(`Seeder "${SeederClass.name}" is not registered. Add it to a module's providers or SeederModule.forRoot({ seeders: [...] }).`);
|
|
680
|
+
await this.runInRequestScope(async (child) => {
|
|
681
|
+
await child.resolve(SeederClass).run();
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/** Assert a row matching `where` exists in `table` (via a {@link TestDatabase}). */
|
|
686
|
+
async assertDatabaseHas(db, table, where) {
|
|
687
|
+
expect(await db.has(table, where), `Expected ${table} to have a row matching ${JSON.stringify(where)}`).toBe(true);
|
|
688
|
+
}
|
|
689
|
+
/** Assert no row matching `where` exists in `table`. */
|
|
690
|
+
async assertDatabaseMissing(db, table, where) {
|
|
691
|
+
expect(await db.has(table, where), `Expected ${table} NOT to have a row matching ${JSON.stringify(where)}`).toBe(false);
|
|
692
|
+
}
|
|
693
|
+
/** Assert `table` has exactly `expected` rows. */
|
|
694
|
+
async assertDatabaseCount(db, table, expected) {
|
|
695
|
+
const actual = await db.count(table);
|
|
696
|
+
expect(actual, `Expected ${table} count ${expected}, got ${actual}`).toBe(expected);
|
|
697
|
+
}
|
|
698
|
+
/** Dispose the application. */
|
|
699
|
+
async close(signal) {
|
|
700
|
+
await this.app.close(signal);
|
|
701
|
+
}
|
|
702
|
+
async ensureHono() {
|
|
703
|
+
if (!this.honoApp) {
|
|
704
|
+
const app = await this.createApplication();
|
|
705
|
+
this.honoApp = app.getHonoApp();
|
|
706
|
+
}
|
|
707
|
+
return this.honoApp;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Build a minimal, functional {@link RequestContext} for out-of-band request
|
|
711
|
+
* scopes. Vela has no `createMockRouterContext`; a real (empty) Hono `Context`
|
|
712
|
+
* backs the `hono` field so nothing dangles.
|
|
713
|
+
*/
|
|
714
|
+
createMockRequestContext() {
|
|
715
|
+
const bag = /* @__PURE__ */ new Map();
|
|
716
|
+
const request = new Request("http://localhost/");
|
|
717
|
+
const hono = new Context(request);
|
|
718
|
+
return {
|
|
719
|
+
id: crypto.randomUUID(),
|
|
720
|
+
receivedAt: /* @__PURE__ */ new Date(),
|
|
721
|
+
request,
|
|
722
|
+
hono,
|
|
723
|
+
set(key, value) {
|
|
724
|
+
bag.set(key, value);
|
|
725
|
+
},
|
|
726
|
+
get(key) {
|
|
727
|
+
return bag.get(key);
|
|
728
|
+
},
|
|
729
|
+
has(key) {
|
|
730
|
+
return bag.has(key);
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
//#endregion
|
|
736
|
+
//#region src/testing-module.builder.ts
|
|
737
|
+
var OverrideBy = class {
|
|
738
|
+
builder;
|
|
739
|
+
token;
|
|
740
|
+
constructor(builder, token) {
|
|
741
|
+
this.builder = builder;
|
|
742
|
+
this.token = token;
|
|
743
|
+
}
|
|
744
|
+
useValue(value) {
|
|
745
|
+
this.builder["addOverride"]({
|
|
746
|
+
token: this.token,
|
|
747
|
+
provider: {
|
|
748
|
+
provide: this.token,
|
|
749
|
+
useValue: value
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
return this.builder;
|
|
753
|
+
}
|
|
754
|
+
useClass(cls) {
|
|
755
|
+
this.builder["addOverride"]({
|
|
756
|
+
token: this.token,
|
|
757
|
+
provider: {
|
|
758
|
+
provide: this.token,
|
|
759
|
+
useClass: cls
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
return this.builder;
|
|
763
|
+
}
|
|
764
|
+
useFactory(options) {
|
|
765
|
+
this.builder["addOverride"]({
|
|
766
|
+
token: this.token,
|
|
767
|
+
provider: {
|
|
768
|
+
provide: this.token,
|
|
769
|
+
useFactory: options.factory,
|
|
770
|
+
inject: options.inject
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
return this.builder;
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
var TestingModuleBuilder = class {
|
|
777
|
+
metadata;
|
|
778
|
+
overrides = [];
|
|
779
|
+
constructor(metadata) {
|
|
780
|
+
this.metadata = metadata;
|
|
781
|
+
}
|
|
782
|
+
overrideProvider(token) {
|
|
783
|
+
return new OverrideBy(this, token);
|
|
784
|
+
}
|
|
785
|
+
overrideGuard(guard) {
|
|
786
|
+
return new OverrideBy(this, guard);
|
|
787
|
+
}
|
|
788
|
+
overridePipe(pipe) {
|
|
789
|
+
return new OverrideBy(this, pipe);
|
|
790
|
+
}
|
|
791
|
+
overrideInterceptor(interceptor) {
|
|
792
|
+
return new OverrideBy(this, interceptor);
|
|
793
|
+
}
|
|
794
|
+
overrideFilter(filter) {
|
|
795
|
+
return new OverrideBy(this, filter);
|
|
796
|
+
}
|
|
797
|
+
addOverride(entry) {
|
|
798
|
+
const idx = this.overrides.findIndex((o) => o.token === entry.token);
|
|
799
|
+
if (idx !== -1) this.overrides[idx] = entry;
|
|
800
|
+
else this.overrides.push(entry);
|
|
801
|
+
}
|
|
802
|
+
async compile() {
|
|
803
|
+
class TestRootModule {}
|
|
804
|
+
MetadataRegistry.setModuleOptions(TestRootModule, {
|
|
805
|
+
imports: this.metadata.imports,
|
|
806
|
+
providers: this.metadata.providers,
|
|
807
|
+
controllers: this.metadata.controllers,
|
|
808
|
+
exports: this.metadata.exports
|
|
809
|
+
});
|
|
810
|
+
const container = new Container();
|
|
811
|
+
container.register({
|
|
812
|
+
provide: Container,
|
|
813
|
+
useValue: container
|
|
814
|
+
});
|
|
815
|
+
container.markGlobalToken(Container);
|
|
816
|
+
container.register({
|
|
817
|
+
provide: ModuleRef,
|
|
818
|
+
useFactory: (c) => new ModuleRef(c),
|
|
819
|
+
inject: [Container]
|
|
820
|
+
});
|
|
821
|
+
container.markGlobalToken(ModuleRef);
|
|
822
|
+
container.register({
|
|
823
|
+
provide: DiscoveryService,
|
|
824
|
+
useFactory: (c) => new DiscoveryService(c),
|
|
825
|
+
inject: [Container]
|
|
826
|
+
});
|
|
827
|
+
container.markGlobalToken(DiscoveryService);
|
|
828
|
+
for (const t of [
|
|
829
|
+
APP_GUARD,
|
|
830
|
+
APP_PIPE,
|
|
831
|
+
APP_INTERCEPTOR,
|
|
832
|
+
APP_FILTER,
|
|
833
|
+
APP_MIDDLEWARE
|
|
834
|
+
]) container.markGlobalToken(t);
|
|
835
|
+
container.register({
|
|
836
|
+
provide: REQUEST_CONTEXT,
|
|
837
|
+
scope: Scope.REQUEST,
|
|
838
|
+
useFactory: () => {
|
|
839
|
+
throw new Error("REQUEST_CONTEXT can only be resolved inside a request — it is seeded by RouteManager when the request enters the pipeline.");
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
container.markGlobalToken(REQUEST_CONTEXT);
|
|
843
|
+
for (const override of this.overrides) container.register(override.provider);
|
|
844
|
+
const routeManager = new RouteManager(container);
|
|
845
|
+
const loader = new ModuleLoader(container, routeManager);
|
|
846
|
+
loader.load(TestRootModule);
|
|
847
|
+
for (const override of this.overrides) container.replaceProvider(override.provider);
|
|
848
|
+
bindAppProviders(routeManager, container, loader);
|
|
849
|
+
const app = new VelaApplication$1(container, routeManager);
|
|
850
|
+
const instances = await loader.resolveAllInstances();
|
|
851
|
+
app.setInstances(instances);
|
|
852
|
+
await app.callOnModuleInit();
|
|
853
|
+
await app.callOnApplicationBootstrap();
|
|
854
|
+
await app.initRoutes();
|
|
855
|
+
return new TestingModule(app, container);
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
//#endregion
|
|
859
|
+
//#region src/test.ts
|
|
860
|
+
const Test = { createTestingModule(metadata) {
|
|
861
|
+
return new TestingModuleBuilder(metadata);
|
|
862
|
+
} };
|
|
863
|
+
//#endregion
|
|
864
|
+
export { OverrideBy, Test, TestHttpClient, TestHttpRequest, TestResponse, TestSseConnection, TestSseRequest, TestWsConnection, TestWsRequest, TestingModule, TestingModuleBuilder, getValueAtPath, getWsConnector, hasValueAtPath, registerWsConnector };
|
|
865
|
+
|
|
866
|
+
//# sourceMappingURL=index.js.map
|