@strav/cli 0.1.0

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.
@@ -0,0 +1,1668 @@
1
+ import { join } from 'node:path'
2
+ import { Archetype } from '@stravigor/database/schema/types'
3
+ import type { SchemaDefinition } from '@stravigor/database/schema/types'
4
+ import type {
5
+ DatabaseRepresentation,
6
+ TableDefinition,
7
+ } from '@stravigor/database/schema/database_representation'
8
+ import type { FieldDefinition } from '@stravigor/database/schema/field_definition'
9
+ import {
10
+ toSnakeCase,
11
+ toCamelCase,
12
+ toPascalCase,
13
+ pluralize,
14
+ } from '@stravigor/kernel/helpers/strings'
15
+ import type { GeneratedFile } from './model_generator.ts'
16
+ import type { GeneratorConfig, GeneratorPaths } from './config.ts'
17
+ import { resolvePaths, relativeImport, formatAndWrite } from './config.ts'
18
+ import { ApiRouting, toRouteSegment, toChildSegment } from './route_generator.ts'
19
+ import type { ApiRoutingConfig } from './route_generator.ts'
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Constants
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const DEPENDENT_ARCHETYPES: Set<Archetype> = new Set([
26
+ Archetype.Component,
27
+ Archetype.Attribute,
28
+ Archetype.Event,
29
+ Archetype.Configuration,
30
+ Archetype.Contribution,
31
+ ])
32
+
33
+ const SYSTEM_COLUMNS = new Set(['id', 'created_at', 'updated_at', 'deleted_at'])
34
+
35
+ const API_DEFAULTS: ApiRoutingConfig = {
36
+ routing: ApiRouting.Prefix,
37
+ prefix: '/api',
38
+ subdomain: 'api',
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // TestGenerator
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Generate integration test files for the API layer.
47
+ *
48
+ * Produces:
49
+ * - `tests/api/setup.ts` — bootstrap, fixtures, request helpers
50
+ * - `tests/api/auth.test.ts` — authentication smoke tests
51
+ * - `tests/api/{schema}.test.ts` — per-schema CRUD tests
52
+ */
53
+ export default class TestGenerator {
54
+ private apiConfig: ApiRoutingConfig
55
+ private paths: GeneratorPaths
56
+ private schemaMap: Map<string, SchemaDefinition>
57
+
58
+ constructor(
59
+ private schemas: SchemaDefinition[],
60
+ private representation: DatabaseRepresentation,
61
+ config?: GeneratorConfig,
62
+ apiConfig?: Partial<ApiRoutingConfig>
63
+ ) {
64
+ this.apiConfig = { ...API_DEFAULTS, ...apiConfig }
65
+ this.paths = resolvePaths(config)
66
+ this.schemaMap = new Map(schemas.map(s => [s.name, s]))
67
+ }
68
+
69
+ generate(): GeneratedFile[] {
70
+ const files: GeneratedFile[] = []
71
+
72
+ files.push(this.generateSetup())
73
+ files.push(this.generateAuthTest())
74
+
75
+ for (const schema of this.schemas) {
76
+ if (schema.archetype === Archetype.Association) continue
77
+ const table = this.representation.tables.find(t => t.name === toSnakeCase(schema.name))
78
+ if (!table) continue
79
+ files.push(this.generateSchemaTest(schema, table))
80
+ }
81
+
82
+ return files
83
+ }
84
+
85
+ async writeAll(): Promise<GeneratedFile[]> {
86
+ const files = this.generate()
87
+ await formatAndWrite(files)
88
+ return files
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // setup.ts
93
+ // ---------------------------------------------------------------------------
94
+
95
+ private generateSetup(): GeneratedFile {
96
+ const isSubdomain = this.apiConfig.routing === ApiRouting.Subdomain
97
+ const routesImport = relativeImport(this.paths.tests, this.paths.routes)
98
+ const userSchema = this.findUserEntity()
99
+ const userPkName = userSchema ? this.findSchemaPK(userSchema.name) : 'id'
100
+ const userPkIsUuid = userSchema ? this.getPkType(userSchema) === 'uuid' : true
101
+
102
+ const lines: string[] = [
103
+ '// Generated by Strav — DO NOT EDIT',
104
+ `import 'reflect-metadata'`,
105
+ `import { app } from '@stravigor/kernel/core'`,
106
+ `import Configuration from '@stravigor/kernel/config/configuration'`,
107
+ `import Database from '@stravigor/database/database/database'`,
108
+ `import Router from '@stravigor/http/http/router'`,
109
+ `import Logger from '@stravigor/kernel/logger/logger'`,
110
+ `import BaseModel from '@stravigor/database/orm/base_model'`,
111
+ `import Auth from '@stravigor/http/auth/auth'`,
112
+ `import AccessToken from '@stravigor/http/auth/access_token'`,
113
+ '',
114
+ '// ---------------------------------------------------------------------------',
115
+ '// Bootstrap (mirrors index.ts, minus Server)',
116
+ '// ---------------------------------------------------------------------------',
117
+ '',
118
+ 'app.singleton(Configuration)',
119
+ 'app.singleton(Database)',
120
+ 'app.singleton(Router)',
121
+ 'app.singleton(Logger)',
122
+ 'app.singleton(Auth)',
123
+ '',
124
+ 'const config = app.resolve(Configuration)',
125
+ 'await config.load()',
126
+ '',
127
+ 'const db = app.resolve(Database)',
128
+ 'new BaseModel(db)',
129
+ '',
130
+ 'app.resolve(Auth)',
131
+ 'await Auth.ensureTables()',
132
+ 'Auth.useResolver(async id => {',
133
+ ]
134
+
135
+ if (userSchema) {
136
+ const modelImport = relativeImport(this.paths.tests, this.paths.models)
137
+ const modelFile = toSnakeCase(userSchema.name)
138
+ lines.push(
139
+ ` const { default: ${toPascalCase(userSchema.name)} } = await import('${modelImport}/${modelFile}')`
140
+ )
141
+ lines.push(` return ${toPascalCase(userSchema.name)}.find(id as string)`)
142
+ } else {
143
+ lines.push(` return null`)
144
+ }
145
+
146
+ lines.push('})')
147
+ lines.push('')
148
+ lines.push(`// Load routes`)
149
+ lines.push(`await import('${routesImport}/api_routes')`)
150
+ lines.push('')
151
+ lines.push('export const routerInstance = app.resolve(Router)')
152
+ lines.push('')
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Test fixtures
156
+ // ---------------------------------------------------------------------------
157
+
158
+ lines.push('// ---------------------------------------------------------------------------')
159
+ lines.push('// Test fixtures')
160
+ lines.push('// ---------------------------------------------------------------------------')
161
+ lines.push('')
162
+ lines.push('export let testUserPid: string')
163
+ lines.push('export let token: string')
164
+ lines.push('')
165
+
166
+ // createTestUser
167
+ lines.push('export async function createTestUser(): Promise<void> {')
168
+ if (userSchema) {
169
+ const snakeUser = toSnakeCase(userSchema.name)
170
+ const pkCol = toSnakeCase(userPkName)
171
+ const insertCols = [pkCol]
172
+ const insertVals: string[] = []
173
+
174
+ if (userPkIsUuid) {
175
+ lines.push(' const pid = crypto.randomUUID()')
176
+ insertVals.push('${pid}')
177
+ } else {
178
+ lines.push(' const pid = 1')
179
+ insertVals.push('${pid}')
180
+ }
181
+
182
+ // Add required non-PK, non-system fields
183
+ for (const [fieldName, fieldDef] of Object.entries(userSchema.fields)) {
184
+ if (fieldDef.primaryKey) continue
185
+ const colName = toSnakeCase(fieldName)
186
+ if (SYSTEM_COLUMNS.has(colName)) continue
187
+ if (!fieldDef.required) continue
188
+
189
+ insertCols.push(colName)
190
+ insertVals.push(`\${'${this.fixtureValue(fieldName, fieldDef)}'}`)
191
+ }
192
+
193
+ // Add timestamps
194
+ insertCols.push('created_at', 'updated_at')
195
+ insertVals.push('NOW()', 'NOW()')
196
+
197
+ lines.push(` await db.sql\``)
198
+ lines.push(` INSERT INTO "${snakeUser}" ("${insertCols.join('", "')}")`)
199
+
200
+ const valueParts = insertVals.map(v => {
201
+ if (v === 'NOW()') return v
202
+ return v
203
+ })
204
+ lines.push(` VALUES (${valueParts.join(', ')})`)
205
+
206
+ // Use ON CONFLICT on unique columns
207
+ const uniqueField = Object.entries(userSchema.fields).find(
208
+ ([, fd]) => fd.unique && !fd.primaryKey
209
+ )
210
+ if (uniqueField) {
211
+ lines.push(` ON CONFLICT ("${toSnakeCase(uniqueField[0])}") DO NOTHING`)
212
+ }
213
+
214
+ lines.push(` \``)
215
+ lines.push(' testUserPid = String(pid)')
216
+ } else {
217
+ lines.push(' testUserPid = crypto.randomUUID()')
218
+ }
219
+
220
+ lines.push('')
221
+ lines.push(" const result = await AccessToken.create(testUserPid, 'test-token')")
222
+ lines.push(' token = result.token')
223
+ lines.push('}')
224
+ lines.push('')
225
+
226
+ // cleanup
227
+ lines.push('export async function cleanup(): Promise<void> {')
228
+ const deleteOrder = this.cleanupOrder()
229
+ for (const tableName of deleteOrder) {
230
+ lines.push(` await db.sql\`DELETE FROM "${tableName}" WHERE 1=1\``)
231
+ }
232
+ lines.push('}')
233
+ lines.push('')
234
+
235
+ // closeDb
236
+ lines.push('export async function closeDb(): Promise<void> {')
237
+ lines.push(' await db.close()')
238
+ lines.push('}')
239
+ lines.push('')
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Request helpers
243
+ // ---------------------------------------------------------------------------
244
+
245
+ lines.push('// ---------------------------------------------------------------------------')
246
+ lines.push('// Request helpers')
247
+ lines.push('// ---------------------------------------------------------------------------')
248
+ lines.push('')
249
+
250
+ if (isSubdomain) {
251
+ const sub = this.apiConfig.subdomain
252
+ lines.push('export async function request(')
253
+ lines.push(' method: string,')
254
+ lines.push(' path: string,')
255
+ lines.push(' body?: Record<string, unknown>')
256
+ lines.push('): Promise<Response> {')
257
+ lines.push(' const headers: Record<string, string> = {')
258
+ lines.push(' Authorization: `Bearer ${token}`,')
259
+ lines.push(` Host: '${sub}.localhost',`)
260
+ lines.push(' }')
261
+ lines.push('')
262
+ lines.push(' let requestBody: string | undefined')
263
+ lines.push(' if (body) {')
264
+ lines.push(` headers['Content-Type'] = 'application/json'`)
265
+ lines.push(' requestBody = JSON.stringify(body)')
266
+ lines.push(' }')
267
+ lines.push('')
268
+ lines.push(' const response = routerInstance.handle(')
269
+ lines.push(` new Request(\`http://${sub}.localhost\${path}\`, {`)
270
+ lines.push(' method,')
271
+ lines.push(' headers,')
272
+ lines.push(' body: requestBody,')
273
+ lines.push(' })')
274
+ lines.push(' )')
275
+ lines.push('')
276
+ lines.push(` return (await response) ?? new Response('Not Found', { status: 404 })`)
277
+ lines.push('}')
278
+ lines.push('')
279
+
280
+ lines.push('export async function requestNoAuth(')
281
+ lines.push(' method: string,')
282
+ lines.push(' path: string,')
283
+ lines.push(' body?: Record<string, unknown>')
284
+ lines.push('): Promise<Response> {')
285
+ lines.push(' const headers: Record<string, string> = {')
286
+ lines.push(` Host: '${sub}.localhost',`)
287
+ lines.push(' }')
288
+ lines.push('')
289
+ lines.push(' let requestBody: string | undefined')
290
+ lines.push(' if (body) {')
291
+ lines.push(` headers['Content-Type'] = 'application/json'`)
292
+ lines.push(' requestBody = JSON.stringify(body)')
293
+ lines.push(' }')
294
+ lines.push('')
295
+ lines.push(' const response = routerInstance.handle(')
296
+ lines.push(` new Request(\`http://${sub}.localhost\${path}\`, {`)
297
+ lines.push(' method,')
298
+ lines.push(' headers,')
299
+ lines.push(' body: requestBody,')
300
+ lines.push(' })')
301
+ lines.push(' )')
302
+ lines.push('')
303
+ lines.push(` return (await response) ?? new Response('Not Found', { status: 404 })`)
304
+ lines.push('}')
305
+ } else {
306
+ lines.push('export async function request(')
307
+ lines.push(' method: string,')
308
+ lines.push(' path: string,')
309
+ lines.push(' body?: Record<string, unknown>')
310
+ lines.push('): Promise<Response> {')
311
+ lines.push(' const headers: Record<string, string> = {')
312
+ lines.push(' Authorization: `Bearer ${token}`,')
313
+ lines.push(' }')
314
+ lines.push('')
315
+ lines.push(' let requestBody: string | undefined')
316
+ lines.push(' if (body) {')
317
+ lines.push(` headers['Content-Type'] = 'application/json'`)
318
+ lines.push(' requestBody = JSON.stringify(body)')
319
+ lines.push(' }')
320
+ lines.push('')
321
+ lines.push(' const response = routerInstance.handle(')
322
+ lines.push(' new Request(`http://localhost${path}`, {')
323
+ lines.push(' method,')
324
+ lines.push(' headers,')
325
+ lines.push(' body: requestBody,')
326
+ lines.push(' })')
327
+ lines.push(' )')
328
+ lines.push('')
329
+ lines.push(` return (await response) ?? new Response('Not Found', { status: 404 })`)
330
+ lines.push('}')
331
+ lines.push('')
332
+
333
+ lines.push('export async function requestNoAuth(')
334
+ lines.push(' method: string,')
335
+ lines.push(' path: string,')
336
+ lines.push(' body?: Record<string, unknown>')
337
+ lines.push('): Promise<Response> {')
338
+ lines.push(' const headers: Record<string, string> = {}')
339
+ lines.push('')
340
+ lines.push(' let requestBody: string | undefined')
341
+ lines.push(' if (body) {')
342
+ lines.push(` headers['Content-Type'] = 'application/json'`)
343
+ lines.push(' requestBody = JSON.stringify(body)')
344
+ lines.push(' }')
345
+ lines.push('')
346
+ lines.push(' const response = routerInstance.handle(')
347
+ lines.push(' new Request(`http://localhost${path}`, {')
348
+ lines.push(' method,')
349
+ lines.push(' headers,')
350
+ lines.push(' body: requestBody,')
351
+ lines.push(' })')
352
+ lines.push(' )')
353
+ lines.push('')
354
+ lines.push(` return (await response) ?? new Response('Not Found', { status: 404 })`)
355
+ lines.push('}')
356
+ }
357
+
358
+ lines.push('')
359
+
360
+ return {
361
+ path: join(this.paths.tests, 'setup.ts'),
362
+ content: lines.join('\n'),
363
+ }
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // auth.test.ts
368
+ // ---------------------------------------------------------------------------
369
+
370
+ private generateAuthTest(): GeneratedFile {
371
+ // Find any root-level resource to use for auth testing
372
+ const rootSchema = this.schemas.find(
373
+ s => s.archetype === Archetype.Reference || s.archetype === Archetype.Entity
374
+ )
375
+ const testPath = rootSchema ? this.buildRoutePath(rootSchema) : '/api/health'
376
+
377
+ const lines: string[] = [
378
+ '// Generated by Strav — DO NOT EDIT',
379
+ `import { describe, test, expect, beforeAll, afterAll } from 'bun:test'`,
380
+ `import { createTestUser, cleanup, request, requestNoAuth, routerInstance } from './setup'`,
381
+ '',
382
+ 'beforeAll(async () => {',
383
+ ' await cleanup()',
384
+ ' await createTestUser()',
385
+ '})',
386
+ '',
387
+ 'afterAll(async () => {',
388
+ ' await cleanup()',
389
+ '})',
390
+ '',
391
+ `describe('bearer token authentication', () => {`,
392
+ ` test('valid token returns 200', async () => {`,
393
+ ` const res = await request('GET', '${testPath}')`,
394
+ ` expect(res.status).toBe(200)`,
395
+ ` })`,
396
+ '',
397
+ ` test('missing Authorization header returns 401', async () => {`,
398
+ ` const res = await requestNoAuth('GET', '${testPath}')`,
399
+ ` expect(res.status).toBe(401)`,
400
+ ` const body = (await res.json()) as any`,
401
+ ` expect(body.error).toBe('Unauthenticated')`,
402
+ ` })`,
403
+ '',
404
+ ` test('invalid token returns 401', async () => {`,
405
+ ` const response = routerInstance.handle(`,
406
+ ` new Request('${this.requestBaseUrl()}${testPath}', {`,
407
+ ` headers: {`,
408
+ ` Authorization: 'Bearer totally_invalid_garbage_token_value',`,
409
+ ]
410
+
411
+ if (this.apiConfig.routing === ApiRouting.Subdomain) {
412
+ lines.push(` Host: '${this.apiConfig.subdomain}.localhost',`)
413
+ }
414
+
415
+ lines.push(
416
+ ` },`,
417
+ ` })`,
418
+ ` )`,
419
+ ` const res = (await response)!`,
420
+ ` expect(res.status).toBe(401)`,
421
+ ` })`,
422
+ '',
423
+ ` test('malformed Authorization header returns 401', async () => {`,
424
+ ` const response = routerInstance.handle(`,
425
+ ` new Request('${this.requestBaseUrl()}${testPath}', {`,
426
+ ` headers: {`,
427
+ ` Authorization: 'NotBearer some_token',`
428
+ )
429
+
430
+ if (this.apiConfig.routing === ApiRouting.Subdomain) {
431
+ lines.push(` Host: '${this.apiConfig.subdomain}.localhost',`)
432
+ }
433
+
434
+ lines.push(
435
+ ` },`,
436
+ ` })`,
437
+ ` )`,
438
+ ` const res = (await response)!`,
439
+ ` expect(res.status).toBe(401)`,
440
+ ` })`,
441
+ '})',
442
+ ''
443
+ )
444
+
445
+ return {
446
+ path: join(this.paths.tests, 'auth.test.ts'),
447
+ content: lines.join('\n'),
448
+ }
449
+ }
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // Per-schema tests
453
+ // ---------------------------------------------------------------------------
454
+
455
+ private generateSchemaTest(schema: SchemaDefinition, table: TableDefinition): GeneratedFile {
456
+ switch (schema.archetype) {
457
+ case Archetype.Entity:
458
+ return this.generateEntityTest(schema, table)
459
+ case Archetype.Contribution:
460
+ return this.generateContributionTest(schema, table)
461
+ case Archetype.Reference:
462
+ return this.generateReferenceTest(schema, table)
463
+ case Archetype.Attribute:
464
+ return this.generateAttributeTest(schema, table)
465
+ case Archetype.Component:
466
+ return this.generateComponentTest(schema, table)
467
+ case Archetype.Event:
468
+ return this.generateEventTest(schema, table)
469
+ case Archetype.Configuration:
470
+ return this.generateConfigurationTest(schema, table)
471
+ default:
472
+ return this.generateEntityTest(schema, table)
473
+ }
474
+ }
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // Entity tests
478
+ // ---------------------------------------------------------------------------
479
+
480
+ private generateEntityTest(schema: SchemaDefinition, table: TableDefinition): GeneratedFile {
481
+ const snakeName = toSnakeCase(schema.name)
482
+ const displayName = snakeName.replace(/_/g, ' ')
483
+ const routePath = this.buildRoutePath(schema)
484
+ const pkName = this.findSchemaPK(schema.name)
485
+ const hasSoftDelete = this.hasSoftDelete(table)
486
+ const storePayload = this.buildTestPayload(schema, table, 'store')
487
+ const updatePayload = this.buildTestPayload(schema, table, 'update')
488
+ const firstRequiredField = this.findFirstRequiredField(schema)
489
+
490
+ const lines: string[] = [
491
+ '// Generated by Strav — DO NOT EDIT',
492
+ `import { describe, test, expect, beforeAll, afterAll } from 'bun:test'`,
493
+ `import { createTestUser, cleanup, request } from './setup'`,
494
+ '',
495
+ 'beforeAll(async () => {',
496
+ ' await cleanup()',
497
+ ' await createTestUser()',
498
+ '})',
499
+ '',
500
+ 'afterAll(async () => {',
501
+ ' await cleanup()',
502
+ '})',
503
+ '',
504
+ `describe('entity archetype: ${snakeName} (full CRUD${hasSoftDelete ? ' + soft delete' : ''})', () => {`,
505
+ ` let createdId: string | number`,
506
+ '',
507
+ ` test('POST ${routePath} → 201 (create)', async () => {`,
508
+ ` const res = await request('POST', '${routePath}', ${storePayload})`,
509
+ ` expect(res.status).toBe(201)`,
510
+ ` const body = (await res.json()) as any`,
511
+ ]
512
+
513
+ // Assert first field value
514
+ const firstField = this.getFirstFieldForAssertion(schema)
515
+ if (firstField) {
516
+ lines.push(` expect(body.${firstField.camel}).toBe(${firstField.value})`)
517
+ }
518
+
519
+ lines.push(
520
+ ` expect(body.${toCamelCase(pkName)}).toBeDefined()`,
521
+ ` createdId = body.${toCamelCase(pkName)}`,
522
+ ` })`,
523
+ '',
524
+ ` test('GET ${routePath} → 200 (list)', async () => {`,
525
+ ` const res = await request('GET', '${routePath}')`,
526
+ ` expect(res.status).toBe(200)`,
527
+ ` const body = (await res.json()) as any[]`,
528
+ ` expect(Array.isArray(body)).toBe(true)`,
529
+ ` expect(body.length).toBeGreaterThanOrEqual(1)`,
530
+ ` })`,
531
+ '',
532
+ ` test('GET ${routePath}/:id → 200 (show)', async () => {`,
533
+ ` const res = await request('GET', \`${routePath}/\${createdId}\`)`,
534
+ ` expect(res.status).toBe(200)`,
535
+ ` })`,
536
+ ''
537
+ )
538
+
539
+ if (Object.keys(updatePayload).length > 0) {
540
+ lines.push(
541
+ ` test('PUT ${routePath}/:id → 200 (update)', async () => {`,
542
+ ` const res = await request('PUT', \`${routePath}/\${createdId}\`, ${updatePayload})`,
543
+ ` expect(res.status).toBe(200)`,
544
+ ` })`,
545
+ ''
546
+ )
547
+ }
548
+
549
+ lines.push(
550
+ ` test('DELETE ${routePath}/:id → 200 (${hasSoftDelete ? 'soft ' : ''}delete)', async () => {`,
551
+ ` const res = await request('DELETE', \`${routePath}/\${createdId}\`)`,
552
+ ` expect(res.status).toBe(200)`,
553
+ ` const body = (await res.json()) as any`,
554
+ ` expect(body.ok).toBe(true)`,
555
+ ` })`,
556
+ '',
557
+ ` test('GET ${routePath}/:id → 404 after ${hasSoftDelete ? 'soft ' : ''}delete', async () => {`,
558
+ ` const res = await request('GET', \`${routePath}/\${createdId}\`)`,
559
+ ` expect(res.status).toBe(404)`,
560
+ ` })`,
561
+ ''
562
+ )
563
+
564
+ // Validation test
565
+ if (firstRequiredField) {
566
+ lines.push(
567
+ ` test('POST ${routePath} with missing required fields → 422', async () => {`,
568
+ ` const res = await request('POST', '${routePath}', {})`,
569
+ ` expect(res.status).toBe(422)`,
570
+ ` const body = (await res.json()) as any`,
571
+ ` expect(body.errors).toBeDefined()`,
572
+ ` })`,
573
+ ''
574
+ )
575
+ }
576
+
577
+ lines.push(
578
+ ` test('GET ${routePath}/:id with nonexistent id → 404', async () => {`,
579
+ ` const res = await request('GET', '${routePath}/00000000-0000-0000-0000-000000000000')`,
580
+ ` expect(res.status).toBe(404)`,
581
+ ` })`,
582
+ '})',
583
+ ''
584
+ )
585
+
586
+ return {
587
+ path: join(this.paths.tests, `${snakeName}.test.ts`),
588
+ content: lines.join('\n'),
589
+ }
590
+ }
591
+
592
+ // ---------------------------------------------------------------------------
593
+ // Contribution tests (dependent, full CRUD + soft delete)
594
+ // ---------------------------------------------------------------------------
595
+
596
+ private generateContributionTest(
597
+ schema: SchemaDefinition,
598
+ table: TableDefinition
599
+ ): GeneratedFile {
600
+ const snakeName = toSnakeCase(schema.name)
601
+ const displayName = snakeName.replace(/_/g, ' ')
602
+ const parentName = schema.parents![0]!
603
+ const routePath = this.buildRoutePath(schema)
604
+ const hasSoftDelete = this.hasSoftDelete(table)
605
+ const storePayload = this.buildTestPayload(schema, table, 'store')
606
+ const updatePayload = this.buildTestPayload(schema, table, 'update')
607
+ const parentChain = this.buildParentChain(schema)
608
+
609
+ const lines: string[] = [
610
+ '// Generated by Strav — DO NOT EDIT',
611
+ `import { describe, test, expect, beforeAll, afterAll } from 'bun:test'`,
612
+ `import { createTestUser, cleanup, request, testUserPid } from './setup'`,
613
+ '',
614
+ ]
615
+
616
+ // Parent chain variable declarations
617
+ for (const p of parentChain) {
618
+ if (p.isTestUser) continue
619
+ lines.push(`let ${p.varName}: string | number`)
620
+ }
621
+ if (parentChain.some(p => !p.isTestUser)) lines.push('')
622
+
623
+ lines.push('beforeAll(async () => {', ' await cleanup()', ' await createTestUser()')
624
+
625
+ // Create parent chain
626
+ for (const p of parentChain) {
627
+ if (p.isTestUser) continue
628
+ lines.push('')
629
+ lines.push(` // Create ${p.schemaName} to serve as parent`)
630
+ lines.push(
631
+ ` const ${p.varName}Res = await request('POST', \`${p.createPath}\`, ${p.createPayload})`
632
+ )
633
+ lines.push(` const ${p.varName}Data = (await ${p.varName}Res.json()) as any`)
634
+ lines.push(` ${p.varName} = ${p.varName}Data.${p.pkField}`)
635
+ }
636
+
637
+ lines.push(
638
+ '})',
639
+ '',
640
+ 'afterAll(async () => {',
641
+ ' await cleanup()',
642
+ '})',
643
+ '',
644
+ `describe('contribution archetype: ${snakeName} (full CRUD${hasSoftDelete ? ' + soft delete' : ''}, parent: ${parentName})', () => {`,
645
+ ` let createdId: string | number`,
646
+ ''
647
+ )
648
+
649
+ const testRoutePath = this.buildTestRoutePath(schema, parentChain)
650
+
651
+ lines.push(
652
+ ` test('POST ${routePath} → 201 (create with parent FK)', async () => {`,
653
+ ` const res = await request('POST', \`${testRoutePath}\`, ${storePayload})`,
654
+ ` expect(res.status).toBe(201)`,
655
+ ` const data = (await res.json()) as any`
656
+ )
657
+ const firstField = this.getFirstFieldForAssertion(schema)
658
+ if (firstField) {
659
+ lines.push(` expect(data.${firstField.camel}).toBe(${firstField.value})`)
660
+ }
661
+ lines.push(
662
+ ` expect(data.id).toBeDefined()`,
663
+ ` createdId = data.id`,
664
+ ` })`,
665
+ '',
666
+ ` test('GET ${routePath} → 200 (list by parent)', async () => {`,
667
+ ` const res = await request('GET', \`${testRoutePath}\`)`,
668
+ ` expect(res.status).toBe(200)`,
669
+ ` const data = (await res.json()) as any[]`,
670
+ ` expect(Array.isArray(data)).toBe(true)`,
671
+ ` expect(data.length).toBeGreaterThanOrEqual(1)`,
672
+ ` })`,
673
+ '',
674
+ ` test('GET ${routePath}/:id → 200 (show)', async () => {`,
675
+ ` const res = await request('GET', \`${testRoutePath}/\${createdId}\`)`,
676
+ ` expect(res.status).toBe(200)`,
677
+ ` })`,
678
+ ''
679
+ )
680
+
681
+ if (Object.keys(updatePayload).length > 0) {
682
+ lines.push(
683
+ ` test('PUT ${routePath}/:id → 200 (update)', async () => {`,
684
+ ` const res = await request('PUT', \`${testRoutePath}/\${createdId}\`, ${updatePayload})`,
685
+ ` expect(res.status).toBe(200)`,
686
+ ` })`,
687
+ ''
688
+ )
689
+ }
690
+
691
+ lines.push(
692
+ ` test('DELETE ${routePath}/:id → 200 (${hasSoftDelete ? 'soft ' : ''}delete)', async () => {`,
693
+ ` const res = await request('DELETE', \`${testRoutePath}/\${createdId}\`)`,
694
+ ` expect(res.status).toBe(200)`,
695
+ ` const data = (await res.json()) as any`,
696
+ ` expect(data.ok).toBe(true)`,
697
+ ` })`,
698
+ '',
699
+ ` test('GET ${routePath}/:id → 404 after ${hasSoftDelete ? 'soft ' : ''}delete', async () => {`,
700
+ ` const res = await request('GET', \`${testRoutePath}/\${createdId}\`)`,
701
+ ` expect(res.status).toBe(404)`,
702
+ ` })`,
703
+ '})',
704
+ ''
705
+ )
706
+
707
+ return {
708
+ path: join(this.paths.tests, `${snakeName}.test.ts`),
709
+ content: lines.join('\n'),
710
+ }
711
+ }
712
+
713
+ // ---------------------------------------------------------------------------
714
+ // Reference tests (root-level, full CRUD, no soft delete)
715
+ // ---------------------------------------------------------------------------
716
+
717
+ private generateReferenceTest(schema: SchemaDefinition, table: TableDefinition): GeneratedFile {
718
+ const snakeName = toSnakeCase(schema.name)
719
+ const routePath = this.buildRoutePath(schema)
720
+ const storePayload = this.buildTestPayload(schema, table, 'store')
721
+ const updatePayload = this.buildTestPayload(schema, table, 'update')
722
+
723
+ const lines: string[] = [
724
+ '// Generated by Strav — DO NOT EDIT',
725
+ `import { describe, test, expect, beforeAll, afterAll } from 'bun:test'`,
726
+ `import { createTestUser, cleanup, request } from './setup'`,
727
+ '',
728
+ 'beforeAll(async () => {',
729
+ ' await cleanup()',
730
+ ' await createTestUser()',
731
+ '})',
732
+ '',
733
+ 'afterAll(async () => {',
734
+ ' await cleanup()',
735
+ '})',
736
+ '',
737
+ `describe('reference archetype: ${snakeName} (full CRUD, no soft delete)', () => {`,
738
+ ` let createdId: string | number`,
739
+ '',
740
+ ` test('POST ${routePath} → 201 (create)', async () => {`,
741
+ ` const res = await request('POST', '${routePath}', ${storePayload})`,
742
+ ` expect(res.status).toBe(201)`,
743
+ ` const body = (await res.json()) as any`,
744
+ ]
745
+
746
+ const firstField = this.getFirstFieldForAssertion(schema)
747
+ if (firstField) {
748
+ lines.push(` expect(body.${firstField.camel}).toBe(${firstField.value})`)
749
+ }
750
+
751
+ lines.push(
752
+ ` expect(body.id).toBeDefined()`,
753
+ ` createdId = body.id`,
754
+ ` })`,
755
+ '',
756
+ ` test('GET ${routePath} → 200 (list)', async () => {`,
757
+ ` const res = await request('GET', '${routePath}')`,
758
+ ` expect(res.status).toBe(200)`,
759
+ ` const body = (await res.json()) as any[]`,
760
+ ` expect(Array.isArray(body)).toBe(true)`,
761
+ ` expect(body.length).toBeGreaterThanOrEqual(1)`,
762
+ ` })`,
763
+ '',
764
+ ` test('GET ${routePath}/:id → 200 (show)', async () => {`,
765
+ ` const res = await request('GET', \`${routePath}/\${createdId}\`)`,
766
+ ` expect(res.status).toBe(200)`,
767
+ ` })`,
768
+ ''
769
+ )
770
+
771
+ if (Object.keys(updatePayload).length > 0) {
772
+ lines.push(
773
+ ` test('PUT ${routePath}/:id → 200 (update)', async () => {`,
774
+ ` const res = await request('PUT', \`${routePath}/\${createdId}\`, ${updatePayload})`,
775
+ ` expect(res.status).toBe(200)`,
776
+ ` })`,
777
+ ''
778
+ )
779
+ }
780
+
781
+ lines.push(
782
+ ` test('DELETE ${routePath}/:id → 200 (hard delete)', async () => {`,
783
+ ` const res = await request('DELETE', \`${routePath}/\${createdId}\`)`,
784
+ ` expect(res.status).toBe(200)`,
785
+ ` const body = (await res.json()) as any`,
786
+ ` expect(body.ok).toBe(true)`,
787
+ ` })`,
788
+ '',
789
+ ` test('GET ${routePath}/:id → 404 after delete', async () => {`,
790
+ ` const res = await request('GET', \`${routePath}/\${createdId}\`)`,
791
+ ` expect(res.status).toBe(404)`,
792
+ ` })`,
793
+ '})',
794
+ ''
795
+ )
796
+
797
+ return {
798
+ path: join(this.paths.tests, `${snakeName}.test.ts`),
799
+ content: lines.join('\n'),
800
+ }
801
+ }
802
+
803
+ // ---------------------------------------------------------------------------
804
+ // Attribute tests (dependent, full CRUD)
805
+ // ---------------------------------------------------------------------------
806
+
807
+ private generateAttributeTest(schema: SchemaDefinition, table: TableDefinition): GeneratedFile {
808
+ const snakeName = toSnakeCase(schema.name)
809
+ const parentName = schema.parents![0]!
810
+ const routePath = this.buildRoutePath(schema)
811
+ const storePayload = this.buildTestPayload(schema, table, 'store')
812
+ const updatePayload = this.buildTestPayload(schema, table, 'update')
813
+ const parentChain = this.buildParentChain(schema)
814
+
815
+ const lines: string[] = [
816
+ '// Generated by Strav — DO NOT EDIT',
817
+ `import { describe, test, expect, beforeAll, afterAll } from 'bun:test'`,
818
+ `import { createTestUser, cleanup, request, testUserPid } from './setup'`,
819
+ '',
820
+ ]
821
+
822
+ for (const p of parentChain) {
823
+ if (p.isTestUser) continue
824
+ lines.push(`let ${p.varName}: string | number`)
825
+ }
826
+ if (parentChain.some(p => !p.isTestUser)) lines.push('')
827
+
828
+ lines.push('beforeAll(async () => {', ' await cleanup()', ' await createTestUser()')
829
+
830
+ for (const p of parentChain) {
831
+ if (p.isTestUser) continue
832
+ lines.push('')
833
+ lines.push(` // Create a ${p.schemaName} to serve as parent`)
834
+ lines.push(
835
+ ` const ${p.varName}Res = await request('POST', \`${p.createPath}\`, ${p.createPayload})`
836
+ )
837
+ lines.push(` const ${p.varName}Data = (await ${p.varName}Res.json()) as any`)
838
+ lines.push(` ${p.varName} = ${p.varName}Data.${p.pkField}`)
839
+ }
840
+
841
+ lines.push(
842
+ '})',
843
+ '',
844
+ 'afterAll(async () => {',
845
+ ' await cleanup()',
846
+ '})',
847
+ '',
848
+ `describe('attribute archetype: ${snakeName} (full CRUD, parent: ${parentName})', () => {`,
849
+ ` let createdId: string | number`,
850
+ ''
851
+ )
852
+
853
+ const testRoutePath = this.buildTestRoutePath(schema, parentChain)
854
+
855
+ lines.push(
856
+ ` test('POST ${routePath} → 201 (create)', async () => {`,
857
+ ` const res = await request('POST', \`${testRoutePath}\`, ${storePayload})`,
858
+ ` expect(res.status).toBe(201)`,
859
+ ` const data = (await res.json()) as any`,
860
+ ` expect(data.id).toBeDefined()`,
861
+ ` createdId = data.id`,
862
+ ` })`,
863
+ '',
864
+ ` test('GET ${routePath} → 200 (list by parent)', async () => {`,
865
+ ` const res = await request('GET', \`${testRoutePath}\`)`,
866
+ ` expect(res.status).toBe(200)`,
867
+ ` const data = (await res.json()) as any[]`,
868
+ ` expect(Array.isArray(data)).toBe(true)`,
869
+ ` expect(data.length).toBe(1)`,
870
+ ` })`,
871
+ '',
872
+ ` test('GET ${routePath}/:id → 200 (show)', async () => {`,
873
+ ` const res = await request('GET', \`${testRoutePath}/\${createdId}\`)`,
874
+ ` expect(res.status).toBe(200)`,
875
+ ` })`,
876
+ ''
877
+ )
878
+
879
+ if (Object.keys(updatePayload).length > 0) {
880
+ lines.push(
881
+ ` test('PUT ${routePath}/:id → 200 (update)', async () => {`,
882
+ ` const res = await request('PUT', \`${testRoutePath}/\${createdId}\`, ${updatePayload})`,
883
+ ` expect(res.status).toBe(200)`,
884
+ ` })`,
885
+ ''
886
+ )
887
+ }
888
+
889
+ lines.push(
890
+ ` test('DELETE ${routePath}/:id → 200 (delete)', async () => {`,
891
+ ` const res = await request('DELETE', \`${testRoutePath}/\${createdId}\`)`,
892
+ ` expect(res.status).toBe(200)`,
893
+ ` const data = (await res.json()) as any`,
894
+ ` expect(data.ok).toBe(true)`,
895
+ ` })`,
896
+ '',
897
+ ` test('GET ${routePath}/:id → 404 after delete', async () => {`,
898
+ ` const res = await request('GET', \`${testRoutePath}/\${createdId}\`)`,
899
+ ` expect(res.status).toBe(404)`,
900
+ ` })`,
901
+ '})',
902
+ ''
903
+ )
904
+
905
+ return {
906
+ path: join(this.paths.tests, `${snakeName}.test.ts`),
907
+ content: lines.join('\n'),
908
+ }
909
+ }
910
+
911
+ // ---------------------------------------------------------------------------
912
+ // Component tests (dependent, index/show/update only)
913
+ // ---------------------------------------------------------------------------
914
+
915
+ private generateComponentTest(schema: SchemaDefinition, table: TableDefinition): GeneratedFile {
916
+ const snakeName = toSnakeCase(schema.name)
917
+ const className = toPascalCase(schema.name)
918
+ const parentName = schema.parents![0]!
919
+ const routePath = this.buildRoutePath(schema)
920
+ const updatePayload = this.buildTestPayload(schema, table, 'update')
921
+ const parentChain = this.buildParentChain(schema)
922
+ const modelImport = relativeImport(this.paths.tests, this.paths.models)
923
+
924
+ const lines: string[] = [
925
+ '// Generated by Strav — DO NOT EDIT',
926
+ `import { describe, test, expect, beforeAll, afterAll } from 'bun:test'`,
927
+ `import { createTestUser, cleanup, request, testUserPid } from './setup'`,
928
+ '',
929
+ ]
930
+
931
+ for (const p of parentChain) {
932
+ if (p.isTestUser) continue
933
+ lines.push(`let ${p.varName}: string | number`)
934
+ }
935
+
936
+ lines.push('let contentId: string | number')
937
+ lines.push('')
938
+
939
+ lines.push('beforeAll(async () => {', ' await cleanup()', ' await createTestUser()')
940
+
941
+ for (const p of parentChain) {
942
+ if (p.isTestUser) continue
943
+ lines.push('')
944
+ lines.push(` // Create a ${p.schemaName} to serve as parent`)
945
+ lines.push(
946
+ ` const ${p.varName}Res = await request('POST', \`${p.createPath}\`, ${p.createPayload})`
947
+ )
948
+ lines.push(` const ${p.varName}Data = (await ${p.varName}Res.json()) as any`)
949
+ lines.push(` ${p.varName} = ${p.varName}Data.${p.pkField}`)
950
+ }
951
+
952
+ // Component has no store route — create via model
953
+ const directParent = parentChain[parentChain.length - 1]!
954
+ const parentFkProp = this.getParentFkProp(schema)
955
+ const createData = this.buildModelCreateData(schema, table, directParent)
956
+
957
+ lines.push('')
958
+ lines.push(` // Component has no store action, so create via model directly`)
959
+ lines.push(` const { default: ${className} } = await import('${modelImport}/${snakeName}')`)
960
+ lines.push(` const record = await ${className}.create(${createData})`)
961
+ lines.push(` contentId = record.id`)
962
+
963
+ lines.push(
964
+ '})',
965
+ '',
966
+ 'afterAll(async () => {',
967
+ ' await cleanup()',
968
+ '})',
969
+ '',
970
+ `describe('component archetype: ${snakeName} (index, show, update only)', () => {`,
971
+ ''
972
+ )
973
+
974
+ const testRoutePath = this.buildTestRoutePath(schema, parentChain)
975
+
976
+ lines.push(
977
+ ` test('GET ${routePath} → 200 (list by parent)', async () => {`,
978
+ ` const res = await request('GET', \`${testRoutePath}\`)`,
979
+ ` expect(res.status).toBe(200)`,
980
+ ` const data = (await res.json()) as any[]`,
981
+ ` expect(Array.isArray(data)).toBe(true)`,
982
+ ` expect(data.length).toBe(1)`,
983
+ ` })`,
984
+ '',
985
+ ` test('GET ${routePath}/:id → 200 (show)', async () => {`,
986
+ ` const res = await request('GET', \`${testRoutePath}/\${contentId}\`)`,
987
+ ` expect(res.status).toBe(200)`,
988
+ ` })`,
989
+ ''
990
+ )
991
+
992
+ if (Object.keys(updatePayload).length > 0) {
993
+ lines.push(
994
+ ` test('PUT ${routePath}/:id → 200 (update)', async () => {`,
995
+ ` const res = await request('PUT', \`${testRoutePath}/\${contentId}\`, ${updatePayload})`,
996
+ ` expect(res.status).toBe(200)`,
997
+ ` })`,
998
+ ''
999
+ )
1000
+ }
1001
+
1002
+ lines.push(
1003
+ ` test('POST ${routePath} → 404 (no store route)', async () => {`,
1004
+ ` const res = await request('POST', \`${testRoutePath}\`, {})`,
1005
+ ` expect(res.status).toBe(404)`,
1006
+ ` })`,
1007
+ '',
1008
+ ` test('DELETE ${routePath}/:id → 404 (no destroy route)', async () => {`,
1009
+ ` const res = await request('DELETE', \`${testRoutePath}/\${contentId}\`)`,
1010
+ ` expect(res.status).toBe(404)`,
1011
+ ` })`,
1012
+ '})',
1013
+ ''
1014
+ )
1015
+
1016
+ return {
1017
+ path: join(this.paths.tests, `${snakeName}.test.ts`),
1018
+ content: lines.join('\n'),
1019
+ }
1020
+ }
1021
+
1022
+ // ---------------------------------------------------------------------------
1023
+ // Event tests (dependent, index/show/store — append only)
1024
+ // ---------------------------------------------------------------------------
1025
+
1026
+ private generateEventTest(schema: SchemaDefinition, table: TableDefinition): GeneratedFile {
1027
+ const snakeName = toSnakeCase(schema.name)
1028
+ const parentName = schema.parents![0]!
1029
+ const routePath = this.buildRoutePath(schema)
1030
+ const storePayload = this.buildTestPayload(schema, table, 'store')
1031
+ const parentChain = this.buildParentChain(schema)
1032
+
1033
+ const lines: string[] = [
1034
+ '// Generated by Strav — DO NOT EDIT',
1035
+ `import { describe, test, expect, beforeAll, afterAll } from 'bun:test'`,
1036
+ `import { createTestUser, cleanup, request, testUserPid } from './setup'`,
1037
+ '',
1038
+ ]
1039
+
1040
+ for (const p of parentChain) {
1041
+ if (p.isTestUser) continue
1042
+ lines.push(`let ${p.varName}: string | number`)
1043
+ }
1044
+ if (parentChain.some(p => !p.isTestUser)) lines.push('')
1045
+
1046
+ lines.push('beforeAll(async () => {', ' await cleanup()', ' await createTestUser()')
1047
+
1048
+ for (const p of parentChain) {
1049
+ if (p.isTestUser) continue
1050
+ lines.push('')
1051
+ lines.push(` // Create a ${p.schemaName} to serve as parent`)
1052
+ lines.push(
1053
+ ` const ${p.varName}Res = await request('POST', \`${p.createPath}\`, ${p.createPayload})`
1054
+ )
1055
+ lines.push(` const ${p.varName}Data = (await ${p.varName}Res.json()) as any`)
1056
+ lines.push(` ${p.varName} = ${p.varName}Data.${p.pkField}`)
1057
+ }
1058
+
1059
+ lines.push(
1060
+ '})',
1061
+ '',
1062
+ 'afterAll(async () => {',
1063
+ ' await cleanup()',
1064
+ '})',
1065
+ '',
1066
+ `describe('event archetype: ${snakeName} (index, show, store — append only)', () => {`,
1067
+ ` let createdId: string | number`,
1068
+ ''
1069
+ )
1070
+
1071
+ const testRoutePath = this.buildTestRoutePath(schema, parentChain)
1072
+
1073
+ lines.push(
1074
+ ` test('POST ${routePath} → 201 (append)', async () => {`,
1075
+ ` const res = await request('POST', \`${testRoutePath}\`, ${storePayload})`,
1076
+ ` expect(res.status).toBe(201)`,
1077
+ ` const data = (await res.json()) as any`,
1078
+ ` expect(data.id).toBeDefined()`,
1079
+ ` createdId = data.id`,
1080
+ ` })`,
1081
+ '',
1082
+ ` test('GET ${routePath} → 200 (list by parent)', async () => {`,
1083
+ ` const res = await request('GET', \`${testRoutePath}\`)`,
1084
+ ` expect(res.status).toBe(200)`,
1085
+ ` const data = (await res.json()) as any[]`,
1086
+ ` expect(Array.isArray(data)).toBe(true)`,
1087
+ ` expect(data.length).toBeGreaterThanOrEqual(1)`,
1088
+ ` })`,
1089
+ '',
1090
+ ` test('GET ${routePath}/:id → 200 (show)', async () => {`,
1091
+ ` const res = await request('GET', \`${testRoutePath}/\${createdId}\`)`,
1092
+ ` expect(res.status).toBe(200)`,
1093
+ ` })`,
1094
+ '',
1095
+ ` test('PUT ${routePath}/:id → 404 (no update route)', async () => {`,
1096
+ ` const res = await request('PUT', \`${testRoutePath}/\${createdId}\`, {})`,
1097
+ ` expect(res.status).toBe(404)`,
1098
+ ` })`,
1099
+ '',
1100
+ ` test('DELETE ${routePath}/:id → 404 (no destroy route)', async () => {`,
1101
+ ` const res = await request('DELETE', \`${testRoutePath}/\${createdId}\`)`,
1102
+ ` expect(res.status).toBe(404)`,
1103
+ ` })`,
1104
+ '})',
1105
+ ''
1106
+ )
1107
+
1108
+ return {
1109
+ path: join(this.paths.tests, `${snakeName}.test.ts`),
1110
+ content: lines.join('\n'),
1111
+ }
1112
+ }
1113
+
1114
+ // ---------------------------------------------------------------------------
1115
+ // Configuration tests (dependent, singleton — show/upsert/reset)
1116
+ // ---------------------------------------------------------------------------
1117
+
1118
+ private generateConfigurationTest(
1119
+ schema: SchemaDefinition,
1120
+ table: TableDefinition
1121
+ ): GeneratedFile {
1122
+ const snakeName = toSnakeCase(schema.name)
1123
+ const parentName = schema.parents![0]!
1124
+ const routePath = this.buildRoutePath(schema)
1125
+ const storePayload = this.buildTestPayload(schema, table, 'store')
1126
+ const updatePayload = this.buildTestPayload(schema, table, 'update')
1127
+ const parentChain = this.buildParentChain(schema)
1128
+
1129
+ const lines: string[] = [
1130
+ '// Generated by Strav — DO NOT EDIT',
1131
+ `import { describe, test, expect, beforeAll, afterAll } from 'bun:test'`,
1132
+ `import { createTestUser, cleanup, request, testUserPid } from './setup'`,
1133
+ '',
1134
+ ]
1135
+
1136
+ for (const p of parentChain) {
1137
+ if (p.isTestUser) continue
1138
+ lines.push(`let ${p.varName}: string | number`)
1139
+ }
1140
+ if (parentChain.some(p => !p.isTestUser)) lines.push('')
1141
+
1142
+ lines.push('beforeAll(async () => {', ' await cleanup()', ' await createTestUser()')
1143
+
1144
+ for (const p of parentChain) {
1145
+ if (p.isTestUser) continue
1146
+ lines.push('')
1147
+ lines.push(` // Create a ${p.schemaName} to serve as parent`)
1148
+ lines.push(
1149
+ ` const ${p.varName}Res = await request('POST', \`${p.createPath}\`, ${p.createPayload})`
1150
+ )
1151
+ lines.push(` const ${p.varName}Data = (await ${p.varName}Res.json()) as any`)
1152
+ lines.push(` ${p.varName} = ${p.varName}Data.${p.pkField}`)
1153
+ }
1154
+
1155
+ lines.push(
1156
+ '})',
1157
+ '',
1158
+ 'afterAll(async () => {',
1159
+ ' await cleanup()',
1160
+ '})',
1161
+ '',
1162
+ `describe('configuration archetype: ${snakeName} (show, upsert, reset — singleton)', () => {`,
1163
+ ''
1164
+ )
1165
+
1166
+ const testRoutePath = this.buildTestRoutePath(schema, parentChain)
1167
+
1168
+ lines.push(
1169
+ ` test('GET ${routePath} → 404 (no config yet)', async () => {`,
1170
+ ` const res = await request('GET', \`${testRoutePath}\`)`,
1171
+ ` expect(res.status).toBe(404)`,
1172
+ ` })`,
1173
+ '',
1174
+ ` test('PUT ${routePath} → 200 (upsert — create)', async () => {`,
1175
+ ` const res = await request('PUT', \`${testRoutePath}\`, ${storePayload})`,
1176
+ ` expect(res.status).toBe(200)`,
1177
+ ` })`,
1178
+ '',
1179
+ ` test('GET ${routePath} → 200 (show after upsert)', async () => {`,
1180
+ ` const res = await request('GET', \`${testRoutePath}\`)`,
1181
+ ` expect(res.status).toBe(200)`,
1182
+ ` })`,
1183
+ ''
1184
+ )
1185
+
1186
+ if (Object.keys(updatePayload).length > 0) {
1187
+ lines.push(
1188
+ ` test('PUT ${routePath} → 200 (upsert — update)', async () => {`,
1189
+ ` const res = await request('PUT', \`${testRoutePath}\`, ${updatePayload})`,
1190
+ ` expect(res.status).toBe(200)`,
1191
+ ` })`,
1192
+ ''
1193
+ )
1194
+ }
1195
+
1196
+ lines.push(
1197
+ ` test('DELETE ${routePath} → 200 (reset)', async () => {`,
1198
+ ` const res = await request('DELETE', \`${testRoutePath}\`)`,
1199
+ ` expect(res.status).toBe(200)`,
1200
+ ` const data = (await res.json()) as any`,
1201
+ ` expect(data.ok).toBe(true)`,
1202
+ ` })`,
1203
+ '',
1204
+ ` test('GET ${routePath} → 404 after reset', async () => {`,
1205
+ ` const res = await request('GET', \`${testRoutePath}\`)`,
1206
+ ` expect(res.status).toBe(404)`,
1207
+ ` })`,
1208
+ '})',
1209
+ ''
1210
+ )
1211
+
1212
+ return {
1213
+ path: join(this.paths.tests, `${snakeName}.test.ts`),
1214
+ content: lines.join('\n'),
1215
+ }
1216
+ }
1217
+
1218
+ // ---------------------------------------------------------------------------
1219
+ // Helpers
1220
+ // ---------------------------------------------------------------------------
1221
+
1222
+ /** Build the route path pattern for display (with :parentId placeholders). */
1223
+ private buildRoutePath(schema: SchemaDefinition): string {
1224
+ const isDependent = DEPENDENT_ARCHETYPES.has(schema.archetype)
1225
+ const prefix = this.apiConfig.routing === ApiRouting.Prefix ? this.apiConfig.prefix : ''
1226
+
1227
+ const routeParent = schema.parents?.[0]
1228
+ if (!isDependent || !routeParent) {
1229
+ return `${prefix}/${toRouteSegment(schema.name)}`
1230
+ }
1231
+
1232
+ const parentSegment = toRouteSegment(routeParent)
1233
+ const childSegment = toChildSegment(schema.name, routeParent)
1234
+
1235
+ return `${prefix}/${parentSegment}/:parentId/${childSegment}`
1236
+ }
1237
+
1238
+ /** Build the route path with template literal variables for actual test requests. */
1239
+ private buildTestRoutePath(schema: SchemaDefinition, parentChain: ParentChainEntry[]): string {
1240
+ const isDependent = DEPENDENT_ARCHETYPES.has(schema.archetype)
1241
+ const prefix = this.apiConfig.routing === ApiRouting.Prefix ? this.apiConfig.prefix : ''
1242
+
1243
+ const routeParent = schema.parents?.[0]
1244
+ if (!isDependent || !routeParent) {
1245
+ return `${prefix}/${toRouteSegment(schema.name)}`
1246
+ }
1247
+
1248
+ const parentSegment = toRouteSegment(routeParent)
1249
+ const childSegment = toChildSegment(schema.name, routeParent)
1250
+
1251
+ // Find the correct variable for the direct parent
1252
+ const directParent = parentChain[parentChain.length - 1]!
1253
+ const parentVar = directParent.isTestUser ? 'testUserPid' : directParent.varName
1254
+
1255
+ return `${prefix}/${parentSegment}/\${${parentVar}}/${childSegment}`
1256
+ }
1257
+
1258
+ /** Build the base URL for requests (differs between prefix and subdomain mode). */
1259
+ private requestBaseUrl(): string {
1260
+ if (this.apiConfig.routing === ApiRouting.Subdomain) {
1261
+ return `http://${this.apiConfig.subdomain}.localhost`
1262
+ }
1263
+ return 'http://localhost'
1264
+ }
1265
+
1266
+ /** Find the user entity schema (used for test user creation). */
1267
+ private findUserEntity(): SchemaDefinition | undefined {
1268
+ return this.schemas.find(s => s.archetype === Archetype.Entity && s.name === 'user')
1269
+ }
1270
+
1271
+ /** Find the primary key field name for a schema. */
1272
+ private findSchemaPK(schemaName: string): string {
1273
+ const schema = this.schemaMap.get(schemaName)
1274
+ if (!schema) return 'id'
1275
+ for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
1276
+ if (fieldDef.primaryKey) return fieldName
1277
+ }
1278
+ return 'id'
1279
+ }
1280
+
1281
+ /** Get the PK type (uuid vs serial vs integer). */
1282
+ private getPkType(schema: SchemaDefinition): string {
1283
+ for (const [, fieldDef] of Object.entries(schema.fields)) {
1284
+ if (fieldDef.primaryKey) return String(fieldDef.pgType)
1285
+ }
1286
+ return 'serial'
1287
+ }
1288
+
1289
+ /** Check if a table has soft deletes (deleted_at column). */
1290
+ private hasSoftDelete(table: TableDefinition): boolean {
1291
+ return table.columns.some(c => c.name === 'deleted_at')
1292
+ }
1293
+
1294
+ /** Get the parent FK prop for a dependent schema. */
1295
+ private getParentFkProp(schema: SchemaDefinition): string {
1296
+ const routeParent = schema.parents?.[0]
1297
+ if (!routeParent) return ''
1298
+ const pkName = this.findSchemaPK(routeParent)
1299
+ return toCamelCase(`${toSnakeCase(routeParent)}_${toSnakeCase(pkName)}`)
1300
+ }
1301
+
1302
+ /** Find the first required non-PK field. */
1303
+ private findFirstRequiredField(schema: SchemaDefinition): { name: string; camel: string } | null {
1304
+ for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
1305
+ if (fieldDef.primaryKey) continue
1306
+ if (SYSTEM_COLUMNS.has(toSnakeCase(fieldName))) continue
1307
+ if (fieldDef.references) continue
1308
+ if (fieldDef.required) {
1309
+ return { name: fieldName, camel: toCamelCase(fieldName) }
1310
+ }
1311
+ }
1312
+ return null
1313
+ }
1314
+
1315
+ /** Get the first non-system field for assertion in create tests. */
1316
+ private getFirstFieldForAssertion(
1317
+ schema: SchemaDefinition
1318
+ ): { camel: string; value: string } | null {
1319
+ for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
1320
+ if (fieldDef.primaryKey) continue
1321
+ if (SYSTEM_COLUMNS.has(toSnakeCase(fieldName))) continue
1322
+ if (fieldDef.sensitive) continue
1323
+ if (fieldDef.references) continue
1324
+
1325
+ const camel = toCamelCase(fieldName)
1326
+ const val = this.sampleValue(fieldName, fieldDef, 'js')
1327
+ return { camel, value: val }
1328
+ }
1329
+ return null
1330
+ }
1331
+
1332
+ /**
1333
+ * Generate a fixture value for the test user insert.
1334
+ * Uses a distinct prefix ('Fixture') to avoid collisions with test payloads.
1335
+ */
1336
+ private fixtureValue(fieldName: string, fieldDef: FieldDefinition): string {
1337
+ if (this.isEmailField(fieldName, fieldDef)) {
1338
+ return `fixture@example.com`
1339
+ }
1340
+ if (this.isUrlField(fieldName, fieldDef)) {
1341
+ return `https://example.com/fixture`
1342
+ }
1343
+ if (fieldDef.enumValues?.length) {
1344
+ return fieldDef.enumValues[0]!
1345
+ }
1346
+ if (fieldDef.sensitive) {
1347
+ return 'hashed_fixture'
1348
+ }
1349
+ const label = toPascalCase(fieldName)
1350
+ .replace(/([A-Z])/g, ' $1')
1351
+ .trim()
1352
+ return `Fixture ${label}`
1353
+ }
1354
+
1355
+ /** Detect whether a field is email-like (by validator or name heuristic). */
1356
+ private isEmailField(fieldName: string, fieldDef: FieldDefinition): boolean {
1357
+ if (fieldDef.validators.some(v => v.type === 'email')) return true
1358
+ const snake = toSnakeCase(fieldName)
1359
+ return snake === 'email' || snake.endsWith('_email')
1360
+ }
1361
+
1362
+ /** Detect whether a field is URL-like (by validator or name heuristic). */
1363
+ private isUrlField(fieldName: string, fieldDef: FieldDefinition): boolean {
1364
+ if (fieldDef.validators.some(v => v.type === 'url')) return true
1365
+ const snake = toSnakeCase(fieldName)
1366
+ return snake === 'url' || snake.endsWith('_url') || snake === 'website' || snake === 'homepage'
1367
+ }
1368
+
1369
+ /** Generate a sample value for a field. */
1370
+ private sampleValue(
1371
+ fieldName: string,
1372
+ fieldDef: FieldDefinition,
1373
+ mode: 'js' | 'literal'
1374
+ ): string {
1375
+ if (this.isEmailField(fieldName, fieldDef)) {
1376
+ const snake = toSnakeCase(fieldName)
1377
+ return mode === 'js' ? `'test-${snake}@example.com'` : `test-${snake}@example.com`
1378
+ }
1379
+
1380
+ if (this.isUrlField(fieldName, fieldDef)) {
1381
+ const snake = toSnakeCase(fieldName)
1382
+ return mode === 'js' ? `'https://example.com/${snake}'` : `https://example.com/${snake}`
1383
+ }
1384
+
1385
+ // Enum values
1386
+ if (fieldDef.enumValues?.length) {
1387
+ return mode === 'js' ? `'${fieldDef.enumValues[0]}'` : fieldDef.enumValues[0]!
1388
+ }
1389
+
1390
+ // Sensitive fields
1391
+ if (fieldDef.sensitive) {
1392
+ return mode === 'js' ? `'hashed_test_value'` : 'hashed_test_value'
1393
+ }
1394
+
1395
+ const pgType = String(fieldDef.pgType)
1396
+
1397
+ switch (pgType) {
1398
+ case 'uuid':
1399
+ return mode === 'js' ? `crypto.randomUUID()` : 'test-uuid'
1400
+ case 'boolean':
1401
+ return mode === 'js' ? 'true' : 'true'
1402
+ case 'integer':
1403
+ case 'smallint':
1404
+ case 'serial':
1405
+ case 'smallserial':
1406
+ return '42'
1407
+ case 'bigint':
1408
+ case 'bigserial':
1409
+ return '42'
1410
+ case 'real':
1411
+ case 'double_precision':
1412
+ case 'decimal':
1413
+ case 'numeric':
1414
+ case 'money':
1415
+ return '9.99'
1416
+ case 'json':
1417
+ case 'jsonb':
1418
+ return mode === 'js' ? `JSON.stringify({ test: true })` : '{"test":true}'
1419
+ case 'varchar':
1420
+ case 'character_varying':
1421
+ case 'char':
1422
+ case 'character':
1423
+ case 'text':
1424
+ default: {
1425
+ const label = toPascalCase(fieldName)
1426
+ .replace(/([A-Z])/g, ' $1')
1427
+ .trim()
1428
+ return mode === 'js' ? `'Test ${label}'` : `Test ${label}`
1429
+ }
1430
+ }
1431
+ }
1432
+
1433
+ /** Generate an updated sample value (different from store). */
1434
+ private sampleUpdateValue(fieldName: string, fieldDef: FieldDefinition): string {
1435
+ if (this.isEmailField(fieldName, fieldDef)) {
1436
+ return `'updated-${toSnakeCase(fieldName)}@example.com'`
1437
+ }
1438
+
1439
+ if (this.isUrlField(fieldName, fieldDef)) {
1440
+ return `'https://example.com/updated-${toSnakeCase(fieldName)}'`
1441
+ }
1442
+
1443
+ if (fieldDef.enumValues?.length) {
1444
+ return `'${fieldDef.enumValues[fieldDef.enumValues.length > 1 ? 1 : 0]}'`
1445
+ }
1446
+
1447
+ if (fieldDef.sensitive) return `'updated_hashed_value'`
1448
+
1449
+ const pgType = String(fieldDef.pgType)
1450
+
1451
+ switch (pgType) {
1452
+ case 'boolean':
1453
+ return 'false'
1454
+ case 'integer':
1455
+ case 'smallint':
1456
+ case 'serial':
1457
+ case 'smallserial':
1458
+ case 'bigint':
1459
+ case 'bigserial':
1460
+ return '99'
1461
+ case 'real':
1462
+ case 'double_precision':
1463
+ case 'decimal':
1464
+ case 'numeric':
1465
+ case 'money':
1466
+ return '19.99'
1467
+ default: {
1468
+ const label = toPascalCase(fieldName)
1469
+ .replace(/([A-Z])/g, ' $1')
1470
+ .trim()
1471
+ return `'Updated ${label}'`
1472
+ }
1473
+ }
1474
+ }
1475
+
1476
+ /** Build a JS object literal string for store or update payloads. */
1477
+ private buildTestPayload(
1478
+ schema: SchemaDefinition,
1479
+ table: TableDefinition,
1480
+ mode: 'store' | 'update'
1481
+ ): string {
1482
+ const entries: string[] = []
1483
+ const parentFkCols = new Set(
1484
+ (schema.parents ?? []).map(p => `${toSnakeCase(p)}_${toSnakeCase(this.findSchemaPK(p))}`)
1485
+ )
1486
+
1487
+ for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
1488
+ if (fieldDef.primaryKey) continue
1489
+ const colName = toSnakeCase(fieldName)
1490
+ if (SYSTEM_COLUMNS.has(colName)) continue
1491
+ if (parentFkCols.has(colName)) continue
1492
+
1493
+ // For references, use the FK column name
1494
+ if (fieldDef.references) {
1495
+ const refPK = this.findSchemaPK(fieldDef.references)
1496
+ const fkColName = `${toSnakeCase(fieldName)}_${toSnakeCase(refPK)}`
1497
+ if (parentFkCols.has(fkColName)) continue
1498
+
1499
+ const camelFk = toCamelCase(fkColName)
1500
+ if (mode === 'store' || fieldDef.required) {
1501
+ entries.push(`${camelFk}: testUserPid`)
1502
+ }
1503
+ continue
1504
+ }
1505
+
1506
+ const camelName = toCamelCase(fieldName)
1507
+
1508
+ if (mode === 'store') {
1509
+ if (fieldDef.required || !fieldDef.defaultValue) {
1510
+ entries.push(`${camelName}: ${this.sampleValue(fieldName, fieldDef, 'js')}`)
1511
+ }
1512
+ } else {
1513
+ // For update, include a subset of fields with different values
1514
+ if (!fieldDef.sensitive) {
1515
+ entries.push(`${camelName}: ${this.sampleUpdateValue(fieldName, fieldDef)}`)
1516
+ }
1517
+ }
1518
+ }
1519
+
1520
+ if (entries.length === 0) return '{}'
1521
+
1522
+ return `{\n ${entries.join(',\n ')},\n }`
1523
+ }
1524
+
1525
+ /** Build model create data for components (which have no store route). */
1526
+ private buildModelCreateData(
1527
+ schema: SchemaDefinition,
1528
+ table: TableDefinition,
1529
+ directParent: ParentChainEntry
1530
+ ): string {
1531
+ const entries: string[] = []
1532
+ const parentFkProp = this.getParentFkProp(schema)
1533
+ const parentVar = directParent.isTestUser ? 'testUserPid' : directParent.varName
1534
+
1535
+ entries.push(`${parentFkProp}: ${parentVar}`)
1536
+
1537
+ for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
1538
+ if (fieldDef.primaryKey) continue
1539
+ const colName = toSnakeCase(fieldName)
1540
+ if (SYSTEM_COLUMNS.has(colName)) continue
1541
+ if (fieldDef.references) continue
1542
+
1543
+ const camelName = toCamelCase(fieldName)
1544
+ entries.push(`${camelName}: ${this.sampleValue(fieldName, fieldDef, 'js')}`)
1545
+ }
1546
+
1547
+ return `{ ${entries.join(', ')} }`
1548
+ }
1549
+
1550
+ /**
1551
+ * Build the parent chain for a dependent schema.
1552
+ * Returns entries from root to direct parent.
1553
+ */
1554
+ private buildParentChain(schema: SchemaDefinition): ParentChainEntry[] {
1555
+ const chain: ParentChainEntry[] = []
1556
+ let current = schema.parents?.[0]
1557
+
1558
+ while (current) {
1559
+ const parentSchema = this.schemaMap.get(current)
1560
+ if (!parentSchema) break
1561
+
1562
+ const isUserEntity =
1563
+ parentSchema.archetype === Archetype.Entity && parentSchema.name === 'user'
1564
+ const pkName = this.findSchemaPK(parentSchema.name)
1565
+
1566
+ chain.unshift({
1567
+ schemaName: parentSchema.name,
1568
+ varName: `${toCamelCase(parentSchema.name)}Id`,
1569
+ pkField: toCamelCase(pkName),
1570
+ isTestUser: isUserEntity,
1571
+ createPath: '', // filled below
1572
+ createPayload: '', // filled below
1573
+ })
1574
+
1575
+ current = parentSchema.parents?.[0]
1576
+ }
1577
+
1578
+ // Now compute createPath and createPayload for each non-testUser entry
1579
+ for (let i = 0; i < chain.length; i++) {
1580
+ const entry = chain[i]!
1581
+ if (entry.isTestUser) continue
1582
+
1583
+ const parentSchema = this.schemaMap.get(entry.schemaName)!
1584
+ const table = this.representation.tables.find(t => t.name === toSnakeCase(entry.schemaName))
1585
+
1586
+ // Build the path to create this parent
1587
+ const parentRouteParent = parentSchema.parents?.[0]
1588
+ if (DEPENDENT_ARCHETYPES.has(parentSchema.archetype) && parentRouteParent) {
1589
+ const grandParent = chain[i - 1]
1590
+ const grandParentVar = grandParent?.isTestUser
1591
+ ? 'testUserPid'
1592
+ : (grandParent?.varName ?? 'testUserPid')
1593
+ const parentSegment = toRouteSegment(parentRouteParent)
1594
+ const childSegment = toChildSegment(parentSchema.name, parentRouteParent)
1595
+ const prefix = this.apiConfig.routing === ApiRouting.Prefix ? this.apiConfig.prefix : ''
1596
+ entry.createPath = `${prefix}/${parentSegment}/\${${grandParentVar}}/${childSegment}`
1597
+ } else {
1598
+ const prefix = this.apiConfig.routing === ApiRouting.Prefix ? this.apiConfig.prefix : ''
1599
+ entry.createPath = `${prefix}/${toRouteSegment(parentSchema.name)}`
1600
+ }
1601
+
1602
+ // Build payload for creating this parent
1603
+ if (table) {
1604
+ entry.createPayload = this.buildTestPayload(parentSchema, table, 'store')
1605
+ } else {
1606
+ entry.createPayload = '{}'
1607
+ }
1608
+ }
1609
+
1610
+ return chain
1611
+ }
1612
+
1613
+ /**
1614
+ * Compute the cleanup order: tables sorted so children come before parents.
1615
+ * Associations first, then dependents, then root-level entities.
1616
+ */
1617
+ private cleanupOrder(): string[] {
1618
+ const associations: string[] = []
1619
+ const dependents: string[] = []
1620
+ const roots: string[] = []
1621
+
1622
+ for (const schema of this.schemas) {
1623
+ const tableName = toSnakeCase(schema.name)
1624
+
1625
+ if (schema.archetype === Archetype.Association) {
1626
+ associations.push(tableName)
1627
+ } else if (DEPENDENT_ARCHETYPES.has(schema.archetype)) {
1628
+ dependents.push(tableName)
1629
+ } else {
1630
+ roots.push(tableName)
1631
+ }
1632
+ }
1633
+
1634
+ // Sort dependents so deeper children come first
1635
+ const depthMap = new Map<string, number>()
1636
+ for (const schema of this.schemas) {
1637
+ depthMap.set(toSnakeCase(schema.name), this.schemaDepth(schema))
1638
+ }
1639
+ dependents.sort((a, b) => (depthMap.get(b) ?? 0) - (depthMap.get(a) ?? 0))
1640
+
1641
+ return [...associations, ...dependents, '_strav_access_tokens', ...roots]
1642
+ }
1643
+
1644
+ /** Compute the nesting depth of a schema (0 for root, 1 for direct child, etc.). */
1645
+ private schemaDepth(schema: SchemaDefinition): number {
1646
+ let depth = 0
1647
+ let current = schema.parents?.[0]
1648
+ while (current) {
1649
+ depth++
1650
+ const parent = this.schemaMap.get(current)
1651
+ current = parent?.parents?.[0]
1652
+ }
1653
+ return depth
1654
+ }
1655
+ }
1656
+
1657
+ // ---------------------------------------------------------------------------
1658
+ // Internal types
1659
+ // ---------------------------------------------------------------------------
1660
+
1661
+ interface ParentChainEntry {
1662
+ schemaName: string
1663
+ varName: string
1664
+ pkField: string
1665
+ isTestUser: boolean
1666
+ createPath: string
1667
+ createPayload: string
1668
+ }