@strav/testing 0.1.1 → 0.1.6
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/package.json +5 -5
- package/src/database_manager.ts +130 -0
- package/src/index.ts +3 -0
- package/src/test_case.ts +41 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/testing",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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/index.ts
CHANGED
package/src/test_case.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { app, Configuration, ExceptionHandler } from '@strav/kernel'
|
|
|
3
3
|
import { Database, BaseModel } from '@strav/database'
|
|
4
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
|
/**
|
|
@@ -26,6 +29,7 @@ export interface TestCaseOptions {
|
|
|
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,16 +65,17 @@ 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)
|
|
78
|
+
this.router.setDomain(this._domain)
|
|
70
79
|
|
|
71
80
|
// Auth + Session
|
|
72
81
|
if (this.options.auth) {
|
|
@@ -120,7 +129,10 @@ export class TestCase {
|
|
|
120
129
|
this._reserved = null
|
|
121
130
|
}
|
|
122
131
|
|
|
123
|
-
|
|
132
|
+
// Release database reference instead of closing it directly
|
|
133
|
+
// This allows multiple test files to share the same connection
|
|
134
|
+
const dbManager = TestDatabaseManager.getInstance()
|
|
135
|
+
await dbManager.releaseDatabase()
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
/** Begin a transaction for test isolation. Call in beforeEach. */
|
|
@@ -153,6 +165,7 @@ export class TestCase {
|
|
|
153
165
|
// Clear per-test state
|
|
154
166
|
this._token = null
|
|
155
167
|
this._headers = {}
|
|
168
|
+
this._subdomain = null
|
|
156
169
|
}
|
|
157
170
|
|
|
158
171
|
// ---------------------------------------------------------------------------
|
|
@@ -211,6 +224,18 @@ export class TestCase {
|
|
|
211
224
|
return this
|
|
212
225
|
}
|
|
213
226
|
|
|
227
|
+
/** Set subdomain for subsequent requests in this test. */
|
|
228
|
+
onSubdomain(subdomain: string): this {
|
|
229
|
+
this._subdomain = subdomain
|
|
230
|
+
return this
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Clear subdomain for subsequent requests. */
|
|
234
|
+
withoutSubdomain(): this {
|
|
235
|
+
this._subdomain = null
|
|
236
|
+
return this
|
|
237
|
+
}
|
|
238
|
+
|
|
214
239
|
// ---------------------------------------------------------------------------
|
|
215
240
|
// Static shorthand
|
|
216
241
|
// ---------------------------------------------------------------------------
|
|
@@ -221,6 +246,7 @@ export class TestCase {
|
|
|
221
246
|
* @example
|
|
222
247
|
* const t = await TestCase.boot({
|
|
223
248
|
* auth: true,
|
|
249
|
+
* domain: 'example.com',
|
|
224
250
|
* routes: () => import('../start/api_routes'),
|
|
225
251
|
* })
|
|
226
252
|
*/
|
|
@@ -250,6 +276,13 @@ export class TestCase {
|
|
|
250
276
|
if (this._token) merged['Authorization'] = `Bearer ${this._token}`
|
|
251
277
|
if (body !== undefined) merged['Content-Type'] = 'application/json'
|
|
252
278
|
|
|
279
|
+
// Set Host header for subdomain routing
|
|
280
|
+
if (this._subdomain) {
|
|
281
|
+
merged['Host'] = `${this._subdomain}.${this._domain}`
|
|
282
|
+
} else {
|
|
283
|
+
merged['Host'] = this._domain
|
|
284
|
+
}
|
|
285
|
+
|
|
253
286
|
if (headers) {
|
|
254
287
|
const entries =
|
|
255
288
|
headers instanceof Headers
|
|
@@ -260,8 +293,10 @@ export class TestCase {
|
|
|
260
293
|
Object.assign(merged, entries)
|
|
261
294
|
}
|
|
262
295
|
|
|
296
|
+
// Use the subdomain in the URL when present for clarity
|
|
297
|
+
const hostname = this._subdomain ? `${this._subdomain}.${this._domain}` : this._domain
|
|
263
298
|
const res = this.router.handle(
|
|
264
|
-
new Request(`http
|
|
299
|
+
new Request(`http://${hostname}${path}`, {
|
|
265
300
|
method,
|
|
266
301
|
headers: merged,
|
|
267
302
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|