@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 CHANGED
@@ -1,14 +1,14 @@
1
- # @stravigor/testing
1
+ # @strav/testing
2
2
 
3
- Testing utilities for the [Strav](https://www.npmjs.com/package/@stravigor/core) framework. Provides HTTP testing helpers, authentication simulation, transaction-based test isolation, and model factories.
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 @stravigor/testing
8
+ bun add -d @strav/testing
9
9
  ```
10
10
 
11
- Requires `@stravigor/core` as a peer dependency.
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 '@stravigor/testing'
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 '@stravigor/testing'
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.0",
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/factory.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BaseModel } from '@stravigor/database'
1
+ import type { BaseModel } from '@strav/database'
2
2
 
3
3
  type ModelClass = typeof BaseModel & { create(attrs: Record<string, unknown>): Promise<any> }
4
4
  type DefinitionFn = (seq: number) => Record<string, unknown>
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
@@ -1,8 +1,9 @@
1
1
  import type { SQL, ReservedSQL } from 'bun'
2
- import { app, Configuration, ExceptionHandler } from '@stravigor/kernel'
3
- import { Database, BaseModel } from '@stravigor/database'
4
- import { Router } from '@stravigor/http'
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 '@stravigor/testing'
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
- 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
 
71
79
  // Auth + Session
72
80
  if (this.options.auth) {
73
- const { SessionManager } = await import('@stravigor/http')
74
- const { Auth } = await import('@stravigor/http')
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('@stravigor/view')
93
- const { Context } = await import('@stravigor/http')
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
- 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
  // ---------------------------------------------------------------------------
@@ -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('@stravigor/http')
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://localhost${path}`, {
298
+ new Request(`http://${hostname}${path}`, {
265
299
  method,
266
300
  headers: merged,
267
301
  body: body !== undefined ? JSON.stringify(body) : undefined,