tina4-nodejs 3.13.64 → 3.13.66
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/CLAUDE.md +3 -3
- package/package.json +1 -1
- package/packages/cli/src/bin.ts +338 -163
- package/packages/cli/src/commands/build.ts +110 -0
- package/packages/cli/src/commands/generate.ts +552 -84
- package/packages/cli/src/commands/queue.ts +325 -0
- package/packages/core/public/js/frond.js +19 -8
- package/packages/core/public/js/frond.min.js +1 -1
- package/packages/core/src/types.ts +9 -1
- package/packages/orm/src/baseModel.ts +4 -1
|
@@ -215,16 +215,51 @@ function isoNow(): string {
|
|
|
215
215
|
return new Date().toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
218
|
+
// ── Generator registry — the single source of truth ─────────────────
|
|
219
|
+
//
|
|
220
|
+
// One entry per generator drives `generate` dispatch (below), the human help
|
|
221
|
+
// (`bin.ts` Generators section), AND the `commands --json` manifest's
|
|
222
|
+
// `generate.subcommands`. Add a generator in ONE place and it appears in
|
|
223
|
+
// dispatch, help, and discovery with no second list to keep in sync.
|
|
224
|
+
//
|
|
225
|
+
// Every handler is normalised to `(name, flags) => void`; `auth` takes no name
|
|
226
|
+
// so it drops it. Mirrors the Python master's GENERATORS registry
|
|
227
|
+
// (tina4_python/cli/__init__.py).
|
|
228
|
+
|
|
229
|
+
export interface GeneratorSpec {
|
|
230
|
+
handler: (name: string, flags: Record<string, string | boolean>) => void;
|
|
231
|
+
/** Arg/flag hint shown in `tina4nodejs help` (human only). */
|
|
232
|
+
usage: string;
|
|
233
|
+
summary: string;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export const GENERATORS: Record<string, GeneratorSpec> = {
|
|
237
|
+
model: { handler: generateModel, usage: '<Name> [--fields "name:string,price:float"]', summary: "ORM model + matching migration" },
|
|
238
|
+
route: { handler: generateRoute, usage: "<name> [--model Name] [--public]", summary: "CRUD route file, secure by default (--public opens writes)" },
|
|
239
|
+
crud: { handler: generateCrud, usage: '<Name> [--fields "..."] [--public]', summary: "Model + migration + routes + form + view + test" },
|
|
240
|
+
migration: { handler: (n, f) => generateMigration(n, f), usage: "<description>", summary: "Timestamped migration file (UP/DOWN)" },
|
|
241
|
+
middleware: { handler: generateMiddleware, usage: "<Name>", summary: "Middleware with before/after hooks" },
|
|
242
|
+
test: { handler: generateTest, usage: "<name> [--model Name]", summary: "Test file" },
|
|
243
|
+
form: { handler: generateForm, usage: '<Name> [--fields "..."]', summary: "Form template with inputs matching model fields" },
|
|
244
|
+
view: { handler: generateView, usage: '<Name> [--fields "..."]', summary: "List + detail view templates" },
|
|
245
|
+
auth: { handler: (_n, f) => generateAuth(f), usage: "", summary: "Login/register routes (public) + User model + templates" },
|
|
246
|
+
service: { handler: generateService, usage: '<Name> [--every 5m | --cron "..."]', summary: "Scheduled ServiceRunner task (src/services/)" },
|
|
247
|
+
queue: { handler: generateQueue, usage: "<topic>", summary: "Producer + consumer daemon worker (src/services/)" },
|
|
248
|
+
validator: { handler: generateValidator, usage: "<Name>", summary: "Request-body Validator (src/validators/)" },
|
|
249
|
+
seeder: { handler: generateSeeder, usage: "<Model>", summary: "FakeData + seedOrm seeder (src/seeds/)" },
|
|
250
|
+
websocket: { handler: generateWebsocket, usage: "<path>", summary: "websocket() handler (src/routes/)" },
|
|
251
|
+
listener: { handler: generateListener, usage: "<event>", summary: "Events.on(event) listener (src/listeners/)" },
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/** Comma-separated generator names for usage/error output — derived, never a hand-kept list. */
|
|
255
|
+
const GENERATOR_LIST = Object.keys(GENERATORS).join(", ");
|
|
221
256
|
|
|
222
257
|
// ── Main entry point ────────────────────────────────────────────────
|
|
223
258
|
|
|
224
259
|
export async function generate(what: string, name: string, extraArgs: string[] = []): Promise<void> {
|
|
225
260
|
if (!what) {
|
|
226
261
|
console.error(" Usage: tina4nodejs generate <what> <name> [options]");
|
|
227
|
-
console.error(` Generators: ${
|
|
262
|
+
console.error(` Generators: ${GENERATOR_LIST}`);
|
|
228
263
|
console.error(' Options: --fields "name:string,price:float" --model ModelName');
|
|
229
264
|
console.error(" --public open a route's writes (default: secure)");
|
|
230
265
|
console.error(' --every 5m | --cron "…" service schedule');
|
|
@@ -240,62 +275,21 @@ export async function generate(what: string, name: string, extraArgs: string[] =
|
|
|
240
275
|
|
|
241
276
|
const { flags } = parseCliArgs(extraArgs);
|
|
242
277
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
break;
|
|
253
|
-
case "migration":
|
|
254
|
-
generateMigration(name, flags);
|
|
255
|
-
break;
|
|
256
|
-
case "middleware":
|
|
257
|
-
generateMiddleware(name, flags);
|
|
258
|
-
break;
|
|
259
|
-
case "test":
|
|
260
|
-
generateTest(name, flags);
|
|
261
|
-
break;
|
|
262
|
-
case "form":
|
|
263
|
-
generateForm(name, flags);
|
|
264
|
-
break;
|
|
265
|
-
case "view":
|
|
266
|
-
generateView(name, flags);
|
|
267
|
-
break;
|
|
268
|
-
case "auth":
|
|
269
|
-
generateAuth(flags);
|
|
270
|
-
break;
|
|
271
|
-
case "service":
|
|
272
|
-
generateService(name, flags);
|
|
273
|
-
break;
|
|
274
|
-
case "queue":
|
|
275
|
-
generateQueue(name, flags);
|
|
276
|
-
break;
|
|
277
|
-
case "validator":
|
|
278
|
-
generateValidator(name, flags);
|
|
279
|
-
break;
|
|
280
|
-
case "seeder":
|
|
281
|
-
generateSeeder(name, flags);
|
|
282
|
-
break;
|
|
283
|
-
case "websocket":
|
|
284
|
-
generateWebsocket(name, flags);
|
|
285
|
-
break;
|
|
286
|
-
case "listener":
|
|
287
|
-
generateListener(name, flags);
|
|
288
|
-
break;
|
|
289
|
-
default:
|
|
290
|
-
console.error(` Unknown generator: ${what}`);
|
|
291
|
-
console.error(` Available: ${GENERATORS}`);
|
|
292
|
-
process.exit(1);
|
|
278
|
+
// Dispatch from the single-source-of-truth GENERATORS registry (also feeds
|
|
279
|
+
// `bin.ts` help + the `commands --json` manifest subcommands).
|
|
280
|
+
const spec = GENERATORS[what];
|
|
281
|
+
if (spec) {
|
|
282
|
+
spec.handler(name, flags);
|
|
283
|
+
} else {
|
|
284
|
+
console.error(` Unknown generator: ${what}`);
|
|
285
|
+
console.error(` Available: ${GENERATOR_LIST}`);
|
|
286
|
+
process.exit(1);
|
|
293
287
|
}
|
|
294
288
|
}
|
|
295
289
|
|
|
296
290
|
// ── Model ───────────────────────────────────────────────────────────
|
|
297
291
|
|
|
298
|
-
function generateModel(name: string, flags: Record<string, string | boolean
|
|
292
|
+
function generateModel(name: string, flags: Record<string, string | boolean>, emitTest = true): void {
|
|
299
293
|
const fields = parseFields((flags.fields as string) || "");
|
|
300
294
|
const table = toTableName(name);
|
|
301
295
|
const dir = resolve("src/models");
|
|
@@ -330,10 +324,16 @@ ${fieldLines.join("\n")}
|
|
|
330
324
|
|
|
331
325
|
writeFileSafe(path, content);
|
|
332
326
|
|
|
333
|
-
// Generate matching migration unless --no-migration
|
|
327
|
+
// Generate matching migration unless --no-migration. The model's own test
|
|
328
|
+
// (below) proves the schema through the real ORM, so the migration sub-call
|
|
329
|
+
// does NOT also co-emit a migration test (emitTest=false).
|
|
334
330
|
if (!flags["no-migration"]) {
|
|
335
|
-
generateMigration(`create_${table}`, flags, fields.length > 0 ? fields : undefined, table);
|
|
331
|
+
generateMigration(`create_${table}`, flags, fields.length > 0 ? fields : undefined, table, false);
|
|
336
332
|
}
|
|
333
|
+
|
|
334
|
+
// Co-emit a real SQLite roundtrip test next to the model. Composite
|
|
335
|
+
// generators (crud/auth) pass emitTest=false and emit their own broader test.
|
|
336
|
+
if (emitTest) emitModelTest(name, table, fields);
|
|
337
337
|
}
|
|
338
338
|
|
|
339
339
|
// ── Route ───────────────────────────────────────────────────────────
|
|
@@ -348,7 +348,7 @@ function secureOptOut(isPublic: boolean): string {
|
|
|
348
348
|
return isPublic ? `export const secure = false;\n\n` : "";
|
|
349
349
|
}
|
|
350
350
|
|
|
351
|
-
function generateRoute(name: string, flags: Record<string, string | boolean
|
|
351
|
+
function generateRoute(name: string, flags: Record<string, string | boolean>, emitTest = true): void {
|
|
352
352
|
const routePath = name.replace(/^\//, "");
|
|
353
353
|
const singular = routePath.endsWith("s") ? routePath.slice(0, -1) : routePath;
|
|
354
354
|
const model = flags.model as string | undefined;
|
|
@@ -565,6 +565,18 @@ ${aiFill(`delete_${singular}`, {
|
|
|
565
565
|
`,
|
|
566
566
|
);
|
|
567
567
|
}
|
|
568
|
+
|
|
569
|
+
// Co-emit a real test. A --model route is working code → the secure-by-default
|
|
570
|
+
// gate test (reads public, writes gated, or open under --public). A no-model
|
|
571
|
+
// route's handlers are loud AI-FILL stubs → the Router-registration + live-stub
|
|
572
|
+
// test. Composite generators (crud) pass emitTest=false and emit their own.
|
|
573
|
+
if (emitTest) {
|
|
574
|
+
if (model) {
|
|
575
|
+
generateTest(routePath, { model, "secure-writes": true, public: isPublic });
|
|
576
|
+
} else {
|
|
577
|
+
emitRouteStubTest(routePath);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
568
580
|
}
|
|
569
581
|
|
|
570
582
|
// ── CRUD ────────────────────────────────────────────────────────────
|
|
@@ -576,12 +588,14 @@ function generateCrud(name: string, flags: Record<string, string | boolean>): vo
|
|
|
576
588
|
|
|
577
589
|
console.log(`\n Generating CRUD for ${name}...\n`);
|
|
578
590
|
|
|
579
|
-
// 1. Model + migration
|
|
580
|
-
|
|
591
|
+
// 1. Model + migration (its own model test is suppressed — the gate test
|
|
592
|
+
// below is CRUD's single, broader co-emitted test).
|
|
593
|
+
generateModel(name, flags, false);
|
|
581
594
|
|
|
582
595
|
// 2. Routes with model — secure by default; thread --public through so
|
|
583
596
|
// `generate crud X --public` opens the writes (mirrors AutoCrud public).
|
|
584
|
-
|
|
597
|
+
// Route test suppressed (the gate test below covers the routes).
|
|
598
|
+
generateRoute(routeName, { ...flags, model: name }, false);
|
|
585
599
|
|
|
586
600
|
// 3. Form
|
|
587
601
|
generateForm(name, flags);
|
|
@@ -604,6 +618,7 @@ function generateMigration(
|
|
|
604
618
|
flags: Record<string, string | boolean>,
|
|
605
619
|
fieldsOverride?: Array<[string, string]>,
|
|
606
620
|
tableOverride?: string,
|
|
621
|
+
emitTest = true,
|
|
607
622
|
): void {
|
|
608
623
|
const ts = timestamp();
|
|
609
624
|
const dir = resolve("migrations");
|
|
@@ -664,6 +679,11 @@ function generateMigration(
|
|
|
664
679
|
`${downSql}\n`;
|
|
665
680
|
|
|
666
681
|
writeFileSafe(downPath, downContent);
|
|
682
|
+
|
|
683
|
+
// Co-emit a real up/down migration test — only for CREATE migrations (a
|
|
684
|
+
// placeholder ALTER migration has no asserted schema yet). Suppressed when a
|
|
685
|
+
// model generates its matching migration (the model's own test covers schema).
|
|
686
|
+
if (emitTest && isCreate) emitMigrationTest(name, table);
|
|
667
687
|
}
|
|
668
688
|
|
|
669
689
|
// ── Middleware ───────────────────────────────────────────────────────
|
|
@@ -707,6 +727,7 @@ export async function after${name}(
|
|
|
707
727
|
`;
|
|
708
728
|
|
|
709
729
|
writeFileSafe(path, content);
|
|
730
|
+
emitMiddlewareTest(name, snake);
|
|
710
731
|
}
|
|
711
732
|
|
|
712
733
|
// ── Test ────────────────────────────────────────────────────────────
|
|
@@ -1004,8 +1025,9 @@ function generateView(name: string, flags: Record<string, string | boolean>): vo
|
|
|
1004
1025
|
function generateAuth(_flags: Record<string, string | boolean>): void {
|
|
1005
1026
|
console.log("\n Generating authentication scaffolding...\n");
|
|
1006
1027
|
|
|
1007
|
-
// 1. User model + migration
|
|
1008
|
-
|
|
1028
|
+
// 1. User model + migration (model test suppressed — the auth test below is
|
|
1029
|
+
// the composite, broader co-emitted test).
|
|
1030
|
+
generateModel("User", { fields: "email:string,password:string,role:string" }, false);
|
|
1009
1031
|
|
|
1010
1032
|
// 2. Auth routes (file-based). register + login are genuinely public — the
|
|
1011
1033
|
// user has no token yet — so they opt out of the write gate with
|
|
@@ -1158,8 +1180,8 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
1158
1180
|
`,
|
|
1159
1181
|
);
|
|
1160
1182
|
|
|
1161
|
-
// 5. Auth test
|
|
1162
|
-
|
|
1183
|
+
// 5. Auth test — real register / login / me end-to-end (no mocks).
|
|
1184
|
+
emitAuthTest();
|
|
1163
1185
|
|
|
1164
1186
|
console.log("\n Authentication scaffolding complete.");
|
|
1165
1187
|
console.log(" Run: tina4nodejs migrate");
|
|
@@ -1228,6 +1250,7 @@ ${scheduleField}
|
|
|
1228
1250
|
`;
|
|
1229
1251
|
|
|
1230
1252
|
writeFileSafe(path, content);
|
|
1253
|
+
emitServiceTest(name, snake, camel);
|
|
1231
1254
|
}
|
|
1232
1255
|
|
|
1233
1256
|
// ── Queue (producer + consumer worker) ──────────────────────────────
|
|
@@ -1288,15 +1311,20 @@ export async function consume${pascal}(_context?: ServiceContext): Promise<void>
|
|
|
1288
1311
|
}
|
|
1289
1312
|
|
|
1290
1313
|
// Discovered by ServiceRunner.discover("src/services"); daemon:true because
|
|
1291
|
-
// consume${pascal} owns its own loop.
|
|
1314
|
+
// consume${pascal} owns its own loop. The topic + per-job handle keys let
|
|
1315
|
+
// \`tina4nodejs queue work ${topic}\` drive this consumer directly (own the poll
|
|
1316
|
+
// loop / bounded --once drain) without wiring a ServiceRunner.
|
|
1292
1317
|
export default {
|
|
1293
1318
|
name: "${topic}-consumer",
|
|
1319
|
+
topic: "${topic}",
|
|
1294
1320
|
handler: consume${pascal},
|
|
1321
|
+
handle: handle${pascal},
|
|
1295
1322
|
daemon: true,
|
|
1296
1323
|
};
|
|
1297
1324
|
`;
|
|
1298
1325
|
|
|
1299
1326
|
writeFileSafe(path, content);
|
|
1327
|
+
emitQueueTest(topic, slug, pascal);
|
|
1300
1328
|
}
|
|
1301
1329
|
|
|
1302
1330
|
// ── Validator (request-body Validator) ──────────────────────────────
|
|
@@ -1309,14 +1337,13 @@ function generateValidator(name: string, _flags: Record<string, string | boolean
|
|
|
1309
1337
|
ensureDir(dir);
|
|
1310
1338
|
const path = join(dir, `${toSnake(name)}.ts`);
|
|
1311
1339
|
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
ground:
|
|
1318
|
-
|
|
1319
|
-
});
|
|
1340
|
+
// Working-default: ship a real starter rule (required "name") so the
|
|
1341
|
+
// co-emitted valid/invalid test is GREEN on generation — a scaffolded test
|
|
1342
|
+
// that fails on creation is bad DX. Extend the rules for your own payload.
|
|
1343
|
+
const rules = extend(
|
|
1344
|
+
"add / adjust the validation rules for this payload",
|
|
1345
|
+
`e.g. .email("email").minLength("name", 2).integer("age"); ground: tina4_context("validate request body with Validator", "nodejs")`,
|
|
1346
|
+
);
|
|
1320
1347
|
|
|
1321
1348
|
const content = `import { Validator } from "tina4-nodejs";
|
|
1322
1349
|
|
|
@@ -1329,11 +1356,13 @@ function generateValidator(name: string, _flags: Record<string, string | boolean
|
|
|
1329
1356
|
*/
|
|
1330
1357
|
export function validate${toPascal(name)}(data: Record<string, unknown>): Validator {
|
|
1331
1358
|
const validator = new Validator(data);
|
|
1332
|
-
${
|
|
1359
|
+
${rules} validator.required("name"); // starter rule (matches the model's default field)
|
|
1360
|
+
return validator;
|
|
1333
1361
|
}
|
|
1334
1362
|
`;
|
|
1335
1363
|
|
|
1336
1364
|
writeFileSafe(path, content);
|
|
1365
|
+
emitValidatorTest(name, toSnake(name), toPascal(name));
|
|
1337
1366
|
}
|
|
1338
1367
|
|
|
1339
1368
|
// ── Seeder (FakeData + seedOrm) ─────────────────────────────────────
|
|
@@ -1348,14 +1377,13 @@ function generateSeeder(name: string, _flags: Record<string, string | boolean>):
|
|
|
1348
1377
|
ensureDir(dir);
|
|
1349
1378
|
const path = join(dir, `${table}_seeder.ts`);
|
|
1350
1379
|
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
ground:
|
|
1357
|
-
|
|
1358
|
-
});
|
|
1380
|
+
// Working-default: return {} so seedOrm auto-fills every field by type/name
|
|
1381
|
+
// and the co-emitted rows-created test is GREEN on generation. Override the
|
|
1382
|
+
// fields that need a specific shape below.
|
|
1383
|
+
const overrides = extend(
|
|
1384
|
+
"override fields that need a specific shape (seedOrm auto-fills the rest)",
|
|
1385
|
+
`e.g. return { email: (f) => f.email(), status: "active" }; ground: tina4_context("seed ORM model with FakeData", "nodejs")`,
|
|
1386
|
+
);
|
|
1359
1387
|
|
|
1360
1388
|
const content = `import { pathToFileURL } from "node:url";
|
|
1361
1389
|
import { FakeData, seedOrm, initDatabase } from "tina4-nodejs/orm";
|
|
@@ -1368,7 +1396,9 @@ import ${name} from "../models/${name}.js";
|
|
|
1368
1396
|
* specific shape below. Each callable receives a FakeData instance.
|
|
1369
1397
|
*/
|
|
1370
1398
|
export function fieldOverrides(fake: FakeData): Record<string, unknown> {
|
|
1371
|
-
${
|
|
1399
|
+
${overrides} void fake; // available for overrides above
|
|
1400
|
+
return {};
|
|
1401
|
+
}
|
|
1372
1402
|
|
|
1373
1403
|
/** Seed rows. Invoked when this file is run directly by \`tina4nodejs seed\`. */
|
|
1374
1404
|
export async function run(): Promise<void> {
|
|
@@ -1385,6 +1415,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
|
1385
1415
|
`;
|
|
1386
1416
|
|
|
1387
1417
|
writeFileSafe(path, content);
|
|
1418
|
+
emitSeederTest(name, table);
|
|
1388
1419
|
}
|
|
1389
1420
|
|
|
1390
1421
|
// ── WebSocket route ─────────────────────────────────────────────────
|
|
@@ -1446,6 +1477,7 @@ websocket("${wsPath}", ${handlerName});
|
|
|
1446
1477
|
`;
|
|
1447
1478
|
|
|
1448
1479
|
writeFileSafe(path, content);
|
|
1480
|
+
emitWebsocketTest(wsPath, base, handlerName);
|
|
1449
1481
|
}
|
|
1450
1482
|
|
|
1451
1483
|
// ── Event listener ──────────────────────────────────────────────────
|
|
@@ -1489,4 +1521,440 @@ Events.on("${event}", ${handlerName});
|
|
|
1489
1521
|
`;
|
|
1490
1522
|
|
|
1491
1523
|
writeFileSafe(path, content);
|
|
1524
|
+
emitListenerTest(event, slug);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
1528
|
+
// Co-emitted test builders — every code-producing generator emits a REAL,
|
|
1529
|
+
// green, no-mock `*.test.ts` next to its scaffold (self-describing CLI epic,
|
|
1530
|
+
// Phase 4). Mirrors the Python master's `_write_test` + `_emit_*_test` builders
|
|
1531
|
+
// (tina4_python/cli/__init__.py): ONE shared writer + ONE focused builder per
|
|
1532
|
+
// generator (not copy-pasted blobs). Each exercises a DIFFERENT real subsystem
|
|
1533
|
+
// (real SQLite / TestClient / Router / MiddlewareChain / ServiceRunner / Queue /
|
|
1534
|
+
// event bus). CRUD-shaped scaffolds (working code) get behavioural tests;
|
|
1535
|
+
// logic-shaped scaffolds (loud AI-FILL stubs) get real wiring tests + a lock-in
|
|
1536
|
+
// that the placeholder fails loud. form/view are template-only → exempt (no
|
|
1537
|
+
// test); `test` itself is exempt (it IS the test generator).
|
|
1538
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* The SINGLE place a co-emitted test is written — `tests/<name>.test.ts`
|
|
1542
|
+
* (Node's `*.test.ts` convention, discovered by `tsx test/run-all.ts`). Shares
|
|
1543
|
+
* writeFileSafe's overwrite-refusal + the same "Created …" line every generator
|
|
1544
|
+
* prints. Generalized from generateTest so builders share one rule.
|
|
1545
|
+
*/
|
|
1546
|
+
function writeTest(testName: string, content: string): void {
|
|
1547
|
+
const dir = resolve("tests");
|
|
1548
|
+
ensureDir(dir);
|
|
1549
|
+
writeFileSafe(join(dir, `${testName}.test.ts`), content);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/**
|
|
1553
|
+
* Assemble a standalone-tsx test: doc header + shared pass/fail tally + body +
|
|
1554
|
+
* summary line + `process.exit`. This is the project's test convention (each
|
|
1555
|
+
* file self-tallies and exits), so `tsx test/run-all.ts` — and the co-emit
|
|
1556
|
+
* meta-test — pick the emitted file up and read its "Results: N passed, M
|
|
1557
|
+
* failed" line. Grounded on the existing secure-gate CRUD test format.
|
|
1558
|
+
*/
|
|
1559
|
+
function standaloneTest(doc: string, body: string): string {
|
|
1560
|
+
return `${doc}
|
|
1561
|
+
|
|
1562
|
+
let pass = 0;
|
|
1563
|
+
let fail = 0;
|
|
1564
|
+
function assert(label: string, ok: boolean): void {
|
|
1565
|
+
if (ok) { pass++; console.log(\` PASS \${label}\`); }
|
|
1566
|
+
else { fail++; console.log(\` FAIL \${label}\`); }
|
|
1567
|
+
}
|
|
1568
|
+
async function assertThrows(label: string, fn: () => unknown | Promise<unknown>): Promise<void> {
|
|
1569
|
+
try { await fn(); assert(label, false); }
|
|
1570
|
+
catch { assert(label, true); }
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
${body}
|
|
1574
|
+
|
|
1575
|
+
console.log(\`\\nResults: \${pass} passed, \${fail} failed\`);
|
|
1576
|
+
process.exit(fail > 0 ? 1 : 0);
|
|
1577
|
+
`;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/** A source-text literal that is a valid value for a scaffolded field type. */
|
|
1581
|
+
function sampleLiteral(fieldType: string): string {
|
|
1582
|
+
switch ((fieldType || "string").toLowerCase()) {
|
|
1583
|
+
case "int": case "integer": return "1";
|
|
1584
|
+
case "float": case "number": case "numeric": case "decimal": return "1.5";
|
|
1585
|
+
case "bool": case "boolean": return "true";
|
|
1586
|
+
case "datetime": return '"2020-01-01 00:00:00"';
|
|
1587
|
+
case "blob": return '"x"';
|
|
1588
|
+
default: return '"sample"';
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/** model → real SQLite roundtrip (create / read back / missing → null). */
|
|
1593
|
+
function emitModelTest(model: string, table: string, fields: Array<[string, string]>): void {
|
|
1594
|
+
const flds = fields.length > 0 ? fields : [["name", "string"] as [string, string]];
|
|
1595
|
+
const payload = flds.map(([f, t]) => `${f}: ${sampleLiteral(t)}`).join(", ");
|
|
1596
|
+
const stringField = flds.find(([, t]) => ["string", "str", "text"].includes((t || "string").toLowerCase()))?.[0];
|
|
1597
|
+
const valueAssert = stringField
|
|
1598
|
+
? `\nassert("string field round-trips", fetched !== null && (fetched.toObject() as Record<string, unknown>).${stringField} === "sample");`
|
|
1599
|
+
: "";
|
|
1600
|
+
const doc = `/**
|
|
1601
|
+
* Real ORM roundtrip for ${model} — no mocks, real SQLite.
|
|
1602
|
+
*
|
|
1603
|
+
* Generated with src/models/${model}.ts by \`tina4nodejs generate model
|
|
1604
|
+
* ${model}\`. The model scaffold is working code, so this passes on generation:
|
|
1605
|
+
* binds a real in-memory SQLite DB, creates the table, saves a row, reads it
|
|
1606
|
+
* back. Run with: npx tsx tests/${table}_model.test.ts
|
|
1607
|
+
*/
|
|
1608
|
+
import ${model} from "../src/models/${model}.js";
|
|
1609
|
+
import { initDatabase } from "tina4-nodejs/orm";`;
|
|
1610
|
+
const body = `await initDatabase({ url: "sqlite:///:memory:" });
|
|
1611
|
+
await ${model}.createTable();
|
|
1612
|
+
|
|
1613
|
+
const row = new ${model}({ ${payload} });
|
|
1614
|
+
const saved = await row.save();
|
|
1615
|
+
assert("create() persists and returns the row", saved !== false && Boolean(row.toObject().id));
|
|
1616
|
+
|
|
1617
|
+
const id = row.toObject().id;
|
|
1618
|
+
const fetched = await ${model}.selectOne("SELECT * FROM ${table} WHERE id = ?", [id]);
|
|
1619
|
+
assert("row reads back by id", fetched !== null);
|
|
1620
|
+
assert("read-back id matches", fetched !== null && (fetched.toObject() as Record<string, unknown>).id === id);${valueAssert}
|
|
1621
|
+
|
|
1622
|
+
const missing = await ${model}.selectOne("SELECT * FROM ${table} WHERE id = ?", [999999]);
|
|
1623
|
+
assert("find missing returns null", missing === null);`;
|
|
1624
|
+
writeTest(`${table}_model`, standaloneTest(doc, body));
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/** route (no --model) → real Router registration + the list handler is a live
|
|
1628
|
+
* loud stub. (route --model reuses the secure-gate generateTest instead.) */
|
|
1629
|
+
function emitRouteStubTest(route: string): void {
|
|
1630
|
+
const doc = `/**
|
|
1631
|
+
* Routing test for ${route} — no mocks, real Router + route discovery.
|
|
1632
|
+
*
|
|
1633
|
+
* Generated with src/routes/api/${route}/ by \`tina4nodejs generate route
|
|
1634
|
+
* ${route}\` (no --model). The handlers are AI-FILL stubs that throw until you
|
|
1635
|
+
* implement them, so this tests what IS live on generation: all five routes
|
|
1636
|
+
* register on the REAL Router, and the list handler fails loud until filled.
|
|
1637
|
+
* Run with: npx tsx tests/${route}.test.ts
|
|
1638
|
+
*/
|
|
1639
|
+
import { dirname, resolve } from "node:path";
|
|
1640
|
+
import { fileURLToPath } from "node:url";
|
|
1641
|
+
import { Router, discoverRoutes } from "tina4-nodejs";
|
|
1642
|
+
import listHandler from "../src/routes/api/${route}/get.js";`;
|
|
1643
|
+
const body = `const here = dirname(fileURLToPath(import.meta.url));
|
|
1644
|
+
const router = new Router();
|
|
1645
|
+
const defs = await discoverRoutes(resolve(here, "../src/routes"));
|
|
1646
|
+
for (const def of defs) router.addRoute(def);
|
|
1647
|
+
|
|
1648
|
+
const sigs = defs.map((d) => \`\${d.method} \${d.pattern}\`);
|
|
1649
|
+
for (const sig of ["GET /api/${route}", "POST /api/${route}", "GET /api/${route}/{id}", "PUT /api/${route}/{id}", "DELETE /api/${route}/{id}"]) {
|
|
1650
|
+
assert(\`route registered: \${sig}\`, sigs.includes(sig));
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// The scaffolded list handler is a loud AI-FILL stub — it throws until filled.
|
|
1654
|
+
await assertThrows("list handler is a live stub (throws until filled)",
|
|
1655
|
+
() => listHandler({} as never, {} as never));`;
|
|
1656
|
+
writeTest(route, standaloneTest(doc, body));
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/** middleware → the scaffolded before/after driven through the REAL
|
|
1660
|
+
* MiddlewareChain with a real Request/Response (no mock req/res). */
|
|
1661
|
+
function emitMiddlewareTest(name: string, snake: string): void {
|
|
1662
|
+
const doc = `/**
|
|
1663
|
+
* Real dispatch test for the ${name} middleware — no mocks.
|
|
1664
|
+
*
|
|
1665
|
+
* Generated with src/middleware/${snake}.ts by \`tina4nodejs generate middleware
|
|
1666
|
+
* ${name}\`. Drives the scaffolded before/after functions through the REAL
|
|
1667
|
+
* MiddlewareChain with a real Tina4Request/Response (built from real node http
|
|
1668
|
+
* objects) — the same continuation dispatch the live server runs.
|
|
1669
|
+
* Run with: npx tsx tests/${snake}.test.ts
|
|
1670
|
+
*/
|
|
1671
|
+
import { IncomingMessage, ServerResponse } from "node:http";
|
|
1672
|
+
import { Socket } from "node:net";
|
|
1673
|
+
import { MiddlewareChain, createRequest, createResponse } from "tina4-nodejs";
|
|
1674
|
+
import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
1675
|
+
import { before${name}, after${name} } from "../src/middleware/${snake}.js";`;
|
|
1676
|
+
const body = `function realPair(headers: Record<string, string>): { req: Tina4Request; res: Tina4Response; raw: ServerResponse } {
|
|
1677
|
+
const socket = new Socket();
|
|
1678
|
+
const rawReq = new IncomingMessage(socket);
|
|
1679
|
+
rawReq.method = "GET";
|
|
1680
|
+
rawReq.url = "/";
|
|
1681
|
+
rawReq.headers = { ...headers, host: "localhost" };
|
|
1682
|
+
rawReq.push(null);
|
|
1683
|
+
const rawRes = new ServerResponse(rawReq);
|
|
1684
|
+
rawRes.write = (() => true) as typeof rawRes.write;
|
|
1685
|
+
rawRes.end = (function (this: ServerResponse) { return this; }) as typeof rawRes.end;
|
|
1686
|
+
return { req: createRequest(rawReq), res: createResponse(rawRes), raw: rawRes };
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// before(): blocks an unauthenticated request (401, does not call next()).
|
|
1690
|
+
{
|
|
1691
|
+
const chain = new MiddlewareChain();
|
|
1692
|
+
chain.use(before${name});
|
|
1693
|
+
let reached = false;
|
|
1694
|
+
chain.use(async (_r, _s, next) => { reached = true; next(); });
|
|
1695
|
+
const { req, res, raw } = realPair({});
|
|
1696
|
+
await chain.run(req, res);
|
|
1697
|
+
assert("before blocks unauthenticated (401, chain short-circuits)", raw.statusCode === 401 && reached === false);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// before(): lets an authenticated request through to the next middleware.
|
|
1701
|
+
{
|
|
1702
|
+
const chain = new MiddlewareChain();
|
|
1703
|
+
chain.use(before${name});
|
|
1704
|
+
let reached = false;
|
|
1705
|
+
chain.use(async (_r, _s, next) => { reached = true; next(); });
|
|
1706
|
+
const { req, res } = realPair({ authorization: "Bearer test" });
|
|
1707
|
+
await chain.run(req, res);
|
|
1708
|
+
assert("before passes an authenticated request through", reached === true);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// after(): always continues the chain.
|
|
1712
|
+
{
|
|
1713
|
+
const chain = new MiddlewareChain();
|
|
1714
|
+
chain.use(after${name});
|
|
1715
|
+
let reached = false;
|
|
1716
|
+
chain.use(async (_r, _s, next) => { reached = true; next(); });
|
|
1717
|
+
const { req, res } = realPair({});
|
|
1718
|
+
await chain.run(req, res);
|
|
1719
|
+
assert("after runs and continues the chain", reached === true);
|
|
1720
|
+
}`;
|
|
1721
|
+
writeTest(snake, standaloneTest(doc, body));
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
/** service → registrable on a REAL ServiceRunner + descriptor shape + loud stub. */
|
|
1725
|
+
function emitServiceTest(name: string, snake: string, camel: string): void {
|
|
1726
|
+
const doc = `/**
|
|
1727
|
+
* Real ServiceRunner test for the ${name} service — no mocks.
|
|
1728
|
+
*
|
|
1729
|
+
* Generated with src/services/${snake}.ts by \`tina4nodejs generate service
|
|
1730
|
+
* ${name}\`. Registers the scaffold on a REAL ServiceRunner and confirms the
|
|
1731
|
+
* descriptor; the task body is an AI-FILL stub that throws until filled.
|
|
1732
|
+
* Run with: npx tsx tests/${snake}.test.ts
|
|
1733
|
+
*/
|
|
1734
|
+
import { ServiceRunner } from "tina4-nodejs";
|
|
1735
|
+
import service, { ${camel}Task } from "../src/services/${snake}.js";`;
|
|
1736
|
+
const body = `assert("descriptor has a name + callable handler",
|
|
1737
|
+
service.name === "${snake}" && typeof service.handler === "function");
|
|
1738
|
+
|
|
1739
|
+
// Register on a REAL ServiceRunner and confirm it is listed.
|
|
1740
|
+
ServiceRunner.register(service.name, service.handler, { interval: (service as { interval?: number }).interval });
|
|
1741
|
+
assert("registers on a real ServiceRunner", ServiceRunner.list().some((s) => s.name === "${snake}"));
|
|
1742
|
+
ServiceRunner.remove("${snake}");
|
|
1743
|
+
|
|
1744
|
+
// The scaffolded task body is an AI-FILL stub — it throws until filled.
|
|
1745
|
+
await assertThrows("task is a live stub (throws until filled)", () => ${camel}Task({} as never));`;
|
|
1746
|
+
writeTest(snake, standaloneTest(doc, body));
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/** queue → push a REAL job onto the real file-backed Queue + daemon wiring. */
|
|
1750
|
+
function emitQueueTest(topic: string, slug: string, pascal: string): void {
|
|
1751
|
+
const doc = `/**
|
|
1752
|
+
* Real file-backed Queue test for the ${topic} worker — no mocks.
|
|
1753
|
+
*
|
|
1754
|
+
* Generated with src/services/${slug}_consumer.ts by \`tina4nodejs generate
|
|
1755
|
+
* queue ${topic}\`. Pushes a REAL job onto the real file-backed Queue and
|
|
1756
|
+
* asserts it is enqueued, and that the consumer is wired as a daemon. The
|
|
1757
|
+
* per-job handle is an AI-FILL stub that throws until filled.
|
|
1758
|
+
* Run with: npx tsx tests/${slug}.test.ts
|
|
1759
|
+
*/
|
|
1760
|
+
import { Queue } from "tina4-nodejs";
|
|
1761
|
+
import worker, { publish${pascal}, handle${pascal} } from "../src/services/${slug}_consumer.js";`;
|
|
1762
|
+
const body = `const jobId = publish${pascal}({ hello: "world" });
|
|
1763
|
+
assert("publish enqueues a real job (returns an id)", typeof jobId === "string" && jobId.length > 0);
|
|
1764
|
+
assert("the job is really on the queue", new Queue({ topic: "${topic}" }).size() >= 1);
|
|
1765
|
+
|
|
1766
|
+
assert("consumer default export is a daemon", worker.daemon === true);
|
|
1767
|
+
assert("consumer handler is wired", typeof worker.handler === "function");
|
|
1768
|
+
|
|
1769
|
+
// The per-job handler is an AI-FILL stub — it throws until filled.
|
|
1770
|
+
await assertThrows("handle is a live stub (throws until filled)", () => handle${pascal}({}));`;
|
|
1771
|
+
writeTest(slug, standaloneTest(doc, body));
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
/** validator → run the scaffold against valid + invalid real input
|
|
1775
|
+
* (working-default: ships a starter required("name") rule). */
|
|
1776
|
+
function emitValidatorTest(name: string, snake: string, pascal: string): void {
|
|
1777
|
+
const doc = `/**
|
|
1778
|
+
* Real validation test for validate${pascal} — no mocks.
|
|
1779
|
+
*
|
|
1780
|
+
* Generated with src/validators/${snake}.ts by \`tina4nodejs generate validator
|
|
1781
|
+
* ${name}\`. The scaffold ships a starter rule (required "name"), so this passes
|
|
1782
|
+
* on generation — adjust the rules for your payload and update these cases.
|
|
1783
|
+
* Run with: npx tsx tests/${snake}.test.ts
|
|
1784
|
+
*/
|
|
1785
|
+
import { validate${pascal} } from "../src/validators/${snake}.js";`;
|
|
1786
|
+
const body = `assert("valid input passes", validate${pascal}({ name: "Ada" }).isValid());
|
|
1787
|
+
|
|
1788
|
+
const bad = validate${pascal}({});
|
|
1789
|
+
assert("invalid input fails", bad.isValid() === false);
|
|
1790
|
+
assert("invalid input reports errors", bad.errors().length > 0);`;
|
|
1791
|
+
writeTest(snake, standaloneTest(doc, body));
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
/** seeder → run the scaffolded seeder against real SQLite, assert rows created
|
|
1795
|
+
* (working-default: fieldOverrides returns {}, seedOrm auto-fills). */
|
|
1796
|
+
function emitSeederTest(model: string, table: string): void {
|
|
1797
|
+
const doc = `/**
|
|
1798
|
+
* Real seeding test for the ${model} seeder — no mocks, real SQLite.
|
|
1799
|
+
*
|
|
1800
|
+
* Generated with src/seeds/${table}_seeder.ts by \`tina4nodejs generate seeder
|
|
1801
|
+
* ${model}\`. Binds a real SQLite DB, creates the table, runs the scaffolded
|
|
1802
|
+
* seeder (auto-fills every field via FakeData) and asserts rows were created.
|
|
1803
|
+
* Run with: npx tsx tests/${table}_seeder.test.ts
|
|
1804
|
+
*/
|
|
1805
|
+
import { initDatabase, FakeData } from "tina4-nodejs/orm";
|
|
1806
|
+
import ${model} from "../src/models/${model}.js";
|
|
1807
|
+
import { fieldOverrides, run } from "../src/seeds/${table}_seeder.js";`;
|
|
1808
|
+
const body = `process.env.TINA4_DATABASE_URL = "sqlite:///test_${table}_seeder.db";
|
|
1809
|
+
await initDatabase({ url: process.env.TINA4_DATABASE_URL });
|
|
1810
|
+
await ${model}.createTable();
|
|
1811
|
+
|
|
1812
|
+
assert("fieldOverrides returns an object", typeof fieldOverrides(new FakeData()) === "object");
|
|
1813
|
+
|
|
1814
|
+
await run(); // run() re-binds the same DB URL and seeds via seedOrm
|
|
1815
|
+
const rows = await ${model}.all();
|
|
1816
|
+
assert("run() seeds real rows", rows.length >= 1);`;
|
|
1817
|
+
writeTest(`${table}_seeder`, standaloneTest(doc, body));
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/** websocket → real Router registration + drive the real handler (socket-free
|
|
1821
|
+
* "close" event) directly; the "message" branch is a loud stub. */
|
|
1822
|
+
function emitWebsocketTest(wsPath: string, base: string, handler: string): void {
|
|
1823
|
+
const doc = `/**
|
|
1824
|
+
* Real handler test for the ${wsPath} WebSocket route — no mocks.
|
|
1825
|
+
*
|
|
1826
|
+
* Generated with src/routes/ws_${base}.ts by \`tina4nodejs generate websocket
|
|
1827
|
+
* ...\`. Confirms the handler registers on the REAL Router (importing runs the
|
|
1828
|
+
* module-level websocket() call) and drives the real handler for the "close"
|
|
1829
|
+
* event (no socket needed). The "message" branch is an AI-FILL stub that throws
|
|
1830
|
+
* until filled. Run with: npx tsx tests/ws_${base}.test.ts
|
|
1831
|
+
*/
|
|
1832
|
+
import { Router } from "tina4-nodejs";
|
|
1833
|
+
import { ${handler} } from "../src/routes/ws_${base}.js"; // importing registers via websocket()`;
|
|
1834
|
+
const body = `assert("handler registered on the real router",
|
|
1835
|
+
Router.getWebSocketRoutes().some((r) => r.pattern === "${wsPath}"));
|
|
1836
|
+
|
|
1837
|
+
// The "close" branch returns cleanly without a live connection.
|
|
1838
|
+
const closed = await ${handler}(null as never, "close", "");
|
|
1839
|
+
assert("close event handled cleanly", closed === undefined);
|
|
1840
|
+
|
|
1841
|
+
// The "message" branch is an AI-FILL stub — it throws until filled.
|
|
1842
|
+
await assertThrows("message branch is a live stub (throws until filled)",
|
|
1843
|
+
() => ${handler}(null as never, "message", "hi"));`;
|
|
1844
|
+
writeTest(`ws_${base}`, standaloneTest(doc, body));
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
/** listener → emit the REAL event on the real bus, assert the listener ran. */
|
|
1848
|
+
function emitListenerTest(event: string, slug: string): void {
|
|
1849
|
+
const doc = `/**
|
|
1850
|
+
* Real event-bus test for the '${event}' listener — no mocks.
|
|
1851
|
+
*
|
|
1852
|
+
* Generated with src/listeners/${slug}.ts by \`tina4nodejs generate listener
|
|
1853
|
+
* ${event}\`. Confirms the listener binds on the REAL event bus (importing runs
|
|
1854
|
+
* the module-level Events.on) and that emitting the event reaches it. The
|
|
1855
|
+
* reaction body is an AI-FILL stub, so a strict emit re-raises here (proving it
|
|
1856
|
+
* ran). Run with: npx tsx tests/${slug}.test.ts
|
|
1857
|
+
*/
|
|
1858
|
+
import { Events } from "tina4-nodejs";
|
|
1859
|
+
import "../src/listeners/${slug}.js"; // importing registers the listener via Events.on()`;
|
|
1860
|
+
const body = `assert("listener registered on the real event bus", Events.listeners("${event}").length >= 1);
|
|
1861
|
+
|
|
1862
|
+
// strict emit re-raises the stub error, proving the listener actually ran.
|
|
1863
|
+
await assertThrows("emitting the event reaches the (stub) listener",
|
|
1864
|
+
() => Events.emit("${event}", { strict: true }, { id: 1 }));`;
|
|
1865
|
+
writeTest(slug, standaloneTest(doc, body));
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/** auth → real register / login / me end-to-end via the real TestClient. This
|
|
1869
|
+
* proves the token→200 path on /api/auth/me (the scaffold passes headers to
|
|
1870
|
+
* authenticateRequest, not the request — no always-401 bug). */
|
|
1871
|
+
function emitAuthTest(): void {
|
|
1872
|
+
const doc = `/**
|
|
1873
|
+
* Real auth test — register / login / me via the real TestClient.
|
|
1874
|
+
*
|
|
1875
|
+
* Generated with the auth scaffold by \`tina4nodejs generate auth\`. No mocks:
|
|
1876
|
+
* real Router + route discovery, real Auth (PBKDF2 + JWT), real SQLite. register
|
|
1877
|
+
* + login are public; the token from login authenticates GET /api/auth/me.
|
|
1878
|
+
* Run with: npx tsx tests/auth.test.ts
|
|
1879
|
+
*/
|
|
1880
|
+
import { dirname, resolve } from "node:path";
|
|
1881
|
+
import { fileURLToPath } from "node:url";
|
|
1882
|
+
import { Router, TestClient, discoverRoutes } from "tina4-nodejs";
|
|
1883
|
+
import { initDatabase } from "tina4-nodejs/orm";
|
|
1884
|
+
import User from "../src/models/User.js";
|
|
1885
|
+
|
|
1886
|
+
process.env.TINA4_SECRET = process.env.TINA4_SECRET ?? "test-secret";
|
|
1887
|
+
delete process.env.TINA4_API_KEY;
|
|
1888
|
+
const here = dirname(fileURLToPath(import.meta.url));`;
|
|
1889
|
+
const body = `await initDatabase({ url: "sqlite:///test_auth.db" });
|
|
1890
|
+
await User.createTable();
|
|
1891
|
+
for (const existing of await User.all()) await existing.delete(); // start from an empty table
|
|
1892
|
+
|
|
1893
|
+
const router = new Router();
|
|
1894
|
+
for (const def of await discoverRoutes(resolve(here, "../src/routes"))) router.addRoute(def);
|
|
1895
|
+
const client = new TestClient(router);
|
|
1896
|
+
|
|
1897
|
+
const registered = await client.post("/api/auth/register", { json: { email: "a@b.c", password: "secret12" } });
|
|
1898
|
+
assert("register a new user -> 201", registered.status === 201);
|
|
1899
|
+
|
|
1900
|
+
const duplicate = await client.post("/api/auth/register", { json: { email: "a@b.c", password: "secret12" } });
|
|
1901
|
+
assert("duplicate register -> 409", duplicate.status === 409);
|
|
1902
|
+
|
|
1903
|
+
const login = await client.post("/api/auth/login", { json: { email: "a@b.c", password: "secret12" } });
|
|
1904
|
+
assert("login -> 200", login.status === 200);
|
|
1905
|
+
const token = (login.json() as { token?: string }).token;
|
|
1906
|
+
assert("login returns a token", typeof token === "string" && token.length > 0);
|
|
1907
|
+
|
|
1908
|
+
const me = await client.get("/api/auth/me", { headers: { authorization: \`Bearer \${token}\` } });
|
|
1909
|
+
assert("authenticated GET /api/auth/me -> 200 (token accepted)", me.status === 200);
|
|
1910
|
+
assert("me returns the authenticated user's email",
|
|
1911
|
+
((me.json() as { user?: { email?: string } }).user?.email) === "a@b.c");
|
|
1912
|
+
|
|
1913
|
+
const anon = await client.get("/api/auth/me");
|
|
1914
|
+
assert("anonymous GET /api/auth/me -> 401", anon.status === 401);
|
|
1915
|
+
|
|
1916
|
+
const bad = await client.post("/api/auth/login", { json: { email: "a@b.c", password: "WRONG" } });
|
|
1917
|
+
assert("wrong password -> 401", bad.status === 401);`;
|
|
1918
|
+
writeTest("auth", standaloneTest(doc, body));
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
/** migration (create_*) → apply UP then DOWN against real SQLite, assert the
|
|
1922
|
+
* table appears then disappears. Only for CREATE migrations. */
|
|
1923
|
+
function emitMigrationTest(migrationName: string, table: string): void {
|
|
1924
|
+
const doc = `/**
|
|
1925
|
+
* Real migration test for ${migrationName} — no mocks, real SQLite.
|
|
1926
|
+
*
|
|
1927
|
+
* Generated with the migration by \`tina4nodejs generate migration
|
|
1928
|
+
* ${migrationName}\`. Applies the generated UP SQL against a fresh real
|
|
1929
|
+
* in-memory SQLite database and asserts the table exists, then applies the DOWN
|
|
1930
|
+
* SQL and asserts it is gone — the raw SQL the migration runner executes.
|
|
1931
|
+
* Run with: npx tsx tests/${table}_migration.test.ts
|
|
1932
|
+
*/
|
|
1933
|
+
import { dirname, join, resolve } from "node:path";
|
|
1934
|
+
import { fileURLToPath } from "node:url";
|
|
1935
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
1936
|
+
import { SQLiteAdapter } from "tina4-nodejs/orm";`;
|
|
1937
|
+
const body = `const here = dirname(fileURLToPath(import.meta.url));
|
|
1938
|
+
const migrationsDir = resolve(here, "../migrations");
|
|
1939
|
+
|
|
1940
|
+
const upFile = readdirSync(migrationsDir).find((f) => f.endsWith("_${migrationName}.sql") && !f.endsWith(".down.sql"));
|
|
1941
|
+
assert("generated UP migration file exists", Boolean(upFile));
|
|
1942
|
+
const downFile = upFile!.replace(/\\.sql$/, ".down.sql");
|
|
1943
|
+
|
|
1944
|
+
function statements(sql: string): string[] {
|
|
1945
|
+
const noComments = sql.split("\\n").filter((l) => !l.trim().startsWith("--")).join("\\n");
|
|
1946
|
+
return noComments.split(";").map((s) => s.trim()).filter(Boolean);
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
const upText = readFileSync(join(migrationsDir, upFile!), "utf-8");
|
|
1950
|
+
const upSql = upText.split("-- UP")[1].split("-- DOWN")[0];
|
|
1951
|
+
const downSql = readFileSync(join(migrationsDir, downFile), "utf-8");
|
|
1952
|
+
|
|
1953
|
+
const db = new SQLiteAdapter(":memory:");
|
|
1954
|
+
for (const stmt of statements(upSql)) db.execute(stmt);
|
|
1955
|
+
assert("UP creates the ${table} table", db.tableExists("${table}"));
|
|
1956
|
+
|
|
1957
|
+
for (const stmt of statements(downSql)) db.execute(stmt);
|
|
1958
|
+
assert("DOWN drops the ${table} table", db.tableExists("${table}") === false);`;
|
|
1959
|
+
writeTest(`${table}_migration`, standaloneTest(doc, body));
|
|
1492
1960
|
}
|