@avelonjs/assay 0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Yannelli
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,146 @@
1
+ # @avelonjs/assay
2
+
3
+ `@avelonjs/assay` is the test harness. It sits on `bun test` and gives you HTTP helpers that dispatch through the core kernel, model factories, and seeds. Reach for it when a feature test should speak in requests, factories, and assertions rather than constructing `HttpRequest` by hand.
4
+
5
+ Assay tests a material for what it really is: the same kernel, routes, and models the adapter will serve.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ bun add -d @avelonjs/assay
11
+ ```
12
+
13
+ Assay expects `@avelonjs/core` and `@avelonjs/orm`. You pass a kernel and route table in; vendor drivers stay in test setup, never in `app/`.
14
+
15
+ ## Basic Usage
16
+
17
+ ```ts
18
+ import { FakeDatabase } from '@avelonjs/conformance'
19
+ import { createKernel, defineConfig, view } from '@avelonjs/core'
20
+ import { assay } from '@avelonjs/assay'
21
+
22
+ defineConfig({ name: 'tests', drivers: { database: new FakeDatabase() } })
23
+
24
+ const kernel = createKernel()
25
+ const client = assay({ kernel, routes })
26
+
27
+ const index = await client.get('/users')
28
+ index.assertOk().assertView('users.index')
29
+ const stored = await client.actingAs({ id: 'u1' }).post('/users', { name: 'Ada' })
30
+ stored.assertRedirect('/users/u1')
31
+ ```
32
+
33
+ ## HTTP Helpers
34
+
35
+ `assay()` matches method plus path against a `RouteDefinition` table, preferring static segments so `/users/create` does not lose to `/users/{user}`. Each helper builds an `HttpRequest`, dispatches through the kernel, and flushes `after` listeners.
36
+
37
+ ```ts
38
+ const shown = await client.get('/users/u1')
39
+ shown.assertStatus(200)
40
+ shown.view().props
41
+ ```
42
+
43
+ `call(name, httpRequest())` dispatches a named route when you already have params. Unauthenticated S4-style kernels still read `cookies.user`; `actingAs('ada')` writes that cookie.
44
+
45
+ ## Factories
46
+
47
+ Factories fill Scrivener models. Sequence numbers start at 1. There is no bundled faker; you return plain attributes so tests stay deterministic.
48
+
49
+ ```ts
50
+ import { defineFactory } from '@avelonjs/assay'
51
+ import { User } from '@/app/Models/User'
52
+
53
+ const UserFactory = defineFactory(User, (sequence) => ({
54
+ id: `user-${sequence}`,
55
+ email: `user-${sequence}@example.test`,
56
+ name: `User ${sequence}`,
57
+ }))
58
+
59
+ UserFactory.state('ada', () => ({ name: 'Ada Lovelace', email: 'ada@example.test' }))
60
+
61
+ await UserFactory.make()
62
+ await UserFactory.create({ name: 'Ada' })
63
+ await UserFactory.as('ada').create()
64
+ await UserFactory.createMany(3)
65
+ ```
66
+
67
+ ## Seeds
68
+
69
+ Seeds are ordinary modules with a default export. `reeve db:seed` and `runSeeds(dir)` load `database/seeds` in filename order.
70
+
71
+ ```ts
72
+ import { defineSeed } from '@avelonjs/assay'
73
+
74
+ export default defineSeed(async () => {
75
+ await UserFactory.createMany(5)
76
+ }, 'users')
77
+ ```
78
+
79
+ ```ts
80
+ import { runSeed, runSeeds } from '@avelonjs/assay'
81
+
82
+ await runSeed(seed)
83
+ await runSeeds('database/seeds')
84
+ ```
85
+
86
+ ## Method Reference
87
+
88
+ | Method / export | Signature | Description |
89
+ | ------------------------------ | --------------------------------------------------- | --------------------------------------------------------- |
90
+ | `assay` | `(options: AssayOptions) => AssayClient` | Creates an HTTP client bound to a kernel and route table. |
91
+ | `AssayClient.get` | `(path, headers?) => Promise<AssayResponse>` | Dispatches GET. |
92
+ | `AssayClient.post` | `(path, body?, headers?) => Promise<AssayResponse>` | Dispatches POST. |
93
+ | `AssayClient.put` | `(path, body?, headers?) => Promise<AssayResponse>` | Dispatches PUT. |
94
+ | `AssayClient.patch` | `(path, body?, headers?) => Promise<AssayResponse>` | Dispatches PATCH. |
95
+ | `AssayClient.delete` | `(path, headers?) => Promise<AssayResponse>` | Dispatches DELETE. |
96
+ | `AssayClient.call` | `(name, request) => Promise<AssayResponse>` | Dispatches a named route with an existing `HttpRequest`. |
97
+ | `AssayClient.actingAs` | `(actor: { id: string } \| string) => this` | Sets the actor cookie for subsequent requests. |
98
+ | `AssayClient.asGuest` | `() => this` | Clears actor cookies. |
99
+ | `AssayResponse.assertOk` | `() => this` | Fails unless the outcome is 2xx and not a failed action. |
100
+ | `AssayResponse.assertStatus` | `(expected: number) => this` | Fails unless the status hint equals `expected`. |
101
+ | `AssayResponse.assertRedirect` | `(location: string) => this` | Fails unless the kernel redirected to `location`. |
102
+ | `AssayResponse.assertView` | `(view: unknown) => this` | Fails unless the kernel rendered `view`. |
103
+ | `AssayResponse.status` | `() => number` | Returns the transport-neutral status hint. |
104
+ | `AssayResponse.view` | `() => ViewResult` | Returns the view result or throws. |
105
+ | `AssayResponse.data` | `() => unknown` | Returns action data or view props. |
106
+ | `AssayResponse.result` | `KernelResult` | Underlying kernel result. |
107
+ | `AssayAssertion` | `class AssayAssertion extends Error` | Thrown when an assertion does not hold. |
108
+ | `matchRoute` | `(routes, method, path) => { route, params }` | Resolves a path, preferring static segments. |
109
+ | `httpRequest` | `(overrides?) => HttpRequest` | Builds a kernel request with test defaults. |
110
+ | `parsePath` | `(path: string) => { pathname, query }` | Splits path and query, preserving repeated keys. |
111
+ | `requestFromCall` | `(options) => HttpRequest` | Builds a request for an HTTP helper call. |
112
+ | `encodeBody` | `(body: unknown) => Uint8Array` | Encodes a body for `rawBody()`. |
113
+ | `defineFactory` | `(model, definition) => Factory` | Creates a model factory. |
114
+ | `Factory.make` | `(overrides?) => Promise<TModel>` | Builds a model without persisting. |
115
+ | `Factory.create` | `(overrides?) => Promise<TModel>` | Persists one model. |
116
+ | `Factory.createMany` | `(count, overrides?) => Promise<readonly TModel[]>` | Persists `count` models. |
117
+ | `Factory.state` | `(name, attributes) => this` | Registers a named attribute overlay. |
118
+ | `Factory.as` | `(name: string) => this` | Applies a named state to the next make/create. |
119
+ | `Factory.reset` | `() => void` | Clears sequence and pending states. |
120
+ | `Factory.sequence` | `number` | Current sequence number. |
121
+ | `defineSeed` | `(run, name?) => Seed` | Wraps a seed callback. |
122
+ | `runSeed` | `(seed, context?) => Promise<void>` | Runs one seed. |
123
+ | `runSeeds` | `(dir: string) => Promise<readonly string[]>` | Imports and runs default exports in filename order. |
124
+ | `AssayOptions` | `interface` | Kernel, routes, and optional actor cookie name. |
125
+ | `Seed` | `interface` | Named seed with a `run` callback. |
126
+ | `FactoryDefinition` | `type` | `(sequence) => attributes` factory callback. |
127
+ | `FactoryState` | `type` | `() => attributes` overlay registered with `Factory.state`. |
128
+ | `SeedCallback` | `type` | `(context: SeedContext) => Promise<void> \| void` seed body. |
129
+ | `SeedContext` | `interface` | Optional `path` of a seed file loaded from disk. |
130
+
131
+ ## Testing
132
+
133
+ Assay is itself tested with `FakeDatabase` and `createKernel`. Point `assay()` at the same kernel your adapter mounts.
134
+
135
+ ```ts
136
+ import { assay } from '@avelonjs/assay'
137
+ import { FakeDatabase } from '@avelonjs/conformance'
138
+
139
+ const client = assay({ kernel, routes })
140
+ await client.get('/users').assertOk()
141
+ ```
142
+
143
+ ```sh
144
+ bun test
145
+ bun run typecheck
146
+ ```
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@avelonjs/assay",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Test harness with HTTP helpers, factories, and seeds for Avelon applications.",
6
+ "license": "MIT",
7
+ "author": "Ryan Yannelli <ryanyannelli@gmail.com>",
8
+ "homepage": "https://github.com/yannelli/avelon",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/yannelli/avelon.git",
12
+ "directory": "packages/assay"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/yannelli/avelon/issues"
16
+ },
17
+ "keywords": [
18
+ "avelon",
19
+ "typescript",
20
+ "testing",
21
+ "harness"
22
+ ],
23
+ "type": "module",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "files": [
28
+ "src",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "exports": {
33
+ ".": "./src/index.ts"
34
+ },
35
+ "scripts": {
36
+ "test": "bun test",
37
+ "typecheck": "tsc --noEmit"
38
+ },
39
+ "dependencies": {
40
+ "@avelonjs/core": "workspace:*",
41
+ "@avelonjs/orm": "workspace:*"
42
+ },
43
+ "devDependencies": {
44
+ "@avelonjs/conformance": "workspace:*",
45
+ "@types/bun": "1.3.14",
46
+ "typescript": "5.9.3"
47
+ },
48
+ "engines": {
49
+ "bun": ">=1.3.14"
50
+ }
51
+ }
package/src/factory.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { Model } from '@avelonjs/orm'
2
+
3
+ type Row = Record<string, unknown>
4
+ type ModelCtor = typeof Model & (new () => Model)
5
+
6
+ /** Definition that produces attributes for one factory sequence number. */
7
+ export type FactoryDefinition<TModel extends Model> = (sequence: number) => Row | Promise<Row>
8
+
9
+ /** Named attribute overlay registered with {@link Factory.state}. */
10
+ export type FactoryState = () => Row | Promise<Row>
11
+
12
+ /** Model factory used by tests and seeds. Sequence numbers start at 1. */
13
+ export class Factory<TCtor extends ModelCtor> {
14
+ #sequence = 0
15
+ readonly #states = new Map<string, FactoryState>()
16
+ readonly #active: FactoryState[] = []
17
+
18
+ constructor(
19
+ readonly model: TCtor,
20
+ readonly definition: FactoryDefinition<InstanceType<TCtor>>,
21
+ ) {}
22
+
23
+ /** Registers a named state that overlays extra attributes. */
24
+ state(name: string, attributes: FactoryState): this {
25
+ this.#states.set(name, attributes)
26
+ return this
27
+ }
28
+
29
+ /** Applies `name` to the next make/create call. */
30
+ as(name: string): this {
31
+ const state = this.#states.get(name)
32
+ if (state === undefined) {
33
+ throw new Error(`Unknown factory state '${name}'.`)
34
+ }
35
+ this.#active.push(state)
36
+ return this
37
+ }
38
+
39
+ /** Builds attributes without persisting. */
40
+ async make(overrides: Row = {}): Promise<InstanceType<TCtor>> {
41
+ const attributes = await this.#attributes(overrides)
42
+ const model = new this.model() as InstanceType<TCtor>
43
+ model.fill(attributes)
44
+ return model
45
+ }
46
+
47
+ /** Persists one model. */
48
+ async create(overrides: Row = {}): Promise<InstanceType<TCtor>> {
49
+ const attributes = await this.#attributes(overrides)
50
+ return this.model.create(attributes) as Promise<InstanceType<TCtor>>
51
+ }
52
+
53
+ /** Persists `count` models. */
54
+ async createMany(count: number, overrides: Row = {}): Promise<readonly InstanceType<TCtor>[]> {
55
+ const models: InstanceType<TCtor>[] = []
56
+ for (let i = 0; i < count; i += 1) models.push(await this.create(overrides))
57
+ return models
58
+ }
59
+
60
+ /** Current sequence number, including the last make/create. */
61
+ get sequence(): number {
62
+ return this.#sequence
63
+ }
64
+
65
+ /** Resets the sequence. Intended for tests. */
66
+ reset(): void {
67
+ this.#sequence = 0
68
+ this.#active.length = 0
69
+ }
70
+
71
+ async #attributes(overrides: Row): Promise<Row> {
72
+ this.#sequence += 1
73
+ const layered: Row = { ...(await this.definition(this.#sequence)) }
74
+ for (const state of this.#active) Object.assign(layered, await state())
75
+ this.#active.length = 0
76
+ Object.assign(layered, overrides)
77
+ return layered
78
+ }
79
+ }
80
+
81
+ /** Creates a factory for `model`. Sequence values are unique per factory instance. */
82
+ export function defineFactory<TCtor extends ModelCtor>(
83
+ model: TCtor,
84
+ definition: FactoryDefinition<InstanceType<TCtor>>,
85
+ ): Factory<TCtor> {
86
+ return new Factory(model, definition)
87
+ }
package/src/http.ts ADDED
@@ -0,0 +1,258 @@
1
+ import {
2
+ finishResponse,
3
+ Invalid,
4
+ type HttpMethod,
5
+ type HttpRequest,
6
+ type Kernel,
7
+ type KernelResult,
8
+ type RouteDefinition,
9
+ type ViewResult,
10
+ } from '@avelonjs/core'
11
+
12
+ import { requestFromCall } from './request'
13
+
14
+ /** Failure raised when an Assay assertion does not hold. */
15
+ export class AssayAssertion extends Error {
16
+ /** Creates an assertion failure with a stable name. */
17
+ constructor(message: string) {
18
+ super(message)
19
+ this.name = 'AssayAssertion'
20
+ }
21
+ }
22
+
23
+ /** Options for {@link assay}. */
24
+ export interface AssayOptions<TController = unknown> {
25
+ /** Kernel every helper dispatches through. */
26
+ kernel: Kernel<TController>
27
+ /** Route table used to resolve method plus path. */
28
+ routes: readonly RouteDefinition<TController>[]
29
+ /** Cookie name written by {@link AssayClient.actingAs}. Defaults to `user`. */
30
+ actorCookie?: string
31
+ }
32
+
33
+ function scorePath(pattern: string, pathname: string): number | undefined {
34
+ const patternParts = pattern.split('/').filter(Boolean)
35
+ const pathParts = pathname.split('/').filter(Boolean)
36
+ if (patternParts.length !== pathParts.length) return undefined
37
+ let score = 0
38
+ for (let i = 0; i < patternParts.length; i += 1) {
39
+ const expected = patternParts[i]
40
+ const actual = pathParts[i]
41
+ if (expected === undefined || actual === undefined) return undefined
42
+ if (expected.startsWith('{') && expected.endsWith('}')) continue
43
+ if (expected !== actual) return undefined
44
+ score += 1
45
+ }
46
+ return score
47
+ }
48
+
49
+ function extractParams(pattern: string, pathname: string): Record<string, string> {
50
+ const params: Record<string, string> = {}
51
+ const patternParts = pattern.split('/').filter(Boolean)
52
+ const pathParts = pathname.split('/').filter(Boolean)
53
+ for (let i = 0; i < patternParts.length; i += 1) {
54
+ const expected = patternParts[i]
55
+ const actual = pathParts[i]
56
+ if (expected === undefined || actual === undefined) continue
57
+ if (expected.startsWith('{') && expected.endsWith('}')) {
58
+ params[expected.slice(1, -1)] = actual
59
+ }
60
+ }
61
+ return params
62
+ }
63
+
64
+ function pathnameOf(path: string): string {
65
+ return new URL(path, 'http://localhost').pathname
66
+ }
67
+
68
+ /** Resolves a method and path against a route table, preferring static segments. */
69
+ export function matchRoute<TController>(
70
+ routes: readonly RouteDefinition<TController>[],
71
+ method: HttpMethod,
72
+ path: string,
73
+ ): { route: RouteDefinition<TController>; params: Record<string, string> } {
74
+ const pathname = pathnameOf(path)
75
+ let best: { route: RouteDefinition<TController>; score: number } | undefined
76
+ for (const route of routes) {
77
+ if (route.method !== method) continue
78
+ const score = scorePath(route.path, pathname)
79
+ if (score === undefined) continue
80
+ if (best === undefined || score > best.score) best = { route, score }
81
+ }
82
+ if (best === undefined) {
83
+ throw new Invalid(`No ${method} route matches ${pathname}.`, {
84
+ metadata: { fields: { route: [`${method} ${pathname} is not registered.`] } },
85
+ })
86
+ }
87
+ return { route: best.route, params: extractParams(best.route.path, pathname) }
88
+ }
89
+
90
+ function statusOf(result: KernelResult): number {
91
+ if (result.type === 'redirect') return result.status
92
+ return result.status ?? (result.type === 'action' && !result.ok ? 422 : 200)
93
+ }
94
+
95
+ /** Kernel result plus assertion helpers used in feature tests. */
96
+ export class AssayResponse {
97
+ constructor(readonly result: KernelResult) {}
98
+
99
+ /** Transport-neutral status hint. */
100
+ status(): number {
101
+ return statusOf(this.result)
102
+ }
103
+
104
+ /** Fails unless the status is `expected`. */
105
+ assertStatus(expected: number): this {
106
+ const actual = this.status()
107
+ if (actual !== expected) {
108
+ throw new AssayAssertion(`Expected status ${expected}, received ${actual}.`)
109
+ }
110
+ return this
111
+ }
112
+
113
+ /** Fails unless the response is a successful 2xx outcome. */
114
+ assertOk(): this {
115
+ const status = this.status()
116
+ if (status < 200 || status >= 300) {
117
+ throw new AssayAssertion(`Expected 2xx, received ${status}.`)
118
+ }
119
+ if (this.result.type === 'action' && !this.result.ok) {
120
+ throw new AssayAssertion('Expected a successful action result.')
121
+ }
122
+ return this
123
+ }
124
+
125
+ /** Fails unless the kernel redirected to `location`. */
126
+ assertRedirect(location: string): this {
127
+ if (this.result.type !== 'redirect') {
128
+ throw new AssayAssertion(`Expected a redirect, received ${this.result.type}.`)
129
+ }
130
+ if (this.result.location !== location) {
131
+ throw new AssayAssertion(`Expected redirect ${location}, received ${this.result.location}.`)
132
+ }
133
+ return this
134
+ }
135
+
136
+ /** Fails unless the kernel rendered `view`. */
137
+ assertView(view: unknown): this {
138
+ if (this.result.type !== 'view') {
139
+ throw new AssayAssertion(`Expected a view, received ${this.result.type}.`)
140
+ }
141
+ if (this.result.view !== view) {
142
+ throw new AssayAssertion(
143
+ `Expected view ${String(view)}, received ${String(this.result.view)}.`,
144
+ )
145
+ }
146
+ return this
147
+ }
148
+
149
+ /** View result when the kernel rendered one. */
150
+ view(): ViewResult<unknown, object> {
151
+ if (this.result.type !== 'view') {
152
+ throw new AssayAssertion(`Expected a view, received ${this.result.type}.`)
153
+ }
154
+ return this.result
155
+ }
156
+
157
+ /** Serializable action/view payload. */
158
+ data(): unknown {
159
+ if (this.result.type === 'action') return this.result.data
160
+ if (this.result.type === 'view') return this.result.props
161
+ return undefined
162
+ }
163
+ }
164
+
165
+ /** HTTP test client that dispatches through the core kernel. */
166
+ export class AssayClient<TController = unknown> {
167
+ #cookies: Record<string, string> = {}
168
+
169
+ constructor(private readonly options: AssayOptions<TController>) {}
170
+
171
+ /** Sets the actor cookie used by subsequent requests. */
172
+ actingAs(actor: { readonly id: string } | string): this {
173
+ const id = typeof actor === 'string' ? actor : actor.id
174
+ this.#cookies = { ...this.#cookies, [this.options.actorCookie ?? 'user']: id }
175
+ return this
176
+ }
177
+
178
+ /** Clears actor cookies. */
179
+ asGuest(): this {
180
+ this.#cookies = {}
181
+ return this
182
+ }
183
+
184
+ /** Dispatches GET. */
185
+ get(path: string, headers?: Readonly<Record<string, string>>): Promise<AssayResponse> {
186
+ return this.#dispatch('GET', path, undefined, headers)
187
+ }
188
+
189
+ /** Dispatches POST. */
190
+ post(
191
+ path: string,
192
+ body?: unknown,
193
+ headers?: Readonly<Record<string, string>>,
194
+ ): Promise<AssayResponse> {
195
+ return this.#dispatch('POST', path, body, headers)
196
+ }
197
+
198
+ /** Dispatches PUT. */
199
+ put(
200
+ path: string,
201
+ body?: unknown,
202
+ headers?: Readonly<Record<string, string>>,
203
+ ): Promise<AssayResponse> {
204
+ return this.#dispatch('PUT', path, body, headers)
205
+ }
206
+
207
+ /** Dispatches PATCH. */
208
+ patch(
209
+ path: string,
210
+ body?: unknown,
211
+ headers?: Readonly<Record<string, string>>,
212
+ ): Promise<AssayResponse> {
213
+ return this.#dispatch('PATCH', path, body, headers)
214
+ }
215
+
216
+ /** Dispatches DELETE. */
217
+ delete(path: string, headers?: Readonly<Record<string, string>>): Promise<AssayResponse> {
218
+ return this.#dispatch('DELETE', path, undefined, headers)
219
+ }
220
+
221
+ /** Dispatches an already-built kernel request against a named route. */
222
+ async call(routeName: string, request: HttpRequest): Promise<AssayResponse> {
223
+ const route = this.options.routes.find((entry) => entry.name === routeName)
224
+ if (route === undefined) {
225
+ throw new Invalid(`Route ${routeName} was not found.`, {
226
+ metadata: { fields: { route: [`${routeName} is not registered.`] } },
227
+ })
228
+ }
229
+ const result = await this.options.kernel.dispatch(route, request)
230
+ await finishResponse()
231
+ return new AssayResponse(result)
232
+ }
233
+
234
+ async #dispatch(
235
+ method: HttpMethod,
236
+ path: string,
237
+ body: unknown,
238
+ headers?: Readonly<Record<string, string>>,
239
+ ): Promise<AssayResponse> {
240
+ const matched = matchRoute(this.options.routes, method, path)
241
+ const request = requestFromCall({
242
+ method,
243
+ path,
244
+ body,
245
+ headers,
246
+ cookies: this.#cookies,
247
+ params: matched.params,
248
+ })
249
+ const result = await this.options.kernel.dispatch(matched.route, request)
250
+ await finishResponse()
251
+ return new AssayResponse(result)
252
+ }
253
+ }
254
+
255
+ /** Creates an HTTP test client bound to a kernel and route table. */
256
+ export function assay<TController>(options: AssayOptions<TController>): AssayClient<TController> {
257
+ return new AssayClient(options)
258
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { assay, AssayAssertion, AssayClient, AssayResponse, matchRoute } from './http'
2
+ export type { AssayOptions } from './http'
3
+ export { defineFactory, Factory } from './factory'
4
+ export type { FactoryDefinition, FactoryState } from './factory'
5
+ export { defineSeed, runSeed, runSeeds } from './seed'
6
+ export type { Seed, SeedCallback, SeedContext } from './seed'
7
+ export { encodeBody, httpRequest, parsePath, requestFromCall } from './request'
package/src/request.ts ADDED
@@ -0,0 +1,64 @@
1
+ import type { HttpMethod, HttpRequest } from '@avelonjs/core'
2
+
3
+ /** Builds a kernel `HttpRequest` with test-sensible defaults. */
4
+ export function httpRequest(overrides: Partial<HttpRequest> = {}): HttpRequest {
5
+ return {
6
+ method: 'GET',
7
+ url: 'http://localhost/',
8
+ params: {},
9
+ query: {},
10
+ headers: {},
11
+ cookies: {},
12
+ body: null,
13
+ rawBody: async () => new Uint8Array(),
14
+ aborted: false,
15
+ ...overrides,
16
+ }
17
+ }
18
+
19
+ /** Parses `path?a=1&b=2` into pathname plus query map, preserving repeated keys. */
20
+ export function parsePath(path: string): {
21
+ pathname: string
22
+ query: Record<string, string | readonly string[]>
23
+ } {
24
+ const url = new URL(path, 'http://localhost')
25
+ const query: Record<string, string | string[]> = {}
26
+ for (const [key, value] of url.searchParams.entries()) {
27
+ const existing = query[key]
28
+ if (existing === undefined) query[key] = value
29
+ else if (typeof existing === 'string') query[key] = [existing, value]
30
+ else existing.push(value)
31
+ }
32
+ return { pathname: url.pathname, query }
33
+ }
34
+
35
+ /** Encodes a request body as UTF-8 bytes for `rawBody()`. */
36
+ export function encodeBody(body: unknown): Uint8Array {
37
+ if (body === null || body === undefined) return new Uint8Array()
38
+ if (body instanceof Uint8Array) return body
39
+ if (typeof body === 'string') return new TextEncoder().encode(body)
40
+ return new TextEncoder().encode(JSON.stringify(body))
41
+ }
42
+
43
+ /** Builds a request for an HTTP helper call. */
44
+ export function requestFromCall(options: {
45
+ method: HttpMethod
46
+ path: string
47
+ body?: unknown
48
+ headers?: Readonly<Record<string, string>>
49
+ cookies?: Readonly<Record<string, string>>
50
+ params?: Readonly<Record<string, string>>
51
+ }): HttpRequest {
52
+ const parsed = parsePath(options.path)
53
+ const raw = encodeBody(options.body)
54
+ return httpRequest({
55
+ method: options.method,
56
+ url: new URL(options.path, 'http://localhost').toString(),
57
+ params: options.params ?? {},
58
+ query: parsed.query,
59
+ headers: options.headers ?? {},
60
+ cookies: options.cookies ?? {},
61
+ body: options.body ?? null,
62
+ rawBody: async () => raw,
63
+ })
64
+ }
package/src/seed.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { readdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ /** Context passed to a seed callback. */
5
+ export interface SeedContext {
6
+ /** Absolute path of the seed file when loaded from disk. */
7
+ path?: string
8
+ }
9
+
10
+ /** Seed callback that inserts fixture data. */
11
+ export type SeedCallback = (context: SeedContext) => Promise<void> | void
12
+
13
+ /** Named seed used by `reeve db:seed` and {@link runSeed}. */
14
+ export interface Seed {
15
+ /** Optional name printed in logs. */
16
+ name?: string
17
+ /** Inserts rows. */
18
+ run: SeedCallback
19
+ }
20
+
21
+ /** Wraps a seed callback so default exports stay typed. */
22
+ export function defineSeed(run: SeedCallback, name?: string): Seed {
23
+ return { name, run }
24
+ }
25
+
26
+ /** Runs one seed. */
27
+ export async function runSeed(seed: Seed | SeedCallback, context: SeedContext = {}): Promise<void> {
28
+ if (typeof seed === 'function') {
29
+ await seed(context)
30
+ return
31
+ }
32
+ await seed.run(context)
33
+ }
34
+
35
+ function isSeedModule(value: unknown): value is { default?: Seed | SeedCallback } {
36
+ return typeof value === 'object' && value !== null
37
+ }
38
+
39
+ /** Loads `database/seeds` (or `dir`) and runs each default export in filename order. */
40
+ export async function runSeeds(dir: string): Promise<readonly string[]> {
41
+ const entries = await readdir(dir)
42
+ const files = entries.filter((name) => name.endsWith('.ts') || name.endsWith('.js')).sort()
43
+ const ran: string[] = []
44
+ for (const file of files) {
45
+ const path = join(dir, file)
46
+ const loaded: unknown = await import(path)
47
+ if (!isSeedModule(loaded) || loaded.default === undefined) continue
48
+ await runSeed(loaded.default, { path })
49
+ ran.push(path)
50
+ }
51
+ return ran
52
+ }