@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 CHANGED
@@ -16,12 +16,58 @@ export interface TestRequestOptions {
16
16
  [key: string]: unknown;
17
17
  };
18
18
  }
19
- export declare class TestApp {
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
- constructor(app: BasaltApp);
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<LightMyRequestResponse>;
33
- get(url: string, options?: TestRequestOptions): Promise<LightMyRequestResponse>;
34
- post(url: string, payload?: unknown, options?: TestRequestOptions): Promise<LightMyRequestResponse>;
35
- put(url: string, payload?: unknown, options?: TestRequestOptions): Promise<LightMyRequestResponse>;
36
- patch(url: string, payload?: unknown, options?: TestRequestOptions): Promise<LightMyRequestResponse>;
37
- delete(url: string, options?: TestRequestOptions): Promise<LightMyRequestResponse>;
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
- /** Boots an app with the impersonation enricher prepended. */
41
- export declare function createTestApp(options?: CreateAppOptions): Promise<TestApp>;
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
- * Never register this plugin in a real app.
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
- constructor(app) {
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
- return this.server.inject({
55
- method,
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
- /** Boots an app with the impersonation enricher prepended. */
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
- ...options,
84
- plugins: [impersonationPlugin, ...(options.plugins ?? [])],
188
+ ...appOptions,
189
+ plugins: [impersonationPlugin, ...(appOptions.plugins ?? [])],
85
190
  });
86
191
  await app.boot();
87
- return new TestApp(app);
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
@@ -1,4 +1,4 @@
1
- export { createTestApp, TestApp } from './app.js';
1
+ export { createTestApp, TestApp, } from './app.js';
2
2
  export { fakeMailer, MailAssertionError } from './mailer.js';
3
3
  export { fakeQueue, QueueAssertionError } from './queue.js';
4
4
  export { time } from './time.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/testing",
3
- "version": "1.0.1",
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/mailer": "^1.2.2"
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",