@stravigor/testing 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/README.md +82 -0
- package/package.json +19 -0
- package/src/factory.ts +68 -0
- package/src/index.ts +3 -0
- package/src/test_case.ts +273 -0
- package/tsconfig.json +4 -0
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @stravigor/testing
|
|
2
|
+
|
|
3
|
+
Testing utilities for the [Strav](https://www.npmjs.com/package/@stravigor/core) framework. Provides HTTP testing helpers, authentication simulation, transaction-based test isolation, and model factories.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add -d @stravigor/testing
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires `@stravigor/core` as a peer dependency.
|
|
12
|
+
|
|
13
|
+
## TestCase
|
|
14
|
+
|
|
15
|
+
Boots the app, provides HTTP helpers, and wraps each test in a rolled-back transaction for full isolation.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { describe, test, expect } from 'bun:test'
|
|
19
|
+
import { TestCase } from '@stravigor/testing'
|
|
20
|
+
|
|
21
|
+
const t = await TestCase.boot({
|
|
22
|
+
auth: true,
|
|
23
|
+
routes: () => import('./start/api_routes'),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('Posts API', () => {
|
|
27
|
+
test('list posts', async () => {
|
|
28
|
+
const res = await t.get('/api/posts')
|
|
29
|
+
expect(res.status).toBe(200)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('create post as authenticated user', async () => {
|
|
33
|
+
const user = await UserFactory.create()
|
|
34
|
+
await t.actingAs(user)
|
|
35
|
+
|
|
36
|
+
const res = await t.post('/api/posts', { title: 'Hello' })
|
|
37
|
+
expect(res.status).toBe(201)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### HTTP Methods
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
await t.get('/path')
|
|
46
|
+
await t.post('/path', body)
|
|
47
|
+
await t.put('/path', body)
|
|
48
|
+
await t.patch('/path', body)
|
|
49
|
+
await t.delete('/path')
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Auth Helpers
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
await t.actingAs(user) // authenticate as user
|
|
56
|
+
t.withHeaders({ 'X-Custom': 'value' })
|
|
57
|
+
t.withoutAuth() // clear auth token
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Factory
|
|
61
|
+
|
|
62
|
+
Lightweight model factory for test data seeding.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { Factory } from '@stravigor/testing'
|
|
66
|
+
|
|
67
|
+
const UserFactory = Factory.define(User, (seq) => ({
|
|
68
|
+
pid: crypto.randomUUID(),
|
|
69
|
+
name: `User ${seq}`,
|
|
70
|
+
email: `user-${seq}@test.com`,
|
|
71
|
+
passwordHash: 'hashed',
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
const user = await UserFactory.create()
|
|
75
|
+
const users = await UserFactory.createMany(5)
|
|
76
|
+
const user = await UserFactory.create({ name: 'Custom' })
|
|
77
|
+
const instance = UserFactory.make() // in-memory only, no DB
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stravigor/testing",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Testing utilities for the Strav framework",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./*": "./src/*.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": ["src/", "package.json", "tsconfig.json"],
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"@stravigor/core": "0.1.0"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "bun test tests/",
|
|
17
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/factory.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type BaseModel from '@stravigor/core/orm/base_model'
|
|
2
|
+
|
|
3
|
+
type ModelClass = typeof BaseModel & { create(attrs: Record<string, unknown>): Promise<any> }
|
|
4
|
+
type DefinitionFn = (seq: number) => Record<string, unknown>
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Lightweight model factory for test seeding.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const UserFactory = Factory.define(User, (seq) => ({
|
|
11
|
+
* pid: crypto.randomUUID(),
|
|
12
|
+
* name: `User ${seq}`,
|
|
13
|
+
* email: `user-${seq}@test.com`,
|
|
14
|
+
* passwordHash: 'hashed',
|
|
15
|
+
* }))
|
|
16
|
+
*
|
|
17
|
+
* const user = await UserFactory.create()
|
|
18
|
+
* const users = await UserFactory.createMany(5)
|
|
19
|
+
* const user = await UserFactory.create({ name: 'Custom' })
|
|
20
|
+
* const instance = UserFactory.make() // in-memory, no DB
|
|
21
|
+
*/
|
|
22
|
+
export class Factory<T extends BaseModel = BaseModel> {
|
|
23
|
+
private static _seq = new Map<Function, number>()
|
|
24
|
+
|
|
25
|
+
private constructor(
|
|
26
|
+
private model: ModelClass,
|
|
27
|
+
private definition: DefinitionFn
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
/** Define a factory for a model class. */
|
|
31
|
+
static define<M extends BaseModel>(
|
|
32
|
+
model: typeof BaseModel,
|
|
33
|
+
definition: (seq: number) => Record<string, unknown>
|
|
34
|
+
): Factory<M> {
|
|
35
|
+
return new Factory<M>(model as ModelClass, definition)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private nextSeq(): number {
|
|
39
|
+
const current = Factory._seq.get(this.model) ?? 0
|
|
40
|
+
const next = current + 1
|
|
41
|
+
Factory._seq.set(this.model, next)
|
|
42
|
+
return next
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Create and persist a single record. */
|
|
46
|
+
async create(overrides?: Record<string, unknown>): Promise<T> {
|
|
47
|
+
const attrs = { ...this.definition(this.nextSeq()), ...overrides }
|
|
48
|
+
return this.model.create(attrs) as Promise<T>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Create and persist multiple records. */
|
|
52
|
+
async createMany(count: number, overrides?: Record<string, unknown>): Promise<T[]> {
|
|
53
|
+
return Promise.all(Array.from({ length: count }, () => this.create(overrides)))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Build an in-memory instance without persisting to the database. */
|
|
57
|
+
make(overrides?: Record<string, unknown>): T {
|
|
58
|
+
const attrs = { ...this.definition(this.nextSeq()), ...overrides }
|
|
59
|
+
const instance = new (this.model as any)()
|
|
60
|
+
instance.merge(attrs)
|
|
61
|
+
return instance as T
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Reset all factory sequences (call between test suites if needed). */
|
|
65
|
+
static resetSequences(): void {
|
|
66
|
+
Factory._seq.clear()
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/index.ts
ADDED
package/src/test_case.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import type { SQL, ReservedSQL } from 'bun'
|
|
2
|
+
import { app } from '@stravigor/core/core/application'
|
|
3
|
+
import Configuration from '@stravigor/core/config/configuration'
|
|
4
|
+
import Database from '@stravigor/core/database/database'
|
|
5
|
+
import Router from '@stravigor/core/http/router'
|
|
6
|
+
import BaseModel from '@stravigor/core/orm/base_model'
|
|
7
|
+
import { ExceptionHandler } from '@stravigor/core/exceptions/exception_handler'
|
|
8
|
+
import { Factory } from './factory.ts'
|
|
9
|
+
|
|
10
|
+
export interface TestCaseOptions {
|
|
11
|
+
/** Route loader — called during setup to register routes. */
|
|
12
|
+
routes?: () => Promise<unknown>
|
|
13
|
+
/** Boot auth + session tables (default: false). */
|
|
14
|
+
auth?: boolean
|
|
15
|
+
/** Boot view engine (default: false). */
|
|
16
|
+
views?: boolean
|
|
17
|
+
/** Wrap each test in a DB transaction that auto-rollbacks (default: true). */
|
|
18
|
+
transaction?: boolean
|
|
19
|
+
/** User resolver for Auth.useResolver() (required when auth: true). */
|
|
20
|
+
userResolver?: (id: string | number) => Promise<unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Boot the app, provide HTTP helpers, and wrap each test in a rolled-back
|
|
25
|
+
* transaction for full isolation.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* import { TestCase, Factory } from '@stravigor/testing'
|
|
29
|
+
*
|
|
30
|
+
* const t = await TestCase.boot({
|
|
31
|
+
* auth: true,
|
|
32
|
+
* routes: () => import('../start/api_routes'),
|
|
33
|
+
* })
|
|
34
|
+
*
|
|
35
|
+
* describe('Posts', () => {
|
|
36
|
+
* test('list', async () => {
|
|
37
|
+
* const user = await UserFactory.create()
|
|
38
|
+
* await t.actingAs(user)
|
|
39
|
+
* const res = await t.get('/api/posts')
|
|
40
|
+
* expect(res.status).toBe(200)
|
|
41
|
+
* })
|
|
42
|
+
* })
|
|
43
|
+
*/
|
|
44
|
+
export class TestCase {
|
|
45
|
+
db!: Database
|
|
46
|
+
router!: Router
|
|
47
|
+
config!: Configuration
|
|
48
|
+
|
|
49
|
+
private _token: string | null = null
|
|
50
|
+
private _headers: Record<string, string> = {}
|
|
51
|
+
private _originalSql: SQL | null = null
|
|
52
|
+
private _reserved: ReservedSQL | null = null
|
|
53
|
+
|
|
54
|
+
constructor(private options: TestCaseOptions = {}) {}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Lifecycle
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/** Boot the app — mirrors index.ts bootstrap, minus Server. Call in beforeAll. */
|
|
61
|
+
async setup(): Promise<void> {
|
|
62
|
+
if (!app.has(Configuration)) app.singleton(Configuration)
|
|
63
|
+
if (!app.has(Database)) app.singleton(Database)
|
|
64
|
+
if (!app.has(Router)) app.singleton(Router)
|
|
65
|
+
|
|
66
|
+
this.config = app.resolve(Configuration)
|
|
67
|
+
await this.config.load()
|
|
68
|
+
|
|
69
|
+
this.db = app.resolve(Database)
|
|
70
|
+
new BaseModel(this.db)
|
|
71
|
+
|
|
72
|
+
this.router = app.resolve(Router)
|
|
73
|
+
|
|
74
|
+
// Auth + Session
|
|
75
|
+
if (this.options.auth) {
|
|
76
|
+
const SessionManager = (await import('@stravigor/core/session/session_manager')).default
|
|
77
|
+
const Auth = (await import('@stravigor/core/auth/auth')).default
|
|
78
|
+
|
|
79
|
+
if (!app.has(SessionManager)) app.singleton(SessionManager)
|
|
80
|
+
if (!app.has(Auth)) app.singleton(Auth)
|
|
81
|
+
|
|
82
|
+
app.resolve(SessionManager)
|
|
83
|
+
await SessionManager.ensureTable()
|
|
84
|
+
|
|
85
|
+
app.resolve(Auth)
|
|
86
|
+
await Auth.ensureTables()
|
|
87
|
+
|
|
88
|
+
if (this.options.userResolver) {
|
|
89
|
+
Auth.useResolver(this.options.userResolver)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// View engine
|
|
94
|
+
if (this.options.views) {
|
|
95
|
+
const ViewEngine = (await import('@stravigor/core/view/engine')).default
|
|
96
|
+
const Context = (await import('@stravigor/core/http/context')).default
|
|
97
|
+
|
|
98
|
+
if (!app.has(ViewEngine)) app.singleton(ViewEngine)
|
|
99
|
+
const viewEngine = app.resolve(ViewEngine)
|
|
100
|
+
Context.setViewEngine(viewEngine)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Routes
|
|
104
|
+
if (this.options.routes) {
|
|
105
|
+
await this.options.routes()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Exception handler (always dev mode in tests)
|
|
109
|
+
const handler = new ExceptionHandler(true)
|
|
110
|
+
this.router.useExceptionHandler(handler)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Close the database connection. Call in afterAll. */
|
|
114
|
+
async teardown(): Promise<void> {
|
|
115
|
+
// Ensure any reserved connection is released first
|
|
116
|
+
if (this._reserved) {
|
|
117
|
+
try {
|
|
118
|
+
await this._reserved`ROLLBACK`
|
|
119
|
+
} catch { /* ignore */ }
|
|
120
|
+
this._reserved.release()
|
|
121
|
+
this._reserved = null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await this.db.close()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Begin a transaction for test isolation. Call in beforeEach. */
|
|
128
|
+
async beforeEach(): Promise<void> {
|
|
129
|
+
if (this.options.transaction !== false) {
|
|
130
|
+
this._originalSql = this.db.sql
|
|
131
|
+
this._reserved = await this._originalSql.reserve()
|
|
132
|
+
await this._reserved`BEGIN`
|
|
133
|
+
|
|
134
|
+
// Monkey-patch Database to use the reserved connection.
|
|
135
|
+
// TypeScript `private` is compile-time only — runtime access works.
|
|
136
|
+
;(this.db as any).connection = this._reserved
|
|
137
|
+
;(Database as any)._connection = this._reserved
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Rollback the transaction and restore state. Call in afterEach. */
|
|
142
|
+
async afterEach(): Promise<void> {
|
|
143
|
+
if (this._reserved) {
|
|
144
|
+
await this._reserved`ROLLBACK`
|
|
145
|
+
this._reserved.release()
|
|
146
|
+
|
|
147
|
+
// Restore original connection
|
|
148
|
+
;(this.db as any).connection = this._originalSql
|
|
149
|
+
;(Database as any)._connection = this._originalSql
|
|
150
|
+
this._reserved = null
|
|
151
|
+
this._originalSql = null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Clear per-test state
|
|
155
|
+
this._token = null
|
|
156
|
+
this._headers = {}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// HTTP helpers
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/** Send a GET request through the router. */
|
|
164
|
+
get(path: string, headers?: HeadersInit): Promise<Response> {
|
|
165
|
+
return this.request('GET', path, undefined, headers)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Send a POST request with JSON body through the router. */
|
|
169
|
+
post(path: string, body?: unknown, headers?: HeadersInit): Promise<Response> {
|
|
170
|
+
return this.request('POST', path, body, headers)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Send a PUT request with JSON body through the router. */
|
|
174
|
+
put(path: string, body?: unknown, headers?: HeadersInit): Promise<Response> {
|
|
175
|
+
return this.request('PUT', path, body, headers)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Send a PATCH request with JSON body through the router. */
|
|
179
|
+
patch(path: string, body?: unknown, headers?: HeadersInit): Promise<Response> {
|
|
180
|
+
return this.request('PATCH', path, body, headers)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Send a DELETE request through the router. */
|
|
184
|
+
delete(path: string, headers?: HeadersInit): Promise<Response> {
|
|
185
|
+
return this.request('DELETE', path, undefined, headers)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Auth helpers
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Authenticate as the given user for subsequent requests in this test.
|
|
194
|
+
* Creates a real AccessToken in the database.
|
|
195
|
+
*/
|
|
196
|
+
async actingAs(user: unknown, tokenName = 'test-token'): Promise<this> {
|
|
197
|
+
const { default: AccessToken } = await import('@stravigor/core/auth/access_token')
|
|
198
|
+
const { token } = await AccessToken.create(user, tokenName)
|
|
199
|
+
this._token = token
|
|
200
|
+
return this
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Add custom headers to all subsequent requests in this test. */
|
|
204
|
+
withHeaders(headers: Record<string, string>): this {
|
|
205
|
+
Object.assign(this._headers, headers)
|
|
206
|
+
return this
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Clear the auth token for the next request. */
|
|
210
|
+
withoutAuth(): this {
|
|
211
|
+
this._token = null
|
|
212
|
+
return this
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Static shorthand
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Boot the TestCase and register bun:test lifecycle hooks automatically.
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* const t = await TestCase.boot({
|
|
224
|
+
* auth: true,
|
|
225
|
+
* routes: () => import('../start/api_routes'),
|
|
226
|
+
* })
|
|
227
|
+
*/
|
|
228
|
+
static async boot(options?: TestCaseOptions): Promise<TestCase> {
|
|
229
|
+
const tc = new TestCase(options)
|
|
230
|
+
await tc.setup()
|
|
231
|
+
|
|
232
|
+
const { afterAll, beforeEach, afterEach } = await import('bun:test')
|
|
233
|
+
afterAll(() => tc.teardown())
|
|
234
|
+
beforeEach(() => tc.beforeEach())
|
|
235
|
+
afterEach(() => tc.afterEach())
|
|
236
|
+
|
|
237
|
+
return tc
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Internal
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
private async request(
|
|
245
|
+
method: string,
|
|
246
|
+
path: string,
|
|
247
|
+
body?: unknown,
|
|
248
|
+
headers?: HeadersInit
|
|
249
|
+
): Promise<Response> {
|
|
250
|
+
const merged: Record<string, string> = { ...this._headers }
|
|
251
|
+
if (this._token) merged['Authorization'] = `Bearer ${this._token}`
|
|
252
|
+
if (body !== undefined) merged['Content-Type'] = 'application/json'
|
|
253
|
+
|
|
254
|
+
if (headers) {
|
|
255
|
+
const entries = headers instanceof Headers
|
|
256
|
+
? Object.fromEntries(headers.entries())
|
|
257
|
+
: Array.isArray(headers)
|
|
258
|
+
? Object.fromEntries(headers)
|
|
259
|
+
: headers
|
|
260
|
+
Object.assign(merged, entries)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const res = this.router.handle(
|
|
264
|
+
new Request(`http://localhost${path}`, {
|
|
265
|
+
method,
|
|
266
|
+
headers: merged,
|
|
267
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
268
|
+
})
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return (await res) ?? new Response('Not Found', { status: 404 })
|
|
272
|
+
}
|
|
273
|
+
}
|
package/tsconfig.json
ADDED