@strav/testing 0.4.30 → 1.0.0-alpha.24

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.
@@ -1,131 +0,0 @@
1
- import { app } from '@strav/kernel'
2
- import { Database, BaseModel } from '@strav/database'
3
-
4
- /**
5
- * Singleton database manager for test isolation
6
- *
7
- * Manages a shared database connection across multiple test files
8
- * while ensuring proper cleanup and preventing "Connection closed" errors.
9
- */
10
- export class TestDatabaseManager {
11
- private static instance: TestDatabaseManager | null = null
12
- private database: Database | null = null
13
- private referenceCount = 0
14
- private isInitialized = false
15
- private closeTimeout: Timer | null = null
16
- private readonly CLOSE_DELAY_MS = 100 // Small delay to allow other test files to start
17
-
18
- private constructor() {
19
- // Private constructor for singleton pattern
20
- }
21
-
22
- /**
23
- * Get the singleton instance
24
- */
25
- static getInstance(): TestDatabaseManager {
26
- if (!TestDatabaseManager.instance) {
27
- TestDatabaseManager.instance = new TestDatabaseManager()
28
- }
29
- return TestDatabaseManager.instance
30
- }
31
-
32
- /**
33
- * Get a shared database instance
34
- * Increments reference count to track active users
35
- */
36
- async getDatabase(): Promise<Database> {
37
- this.referenceCount++
38
-
39
- // Cancel any pending close operation
40
- if (this.closeTimeout) {
41
- clearTimeout(this.closeTimeout)
42
- this.closeTimeout = null
43
- }
44
-
45
- if (!this.database || !this.isInitialized) {
46
- await this.initializeDatabase()
47
- }
48
-
49
- return this.database!
50
- }
51
-
52
- /**
53
- * Release the database reference
54
- * Decrements reference count and closes connection when no more users
55
- */
56
- async releaseDatabase(): Promise<void> {
57
- this.referenceCount--
58
-
59
- if (this.referenceCount <= 0 && this.database) {
60
- // Schedule database close with a small delay
61
- // This allows other test files to start and increment the reference count
62
- this.closeTimeout = setTimeout(async () => {
63
- if (this.referenceCount <= 0 && this.database) {
64
- await this.database.close()
65
- this.database = null
66
- this.isInitialized = false
67
- this.referenceCount = 0
68
- }
69
- this.closeTimeout = null
70
- }, this.CLOSE_DELAY_MS)
71
- }
72
- }
73
-
74
- /**
75
- * Initialize the shared database connection
76
- */
77
- private async initializeDatabase(): Promise<void> {
78
- if (this.isInitialized && this.database) {
79
- return
80
- }
81
-
82
- // Register database singleton if not already registered
83
- if (!app.has(Database)) {
84
- app.singleton(Database)
85
- }
86
-
87
- this.database = app.resolve(Database)
88
- await this.database.init()
89
-
90
- // Configure BaseModel with the shared database
91
- new BaseModel(this.database)
92
-
93
- this.isInitialized = true
94
- }
95
-
96
- /**
97
- * Get current reference count (for debugging)
98
- */
99
- getReferenceCount(): number {
100
- return this.referenceCount
101
- }
102
-
103
- /**
104
- * Force close the database connection (for cleanup)
105
- */
106
- async forceClose(): Promise<void> {
107
- if (this.database) {
108
- await this.database.close()
109
- this.database = null
110
- this.isInitialized = false
111
- this.referenceCount = 0
112
- }
113
- }
114
- }
115
-
116
- /**
117
- * Utility function to clean up database connections on process exit
118
- */
119
- export function cleanupTestDatabase(): void {
120
- const manager = TestDatabaseManager.getInstance()
121
-
122
- process.on('SIGINT', async () => {
123
- await manager.forceClose()
124
- process.exit(0)
125
- })
126
-
127
- process.on('SIGTERM', async () => {
128
- await manager.forceClose()
129
- process.exit(0)
130
- })
131
- }
package/src/factory.ts DELETED
@@ -1,68 +0,0 @@
1
- import type { BaseModel } from '@strav/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/test_case.ts DELETED
@@ -1,312 +0,0 @@
1
- import type { SQL, ReservedSQL } from 'bun'
2
- import { app, Configuration, ExceptionHandler } from '@strav/kernel'
3
- import { Database, BaseModel } from '@strav/database'
4
- import { Router } from '@strav/http'
5
- import { Factory } from './factory.ts'
6
- import { TestDatabaseManager } from './database_manager.ts'
7
-
8
- export interface TestCaseOptions {
9
- /** Route loader — called during setup to register routes. */
10
- routes?: () => Promise<unknown>
11
- /** Boot auth + session tables (default: false). */
12
- auth?: boolean
13
- /** Boot view engine (default: false). */
14
- views?: boolean
15
- /** Wrap each test in a DB transaction that auto-rollbacks (default: true). */
16
- transaction?: boolean
17
- /** User resolver for Auth.useResolver() (required when auth: true). */
18
- userResolver?: (id: string | number) => Promise<unknown>
19
- /** Base domain for subdomain extraction (default: 'localhost'). */
20
- domain?: string
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 '@strav/testing'
29
- *
30
- * const t = await TestCase.boot({
31
- * auth: true,
32
- * domain: 'example.com',
33
- * routes: () => import('../start/api_routes'),
34
- * })
35
- *
36
- * describe('Posts', () => {
37
- * test('list', async () => {
38
- * const user = await UserFactory.create()
39
- * await t.actingAs(user)
40
- * const res = await t.get('/api/posts')
41
- * expect(res.status).toBe(200)
42
- * })
43
- * })
44
- */
45
- export class TestCase {
46
- db!: Database
47
- router!: Router
48
- config!: Configuration
49
-
50
- private _token: string | null = null
51
- private _headers: Record<string, string> = {}
52
- private _originalSql: SQL | null = null
53
- private _reserved: ReservedSQL | null = null
54
- private _subdomain: string | null = null
55
- private _domain: string
56
-
57
- constructor(private options: TestCaseOptions = {}) {
58
- this._domain = options.domain || 'localhost'
59
- }
60
-
61
- // ---------------------------------------------------------------------------
62
- // Lifecycle
63
- // ---------------------------------------------------------------------------
64
-
65
- /** Boot the app — mirrors index.ts bootstrap, minus Server. Call in beforeAll. */
66
- async setup(): Promise<void> {
67
- if (!app.has(Configuration)) app.singleton(Configuration)
68
- if (!app.has(Router)) app.singleton(Router)
69
-
70
- this.config = app.resolve(Configuration)
71
- await this.config.load()
72
-
73
- // Use shared database manager to prevent connection closed errors
74
- const dbManager = TestDatabaseManager.getInstance()
75
- this.db = await dbManager.getDatabase()
76
-
77
- this.router = app.resolve(Router)
78
- this.router.setDomain(this._domain)
79
-
80
- // Auth + Session
81
- if (this.options.auth) {
82
- const { SessionManager } = await import('@strav/http')
83
- const { Auth } = await import('@strav/http')
84
- const { PostgresSessionStore } = await import('@strav/database')
85
-
86
- if (!app.has(SessionManager)) app.singleton(SessionManager)
87
- if (!app.has(Auth)) app.singleton(Auth)
88
- if (!app.has(PostgresSessionStore)) app.singleton(PostgresSessionStore)
89
-
90
- app.resolve(SessionManager)
91
- const sessionStore = app.resolve(PostgresSessionStore)
92
- SessionManager.useStore(sessionStore)
93
- await sessionStore.ensureSchema()
94
-
95
- app.resolve(Auth)
96
- await Auth.ensureTables()
97
-
98
- if (this.options.userResolver) {
99
- Auth.useResolver(this.options.userResolver)
100
- }
101
- }
102
-
103
- // View engine
104
- if (this.options.views) {
105
- const { ViewEngine } = await import('@strav/view')
106
- const { Context } = await import('@strav/http')
107
-
108
- if (!app.has(ViewEngine)) app.singleton(ViewEngine)
109
- const viewEngine = app.resolve(ViewEngine)
110
- Context.setViewEngine(viewEngine)
111
- }
112
-
113
- // Routes
114
- if (this.options.routes) {
115
- await this.options.routes()
116
- }
117
-
118
- // Exception handler (always dev mode in tests)
119
- const handler = new ExceptionHandler(true)
120
- this.router.useExceptionHandler(handler)
121
- }
122
-
123
- /** Close the database connection. Call in afterAll. */
124
- async teardown(): Promise<void> {
125
- // Ensure any reserved connection is released first
126
- if (this._reserved) {
127
- try {
128
- await this._reserved`ROLLBACK`
129
- } catch {
130
- /* ignore */
131
- }
132
- this._reserved.release()
133
- this._reserved = null
134
- }
135
-
136
- // Release database reference instead of closing it directly
137
- // This allows multiple test files to share the same connection
138
- const dbManager = TestDatabaseManager.getInstance()
139
- await dbManager.releaseDatabase()
140
- }
141
-
142
- /** Begin a transaction for test isolation. Call in beforeEach. */
143
- async beforeEach(): Promise<void> {
144
- if (this.options.transaction !== false) {
145
- this._originalSql = this.db.sql
146
- this._reserved = await this._originalSql.reserve()
147
- await this._reserved`BEGIN`
148
-
149
- // Monkey-patch Database to use the reserved connection.
150
- // TypeScript `private` is compile-time only — runtime access works.
151
- ;(this.db as any).appConnection = this._reserved
152
- ;(Database as any)._appConnection = this._reserved
153
- }
154
- }
155
-
156
- /** Rollback the transaction and restore state. Call in afterEach. */
157
- async afterEach(): Promise<void> {
158
- if (this._reserved) {
159
- await this._reserved`ROLLBACK`
160
- this._reserved.release()
161
-
162
- // Restore original connection
163
- ;(this.db as any).appConnection = this._originalSql
164
- ;(Database as any)._appConnection = this._originalSql
165
- this._reserved = null
166
- this._originalSql = null
167
- }
168
-
169
- // Clear per-test state
170
- this._token = null
171
- this._headers = {}
172
- this._subdomain = null
173
- }
174
-
175
- // ---------------------------------------------------------------------------
176
- // HTTP helpers
177
- // ---------------------------------------------------------------------------
178
-
179
- /** Send a GET request through the router. */
180
- get(path: string, headers?: Bun.HeadersInit): Promise<Response> {
181
- return this.request('GET', path, undefined, headers)
182
- }
183
-
184
- /** Send a POST request with JSON body through the router. */
185
- post(path: string, body?: unknown, headers?: Bun.HeadersInit): Promise<Response> {
186
- return this.request('POST', path, body, headers)
187
- }
188
-
189
- /** Send a PUT request with JSON body through the router. */
190
- put(path: string, body?: unknown, headers?: Bun.HeadersInit): Promise<Response> {
191
- return this.request('PUT', path, body, headers)
192
- }
193
-
194
- /** Send a PATCH request with JSON body through the router. */
195
- patch(path: string, body?: unknown, headers?: Bun.HeadersInit): Promise<Response> {
196
- return this.request('PATCH', path, body, headers)
197
- }
198
-
199
- /** Send a DELETE request through the router. */
200
- delete(path: string, headers?: Bun.HeadersInit): Promise<Response> {
201
- return this.request('DELETE', path, undefined, headers)
202
- }
203
-
204
- // ---------------------------------------------------------------------------
205
- // Auth helpers
206
- // ---------------------------------------------------------------------------
207
-
208
- /**
209
- * Authenticate as the given user for subsequent requests in this test.
210
- * Creates a real AccessToken in the database.
211
- */
212
- async actingAs(user: unknown, tokenName = 'test-token'): Promise<this> {
213
- const { AccessToken } = await import('@strav/http')
214
- const { token } = await AccessToken.create(user, tokenName)
215
- this._token = token
216
- return this
217
- }
218
-
219
- /** Add custom headers to all subsequent requests in this test. */
220
- withHeaders(headers: Record<string, string>): this {
221
- Object.assign(this._headers, headers)
222
- return this
223
- }
224
-
225
- /** Clear the auth token for the next request. */
226
- withoutAuth(): this {
227
- this._token = null
228
- return this
229
- }
230
-
231
- /** Set subdomain for subsequent requests in this test. */
232
- onSubdomain(subdomain: string): this {
233
- this._subdomain = subdomain
234
- return this
235
- }
236
-
237
- /** Clear subdomain for subsequent requests. */
238
- withoutSubdomain(): this {
239
- this._subdomain = null
240
- return this
241
- }
242
-
243
- // ---------------------------------------------------------------------------
244
- // Static shorthand
245
- // ---------------------------------------------------------------------------
246
-
247
- /**
248
- * Boot the TestCase and register bun:test lifecycle hooks automatically.
249
- *
250
- * @example
251
- * const t = await TestCase.boot({
252
- * auth: true,
253
- * domain: 'example.com',
254
- * routes: () => import('../start/api_routes'),
255
- * })
256
- */
257
- static async boot(options?: TestCaseOptions): Promise<TestCase> {
258
- const tc = new TestCase(options)
259
- await tc.setup()
260
-
261
- const { afterAll, beforeEach, afterEach } = await import('bun:test')
262
- afterAll(() => tc.teardown())
263
- beforeEach(() => tc.beforeEach())
264
- afterEach(() => tc.afterEach())
265
-
266
- return tc
267
- }
268
-
269
- // ---------------------------------------------------------------------------
270
- // Internal
271
- // ---------------------------------------------------------------------------
272
-
273
- private async request(
274
- method: string,
275
- path: string,
276
- body?: unknown,
277
- headers?: Bun.HeadersInit
278
- ): Promise<Response> {
279
- const merged: Record<string, string> = { ...this._headers }
280
- if (this._token) merged['Authorization'] = `Bearer ${this._token}`
281
- if (body !== undefined) merged['Content-Type'] = 'application/json'
282
-
283
- // Set Host header for subdomain routing
284
- if (this._subdomain) {
285
- merged['Host'] = `${this._subdomain}.${this._domain}`
286
- } else {
287
- merged['Host'] = this._domain
288
- }
289
-
290
- if (headers) {
291
- const entries =
292
- headers instanceof Headers
293
- ? Object.fromEntries(headers.entries())
294
- : Array.isArray(headers)
295
- ? Object.fromEntries(headers)
296
- : headers
297
- Object.assign(merged, entries)
298
- }
299
-
300
- // Use the subdomain in the URL when present for clarity
301
- const hostname = this._subdomain ? `${this._subdomain}.${this._domain}` : this._domain
302
- const res = this.router.handle(
303
- new Request(`http://${hostname}${path}`, {
304
- method,
305
- headers: merged,
306
- body: body !== undefined ? JSON.stringify(body) : undefined,
307
- })
308
- )
309
-
310
- return (await res) ?? new Response('Not Found', { status: 404 })
311
- }
312
- }
package/tsconfig.json DELETED
@@ -1,5 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "include": ["src/**/*.ts"],
4
- "exclude": ["node_modules", "tests"]
5
- }