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.
@@ -215,16 +215,51 @@ function isoNow(): string {
215
215
  return new Date().toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
216
216
  }
217
217
 
218
- const GENERATORS =
219
- "model, route, crud, migration, middleware, test, form, view, auth, " +
220
- "service, queue, validator, seeder, websocket, listener";
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: ${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
- switch (what) {
244
- case "model":
245
- generateModel(name, flags);
246
- break;
247
- case "route":
248
- generateRoute(name, flags);
249
- break;
250
- case "crud":
251
- generateCrud(name, flags);
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>): void {
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>): void {
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
- generateModel(name, flags);
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
- generateRoute(routeName, { ...flags, model: name });
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
- generateModel("User", { fields: "email:string,password:string,role:string" });
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
- generateTest("auth", { model: "User" });
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
- const body = aiFill(`validate${toPascal(name)}`, {
1313
- intent: `declare the validation rules for a ${name} payload`,
1314
- given: "validator -> Validator(data) (chainable)",
1315
- use: `validator.required("name").email("email").minLength("name", 2).integer("age")`,
1316
- ret: "the same validator (caller checks .isValid() / .errors())",
1317
- ground: `tina4_context("validate request body with Validator", "nodejs") · skill tina4-developer-nodejs`,
1318
- raise: `validator ${toSnake(name)} not implemented`,
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
- ${body} return validator;
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
- const body = aiFill("fieldOverrides", {
1352
- intent: `map ${name} fields to fake-data generators (only those needing a specific shape)`,
1353
- given: "fake -> FakeData instance",
1354
- use: "fake.email() / fake.name() / fake.integer(1, 99) / fake.company()",
1355
- ret: `{ email: (f) => f.email(), status: "active" } (Record<string, unknown>)`,
1356
- ground: `tina4_context("seed ORM model with FakeData", "nodejs") · skill tina4-developer-nodejs`,
1357
- raise: `seeder ${name} overrides not implemented`,
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
- ${body}}
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
  }