@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/testing",
3
- "version": "0.1.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.0",
19
- "@strav/http": "0.1.0",
20
- "@strav/view": "0.1.0",
21
- "@strav/database": "0.1.0"
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
@@ -1,3 +1,6 @@
1
1
  export { TestCase } from './test_case.ts'
2
2
  export type { TestCaseOptions } from './test_case.ts'
3
3
  export { Factory } from './factory.ts'
4
+
5
+ // Database management
6
+ export { TestDatabaseManager, cleanupTestDatabase } from './database_manager.ts'
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
- this.db = app.resolve(Database)
67
- new BaseModel(this.db)
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
- await this.db.close()
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://localhost${path}`, {
299
+ new Request(`http://${hostname}${path}`, {
265
300
  method,
266
301
  headers: merged,
267
302
  body: body !== undefined ? JSON.stringify(body) : undefined,