@strav/testing 0.1.0 → 0.1.5
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 +6 -6
- package/package.json +5 -5
- package/src/database_manager.ts +130 -0
- package/src/factory.ts +1 -1
- package/src/index.ts +3 -0
- package/src/test_case.ts +49 -15
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
# @
|
|
1
|
+
# @strav/testing
|
|
2
2
|
|
|
3
|
-
Testing utilities for the [Strav](https://www.npmjs.com/package/@
|
|
3
|
+
Testing utilities for the [Strav](https://www.npmjs.com/package/@strav/core) framework. Provides HTTP testing helpers, authentication simulation, transaction-based test isolation, and model factories.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
bun add -d @
|
|
8
|
+
bun add -d @strav/testing
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Requires `@
|
|
11
|
+
Requires `@strav/core` as a peer dependency.
|
|
12
12
|
|
|
13
13
|
## TestCase
|
|
14
14
|
|
|
@@ -16,7 +16,7 @@ Boots the app, provides HTTP helpers, and wraps each test in a rolled-back trans
|
|
|
16
16
|
|
|
17
17
|
```ts
|
|
18
18
|
import { describe, test, expect } from 'bun:test'
|
|
19
|
-
import { TestCase } from '@
|
|
19
|
+
import { TestCase } from '@strav/testing'
|
|
20
20
|
|
|
21
21
|
const t = await TestCase.boot({
|
|
22
22
|
auth: true,
|
|
@@ -62,7 +62,7 @@ t.withoutAuth() // clear auth token
|
|
|
62
62
|
Lightweight model factory for test data seeding.
|
|
63
63
|
|
|
64
64
|
```ts
|
|
65
|
-
import { Factory } from '@
|
|
65
|
+
import { Factory } from '@strav/testing'
|
|
66
66
|
|
|
67
67
|
const UserFactory = Factory.define(User, (seq) => ({
|
|
68
68
|
pid: crypto.randomUUID(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/testing",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Testing utilities for the Strav framework",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
"CHANGELOG.md"
|
|
16
16
|
],
|
|
17
17
|
"peerDependencies": {
|
|
18
|
-
"@strav/kernel": "0.1.
|
|
19
|
-
"@strav/http": "0.1.
|
|
20
|
-
"@strav/view": "0.1.
|
|
21
|
-
"@strav/database": "0.1.
|
|
18
|
+
"@strav/kernel": "0.1.4",
|
|
19
|
+
"@strav/http": "0.1.4",
|
|
20
|
+
"@strav/view": "0.1.4",
|
|
21
|
+
"@strav/database": "0.1.4"
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
24
|
"test": "bun test tests/",
|
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
|
|
89
|
+
// Configure BaseModel with the shared database
|
|
90
|
+
new BaseModel(this.database)
|
|
91
|
+
|
|
92
|
+
this.isInitialized = true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get current reference count (for debugging)
|
|
97
|
+
*/
|
|
98
|
+
getReferenceCount(): number {
|
|
99
|
+
return this.referenceCount
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Force close the database connection (for cleanup)
|
|
104
|
+
*/
|
|
105
|
+
async forceClose(): Promise<void> {
|
|
106
|
+
if (this.database) {
|
|
107
|
+
await this.database.close()
|
|
108
|
+
this.database = null
|
|
109
|
+
this.isInitialized = false
|
|
110
|
+
this.referenceCount = 0
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Utility function to clean up database connections on process exit
|
|
117
|
+
*/
|
|
118
|
+
export function cleanupTestDatabase(): void {
|
|
119
|
+
const manager = TestDatabaseManager.getInstance()
|
|
120
|
+
|
|
121
|
+
process.on('SIGINT', async () => {
|
|
122
|
+
await manager.forceClose()
|
|
123
|
+
process.exit(0)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
process.on('SIGTERM', async () => {
|
|
127
|
+
await manager.forceClose()
|
|
128
|
+
process.exit(0)
|
|
129
|
+
})
|
|
130
|
+
}
|
package/src/factory.ts
CHANGED
package/src/index.ts
CHANGED
package/src/test_case.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { SQL, ReservedSQL } from 'bun'
|
|
2
|
-
import { app, Configuration, ExceptionHandler } from '@
|
|
3
|
-
import { Database, BaseModel } from '@
|
|
4
|
-
import { Router } from '@
|
|
2
|
+
import { app, Configuration, ExceptionHandler } from '@strav/kernel'
|
|
3
|
+
import { Database, BaseModel } from '@strav/database'
|
|
4
|
+
import { Router } from '@strav/http'
|
|
5
5
|
import { Factory } from './factory.ts'
|
|
6
|
+
import { TestDatabaseManager } from './database_manager.ts'
|
|
6
7
|
|
|
7
8
|
export interface TestCaseOptions {
|
|
8
9
|
/** Route loader — called during setup to register routes. */
|
|
@@ -15,6 +16,8 @@ export interface TestCaseOptions {
|
|
|
15
16
|
transaction?: boolean
|
|
16
17
|
/** User resolver for Auth.useResolver() (required when auth: true). */
|
|
17
18
|
userResolver?: (id: string | number) => Promise<unknown>
|
|
19
|
+
/** Base domain for subdomain extraction (default: 'localhost'). */
|
|
20
|
+
domain?: string
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
/**
|
|
@@ -22,10 +25,11 @@ export interface TestCaseOptions {
|
|
|
22
25
|
* transaction for full isolation.
|
|
23
26
|
*
|
|
24
27
|
* @example
|
|
25
|
-
* import { TestCase, Factory } from '@
|
|
28
|
+
* import { TestCase, Factory } from '@strav/testing'
|
|
26
29
|
*
|
|
27
30
|
* const t = await TestCase.boot({
|
|
28
31
|
* auth: true,
|
|
32
|
+
* domain: 'example.com',
|
|
29
33
|
* routes: () => import('../start/api_routes'),
|
|
30
34
|
* })
|
|
31
35
|
*
|
|
@@ -47,8 +51,12 @@ export class TestCase {
|
|
|
47
51
|
private _headers: Record<string, string> = {}
|
|
48
52
|
private _originalSql: SQL | null = null
|
|
49
53
|
private _reserved: ReservedSQL | null = null
|
|
54
|
+
private _subdomain: string | null = null
|
|
55
|
+
private _domain: string
|
|
50
56
|
|
|
51
|
-
constructor(private options: TestCaseOptions = {}) {
|
|
57
|
+
constructor(private options: TestCaseOptions = {}) {
|
|
58
|
+
this._domain = options.domain || 'localhost'
|
|
59
|
+
}
|
|
52
60
|
|
|
53
61
|
// ---------------------------------------------------------------------------
|
|
54
62
|
// Lifecycle
|
|
@@ -57,21 +65,21 @@ export class TestCase {
|
|
|
57
65
|
/** Boot the app — mirrors index.ts bootstrap, minus Server. Call in beforeAll. */
|
|
58
66
|
async setup(): Promise<void> {
|
|
59
67
|
if (!app.has(Configuration)) app.singleton(Configuration)
|
|
60
|
-
if (!app.has(Database)) app.singleton(Database)
|
|
61
68
|
if (!app.has(Router)) app.singleton(Router)
|
|
62
69
|
|
|
63
70
|
this.config = app.resolve(Configuration)
|
|
64
71
|
await this.config.load()
|
|
65
72
|
|
|
66
|
-
|
|
67
|
-
|
|
73
|
+
// Use shared database manager to prevent connection closed errors
|
|
74
|
+
const dbManager = TestDatabaseManager.getInstance()
|
|
75
|
+
this.db = await dbManager.getDatabase()
|
|
68
76
|
|
|
69
77
|
this.router = app.resolve(Router)
|
|
70
78
|
|
|
71
79
|
// Auth + Session
|
|
72
80
|
if (this.options.auth) {
|
|
73
|
-
const { SessionManager } = await import('@
|
|
74
|
-
const { Auth } = await import('@
|
|
81
|
+
const { SessionManager } = await import('@strav/http')
|
|
82
|
+
const { Auth } = await import('@strav/http')
|
|
75
83
|
|
|
76
84
|
if (!app.has(SessionManager)) app.singleton(SessionManager)
|
|
77
85
|
if (!app.has(Auth)) app.singleton(Auth)
|
|
@@ -89,8 +97,8 @@ export class TestCase {
|
|
|
89
97
|
|
|
90
98
|
// View engine
|
|
91
99
|
if (this.options.views) {
|
|
92
|
-
const { ViewEngine } = await import('@
|
|
93
|
-
const { Context } = await import('@
|
|
100
|
+
const { ViewEngine } = await import('@strav/view')
|
|
101
|
+
const { Context } = await import('@strav/http')
|
|
94
102
|
|
|
95
103
|
if (!app.has(ViewEngine)) app.singleton(ViewEngine)
|
|
96
104
|
const viewEngine = app.resolve(ViewEngine)
|
|
@@ -120,7 +128,10 @@ export class TestCase {
|
|
|
120
128
|
this._reserved = null
|
|
121
129
|
}
|
|
122
130
|
|
|
123
|
-
|
|
131
|
+
// Release database reference instead of closing it directly
|
|
132
|
+
// This allows multiple test files to share the same connection
|
|
133
|
+
const dbManager = TestDatabaseManager.getInstance()
|
|
134
|
+
await dbManager.releaseDatabase()
|
|
124
135
|
}
|
|
125
136
|
|
|
126
137
|
/** Begin a transaction for test isolation. Call in beforeEach. */
|
|
@@ -153,6 +164,7 @@ export class TestCase {
|
|
|
153
164
|
// Clear per-test state
|
|
154
165
|
this._token = null
|
|
155
166
|
this._headers = {}
|
|
167
|
+
this._subdomain = null
|
|
156
168
|
}
|
|
157
169
|
|
|
158
170
|
// ---------------------------------------------------------------------------
|
|
@@ -193,7 +205,7 @@ export class TestCase {
|
|
|
193
205
|
* Creates a real AccessToken in the database.
|
|
194
206
|
*/
|
|
195
207
|
async actingAs(user: unknown, tokenName = 'test-token'): Promise<this> {
|
|
196
|
-
const { AccessToken } = await import('@
|
|
208
|
+
const { AccessToken } = await import('@strav/http')
|
|
197
209
|
const { token } = await AccessToken.create(user, tokenName)
|
|
198
210
|
this._token = token
|
|
199
211
|
return this
|
|
@@ -211,6 +223,18 @@ export class TestCase {
|
|
|
211
223
|
return this
|
|
212
224
|
}
|
|
213
225
|
|
|
226
|
+
/** Set subdomain for subsequent requests in this test. */
|
|
227
|
+
onSubdomain(subdomain: string): this {
|
|
228
|
+
this._subdomain = subdomain
|
|
229
|
+
return this
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Clear subdomain for subsequent requests. */
|
|
233
|
+
withoutSubdomain(): this {
|
|
234
|
+
this._subdomain = null
|
|
235
|
+
return this
|
|
236
|
+
}
|
|
237
|
+
|
|
214
238
|
// ---------------------------------------------------------------------------
|
|
215
239
|
// Static shorthand
|
|
216
240
|
// ---------------------------------------------------------------------------
|
|
@@ -221,6 +245,7 @@ export class TestCase {
|
|
|
221
245
|
* @example
|
|
222
246
|
* const t = await TestCase.boot({
|
|
223
247
|
* auth: true,
|
|
248
|
+
* domain: 'example.com',
|
|
224
249
|
* routes: () => import('../start/api_routes'),
|
|
225
250
|
* })
|
|
226
251
|
*/
|
|
@@ -250,6 +275,13 @@ export class TestCase {
|
|
|
250
275
|
if (this._token) merged['Authorization'] = `Bearer ${this._token}`
|
|
251
276
|
if (body !== undefined) merged['Content-Type'] = 'application/json'
|
|
252
277
|
|
|
278
|
+
// Set Host header for subdomain routing
|
|
279
|
+
if (this._subdomain) {
|
|
280
|
+
merged['Host'] = `${this._subdomain}.${this._domain}`
|
|
281
|
+
} else {
|
|
282
|
+
merged['Host'] = this._domain
|
|
283
|
+
}
|
|
284
|
+
|
|
253
285
|
if (headers) {
|
|
254
286
|
const entries =
|
|
255
287
|
headers instanceof Headers
|
|
@@ -260,8 +292,10 @@ export class TestCase {
|
|
|
260
292
|
Object.assign(merged, entries)
|
|
261
293
|
}
|
|
262
294
|
|
|
295
|
+
// Use the subdomain in the URL when present for clarity
|
|
296
|
+
const hostname = this._subdomain ? `${this._subdomain}.${this._domain}` : this._domain
|
|
263
297
|
const res = this.router.handle(
|
|
264
|
-
new Request(`http
|
|
298
|
+
new Request(`http://${hostname}${path}`, {
|
|
265
299
|
method,
|
|
266
300
|
headers: merged,
|
|
267
301
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|