@basaltkit/testing 1.0.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 +21 -0
- package/README.md +311 -0
- package/dist/index.d.ts +98 -0
- package/dist/index.js +253 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Machize Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# @basaltkit/testing
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## What this module solves
|
|
6
|
+
|
|
7
|
+
Testing a "real" web application is a lot of work: you'd have to start the server on a port, authenticate a real user, wait for real emails, and wait days to see a subscription expire. None of this is practical in an automated test, which should run in milliseconds and always produce the same result.
|
|
8
|
+
|
|
9
|
+
This package solves the problem with four tools. `createTestApp` boots your application and injects HTTP requests directly into the Fastify server, without the network — and lets you "pretend" the request comes from a specific user or tenant (`actingAs` / `asTenant`), without going through login. A mail **fake** (a fake object that replaces a real service during tests), `fakeMailer`, records emails instead of sending them; a queue fake, `fakeQueue`, captures jobs instead of running them — both with Laravel-style assertions (`assertSent`, `assertDispatched`). Finally, `time` shifts the clock (`time.travel('15d')`) so you can test expirations and deadlines without waiting.
|
|
10
|
+
|
|
11
|
+
Everything works with any test runner (Vitest, Jest, node:test…), because nothing here depends on the runner.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add -D @basaltkit/testing
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
> Note: it depends on `@basaltkit/core`, `@basaltkit/fastify`, `@basaltkit/mailer`, `@basaltkit/queue`, and `fastify`. Projects created with `create-basalt` already include `@basaltkit/testing` in `devDependencies`.
|
|
20
|
+
|
|
21
|
+
## Get started in 5 minutes
|
|
22
|
+
|
|
23
|
+
1. Create a simple route and a test. In `tests/health.test.ts`:
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { describe, expect, it } from 'vitest'
|
|
27
|
+
import { fastifyPlugin, route } from '@basaltkit/fastify'
|
|
28
|
+
import { createTestApp } from '@basaltkit/testing'
|
|
29
|
+
|
|
30
|
+
const health = route({
|
|
31
|
+
method: 'GET',
|
|
32
|
+
url: '/health',
|
|
33
|
+
async handler() {
|
|
34
|
+
return { ok: true }
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('health', () => {
|
|
39
|
+
it('responds 200 with ok: true', async () => {
|
|
40
|
+
const app = await createTestApp({
|
|
41
|
+
plugins: [fastifyPlugin({ routes: [health] })],
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const response = await app.get('/health')
|
|
45
|
+
expect(response.statusCode).toBe(200)
|
|
46
|
+
expect(response.json()).toEqual({ ok: true })
|
|
47
|
+
|
|
48
|
+
await app.shutdown() // always shut down the app at the end
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
2. Run the test:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pnpm vitest run
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
There's no port, no network, no separate server starting up — the request is injected directly into Fastify (Fastify's own `inject` mechanism).
|
|
60
|
+
|
|
61
|
+
## Usage guide
|
|
62
|
+
|
|
63
|
+
### Fluent HTTP requests
|
|
64
|
+
|
|
65
|
+
`TestApp` has one method per HTTP verb. For verbs with a body (`post`, `put`, `patch`), the second argument is the payload:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
const created = await app.post('/projects', { name: 'First' })
|
|
69
|
+
expect(created.statusCode).toBe(201)
|
|
70
|
+
const id = created.json().id
|
|
71
|
+
|
|
72
|
+
await app.patch(`/projects/${id}`, { name: 'Renamed' })
|
|
73
|
+
await app.delete(`/projects/${id}`)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The response is a Fastify `LightMyRequestResponse`: use `.statusCode`, `.json()`, `.body`, `.headers`.
|
|
77
|
+
|
|
78
|
+
### Faking users and tenants (impersonation)
|
|
79
|
+
|
|
80
|
+
`createTestApp` automatically adds a test plugin that reads the special `x-test-user` / `x-test-tenant` headers and populates `ctx().user` / `ctx().tenant` — the same context your application uses in production. **Never register this mechanism in a real application.**
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
import { ctx } from '@basaltkit/core'
|
|
84
|
+
import { fastifyPlugin, route } from '@basaltkit/fastify'
|
|
85
|
+
import { createTestApp } from '@basaltkit/testing'
|
|
86
|
+
|
|
87
|
+
const whoami = route({
|
|
88
|
+
method: 'GET',
|
|
89
|
+
url: '/whoami',
|
|
90
|
+
async handler() {
|
|
91
|
+
const { user, tenant } = ctx()
|
|
92
|
+
return { user: user ?? null, tenant: tenant ?? null }
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const app = await createTestApp({ plugins: [fastifyPlugin({ routes: [whoami] })] })
|
|
97
|
+
|
|
98
|
+
// defaults for every subsequent request (chainable)
|
|
99
|
+
app.actingAs({ id: 'u1', email: 'ada@example.com' }).asTenant('acme')
|
|
100
|
+
const me = await app.get('/whoami')
|
|
101
|
+
// → { user: { id: 'u1', email: 'ada@example.com' }, tenant: { id: 'acme' } }
|
|
102
|
+
|
|
103
|
+
// override for a single request only
|
|
104
|
+
const other = await app.get('/whoami', { tenant: 'globex' })
|
|
105
|
+
// → tenant: { id: 'globex' }
|
|
106
|
+
|
|
107
|
+
await app.shutdown()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Fake mail with assertions — `fakeMailer`
|
|
111
|
+
|
|
112
|
+
Records "sent" emails in memory instead of sending them:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
import { describe, expect, it } from 'vitest'
|
|
116
|
+
import { z } from 'zod'
|
|
117
|
+
import { defineMail, MAILER } from '@basaltkit/mailer'
|
|
118
|
+
import { createTestApp, fakeMailer } from '@basaltkit/testing'
|
|
119
|
+
|
|
120
|
+
const WelcomeEmail = defineMail({
|
|
121
|
+
name: 'welcome',
|
|
122
|
+
schema: z.object({ name: z.string() }),
|
|
123
|
+
subject: ({ name }) => `Welcome, ${name}!`,
|
|
124
|
+
text: ({ name }) => `Hello ${name}`,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('sends the welcome email', async () => {
|
|
128
|
+
const mail = fakeMailer()
|
|
129
|
+
const app = await createTestApp({ plugins: [mail.plugin] })
|
|
130
|
+
|
|
131
|
+
mail.assertNothingSent()
|
|
132
|
+
const mailer = app.container.get(MAILER)
|
|
133
|
+
await mailer.send(WelcomeEmail, { name: 'Ada' }, { to: 'ada@example.com' })
|
|
134
|
+
|
|
135
|
+
const sent = mail.assertSent(WelcomeEmail, (m) => m.to.includes('ada@example.com'))
|
|
136
|
+
expect(sent.subject).toBe('Welcome, Ada!')
|
|
137
|
+
|
|
138
|
+
await app.shutdown()
|
|
139
|
+
})
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`assertSent` returns the first matching message (so you can check the subject, recipients, etc.) and throws `MailAssertionError` if nothing matches; `assertNothingSent` throws if anything was sent. The `mail.sent` array has everything, in order.
|
|
143
|
+
|
|
144
|
+
### Fake queue — `fakeQueue`
|
|
145
|
+
|
|
146
|
+
Captures job dispatches **without running them**; `drain()` runs the accumulated jobs through the real handlers:
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
import { expect, it } from 'vitest'
|
|
150
|
+
import { z } from 'zod'
|
|
151
|
+
import { defineJob } from '@basaltkit/queue'
|
|
152
|
+
import { createTestApp, fakeQueue } from '@basaltkit/testing'
|
|
153
|
+
|
|
154
|
+
const SendWelcome = defineJob({
|
|
155
|
+
name: 'email.welcome',
|
|
156
|
+
schema: z.object({ userId: z.string() }),
|
|
157
|
+
handle: ({ userId }) => console.log('processing', userId),
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('dispatches the welcome job', async () => {
|
|
161
|
+
const queue = fakeQueue({ jobs: [SendWelcome] })
|
|
162
|
+
const app = await createTestApp({ plugins: [queue.plugin] })
|
|
163
|
+
|
|
164
|
+
await SendWelcome.dispatch({ userId: 'u-1' })
|
|
165
|
+
|
|
166
|
+
const captured = queue.assertDispatched(SendWelcome)
|
|
167
|
+
expect(captured.queue).toBe('default')
|
|
168
|
+
expect(captured.payload).toEqual({ userId: 'u-1' })
|
|
169
|
+
|
|
170
|
+
// nothing has run yet; now run the real handlers:
|
|
171
|
+
expect(await queue.drain()).toBe(1)
|
|
172
|
+
|
|
173
|
+
await app.shutdown()
|
|
174
|
+
})
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Time travel — `time`
|
|
178
|
+
|
|
179
|
+
Shifts "now" (`Date.now()` and `new Date()` with no arguments) without depending on the test runner. Explicit dates (`new Date('2026-01-01')`) aren't affected.
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import { afterEach, expect, it } from 'vitest'
|
|
183
|
+
import { time } from '@basaltkit/testing'
|
|
184
|
+
|
|
185
|
+
afterEach(() => time.restore()) // ALWAYS call this in afterEach
|
|
186
|
+
|
|
187
|
+
it('the trial expires after 15 days', () => {
|
|
188
|
+
time.travel('15d') // advances 15 days (accumulates)
|
|
189
|
+
time.travelTo(new Date('2030-06-01')) // or pin an exact date
|
|
190
|
+
expect(new Date().toISOString().slice(0, 10)).toBe('2030-06-01')
|
|
191
|
+
})
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The duration format (`'15d'`, `'2h'`, …) is `@basaltkit/core`'s `DurationInput` (`parseDuration`).
|
|
195
|
+
|
|
196
|
+
## API reference
|
|
197
|
+
|
|
198
|
+
Exported from `@basaltkit/testing`:
|
|
199
|
+
|
|
200
|
+
### `createTestApp(options?): Promise<TestApp>`
|
|
201
|
+
|
|
202
|
+
Creates the application with `createApp` (the same `CreateAppOptions` as `@basaltkit/core`), prepends the impersonation plugin, calls `boot()`, and returns a `TestApp`.
|
|
203
|
+
|
|
204
|
+
| Parameter | Type | Required? | Default | Description |
|
|
205
|
+
| --- | --- | --- | --- | --- |
|
|
206
|
+
| `options` | `CreateAppOptions` | No | `{}` | Options for `createApp`; your `plugins` are added after the impersonation plugin |
|
|
207
|
+
|
|
208
|
+
### `TestApp` class
|
|
209
|
+
|
|
210
|
+
| Member | Signature | Description |
|
|
211
|
+
| --- | --- | --- |
|
|
212
|
+
| `app` | `BasaltApp` | The underlying application |
|
|
213
|
+
| `container` | `Container` (getter) | Dependency container — `app.container.get(TOKEN)` |
|
|
214
|
+
| `server` | `FastifyInstance` (getter) | The Fastify server (token `FASTIFY`) |
|
|
215
|
+
| `actingAs(user)` | `(user: TestActor) => this` | Sets the default user for subsequent requests |
|
|
216
|
+
| `asTenant(tenant)` | `(tenant: string \| { id: string }) => this` | Sets the default tenant for subsequent requests |
|
|
217
|
+
| `request(method, url, options?)` | `Promise<LightMyRequestResponse>` | Generic request |
|
|
218
|
+
| `get(url, options?)` | same | GET |
|
|
219
|
+
| `post(url, payload?, options?)` | same | POST with body |
|
|
220
|
+
| `put(url, payload?, options?)` | same | PUT with body |
|
|
221
|
+
| `patch(url, payload?, options?)` | same | PATCH with body |
|
|
222
|
+
| `delete(url, options?)` | same | DELETE |
|
|
223
|
+
| `shutdown()` | `Promise<void>` | Shuts down the application (call at the end of every test) |
|
|
224
|
+
|
|
225
|
+
`TestActor`: `{ id: string; email?: string; [key: string]: unknown }`.
|
|
226
|
+
|
|
227
|
+
`TestRequestOptions`:
|
|
228
|
+
|
|
229
|
+
| Field | Type | Required? | Default | Description |
|
|
230
|
+
| --- | --- | --- | --- | --- |
|
|
231
|
+
| `payload` | `unknown` | No | — | Request body |
|
|
232
|
+
| `headers` | `Record<string, string>` | No | — | Extra headers |
|
|
233
|
+
| `user` | `TestActor` | No | `actingAs` default | User for this request only |
|
|
234
|
+
| `tenant` | `string \| { id: string; … }` | No | `asTenant` default | Tenant for this request only |
|
|
235
|
+
|
|
236
|
+
### `fakeMailer(options?): FakeMailer`
|
|
237
|
+
|
|
238
|
+
| Parameter | Type | Required? | Default | Description |
|
|
239
|
+
| --- | --- | --- | --- | --- |
|
|
240
|
+
| `options` | `MailerOptions` | No | `{ from: 'test@basalt.dev' }` | Options for the real `Mailer` (sender, etc.) |
|
|
241
|
+
|
|
242
|
+
`FakeMailer`:
|
|
243
|
+
|
|
244
|
+
| Member | Type | Description |
|
|
245
|
+
| --- | --- | --- |
|
|
246
|
+
| `plugin` | Basalt plugin | Registers the fake mailer — pass it in `createTestApp({ plugins: [mail.plugin, …] })` |
|
|
247
|
+
| `sent` | `ResolvedMail[]` | Everything "sent", in order |
|
|
248
|
+
| `assertSent(mail, predicate?)` | `(MailDefinition \| string, (m: ResolvedMail) => boolean) => ResolvedMail` | Returns the first match; throws `MailAssertionError` if none |
|
|
249
|
+
| `assertNothingSent()` | `() => void` | Throws `MailAssertionError` if anything was sent |
|
|
250
|
+
|
|
251
|
+
`FAKE_MAILER` — token `createToken<FakeMailer>('testing:mailer')`. *(Advanced.)*
|
|
252
|
+
|
|
253
|
+
### `fakeQueue(options?): FakeQueue`
|
|
254
|
+
|
|
255
|
+
| Parameter | Type | Required? | Default | Description |
|
|
256
|
+
| --- | --- | --- | --- | --- |
|
|
257
|
+
| `options.jobs` | `JobDefinition[]` | No | — | Jobs to register in `queuePlugin` (required for `drain()` to run the handlers) |
|
|
258
|
+
|
|
259
|
+
`FakeQueue`:
|
|
260
|
+
|
|
261
|
+
| Member | Type | Description |
|
|
262
|
+
| --- | --- | --- |
|
|
263
|
+
| `plugin` | `queuePlugin(...)` | Registers the fake queue in the test application |
|
|
264
|
+
| `dispatched` | `CapturedJob[]` | All dispatches, in order |
|
|
265
|
+
| `assertDispatched(job, predicate?)` | `(JobDefinition \| string, (c: CapturedJob) => boolean) => CapturedJob` | Returns the first match; throws `QueueAssertionError` if none |
|
|
266
|
+
| `assertNothingDispatched()` | `() => void` | Throws `QueueAssertionError` if anything was dispatched |
|
|
267
|
+
| `drain()` | `() => Promise<number>` | Runs the accumulated jobs through the real handlers; returns how many ran |
|
|
268
|
+
|
|
269
|
+
`CapturedJob`: `{ queue: string; job: string; payload: unknown; context: unknown; options: AddJobOptions }`.
|
|
270
|
+
|
|
271
|
+
### `time`
|
|
272
|
+
|
|
273
|
+
| Method | Signature | Description |
|
|
274
|
+
| --- | --- | --- |
|
|
275
|
+
| `time.travel(duration)` | `(duration: DurationInput) => void` | Advances the clock (accumulates with previous calls) |
|
|
276
|
+
| `time.travelTo(date)` | `(date: Date) => void` | Pins "now" to an exact date |
|
|
277
|
+
| `time.restore()` | `() => void` | Undoes the patch and resets the offset to zero — always call in `afterEach` |
|
|
278
|
+
|
|
279
|
+
### Errors
|
|
280
|
+
|
|
281
|
+
- `MailAssertionError` — code `TEST_MAIL_ASSERTION` (extends `BasaltError`).
|
|
282
|
+
- `QueueAssertionError` — code `TEST_QUEUE_ASSERTION` (extends `BasaltError`).
|
|
283
|
+
|
|
284
|
+
## Common errors and solutions (FAQ)
|
|
285
|
+
|
|
286
|
+
**The test hangs and Vitest doesn't finish.**
|
|
287
|
+
You're missing `await app.shutdown()` at the end of the test. The application keeps resources open until it's shut down.
|
|
288
|
+
|
|
289
|
+
**`ctx().user` always comes back `undefined` in handlers.**
|
|
290
|
+
Make sure you created the app with `createTestApp` (it's the one that installs impersonation) and that you called `actingAs(...)` before the request — or passed `{ user: ... }` in that request's options. Impersonation works through `@basaltkit/fastify` request enrichers; it needs `fastifyPlugin` registered.
|
|
291
|
+
|
|
292
|
+
**`Expected mail "welcome" to have been sent. Sent: (nothing)`**
|
|
293
|
+
The code never actually sent the email, or the `Mailer` used isn't the fake one. Make sure `mail.plugin` is in `createTestApp`'s `plugins` list **before** you resolve `MAILER` from the container.
|
|
294
|
+
|
|
295
|
+
**`drain()` returns 0 or the handlers don't run.**
|
|
296
|
+
Pass the jobs when creating the fake queue: `fakeQueue({ jobs: [MyJob] })`. Without the registration, the runner doesn't know which handler to call.
|
|
297
|
+
|
|
298
|
+
**A time travel "contaminated" subsequent tests.**
|
|
299
|
+
The `Date` patch is global. Call `time.restore()` in `afterEach` — even if only one test travels in time.
|
|
300
|
+
|
|
301
|
+
**Can I use `actingAs` in production?**
|
|
302
|
+
No. The impersonation plugin reads headers (`x-test-user`) without any validation — it's exclusively for tests and only exists inside `createTestApp`.
|
|
303
|
+
|
|
304
|
+
## How it connects to other modules
|
|
305
|
+
|
|
306
|
+
- **`@basaltkit/core`** — `createTestApp` wraps `createApp`; `time` uses `parseDuration`; the errors extend `BasaltError`.
|
|
307
|
+
- **`@basaltkit/fastify`** — requests are injected into the `FastifyInstance` (token `FASTIFY`); impersonation is a `RequestEnricher` registered in the `http:enrichers` bucket.
|
|
308
|
+
- **`@basaltkit/mailer`** — `fakeMailer` registers a real `Mailer` with `MemoryMailDriver`, under the same `MAILER` token the application uses.
|
|
309
|
+
- **`@basaltkit/queue`** — `fakeQueue` uses the real `queuePlugin` with a driver that captures instead of running.
|
|
310
|
+
- **`@basaltkit/generator`** — tests generated by `basalt make:resource` use `createTestApp` from this package.
|
|
311
|
+
- **`create-basalt`** — new projects include `@basaltkit/testing` in `devDependencies` and a ready-to-run startup test.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
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 };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
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
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@basaltkit/testing",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Testing kit for Basalt apps: createTestApp with user/tenant impersonation, mail and queue fakes with assertions, and time travel.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
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"
|
|
22
|
+
},
|
|
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",
|
|
29
|
+
"@basaltkit/tsconfig": "^0.24.0"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/Zebedeu/basalt.git",
|
|
37
|
+
"directory": "packages/testing"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/testing#readme",
|
|
40
|
+
"bugs": "https://github.com/Zebedeu/basalt/issues",
|
|
41
|
+
"keywords": [
|
|
42
|
+
"basalt",
|
|
43
|
+
"typescript",
|
|
44
|
+
"testing"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"typecheck": "tsc --noEmit"
|
|
50
|
+
}
|
|
51
|
+
}
|