@basaltkit/testing 1.0.0 → 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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Machize Contributors
3
+ Copyright (c) 2026 Basalt Contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,3 +1,9 @@
1
+ <p align="center">
2
+ <a href="https://basaltkit-docs.pages.dev">
3
+ <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440">
4
+ </a>
5
+ </p>
6
+
1
7
  # @basaltkit/testing
2
8
 
3
9
  Testing kit for Basalt applications: boots the application in memory with `createTestApp`, makes HTTP requests impersonating users and tenants, replaces mail and queue with fake versions that support assertions, and travels through time. You need it whenever you want to write automated tests for your application without real servers, databases, or external services.
package/dist/app.d.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { BasaltApp, type Container, type CreateAppOptions } from '@basaltkit/core';
2
+ import type { FastifyInstance, InjectOptions, LightMyRequestResponse } from 'fastify';
3
+ export interface TestActor {
4
+ id: string;
5
+ email?: string;
6
+ [key: string]: unknown;
7
+ }
8
+ export interface TestRequestOptions {
9
+ payload?: unknown;
10
+ headers?: Record<string, string>;
11
+ /** Impersonate a user for this request (overrides actingAs). */
12
+ user?: TestActor;
13
+ /** Impersonate a tenant for this request (overrides asTenant). */
14
+ tenant?: string | {
15
+ id: string;
16
+ [key: string]: unknown;
17
+ };
18
+ }
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> {
61
+ readonly app: BasaltApp;
62
+ private defaultUser;
63
+ private defaultTenant;
64
+ private driver;
65
+ constructor(app: BasaltApp, driver?: ConnectedDriver);
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
+ */
71
+ get server(): FastifyInstance;
72
+ /** Sets the default authenticated user for subsequent requests. */
73
+ actingAs(user: TestActor): this;
74
+ /** Sets the default tenant for subsequent requests. */
75
+ asTenant(tenant: string | {
76
+ id: string;
77
+ }): this;
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>;
84
+ shutdown(): Promise<void>;
85
+ }
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 ADDED
@@ -0,0 +1,194 @@
1
+ import { createApp, definePlugin, ensureMetadata, BasaltApp, } from '@basaltkit/core';
2
+ import { FASTIFY } from '@basaltkit/fastify';
3
+ /**
4
+ * Test-only impersonation: createTestApp prepends an enricher that reads
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.
8
+ */
9
+ const impersonationPlugin = definePlugin({
10
+ name: 'basalt:testing:impersonation',
11
+ register({ container }) {
12
+ const enricher = ({ request, context }) => {
13
+ const rawUser = request.headers['x-test-user'];
14
+ if (typeof rawUser === 'string')
15
+ context.user = JSON.parse(rawUser);
16
+ const rawTenant = request.headers['x-test-tenant'];
17
+ if (typeof rawTenant === 'string')
18
+ context.tenant = JSON.parse(rawTenant);
19
+ };
20
+ ensureMetadata(container).add('http:enrichers', enricher);
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 -------------------------------------------------------------
116
+ export class TestApp {
117
+ app;
118
+ defaultUser;
119
+ defaultTenant;
120
+ driver;
121
+ constructor(app, driver) {
122
+ this.app = app;
123
+ this.driver = driver;
124
+ }
125
+ get container() {
126
+ return this.app.container;
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
+ */
132
+ get server() {
133
+ return this.container.get(FASTIFY);
134
+ }
135
+ /** Sets the default authenticated user for subsequent requests. */
136
+ actingAs(user) {
137
+ this.defaultUser = user;
138
+ return this;
139
+ }
140
+ /** Sets the default tenant for subsequent requests. */
141
+ asTenant(tenant) {
142
+ this.defaultTenant = tenant;
143
+ return this;
144
+ }
145
+ async request(method, url, options = {}) {
146
+ const user = options.user ?? this.defaultUser;
147
+ const tenant = options.tenant ?? this.defaultTenant;
148
+ const headers = { ...options.headers };
149
+ if (user)
150
+ headers['x-test-user'] = JSON.stringify(user);
151
+ if (tenant) {
152
+ headers['x-test-tenant'] = JSON.stringify(typeof tenant === 'string' ? { id: tenant } : tenant);
153
+ }
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),
159
+ url,
160
+ headers,
161
+ ...(options.payload !== undefined ? { payload: options.payload } : {}),
162
+ });
163
+ }
164
+ get(url, options) {
165
+ return this.request('GET', url, options);
166
+ }
167
+ post(url, payload, options) {
168
+ return this.request('POST', url, { ...options, payload });
169
+ }
170
+ put(url, payload, options) {
171
+ return this.request('PUT', url, { ...options, payload });
172
+ }
173
+ patch(url, payload, options) {
174
+ return this.request('PATCH', url, { ...options, payload });
175
+ }
176
+ delete(url, options) {
177
+ return this.request('DELETE', url, options);
178
+ }
179
+ async shutdown() {
180
+ await this.driver?.close?.();
181
+ return this.app.shutdown();
182
+ }
183
+ }
184
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
185
+ export async function createTestApp(options = {}) {
186
+ const { adapter = 'fastify', ...appOptions } = options;
187
+ const app = createApp({
188
+ ...appOptions,
189
+ plugins: [impersonationPlugin, ...(appOptions.plugins ?? [])],
190
+ });
191
+ await app.boot();
192
+ const driver = connect(adapter, app.container);
193
+ return new TestApp(app, driver ? await driver : undefined);
194
+ }
package/dist/index.d.ts CHANGED
@@ -1,98 +1,4 @@
1
- import { BasaltApp, Container, CreateAppOptions, definePlugin, BasaltError, DurationInput } from '@basaltkit/core';
2
- import { FastifyInstance, InjectOptions, LightMyRequestResponse } from 'fastify';
3
- import { ResolvedMail, MailDefinition, MailerOptions } from '@basaltkit/mailer';
4
- import { AddJobOptions, queuePlugin, JobDefinition } from '@basaltkit/queue';
5
-
6
- interface TestActor {
7
- id: string;
8
- email?: string;
9
- [key: string]: unknown;
10
- }
11
- interface TestRequestOptions {
12
- payload?: unknown;
13
- headers?: Record<string, string>;
14
- /** Impersonate a user for this request (overrides actingAs). */
15
- user?: TestActor;
16
- /** Impersonate a tenant for this request (overrides asTenant). */
17
- tenant?: string | {
18
- id: string;
19
- [key: string]: unknown;
20
- };
21
- }
22
- declare class TestApp {
23
- readonly app: BasaltApp;
24
- private defaultUser;
25
- private defaultTenant;
26
- constructor(app: BasaltApp);
27
- get container(): Container;
28
- get server(): FastifyInstance;
29
- /** Sets the default authenticated user for subsequent requests. */
30
- actingAs(user: TestActor): this;
31
- /** Sets the default tenant for subsequent requests. */
32
- asTenant(tenant: string | {
33
- id: string;
34
- }): this;
35
- request(method: NonNullable<InjectOptions['method']>, url: string, options?: TestRequestOptions): Promise<LightMyRequestResponse>;
36
- get(url: string, options?: TestRequestOptions): Promise<LightMyRequestResponse>;
37
- post(url: string, payload?: unknown, options?: TestRequestOptions): Promise<LightMyRequestResponse>;
38
- put(url: string, payload?: unknown, options?: TestRequestOptions): Promise<LightMyRequestResponse>;
39
- patch(url: string, payload?: unknown, options?: TestRequestOptions): Promise<LightMyRequestResponse>;
40
- delete(url: string, options?: TestRequestOptions): Promise<LightMyRequestResponse>;
41
- shutdown(): Promise<void>;
42
- }
43
- /** Boots an app with the impersonation enricher prepended. */
44
- declare function createTestApp(options?: CreateAppOptions): Promise<TestApp>;
45
-
46
- declare class MailAssertionError extends BasaltError {
47
- constructor(message: string);
48
- }
49
- interface FakeMailer {
50
- /** Register this in createTestApp({ plugins: [mail.plugin, ...] }). */
51
- plugin: ReturnType<typeof definePlugin>;
52
- /** Everything "sent", in order. */
53
- sent: ResolvedMail[];
54
- assertSent(mail: MailDefinition<any> | string, predicate?: (message: ResolvedMail) => boolean): ResolvedMail;
55
- assertNothingSent(): void;
56
- }
57
- /** Mail fake: records instead of sending, with Laravel-style assertions. */
58
- declare function fakeMailer(options?: MailerOptions): FakeMailer;
59
-
60
- declare class QueueAssertionError extends BasaltError {
61
- constructor(message: string);
62
- }
63
- interface CapturedJob {
64
- queue: string;
65
- job: string;
66
- payload: unknown;
67
- context: unknown;
68
- options: AddJobOptions;
69
- }
70
- interface FakeQueue {
71
- /** Register this in createTestApp({ plugins: [queue.plugin, ...] }). */
72
- plugin: ReturnType<typeof queuePlugin>;
73
- /** Every dispatch, in order — payload and context snapshot included. */
74
- dispatched: CapturedJob[];
75
- assertDispatched(job: JobDefinition<any> | string, predicate?: (captured: CapturedJob) => boolean): CapturedJob;
76
- assertNothingDispatched(): void;
77
- /** Executes the captured backlog through the real handlers. */
78
- drain(): Promise<number>;
79
- }
80
- declare function fakeQueue(options?: {
81
- jobs?: JobDefinition<any>[];
82
- }): FakeQueue;
83
-
84
- /**
85
- * Time travel without a test-runner dependency — works in any runner:
86
- *
87
- * time.travel('15d') // trials expire, meters roll over
88
- * time.travelTo(new Date('2027-01-01'))
89
- * time.restore() // always call in afterEach
90
- */
91
- declare const time: {
92
- travel(duration: DurationInput): void;
93
- travelTo(date: Date): void;
94
- /** Undoes the patch and resets the offset. */
95
- restore(): void;
96
- };
97
-
98
- export { type CapturedJob, type FakeMailer, type FakeQueue, MailAssertionError, QueueAssertionError, type TestActor, TestApp, type TestRequestOptions, createTestApp, fakeMailer, fakeQueue, time };
1
+ export { createTestApp, TestApp, type TestActor, type TestRequestOptions, type TestAdapterName, type TestResponse, type CreateTestAppOptions, } from './app.js';
2
+ export { fakeMailer, MailAssertionError, type FakeMailer } from './mailer.js';
3
+ export { fakeQueue, QueueAssertionError, type FakeQueue, type CapturedJob } from './queue.js';
4
+ export { time } from './time.js';
package/dist/index.js CHANGED
@@ -1,253 +1,4 @@
1
- // src/app.ts
2
- import {
3
- createApp,
4
- definePlugin,
5
- ensureMetadata
6
- } from "@basaltkit/core";
7
- import { FASTIFY } from "@basaltkit/fastify";
8
- var impersonationPlugin = definePlugin({
9
- name: "basalt:testing:impersonation",
10
- register({ container }) {
11
- const enricher = ({ request, context }) => {
12
- const rawUser = request.headers["x-test-user"];
13
- if (typeof rawUser === "string") context.user = JSON.parse(rawUser);
14
- const rawTenant = request.headers["x-test-tenant"];
15
- if (typeof rawTenant === "string") context.tenant = JSON.parse(rawTenant);
16
- };
17
- ensureMetadata(container).add("http:enrichers", enricher);
18
- }
19
- });
20
- var TestApp = class {
21
- constructor(app) {
22
- this.app = app;
23
- }
24
- app;
25
- defaultUser;
26
- defaultTenant;
27
- get container() {
28
- return this.app.container;
29
- }
30
- get server() {
31
- return this.container.get(FASTIFY);
32
- }
33
- /** Sets the default authenticated user for subsequent requests. */
34
- actingAs(user) {
35
- this.defaultUser = user;
36
- return this;
37
- }
38
- /** Sets the default tenant for subsequent requests. */
39
- asTenant(tenant) {
40
- this.defaultTenant = tenant;
41
- return this;
42
- }
43
- async request(method, url, options = {}) {
44
- const user = options.user ?? this.defaultUser;
45
- const tenant = options.tenant ?? this.defaultTenant;
46
- const headers = { ...options.headers };
47
- if (user) headers["x-test-user"] = JSON.stringify(user);
48
- if (tenant) {
49
- headers["x-test-tenant"] = JSON.stringify(typeof tenant === "string" ? { id: tenant } : tenant);
50
- }
51
- return this.server.inject({
52
- method,
53
- url,
54
- headers,
55
- ...options.payload !== void 0 ? { payload: options.payload } : {}
56
- });
57
- }
58
- get(url, options) {
59
- return this.request("GET", url, options);
60
- }
61
- post(url, payload, options) {
62
- return this.request("POST", url, { ...options, payload });
63
- }
64
- put(url, payload, options) {
65
- return this.request("PUT", url, { ...options, payload });
66
- }
67
- patch(url, payload, options) {
68
- return this.request("PATCH", url, { ...options, payload });
69
- }
70
- delete(url, options) {
71
- return this.request("DELETE", url, options);
72
- }
73
- shutdown() {
74
- return this.app.shutdown();
75
- }
76
- };
77
- async function createTestApp(options = {}) {
78
- const app = createApp({
79
- ...options,
80
- plugins: [impersonationPlugin, ...options.plugins ?? []]
81
- });
82
- await app.boot();
83
- return new TestApp(app);
84
- }
85
-
86
- // src/mailer.ts
87
- import { createToken, definePlugin as definePlugin2, BasaltError } from "@basaltkit/core";
88
- import {
89
- MAILER,
90
- Mailer,
91
- MemoryMailDriver
92
- } from "@basaltkit/mailer";
93
- var MailAssertionError = class extends BasaltError {
94
- constructor(message) {
95
- super("TEST_MAIL_ASSERTION", message);
96
- }
97
- };
98
- function fakeMailer(options = { from: "test@basalt.dev" }) {
99
- const driver = new MemoryMailDriver();
100
- const plugin = definePlugin2({
101
- name: "basalt:mailer",
102
- register({ container }) {
103
- container.singleton(MAILER, () => new Mailer(driver, options));
104
- }
105
- });
106
- return {
107
- plugin,
108
- sent: driver.sent,
109
- assertSent(mail, predicate) {
110
- const name = typeof mail === "string" ? mail : mail.name;
111
- const matches = driver.sent.filter(
112
- (message) => message.mail === name && (predicate ? predicate(message) : true)
113
- );
114
- if (matches.length === 0) {
115
- const seen = driver.sent.map((message) => message.mail).join(", ") || "(nothing)";
116
- throw new MailAssertionError(
117
- `Expected mail "${name}" to have been sent${predicate ? " matching the predicate" : ""}. Sent: ${seen}`
118
- );
119
- }
120
- return matches[0];
121
- },
122
- assertNothingSent() {
123
- if (driver.sent.length > 0) {
124
- throw new MailAssertionError(
125
- `Expected no mail, but ${driver.sent.length} message(s) were sent: ${driver.sent.map((message) => message.mail).join(", ")}`
126
- );
127
- }
128
- }
129
- };
130
- }
131
- var FAKE_MAILER = createToken("testing:mailer");
132
-
133
- // src/queue.ts
134
- import { BasaltError as BasaltError2 } from "@basaltkit/core";
135
- import {
136
- queuePlugin
137
- } from "@basaltkit/queue";
138
- var QueueAssertionError = class extends BasaltError2 {
139
- constructor(message) {
140
- super("TEST_QUEUE_ASSERTION", message);
141
- }
142
- };
143
- var CapturingQueueDriver = class {
144
- captured = [];
145
- pending = [];
146
- executor;
147
- setExecutor(executor) {
148
- this.executor = executor;
149
- }
150
- async add(queue, jobName, data, options) {
151
- const envelope = data;
152
- this.captured.push({
153
- queue,
154
- job: jobName,
155
- payload: envelope.payload,
156
- context: envelope.context,
157
- options
158
- });
159
- this.pending.push({ jobName, data });
160
- }
161
- async drain() {
162
- let ran = 0;
163
- while (this.pending.length > 0) {
164
- const next = this.pending.shift();
165
- await this.executor?.(next.jobName, next.data);
166
- ran++;
167
- }
168
- return ran;
169
- }
170
- startWorker() {
171
- }
172
- async close() {
173
- }
174
- };
175
- function fakeQueue(options = {}) {
176
- const driver = new CapturingQueueDriver();
177
- const plugin = queuePlugin({
178
- driver,
179
- ...options.jobs ? { jobs: options.jobs } : {}
180
- });
181
- return {
182
- plugin,
183
- dispatched: driver.captured,
184
- assertDispatched(job, predicate) {
185
- const name = typeof job === "string" ? job : job.name;
186
- const matches = driver.captured.filter(
187
- (captured) => captured.job === name && (predicate ? predicate(captured) : true)
188
- );
189
- if (matches.length === 0) {
190
- const seen = driver.captured.map((captured) => captured.job).join(", ") || "(nothing)";
191
- throw new QueueAssertionError(
192
- `Expected job "${name}" to have been dispatched${predicate ? " matching the predicate" : ""}. Dispatched: ${seen}`
193
- );
194
- }
195
- return matches[0];
196
- },
197
- assertNothingDispatched() {
198
- if (driver.captured.length > 0) {
199
- throw new QueueAssertionError(
200
- `Expected no jobs, but ${driver.captured.length} were dispatched: ${driver.captured.map((captured) => captured.job).join(", ")}`
201
- );
202
- }
203
- },
204
- drain: () => driver.drain()
205
- };
206
- }
207
-
208
- // src/time.ts
209
- import { parseDuration } from "@basaltkit/core";
210
- var RealDate = globalThis.Date;
211
- var offset = 0;
212
- var installed = false;
213
- var ShiftedDate = class extends RealDate {
214
- constructor(...args) {
215
- if (args.length === 0) super(RealDate.now() + offset);
216
- else super(...args);
217
- }
218
- static now() {
219
- return RealDate.now() + offset;
220
- }
221
- };
222
- function install() {
223
- if (installed) return;
224
- globalThis.Date = ShiftedDate;
225
- installed = true;
226
- }
227
- var time = {
228
- travel(duration) {
229
- install();
230
- offset += parseDuration(duration);
231
- },
232
- travelTo(date) {
233
- install();
234
- offset = date.getTime() - RealDate.now();
235
- },
236
- /** Undoes the patch and resets the offset. */
237
- restore() {
238
- offset = 0;
239
- if (installed) {
240
- globalThis.Date = RealDate;
241
- installed = false;
242
- }
243
- }
244
- };
245
- export {
246
- MailAssertionError,
247
- QueueAssertionError,
248
- TestApp,
249
- createTestApp,
250
- fakeMailer,
251
- fakeQueue,
252
- time
253
- };
1
+ export { createTestApp, TestApp, } from './app.js';
2
+ export { fakeMailer, MailAssertionError } from './mailer.js';
3
+ export { fakeQueue, QueueAssertionError } from './queue.js';
4
+ export { time } from './time.js';
@@ -0,0 +1,16 @@
1
+ import { definePlugin, BasaltError } from '@basaltkit/core';
2
+ import { type MailDefinition, type MailerOptions, type ResolvedMail } from '@basaltkit/mailer';
3
+ export declare class MailAssertionError extends BasaltError {
4
+ constructor(message: string);
5
+ }
6
+ export interface FakeMailer {
7
+ /** Register this in createTestApp({ plugins: [mail.plugin, ...] }). */
8
+ plugin: ReturnType<typeof definePlugin>;
9
+ /** Everything "sent", in order. */
10
+ sent: ResolvedMail[];
11
+ assertSent(mail: MailDefinition<any> | string, predicate?: (message: ResolvedMail) => boolean): ResolvedMail;
12
+ assertNothingSent(): void;
13
+ }
14
+ /** Mail fake: records instead of sending, with Laravel-style assertions. */
15
+ export declare function fakeMailer(options?: MailerOptions): FakeMailer;
16
+ export declare const FAKE_MAILER: import("@basaltkit/core").Token<FakeMailer>;
package/dist/mailer.js ADDED
@@ -0,0 +1,38 @@
1
+ import { createToken, definePlugin, BasaltError } from '@basaltkit/core';
2
+ import { MAILER, Mailer, MemoryMailDriver, } from '@basaltkit/mailer';
3
+ export class MailAssertionError extends BasaltError {
4
+ constructor(message) {
5
+ super('TEST_MAIL_ASSERTION', message);
6
+ }
7
+ }
8
+ /** Mail fake: records instead of sending, with Laravel-style assertions. */
9
+ export function fakeMailer(options = { from: 'test@basalt.dev' }) {
10
+ const driver = new MemoryMailDriver();
11
+ const plugin = definePlugin({
12
+ name: 'basalt:mailer',
13
+ register({ container }) {
14
+ container.singleton(MAILER, () => new Mailer(driver, options));
15
+ },
16
+ });
17
+ return {
18
+ plugin,
19
+ sent: driver.sent,
20
+ assertSent(mail, predicate) {
21
+ const name = typeof mail === 'string' ? mail : mail.name;
22
+ const matches = driver.sent.filter((message) => message.mail === name && (predicate ? predicate(message) : true));
23
+ if (matches.length === 0) {
24
+ const seen = driver.sent.map((message) => message.mail).join(', ') || '(nothing)';
25
+ throw new MailAssertionError(`Expected mail "${name}" to have been sent${predicate ? ' matching the predicate' : ''}. Sent: ${seen}`);
26
+ }
27
+ return matches[0];
28
+ },
29
+ assertNothingSent() {
30
+ if (driver.sent.length > 0) {
31
+ throw new MailAssertionError(`Expected no mail, but ${driver.sent.length} message(s) were sent: ${driver.sent
32
+ .map((message) => message.mail)
33
+ .join(', ')}`);
34
+ }
35
+ },
36
+ };
37
+ }
38
+ export const FAKE_MAILER = createToken('testing:mailer');
@@ -0,0 +1,25 @@
1
+ import { BasaltError } from '@basaltkit/core';
2
+ import { queuePlugin, type AddJobOptions, type JobDefinition } from '@basaltkit/queue';
3
+ export declare class QueueAssertionError extends BasaltError {
4
+ constructor(message: string);
5
+ }
6
+ export interface CapturedJob {
7
+ queue: string;
8
+ job: string;
9
+ payload: unknown;
10
+ context: unknown;
11
+ options: AddJobOptions;
12
+ }
13
+ export interface FakeQueue {
14
+ /** Register this in createTestApp({ plugins: [queue.plugin, ...] }). */
15
+ plugin: ReturnType<typeof queuePlugin>;
16
+ /** Every dispatch, in order — payload and context snapshot included. */
17
+ dispatched: CapturedJob[];
18
+ assertDispatched(job: JobDefinition<any> | string, predicate?: (captured: CapturedJob) => boolean): CapturedJob;
19
+ assertNothingDispatched(): void;
20
+ /** Executes the captured backlog through the real handlers. */
21
+ drain(): Promise<number>;
22
+ }
23
+ export declare function fakeQueue(options?: {
24
+ jobs?: JobDefinition<any>[];
25
+ }): FakeQueue;
package/dist/queue.js ADDED
@@ -0,0 +1,66 @@
1
+ import { BasaltError } from '@basaltkit/core';
2
+ import { queuePlugin, } from '@basaltkit/queue';
3
+ export class QueueAssertionError extends BasaltError {
4
+ constructor(message) {
5
+ super('TEST_QUEUE_ASSERTION', message);
6
+ }
7
+ }
8
+ /** Captures dispatches without executing them; drain() runs the backlog. */
9
+ class CapturingQueueDriver {
10
+ captured = [];
11
+ pending = [];
12
+ executor;
13
+ setExecutor(executor) {
14
+ this.executor = executor;
15
+ }
16
+ async add(queue, jobName, data, options) {
17
+ const envelope = data;
18
+ this.captured.push({
19
+ queue,
20
+ job: jobName,
21
+ payload: envelope.payload,
22
+ context: envelope.context,
23
+ options,
24
+ });
25
+ this.pending.push({ jobName, data });
26
+ }
27
+ async drain() {
28
+ let ran = 0;
29
+ while (this.pending.length > 0) {
30
+ const next = this.pending.shift();
31
+ await this.executor?.(next.jobName, next.data);
32
+ ran++;
33
+ }
34
+ return ran;
35
+ }
36
+ startWorker() { }
37
+ async close() { }
38
+ }
39
+ export function fakeQueue(options = {}) {
40
+ const driver = new CapturingQueueDriver();
41
+ const plugin = queuePlugin({
42
+ driver,
43
+ ...(options.jobs ? { jobs: options.jobs } : {}),
44
+ });
45
+ return {
46
+ plugin,
47
+ dispatched: driver.captured,
48
+ assertDispatched(job, predicate) {
49
+ const name = typeof job === 'string' ? job : job.name;
50
+ const matches = driver.captured.filter((captured) => captured.job === name && (predicate ? predicate(captured) : true));
51
+ if (matches.length === 0) {
52
+ const seen = driver.captured.map((captured) => captured.job).join(', ') || '(nothing)';
53
+ throw new QueueAssertionError(`Expected job "${name}" to have been dispatched${predicate ? ' matching the predicate' : ''}. Dispatched: ${seen}`);
54
+ }
55
+ return matches[0];
56
+ },
57
+ assertNothingDispatched() {
58
+ if (driver.captured.length > 0) {
59
+ throw new QueueAssertionError(`Expected no jobs, but ${driver.captured.length} were dispatched: ${driver.captured
60
+ .map((captured) => captured.job)
61
+ .join(', ')}`);
62
+ }
63
+ },
64
+ drain: () => driver.drain(),
65
+ };
66
+ }
package/dist/time.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { type DurationInput } from '@basaltkit/core';
2
+ /**
3
+ * Time travel without a test-runner dependency — works in any runner:
4
+ *
5
+ * time.travel('15d') // trials expire, meters roll over
6
+ * time.travelTo(new Date('2027-01-01'))
7
+ * time.restore() // always call in afterEach
8
+ */
9
+ export declare const time: {
10
+ travel(duration: DurationInput): void;
11
+ travelTo(date: Date): void;
12
+ /** Undoes the patch and resets the offset. */
13
+ restore(): void;
14
+ };
package/dist/time.js ADDED
@@ -0,0 +1,50 @@
1
+ import { parseDuration } from '@basaltkit/core';
2
+ const RealDate = globalThis.Date;
3
+ let offset = 0;
4
+ let installed = false;
5
+ /**
6
+ * Patched Date: shifts `now` by the travel offset. Explicit constructor
7
+ * arguments are untouched — only "current time" moves.
8
+ */
9
+ class ShiftedDate extends RealDate {
10
+ constructor(...args) {
11
+ if (args.length === 0)
12
+ super(RealDate.now() + offset);
13
+ else
14
+ super(...args);
15
+ }
16
+ static now() {
17
+ return RealDate.now() + offset;
18
+ }
19
+ }
20
+ function install() {
21
+ if (installed)
22
+ return;
23
+ globalThis.Date = ShiftedDate;
24
+ installed = true;
25
+ }
26
+ /**
27
+ * Time travel without a test-runner dependency — works in any runner:
28
+ *
29
+ * time.travel('15d') // trials expire, meters roll over
30
+ * time.travelTo(new Date('2027-01-01'))
31
+ * time.restore() // always call in afterEach
32
+ */
33
+ export const time = {
34
+ travel(duration) {
35
+ install();
36
+ offset += parseDuration(duration);
37
+ },
38
+ travelTo(date) {
39
+ install();
40
+ offset = date.getTime() - RealDate.now();
41
+ },
42
+ /** Undoes the patch and resets the offset. */
43
+ restore() {
44
+ offset = 0;
45
+ if (installed) {
46
+ globalThis.Date = RealDate;
47
+ installed = false;
48
+ }
49
+ },
50
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/testing",
3
- "version": "1.0.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",
@@ -14,18 +14,22 @@
14
14
  "dist"
15
15
  ],
16
16
  "dependencies": {
17
- "fastify": "^5.3.0",
18
- "@basaltkit/core": "^1.0.0",
19
- "@basaltkit/queue": "^1.0.0",
20
- "@basaltkit/fastify": "^1.0.0",
21
- "@basaltkit/mailer": "^1.0.0"
17
+ "fastify": "^5.12.1",
18
+ "@basaltkit/core": "^1.1.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
- "@types/node": "^22.15.0",
25
- "tsup": "^8.4.0",
26
- "typescript": "^5.8.0",
27
- "vitest": "^3.1.0",
28
- "zod": "^3.24.0",
24
+ "@types/node": "^26.3.0",
25
+ "express": "^5.0.0",
26
+ "hono": "^4.13.4",
27
+ "typescript": "^7.0.2",
28
+ "vitest": "^4.1.11",
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",
29
33
  "@basaltkit/tsconfig": "^0.24.0"
30
34
  },
31
35
  "publishConfig": {
@@ -33,18 +37,30 @@
33
37
  },
34
38
  "repository": {
35
39
  "type": "git",
36
- "url": "git+https://github.com/Zebedeu/basalt.git",
40
+ "url": "git+https://github.com/basaltkit/basalt.git",
37
41
  "directory": "packages/testing"
38
42
  },
39
- "homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/testing#readme",
40
- "bugs": "https://github.com/Zebedeu/basalt/issues",
43
+ "homepage": "https://github.com/basaltkit/basalt/tree/main/packages/testing#readme",
44
+ "bugs": "https://github.com/basaltkit/basalt/issues",
41
45
  "keywords": [
42
46
  "basalt",
43
47
  "typescript",
44
48
  "testing"
45
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
+ },
46
62
  "scripts": {
47
- "build": "tsup src/index.ts --format esm --dts --clean",
63
+ "build": "tsc -p tsconfig.build.json",
48
64
  "test": "vitest run",
49
65
  "typecheck": "tsc --noEmit"
50
66
  }