@strav/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/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ ### Changed
6
+
7
+ - Applied consistent code formatting across all source files
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,27 @@
1
+ {
2
+ "name": "@strav/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": [
12
+ "src/",
13
+ "package.json",
14
+ "tsconfig.json",
15
+ "CHANGELOG.md"
16
+ ],
17
+ "peerDependencies": {
18
+ "@strav/kernel": "0.1.0",
19
+ "@strav/http": "0.1.0",
20
+ "@strav/view": "0.1.0",
21
+ "@strav/database": "0.1.0"
22
+ },
23
+ "scripts": {
24
+ "test": "bun test tests/",
25
+ "typecheck": "tsc --noEmit"
26
+ }
27
+ }
package/src/factory.ts ADDED
@@ -0,0 +1,68 @@
1
+ import type { BaseModel } from '@stravigor/database'
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, Configuration, ExceptionHandler } from '@stravigor/kernel'
3
+ import { Database, BaseModel } from '@stravigor/database'
4
+ import { Router } from '@stravigor/http'
5
+ import { Factory } from './factory.ts'
6
+
7
+ export interface TestCaseOptions {
8
+ /** Route loader — called during setup to register routes. */
9
+ routes?: () => Promise<unknown>
10
+ /** Boot auth + session tables (default: false). */
11
+ auth?: boolean
12
+ /** Boot view engine (default: false). */
13
+ views?: boolean
14
+ /** Wrap each test in a DB transaction that auto-rollbacks (default: true). */
15
+ transaction?: boolean
16
+ /** User resolver for Auth.useResolver() (required when auth: true). */
17
+ userResolver?: (id: string | number) => Promise<unknown>
18
+ }
19
+
20
+ /**
21
+ * Boot the app, provide HTTP helpers, and wrap each test in a rolled-back
22
+ * transaction for full isolation.
23
+ *
24
+ * @example
25
+ * import { TestCase, Factory } from '@stravigor/testing'
26
+ *
27
+ * const t = await TestCase.boot({
28
+ * auth: true,
29
+ * routes: () => import('../start/api_routes'),
30
+ * })
31
+ *
32
+ * describe('Posts', () => {
33
+ * test('list', async () => {
34
+ * const user = await UserFactory.create()
35
+ * await t.actingAs(user)
36
+ * const res = await t.get('/api/posts')
37
+ * expect(res.status).toBe(200)
38
+ * })
39
+ * })
40
+ */
41
+ export class TestCase {
42
+ db!: Database
43
+ router!: Router
44
+ config!: Configuration
45
+
46
+ private _token: string | null = null
47
+ private _headers: Record<string, string> = {}
48
+ private _originalSql: SQL | null = null
49
+ private _reserved: ReservedSQL | null = null
50
+
51
+ constructor(private options: TestCaseOptions = {}) {}
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Lifecycle
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /** Boot the app — mirrors index.ts bootstrap, minus Server. Call in beforeAll. */
58
+ async setup(): Promise<void> {
59
+ if (!app.has(Configuration)) app.singleton(Configuration)
60
+ if (!app.has(Database)) app.singleton(Database)
61
+ if (!app.has(Router)) app.singleton(Router)
62
+
63
+ this.config = app.resolve(Configuration)
64
+ await this.config.load()
65
+
66
+ this.db = app.resolve(Database)
67
+ new BaseModel(this.db)
68
+
69
+ this.router = app.resolve(Router)
70
+
71
+ // Auth + Session
72
+ if (this.options.auth) {
73
+ const { SessionManager } = await import('@stravigor/http')
74
+ const { Auth } = await import('@stravigor/http')
75
+
76
+ if (!app.has(SessionManager)) app.singleton(SessionManager)
77
+ if (!app.has(Auth)) app.singleton(Auth)
78
+
79
+ app.resolve(SessionManager)
80
+ await SessionManager.ensureTable()
81
+
82
+ app.resolve(Auth)
83
+ await Auth.ensureTables()
84
+
85
+ if (this.options.userResolver) {
86
+ Auth.useResolver(this.options.userResolver)
87
+ }
88
+ }
89
+
90
+ // View engine
91
+ if (this.options.views) {
92
+ const { ViewEngine } = await import('@stravigor/view')
93
+ const { Context } = await import('@stravigor/http')
94
+
95
+ if (!app.has(ViewEngine)) app.singleton(ViewEngine)
96
+ const viewEngine = app.resolve(ViewEngine)
97
+ Context.setViewEngine(viewEngine)
98
+ }
99
+
100
+ // Routes
101
+ if (this.options.routes) {
102
+ await this.options.routes()
103
+ }
104
+
105
+ // Exception handler (always dev mode in tests)
106
+ const handler = new ExceptionHandler(true)
107
+ this.router.useExceptionHandler(handler)
108
+ }
109
+
110
+ /** Close the database connection. Call in afterAll. */
111
+ async teardown(): Promise<void> {
112
+ // Ensure any reserved connection is released first
113
+ if (this._reserved) {
114
+ try {
115
+ await this._reserved`ROLLBACK`
116
+ } catch {
117
+ /* ignore */
118
+ }
119
+ this._reserved.release()
120
+ this._reserved = null
121
+ }
122
+
123
+ await this.db.close()
124
+ }
125
+
126
+ /** Begin a transaction for test isolation. Call in beforeEach. */
127
+ async beforeEach(): Promise<void> {
128
+ if (this.options.transaction !== false) {
129
+ this._originalSql = this.db.sql
130
+ this._reserved = await this._originalSql.reserve()
131
+ await this._reserved`BEGIN`
132
+
133
+ // Monkey-patch Database to use the reserved connection.
134
+ // TypeScript `private` is compile-time only — runtime access works.
135
+ ;(this.db as any).connection = this._reserved
136
+ ;(Database as any)._connection = this._reserved
137
+ }
138
+ }
139
+
140
+ /** Rollback the transaction and restore state. Call in afterEach. */
141
+ async afterEach(): Promise<void> {
142
+ if (this._reserved) {
143
+ await this._reserved`ROLLBACK`
144
+ this._reserved.release()
145
+
146
+ // Restore original connection
147
+ ;(this.db as any).connection = this._originalSql
148
+ ;(Database as any)._connection = this._originalSql
149
+ this._reserved = null
150
+ this._originalSql = null
151
+ }
152
+
153
+ // Clear per-test state
154
+ this._token = null
155
+ this._headers = {}
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // HTTP helpers
160
+ // ---------------------------------------------------------------------------
161
+
162
+ /** Send a GET request through the router. */
163
+ get(path: string, headers?: Bun.HeadersInit): Promise<Response> {
164
+ return this.request('GET', path, undefined, headers)
165
+ }
166
+
167
+ /** Send a POST request with JSON body through the router. */
168
+ post(path: string, body?: unknown, headers?: Bun.HeadersInit): Promise<Response> {
169
+ return this.request('POST', path, body, headers)
170
+ }
171
+
172
+ /** Send a PUT request with JSON body through the router. */
173
+ put(path: string, body?: unknown, headers?: Bun.HeadersInit): Promise<Response> {
174
+ return this.request('PUT', path, body, headers)
175
+ }
176
+
177
+ /** Send a PATCH request with JSON body through the router. */
178
+ patch(path: string, body?: unknown, headers?: Bun.HeadersInit): Promise<Response> {
179
+ return this.request('PATCH', path, body, headers)
180
+ }
181
+
182
+ /** Send a DELETE request through the router. */
183
+ delete(path: string, headers?: Bun.HeadersInit): Promise<Response> {
184
+ return this.request('DELETE', path, undefined, headers)
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Auth helpers
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /**
192
+ * Authenticate as the given user for subsequent requests in this test.
193
+ * Creates a real AccessToken in the database.
194
+ */
195
+ async actingAs(user: unknown, tokenName = 'test-token'): Promise<this> {
196
+ const { AccessToken } = await import('@stravigor/http')
197
+ const { token } = await AccessToken.create(user, tokenName)
198
+ this._token = token
199
+ return this
200
+ }
201
+
202
+ /** Add custom headers to all subsequent requests in this test. */
203
+ withHeaders(headers: Record<string, string>): this {
204
+ Object.assign(this._headers, headers)
205
+ return this
206
+ }
207
+
208
+ /** Clear the auth token for the next request. */
209
+ withoutAuth(): this {
210
+ this._token = null
211
+ return this
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Static shorthand
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /**
219
+ * Boot the TestCase and register bun:test lifecycle hooks automatically.
220
+ *
221
+ * @example
222
+ * const t = await TestCase.boot({
223
+ * auth: true,
224
+ * routes: () => import('../start/api_routes'),
225
+ * })
226
+ */
227
+ static async boot(options?: TestCaseOptions): Promise<TestCase> {
228
+ const tc = new TestCase(options)
229
+ await tc.setup()
230
+
231
+ const { afterAll, beforeEach, afterEach } = await import('bun:test')
232
+ afterAll(() => tc.teardown())
233
+ beforeEach(() => tc.beforeEach())
234
+ afterEach(() => tc.afterEach())
235
+
236
+ return tc
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Internal
241
+ // ---------------------------------------------------------------------------
242
+
243
+ private async request(
244
+ method: string,
245
+ path: string,
246
+ body?: unknown,
247
+ headers?: Bun.HeadersInit
248
+ ): Promise<Response> {
249
+ const merged: Record<string, string> = { ...this._headers }
250
+ if (this._token) merged['Authorization'] = `Bearer ${this._token}`
251
+ if (body !== undefined) merged['Content-Type'] = 'application/json'
252
+
253
+ if (headers) {
254
+ const entries =
255
+ 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,5 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"],
4
+ "exclude": ["node_modules", "tests"]
5
+ }