@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 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
@@ -0,0 +1,3 @@
1
+ export { TestCase } from './test_case.ts'
2
+ export type { TestCaseOptions } from './test_case.ts'
3
+ export { Factory } from './factory.ts'
@@ -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
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"]
4
+ }