@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.
- package/README.md +45 -0
- package/package.json +83 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +86 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/middleware/authenticate.ts +64 -0
- package/src/auth/middleware/csrf.ts +62 -0
- package/src/auth/middleware/guest.ts +46 -0
- package/src/broadcast/broadcast_manager.ts +411 -0
- package/src/broadcast/client.ts +302 -0
- package/src/broadcast/index.ts +58 -0
- package/src/cache/cache_manager.ts +56 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/http_cache.ts +109 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/cli/bootstrap.ts +37 -0
- package/src/cli/commands/generate_api.ts +74 -0
- package/src/cli/commands/generate_key.ts +46 -0
- package/src/cli/commands/generate_models.ts +48 -0
- package/src/cli/commands/migration_compare.ts +152 -0
- package/src/cli/commands/migration_fresh.ts +123 -0
- package/src/cli/commands/migration_generate.ts +79 -0
- package/src/cli/commands/migration_rollback.ts +53 -0
- package/src/cli/commands/migration_run.ts +44 -0
- package/src/cli/commands/queue_flush.ts +35 -0
- package/src/cli/commands/queue_retry.ts +34 -0
- package/src/cli/commands/queue_work.ts +40 -0
- package/src/cli/commands/scheduler_work.ts +45 -0
- package/src/cli/strav.ts +33 -0
- package/src/config/configuration.ts +105 -0
- package/src/config/loaders/base_loader.ts +69 -0
- package/src/config/loaders/env_loader.ts +112 -0
- package/src/config/loaders/typescript_loader.ts +56 -0
- package/src/config/types.ts +8 -0
- package/src/core/application.ts +4 -0
- package/src/core/container.ts +117 -0
- package/src/core/index.ts +3 -0
- package/src/core/inject.ts +39 -0
- package/src/database/database.ts +54 -0
- package/src/database/index.ts +30 -0
- package/src/database/introspector.ts +446 -0
- package/src/database/migration/differ.ts +308 -0
- package/src/database/migration/file_generator.ts +125 -0
- package/src/database/migration/index.ts +18 -0
- package/src/database/migration/runner.ts +133 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +76 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +474 -0
- package/src/encryption/encryption_manager.ts +209 -0
- package/src/encryption/helpers.ts +158 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/types.ts +6 -0
- package/src/events/emitter.ts +101 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/errors.ts +75 -0
- package/src/exceptions/exception_handler.ts +126 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +129 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/generators/api_generator.ts +972 -0
- package/src/generators/config.ts +87 -0
- package/src/generators/doc_generator.ts +974 -0
- package/src/generators/index.ts +11 -0
- package/src/generators/model_generator.ts +586 -0
- package/src/generators/route_generator.ts +188 -0
- package/src/generators/test_generator.ts +1666 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +4 -0
- package/src/helpers/strings.ts +67 -0
- package/src/http/context.ts +215 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +16 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +79 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +72 -0
- package/src/i18n/i18n_manager.ts +155 -0
- package/src/i18n/index.ts +4 -0
- package/src/i18n/middleware.ts +90 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/logger/index.ts +6 -0
- package/src/logger/logger.ts +100 -0
- package/src/logger/request_logger.ts +19 -0
- package/src/logger/sinks/console_sink.ts +24 -0
- package/src/logger/sinks/file_sink.ts +24 -0
- package/src/logger/sinks/sink.ts +36 -0
- package/src/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +19 -0
- package/src/mail/mail_manager.ts +92 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/resend_transport.ts +59 -0
- package/src/mail/transports/sendgrid_transport.ts +77 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +80 -0
- package/src/notification/base_notification.ts +67 -0
- package/src/notification/channels/database_channel.ts +30 -0
- package/src/notification/channels/discord_channel.ts +43 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +45 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +126 -0
- package/src/notification/types.ts +122 -0
- package/src/orm/base_model.ts +351 -0
- package/src/orm/decorators.ts +127 -0
- package/src/orm/index.ts +4 -0
- package/src/policy/authorize.ts +44 -0
- package/src/policy/index.ts +3 -0
- package/src/policy/policy_result.ts +13 -0
- package/src/queue/index.ts +11 -0
- package/src/queue/queue.ts +338 -0
- package/src/queue/worker.ts +197 -0
- package/src/scheduler/cron.ts +140 -0
- package/src/scheduler/index.ts +7 -0
- package/src/scheduler/runner.ts +116 -0
- package/src/scheduler/schedule.ts +183 -0
- package/src/scheduler/scheduler.ts +47 -0
- package/src/schema/database_representation.ts +122 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/field_builder.ts +155 -0
- package/src/schema/field_definition.ts +66 -0
- package/src/schema/index.ts +21 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +157 -0
- package/src/schema/representation_builder.ts +479 -0
- package/src/schema/type_builder.ts +107 -0
- package/src/schema/types.ts +35 -0
- package/src/session/index.ts +4 -0
- package/src/session/middleware.ts +46 -0
- package/src/session/session.ts +308 -0
- package/src/session/session_manager.ts +81 -0
- package/src/storage/index.ts +13 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +59 -0
- package/src/storage/types.ts +42 -0
- package/src/storage/upload.ts +91 -0
- package/src/validation/index.ts +18 -0
- package/src/validation/rules.ts +170 -0
- package/src/validation/validate.ts +41 -0
- package/src/view/cache.ts +47 -0
- package/src/view/client/islands.ts +50 -0
- package/src/view/compiler.ts +185 -0
- package/src/view/engine.ts +139 -0
- package/src/view/escape.ts +14 -0
- package/src/view/index.ts +13 -0
- package/src/view/islands/island_builder.ts +161 -0
- package/src/view/islands/vue_plugin.ts +140 -0
- package/src/view/middleware/static.ts +35 -0
- package/src/view/tokenizer.ts +172 -0
- 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
|
+
}
|