@strav/cli 0.4.30 → 1.0.0-alpha.4

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