@strav/testing 0.1.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/testing",
3
- "version": "0.1.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.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,14 +65,14 @@ 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)
70
78
 
@@ -120,7 +128,10 @@ export class TestCase {
120
128
  this._reserved = null
121
129
  }
122
130
 
123
- await this.db.close()
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
  // ---------------------------------------------------------------------------
@@ -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://localhost${path}`, {
298
+ new Request(`http://${hostname}${path}`, {
265
299
  method,
266
300
  headers: merged,
267
301
  body: body !== undefined ? JSON.stringify(body) : undefined,