@basaltkit/testing 1.0.0 → 1.0.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/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,41 @@
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
+ export declare class TestApp {
20
+ readonly app: BasaltApp;
21
+ private defaultUser;
22
+ private defaultTenant;
23
+ constructor(app: BasaltApp);
24
+ get container(): Container;
25
+ get server(): FastifyInstance;
26
+ /** Sets the default authenticated user for subsequent requests. */
27
+ actingAs(user: TestActor): this;
28
+ /** Sets the default tenant for subsequent requests. */
29
+ asTenant(tenant: string | {
30
+ id: string;
31
+ }): 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>;
38
+ shutdown(): Promise<void>;
39
+ }
40
+ /** Boots an app with the impersonation enricher prepended. */
41
+ export declare function createTestApp(options?: CreateAppOptions): Promise<TestApp>;
package/dist/app.js ADDED
@@ -0,0 +1,88 @@
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.
6
+ * Never register this plugin in a real app.
7
+ */
8
+ const 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')
14
+ context.user = JSON.parse(rawUser);
15
+ const rawTenant = request.headers['x-test-tenant'];
16
+ if (typeof rawTenant === 'string')
17
+ context.tenant = JSON.parse(rawTenant);
18
+ };
19
+ ensureMetadata(container).add('http:enrichers', enricher);
20
+ },
21
+ });
22
+ export class TestApp {
23
+ app;
24
+ defaultUser;
25
+ defaultTenant;
26
+ constructor(app) {
27
+ this.app = app;
28
+ }
29
+ get container() {
30
+ return this.app.container;
31
+ }
32
+ get server() {
33
+ return this.container.get(FASTIFY);
34
+ }
35
+ /** Sets the default authenticated user for subsequent requests. */
36
+ actingAs(user) {
37
+ this.defaultUser = user;
38
+ return this;
39
+ }
40
+ /** Sets the default tenant for subsequent requests. */
41
+ asTenant(tenant) {
42
+ this.defaultTenant = tenant;
43
+ return this;
44
+ }
45
+ async request(method, url, options = {}) {
46
+ const user = options.user ?? this.defaultUser;
47
+ const tenant = options.tenant ?? this.defaultTenant;
48
+ const headers = { ...options.headers };
49
+ if (user)
50
+ headers['x-test-user'] = JSON.stringify(user);
51
+ if (tenant) {
52
+ headers['x-test-tenant'] = JSON.stringify(typeof tenant === 'string' ? { id: tenant } : tenant);
53
+ }
54
+ return this.server.inject({
55
+ method,
56
+ url,
57
+ headers,
58
+ ...(options.payload !== undefined ? { payload: options.payload } : {}),
59
+ });
60
+ }
61
+ get(url, options) {
62
+ return this.request('GET', url, options);
63
+ }
64
+ post(url, payload, options) {
65
+ return this.request('POST', url, { ...options, payload });
66
+ }
67
+ put(url, payload, options) {
68
+ return this.request('PUT', url, { ...options, payload });
69
+ }
70
+ patch(url, payload, options) {
71
+ return this.request('PATCH', url, { ...options, payload });
72
+ }
73
+ delete(url, options) {
74
+ return this.request('DELETE', url, options);
75
+ }
76
+ shutdown() {
77
+ return this.app.shutdown();
78
+ }
79
+ }
80
+ /** Boots an app with the impersonation enricher prepended. */
81
+ export async function createTestApp(options = {}) {
82
+ const app = createApp({
83
+ ...options,
84
+ plugins: [impersonationPlugin, ...(options.plugins ?? [])],
85
+ });
86
+ await app.boot();
87
+ return new TestApp(app);
88
+ }
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 } 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.0.1",
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,17 @@
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/fastify": "^1.6.1",
19
+ "@basaltkit/queue": "^1.2.1",
20
+ "@basaltkit/core": "^1.1.2",
21
+ "@basaltkit/mailer": "^1.2.2"
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
+ "typescript": "^7.0.2",
26
+ "vitest": "^4.1.11",
27
+ "zod": "^3.24.0 || ^4.0.0",
29
28
  "@basaltkit/tsconfig": "^0.24.0"
30
29
  },
31
30
  "publishConfig": {
@@ -33,18 +32,18 @@
33
32
  },
34
33
  "repository": {
35
34
  "type": "git",
36
- "url": "git+https://github.com/Zebedeu/basalt.git",
35
+ "url": "git+https://github.com/basaltkit/basalt.git",
37
36
  "directory": "packages/testing"
38
37
  },
39
- "homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/testing#readme",
40
- "bugs": "https://github.com/Zebedeu/basalt/issues",
38
+ "homepage": "https://github.com/basaltkit/basalt/tree/main/packages/testing#readme",
39
+ "bugs": "https://github.com/basaltkit/basalt/issues",
41
40
  "keywords": [
42
41
  "basalt",
43
42
  "typescript",
44
43
  "testing"
45
44
  ],
46
45
  "scripts": {
47
- "build": "tsup src/index.ts --format esm --dts --clean",
46
+ "build": "tsc -p tsconfig.build.json",
48
47
  "test": "vitest run",
49
48
  "typecheck": "tsc --noEmit"
50
49
  }