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