@basaltkit/testing 1.0.1 → 1.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/app.d.ts +67 -10
- package/dist/app.js +116 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +21 -4
package/dist/app.d.ts
CHANGED
|
@@ -16,12 +16,58 @@ export interface TestRequestOptions {
|
|
|
16
16
|
[key: string]: unknown;
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
/** Which HTTP adapter drives the test requests. Default: 'fastify'. */
|
|
20
|
+
export type TestAdapterName = 'fastify' | 'express' | 'hono';
|
|
21
|
+
/**
|
|
22
|
+
* Adapter-neutral response every driver returns. Fastify's inject response
|
|
23
|
+
* satisfies it structurally (statusCode/headers/body/json()), so suites written
|
|
24
|
+
* against the default adapter read responses exactly as before; the Express and
|
|
25
|
+
* Hono drivers build the same shape from a real fetch Response.
|
|
26
|
+
*/
|
|
27
|
+
export interface TestResponse {
|
|
28
|
+
statusCode: number;
|
|
29
|
+
headers: Record<string, string | number | string[] | undefined>;
|
|
30
|
+
body: string;
|
|
31
|
+
json<T = any>(): T;
|
|
32
|
+
}
|
|
33
|
+
export interface CreateTestAppOptions extends CreateAppOptions {
|
|
34
|
+
/**
|
|
35
|
+
* HTTP adapter to drive requests through. Pass the matching adapter plugin
|
|
36
|
+
* (`fastifyPlugin`/`expressPlugin`/`honoPlugin`) in `plugins` yourself — the
|
|
37
|
+
* harness only decides how requests are dispatched:
|
|
38
|
+
*
|
|
39
|
+
* - 'fastify' (default): in-process via `inject()` — no socket.
|
|
40
|
+
* - 'express': real `listen(0)` on 127.0.0.1 + fetch (Express has no inject);
|
|
41
|
+
* the socket is closed on `shutdown()`.
|
|
42
|
+
* - 'hono': in-process via `hono.fetch(new Request(…))` — no socket.
|
|
43
|
+
*
|
|
44
|
+
* 'express' and 'hono' need `@basaltkit/express` / `@basaltkit/hono`
|
|
45
|
+
* installed (optional peers of this package); the default needs nothing new.
|
|
46
|
+
*/
|
|
47
|
+
adapter?: TestAdapterName;
|
|
48
|
+
}
|
|
49
|
+
interface DispatchRequest {
|
|
50
|
+
method: string;
|
|
51
|
+
url: string;
|
|
52
|
+
headers: Record<string, string>;
|
|
53
|
+
payload?: unknown;
|
|
54
|
+
}
|
|
55
|
+
/** What a connected adapter driver provides: dispatch + optional teardown. */
|
|
56
|
+
interface ConnectedDriver {
|
|
57
|
+
dispatch(request: DispatchRequest): Promise<TestResponse>;
|
|
58
|
+
close?(): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
export declare class TestApp<Res extends TestResponse = LightMyRequestResponse> {
|
|
20
61
|
readonly app: BasaltApp;
|
|
21
62
|
private defaultUser;
|
|
22
63
|
private defaultTenant;
|
|
23
|
-
|
|
64
|
+
private driver;
|
|
65
|
+
constructor(app: BasaltApp, driver?: ConnectedDriver);
|
|
24
66
|
get container(): Container;
|
|
67
|
+
/**
|
|
68
|
+
* The raw Fastify instance — only meaningful on the default adapter; on
|
|
69
|
+
* Express/Hono resolve the `EXPRESS`/`HONO` token from `container` instead.
|
|
70
|
+
*/
|
|
25
71
|
get server(): FastifyInstance;
|
|
26
72
|
/** Sets the default authenticated user for subsequent requests. */
|
|
27
73
|
actingAs(user: TestActor): this;
|
|
@@ -29,13 +75,24 @@ export declare class TestApp {
|
|
|
29
75
|
asTenant(tenant: string | {
|
|
30
76
|
id: string;
|
|
31
77
|
}): this;
|
|
32
|
-
request(method: NonNullable<InjectOptions['method']>, url: string, options?: TestRequestOptions): Promise<
|
|
33
|
-
get(url: string, options?: TestRequestOptions): Promise<
|
|
34
|
-
post(url: string, payload?: unknown, options?: TestRequestOptions): Promise<
|
|
35
|
-
put(url: string, payload?: unknown, options?: TestRequestOptions): Promise<
|
|
36
|
-
patch(url: string, payload?: unknown, options?: TestRequestOptions): Promise<
|
|
37
|
-
delete(url: string, options?: TestRequestOptions): Promise<
|
|
78
|
+
request(method: NonNullable<InjectOptions['method']>, url: string, options?: TestRequestOptions): Promise<Res>;
|
|
79
|
+
get(url: string, options?: TestRequestOptions): Promise<Res>;
|
|
80
|
+
post(url: string, payload?: unknown, options?: TestRequestOptions): Promise<Res>;
|
|
81
|
+
put(url: string, payload?: unknown, options?: TestRequestOptions): Promise<Res>;
|
|
82
|
+
patch(url: string, payload?: unknown, options?: TestRequestOptions): Promise<Res>;
|
|
83
|
+
delete(url: string, options?: TestRequestOptions): Promise<Res>;
|
|
38
84
|
shutdown(): Promise<void>;
|
|
39
85
|
}
|
|
40
|
-
/**
|
|
41
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Boots an app with the impersonation enricher prepended.
|
|
88
|
+
*
|
|
89
|
+
* Pass `adapter` to drive requests through Express or Hono instead of the
|
|
90
|
+
* default Fastify inject — the same suite then runs unchanged on any adapter
|
|
91
|
+
* (the neutral 'http:enrichers'/'http:guards' buckets make impersonation and
|
|
92
|
+
* guards behave identically).
|
|
93
|
+
*/
|
|
94
|
+
export declare function createTestApp(options?: CreateAppOptions & {
|
|
95
|
+
adapter?: 'fastify';
|
|
96
|
+
}): Promise<TestApp>;
|
|
97
|
+
export declare function createTestApp(options: CreateTestAppOptions): Promise<TestApp<TestResponse>>;
|
|
98
|
+
export {};
|
package/dist/app.js
CHANGED
|
@@ -2,8 +2,9 @@ import { createApp, definePlugin, ensureMetadata, BasaltApp, } from '@basaltkit/
|
|
|
2
2
|
import { FASTIFY } from '@basaltkit/fastify';
|
|
3
3
|
/**
|
|
4
4
|
* Test-only impersonation: createTestApp prepends an enricher that reads
|
|
5
|
-
* the x-test-user / x-test-tenant headers set by the request helpers.
|
|
6
|
-
*
|
|
5
|
+
* the x-test-user / x-test-tenant headers set by the request helpers. The
|
|
6
|
+
* 'http:enrichers' bucket is framework-neutral, so impersonation works
|
|
7
|
+
* identically on every adapter. Never register this plugin in a real app.
|
|
7
8
|
*/
|
|
8
9
|
const impersonationPlugin = definePlugin({
|
|
9
10
|
name: 'basalt:testing:impersonation',
|
|
@@ -19,16 +20,115 @@ const impersonationPlugin = definePlugin({
|
|
|
19
20
|
ensureMetadata(container).add('http:enrichers', enricher);
|
|
20
21
|
},
|
|
21
22
|
});
|
|
23
|
+
// ---- drivers -------------------------------------------------------------
|
|
24
|
+
/** Fastify: light-my-request inject — in-process, no socket, sync-json reply. */
|
|
25
|
+
function connectFastify(container) {
|
|
26
|
+
const server = container.get(FASTIFY);
|
|
27
|
+
return {
|
|
28
|
+
dispatch: ({ method, url, headers, payload }) => server.inject({
|
|
29
|
+
method: method,
|
|
30
|
+
url,
|
|
31
|
+
headers,
|
|
32
|
+
...(payload !== undefined ? { payload: payload } : {}),
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Serialize a payload the way fastify's inject does: objects become JSON. */
|
|
37
|
+
function bodyInit(method, headers, payload) {
|
|
38
|
+
if (payload === undefined || method === 'GET' || method === 'HEAD')
|
|
39
|
+
return { headers };
|
|
40
|
+
if (typeof payload === 'string')
|
|
41
|
+
return { headers, body: payload };
|
|
42
|
+
return { headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(payload) };
|
|
43
|
+
}
|
|
44
|
+
/** Build the neutral response from a fetch Response (Express/Hono drivers). */
|
|
45
|
+
async function toTestResponse(response) {
|
|
46
|
+
const body = await response.text();
|
|
47
|
+
const headers = {};
|
|
48
|
+
response.headers.forEach((value, key) => {
|
|
49
|
+
headers[key] = value;
|
|
50
|
+
});
|
|
51
|
+
const cookies = response.headers.getSetCookie();
|
|
52
|
+
if (cookies.length > 0)
|
|
53
|
+
headers['set-cookie'] = cookies;
|
|
54
|
+
return {
|
|
55
|
+
statusCode: response.status,
|
|
56
|
+
headers,
|
|
57
|
+
body,
|
|
58
|
+
json: () => JSON.parse(body),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Express: unlike Fastify/Hono it has no in-process dispatch, so the driver
|
|
63
|
+
* listens on an ephemeral 127.0.0.1 port and fetches; `close()` (called by
|
|
64
|
+
* `TestApp.shutdown()`) tears the socket down.
|
|
65
|
+
*/
|
|
66
|
+
async function connectExpress(container) {
|
|
67
|
+
const { EXPRESS } = await loadAdapter('express');
|
|
68
|
+
const server = container.get(EXPRESS).listen(0, '127.0.0.1');
|
|
69
|
+
await new Promise((resolve, reject) => {
|
|
70
|
+
server.once('listening', resolve);
|
|
71
|
+
server.once('error', reject);
|
|
72
|
+
});
|
|
73
|
+
const { port } = server.address();
|
|
74
|
+
const base = `http://127.0.0.1:${port}`;
|
|
75
|
+
return {
|
|
76
|
+
dispatch: async ({ method, url, headers, payload }) => toTestResponse(await fetch(`${base}${url}`, { method, ...bodyInit(method, headers, payload) })),
|
|
77
|
+
close: () => new Promise((resolve, reject) => {
|
|
78
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Hono: in-process `hono.fetch(new Request(…))` — no socket. */
|
|
83
|
+
async function connectHono(container) {
|
|
84
|
+
const { HONO } = await loadAdapter('hono');
|
|
85
|
+
const hono = container.get(HONO);
|
|
86
|
+
return {
|
|
87
|
+
dispatch: async ({ method, url, headers, payload }) => toTestResponse(await hono.fetch(new Request(`http://basalt.test${url}`, { method, ...bodyInit(method, headers, payload) }))),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
async function loadAdapter(name) {
|
|
91
|
+
try {
|
|
92
|
+
return name === 'express' ? await import('@basaltkit/express') : await import('@basaltkit/hono');
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
throw new Error(`createTestApp({ adapter: '${name}' }) requires @basaltkit/${name} (an optional peer of ` +
|
|
96
|
+
`@basaltkit/testing) and the ${name} framework itself. Install them as devDependencies.`, { cause: error });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Only the non-default adapters connect eagerly (Express must `listen`).
|
|
101
|
+
* The fastify driver stays lazy — resolved on the first request — so apps
|
|
102
|
+
* booted without any HTTP plugin (mailer/queue-only tests) keep working
|
|
103
|
+
* exactly as before.
|
|
104
|
+
*/
|
|
105
|
+
function connect(adapter, container) {
|
|
106
|
+
switch (adapter) {
|
|
107
|
+
case 'fastify':
|
|
108
|
+
return undefined;
|
|
109
|
+
case 'express':
|
|
110
|
+
return connectExpress(container);
|
|
111
|
+
case 'hono':
|
|
112
|
+
return connectHono(container);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// ---- harness -------------------------------------------------------------
|
|
22
116
|
export class TestApp {
|
|
23
117
|
app;
|
|
24
118
|
defaultUser;
|
|
25
119
|
defaultTenant;
|
|
26
|
-
|
|
120
|
+
driver;
|
|
121
|
+
constructor(app, driver) {
|
|
27
122
|
this.app = app;
|
|
123
|
+
this.driver = driver;
|
|
28
124
|
}
|
|
29
125
|
get container() {
|
|
30
126
|
return this.app.container;
|
|
31
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* The raw Fastify instance — only meaningful on the default adapter; on
|
|
130
|
+
* Express/Hono resolve the `EXPRESS`/`HONO` token from `container` instead.
|
|
131
|
+
*/
|
|
32
132
|
get server() {
|
|
33
133
|
return this.container.get(FASTIFY);
|
|
34
134
|
}
|
|
@@ -51,8 +151,11 @@ export class TestApp {
|
|
|
51
151
|
if (tenant) {
|
|
52
152
|
headers['x-test-tenant'] = JSON.stringify(typeof tenant === 'string' ? { id: tenant } : tenant);
|
|
53
153
|
}
|
|
54
|
-
|
|
55
|
-
|
|
154
|
+
// Instantiating TestApp directly (without createTestApp) keeps the old
|
|
155
|
+
// fastify-inject behavior — connect lazily on first use.
|
|
156
|
+
this.driver ??= connectFastify(this.container);
|
|
157
|
+
return this.driver.dispatch({
|
|
158
|
+
method: String(method),
|
|
56
159
|
url,
|
|
57
160
|
headers,
|
|
58
161
|
...(options.payload !== undefined ? { payload: options.payload } : {}),
|
|
@@ -73,16 +176,19 @@ export class TestApp {
|
|
|
73
176
|
delete(url, options) {
|
|
74
177
|
return this.request('DELETE', url, options);
|
|
75
178
|
}
|
|
76
|
-
shutdown() {
|
|
179
|
+
async shutdown() {
|
|
180
|
+
await this.driver?.close?.();
|
|
77
181
|
return this.app.shutdown();
|
|
78
182
|
}
|
|
79
183
|
}
|
|
80
|
-
|
|
184
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
81
185
|
export async function createTestApp(options = {}) {
|
|
186
|
+
const { adapter = 'fastify', ...appOptions } = options;
|
|
82
187
|
const app = createApp({
|
|
83
|
-
...
|
|
84
|
-
plugins: [impersonationPlugin, ...(
|
|
188
|
+
...appOptions,
|
|
189
|
+
plugins: [impersonationPlugin, ...(appOptions.plugins ?? [])],
|
|
85
190
|
});
|
|
86
191
|
await app.boot();
|
|
87
|
-
|
|
192
|
+
const driver = connect(adapter, app.container);
|
|
193
|
+
return new TestApp(app, driver ? await driver : undefined);
|
|
88
194
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createTestApp, TestApp, type TestActor, type TestRequestOptions } from './app.js';
|
|
1
|
+
export { createTestApp, TestApp, type TestActor, type TestRequestOptions, type TestAdapterName, type TestResponse, type CreateTestAppOptions, } from './app.js';
|
|
2
2
|
export { fakeMailer, MailAssertionError, type FakeMailer } from './mailer.js';
|
|
3
3
|
export { fakeQueue, QueueAssertionError, type FakeQueue, type CapturedJob } from './queue.js';
|
|
4
4
|
export { time } from './time.js';
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/testing",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Testing kit for Basalt apps: createTestApp with user/tenant impersonation, mail and queue fakes with assertions, and time travel.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -15,16 +15,21 @@
|
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"fastify": "^5.12.1",
|
|
18
|
-
"@basaltkit/fastify": "^1.6.1",
|
|
19
|
-
"@basaltkit/queue": "^1.2.1",
|
|
20
18
|
"@basaltkit/core": "^1.1.2",
|
|
21
|
-
"@basaltkit/
|
|
19
|
+
"@basaltkit/fastify": "^1.6.1",
|
|
20
|
+
"@basaltkit/mailer": "^1.2.2",
|
|
21
|
+
"@basaltkit/queue": "^1.2.1"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@types/node": "^26.3.0",
|
|
25
|
+
"express": "^5.0.0",
|
|
26
|
+
"hono": "^4.13.4",
|
|
25
27
|
"typescript": "^7.0.2",
|
|
26
28
|
"vitest": "^4.1.11",
|
|
27
29
|
"zod": "^3.24.0 || ^4.0.0",
|
|
30
|
+
"@basaltkit/express": "^1.2.1",
|
|
31
|
+
"@basaltkit/hono": "^1.2.1",
|
|
32
|
+
"@basaltkit/http": "^1.9.1",
|
|
28
33
|
"@basaltkit/tsconfig": "^0.24.0"
|
|
29
34
|
},
|
|
30
35
|
"publishConfig": {
|
|
@@ -42,6 +47,18 @@
|
|
|
42
47
|
"typescript",
|
|
43
48
|
"testing"
|
|
44
49
|
],
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@basaltkit/express": "^1.2.1",
|
|
52
|
+
"@basaltkit/hono": "^1.2.1"
|
|
53
|
+
},
|
|
54
|
+
"peerDependenciesMeta": {
|
|
55
|
+
"@basaltkit/express": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"@basaltkit/hono": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
61
|
+
},
|
|
45
62
|
"scripts": {
|
|
46
63
|
"build": "tsc -p tsconfig.build.json",
|
|
47
64
|
"test": "vitest run",
|