db-model-router 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +25 -4
  2. package/db-manager/.dbmanager.sqlite-shm +0 -0
  3. package/db-manager/.dbmanager.sqlite-wal +0 -0
  4. package/demo/.env.example +1 -0
  5. package/demo/app.js +2 -2
  6. package/demo/commons/db.js +0 -11
  7. package/demo/middleware/tenantIsolation.js +2 -0
  8. package/demo/package-lock.json +1224 -62
  9. package/demo/package.json +6 -3
  10. package/demo/routes/addresses/index.js +5 -1
  11. package/demo/routes/auth/index.js +1 -1
  12. package/demo/routes/carts/cart_items/index.js +5 -1
  13. package/demo/routes/carts/index.js +9 -1
  14. package/demo/routes/categories/index.js +5 -1
  15. package/demo/routes/coupons/index.js +5 -1
  16. package/demo/routes/index.js +1 -15
  17. package/demo/routes/orders/index.js +13 -1
  18. package/demo/routes/orders/order_items/index.js +5 -1
  19. package/demo/routes/orders/payments/index.js +5 -1
  20. package/demo/routes/orders/shipments/index.js +5 -1
  21. package/demo/routes/products/index.js +13 -1
  22. package/demo/routes/products/product_images/index.js +5 -1
  23. package/demo/routes/products/product_reviews/index.js +5 -1
  24. package/demo/routes/products/product_variants/index.js +5 -1
  25. package/demo/routes/roles/index.js +1 -1
  26. package/demo/routes/tenants/index.js +1 -1
  27. package/demo/routes/users/index.js +1 -1
  28. package/demo/routes/wishlists/index.js +5 -1
  29. package/demo/seeds/saas-seed.js +1 -1
  30. package/docs/dbmr-schema-spec.md +393 -0
  31. package/package.json +4 -2
  32. package/skill/SKILL.md +47 -4
  33. package/src/cli/commands/generate.js +45 -15
  34. package/src/cli/diff-engine.js +17 -5
  35. package/src/cli/generate-migration.js +207 -19
  36. package/src/cli/generate-route.js +156 -58
  37. package/src/cli/generate-saas-structure.js +8 -1
  38. package/src/cli/init/dependencies.js +5 -1
  39. package/src/cli/init/generators.js +4 -81
  40. package/src/cli/init.js +1 -2
  41. package/src/cli/saas/generate-saas-middleware.js +2 -0
  42. package/src/cli/saas/generate-saas-routes.js +3 -13
  43. package/src/cli/saas/generate-saas-tests.js +473 -0
  44. package/src/commons/route.js +6 -6
  45. /package/demo/migrations/{20260509170349_create_migrations_table.sql → 20260510193736_create_migrations_table.sql} +0 -0
  46. /package/demo/migrations/{20260509170349_create_saas_tables.sql → 20260510193737_create_saas_tables.sql} +0 -0
  47. /package/demo/migrations/{20260509170349_create_tables.sql → 20260510193737_create_tables.sql} +0 -0
@@ -11,17 +11,83 @@
11
11
  // Column type mapping per adapter
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
+ /**
15
+ * Known base types — anything not in this set is treated as a validator.
16
+ */
17
+ const BASE_TYPES = new Set([
18
+ "auto_increment",
19
+ "string",
20
+ "integer",
21
+ "numeric",
22
+ "boolean",
23
+ "datetime",
24
+ "object",
25
+ ]);
26
+
27
+ /**
28
+ * Parse a dbmr column rule string into its components.
29
+ *
30
+ * Format: [required|]<type>[:<subtype>][|<validator>...]
31
+ *
32
+ * @param {string} rule - e.g. "required|string:text|minLength:10|maxLength:5000"
33
+ * @returns {{ isRequired: boolean, baseType: string, subType: string|null, validators: string[] }}
34
+ */
35
+ function parseColumnRule(rule) {
36
+ const parts = rule.split("|");
37
+ const isRequired = parts.includes("required");
38
+
39
+ let baseType = "string";
40
+ let subType = null;
41
+ const validators = [];
42
+
43
+ for (const part of parts) {
44
+ if (part === "required") continue;
45
+
46
+ // Check if this part is a base type (possibly with :subtype)
47
+ const colonIdx = part.indexOf(":");
48
+ const token = colonIdx > -1 ? part.slice(0, colonIdx) : part;
49
+
50
+ if (!baseType || baseType === "string") {
51
+ if (BASE_TYPES.has(token)) {
52
+ baseType = token;
53
+ subType = colonIdx > -1 ? part.slice(colonIdx + 1) : null;
54
+ continue;
55
+ }
56
+ }
57
+
58
+ // If we already found the base type, or this isn't a known type, it's a validator
59
+ if (BASE_TYPES.has(token) && baseType === "string" && token !== "string") {
60
+ baseType = token;
61
+ subType = colonIdx > -1 ? part.slice(colonIdx + 1) : null;
62
+ } else if (BASE_TYPES.has(part)) {
63
+ // Bare base type without colon, and we haven't set one yet
64
+ if (baseType === "string" && part !== "string") {
65
+ baseType = part;
66
+ } else if (baseType === "string" && part === "string") {
67
+ // already default, skip
68
+ } else {
69
+ validators.push(part);
70
+ }
71
+ } else if (BASE_TYPES.has(token)) {
72
+ baseType = token;
73
+ subType = colonIdx > -1 ? part.slice(colonIdx + 1) : null;
74
+ } else {
75
+ validators.push(part);
76
+ }
77
+ }
78
+
79
+ return { isRequired, baseType, subType, validators };
80
+ }
81
+
14
82
  /**
15
83
  * Map a dbmr column rule to a SQL column type for the given adapter.
16
84
  *
17
- * @param {string} rule - e.g. "required|string", "auto_increment", "integer"
85
+ * @param {string} rule - e.g. "required|string:text|minLength:10", "auto_increment", "integer:bigint"
18
86
  * @param {string} adapter - e.g. "postgres", "mysql", "sqlite3"
19
- * @returns {{ sqlType: string, nullable: boolean, isAutoIncrement: boolean }}
87
+ * @returns {{ sqlType: string, nullable: boolean, isAutoIncrement: boolean, validators: string[] }}
20
88
  */
21
89
  function mapColumnType(rule, adapter) {
22
- const parts = rule.split("|");
23
- const isRequired = parts.includes("required");
24
- const baseType = parts.filter((p) => p !== "required")[0] || "string";
90
+ const { isRequired, baseType, subType, validators } = parseColumnRule(rule);
25
91
 
26
92
  let sqlType;
27
93
  let isAutoIncrement = false;
@@ -32,13 +98,13 @@ function mapColumnType(rule, adapter) {
32
98
  sqlType = autoIncrementType(adapter);
33
99
  break;
34
100
  case "string":
35
- sqlType = stringType(adapter);
101
+ sqlType = stringType(adapter, subType);
36
102
  break;
37
103
  case "integer":
38
- sqlType = integerType(adapter);
104
+ sqlType = integerType(adapter, subType);
39
105
  break;
40
106
  case "numeric":
41
- sqlType = numericType(adapter);
107
+ sqlType = numericType(adapter, subType);
42
108
  break;
43
109
  case "boolean":
44
110
  sqlType = booleanType(adapter);
@@ -50,13 +116,14 @@ function mapColumnType(rule, adapter) {
50
116
  sqlType = jsonType(adapter);
51
117
  break;
52
118
  default:
53
- sqlType = stringType(adapter);
119
+ sqlType = stringType(adapter, subType);
54
120
  }
55
121
 
56
122
  return {
57
123
  sqlType,
58
124
  nullable: !isRequired && !isAutoIncrement,
59
125
  isAutoIncrement,
126
+ validators,
60
127
  };
61
128
  }
62
129
 
@@ -77,20 +144,140 @@ function autoIncrementType(adapter) {
77
144
  }
78
145
  }
79
146
 
80
- function stringType(adapter) {
81
- if (adapter === "oracle") return "VARCHAR2(255)";
82
- return "VARCHAR(255)";
147
+ function stringType(adapter, subType) {
148
+ if (!subType) {
149
+ if (adapter === "oracle") return "VARCHAR2(255)";
150
+ if (adapter === "mssql") return "NVARCHAR(255)";
151
+ return "VARCHAR(255)";
152
+ }
153
+
154
+ // varchar(N) or char(N) with explicit length
155
+ const varcharMatch = subType.match(/^varchar\((\d+)\)$/i);
156
+ if (varcharMatch) {
157
+ const n = varcharMatch[1];
158
+ if (adapter === "oracle") return `VARCHAR2(${n})`;
159
+ if (adapter === "mssql") return `NVARCHAR(${n})`;
160
+ return `VARCHAR(${n})`;
161
+ }
162
+
163
+ const charMatch = subType.match(/^char\((\d+)\)$/i);
164
+ if (charMatch) {
165
+ const n = charMatch[1];
166
+ if (adapter === "oracle") return `CHAR(${n})`;
167
+ if (adapter === "mssql") return `NCHAR(${n})`;
168
+ if (adapter === "sqlite3") return "TEXT";
169
+ return `CHAR(${n})`;
170
+ }
171
+
172
+ switch (subType.toLowerCase()) {
173
+ case "text":
174
+ if (adapter === "oracle") return "CLOB";
175
+ if (adapter === "mssql") return "NVARCHAR(MAX)";
176
+ return "TEXT";
177
+ case "mediumtext":
178
+ if (adapter === "mysql" || adapter === "mariadb") return "MEDIUMTEXT";
179
+ if (adapter === "oracle") return "CLOB";
180
+ if (adapter === "mssql") return "NVARCHAR(MAX)";
181
+ return "TEXT";
182
+ case "longtext":
183
+ if (adapter === "mysql" || adapter === "mariadb") return "LONGTEXT";
184
+ if (adapter === "oracle") return "CLOB";
185
+ if (adapter === "mssql") return "NVARCHAR(MAX)";
186
+ return "TEXT";
187
+ case "char":
188
+ if (adapter === "oracle") return "CHAR(255)";
189
+ if (adapter === "mssql") return "NCHAR(255)";
190
+ if (adapter === "sqlite3") return "TEXT";
191
+ return "CHAR(255)";
192
+ case "uuid":
193
+ if (adapter === "postgres" || adapter === "cockroachdb") return "UUID";
194
+ if (adapter === "mssql") return "UNIQUEIDENTIFIER";
195
+ if (adapter === "oracle") return "CHAR(36)";
196
+ return "CHAR(36)";
197
+ default:
198
+ if (adapter === "oracle") return "VARCHAR2(255)";
199
+ if (adapter === "mssql") return "NVARCHAR(255)";
200
+ return "VARCHAR(255)";
201
+ }
83
202
  }
84
203
 
85
- function integerType(adapter) {
86
- if (adapter === "oracle") return "NUMBER(10)";
87
- return "INTEGER";
204
+ function integerType(adapter, subType) {
205
+ if (!subType) {
206
+ if (adapter === "oracle") return "NUMBER(10)";
207
+ return "INTEGER";
208
+ }
209
+
210
+ switch (subType.toLowerCase()) {
211
+ case "tinyint":
212
+ if (adapter === "oracle") return "NUMBER(3)";
213
+ if (adapter === "mssql") return "TINYINT";
214
+ if (adapter === "postgres" || adapter === "cockroachdb")
215
+ return "SMALLINT";
216
+ if (adapter === "sqlite3") return "INTEGER";
217
+ return "TINYINT";
218
+ case "smallint":
219
+ if (adapter === "oracle") return "NUMBER(5)";
220
+ if (adapter === "sqlite3") return "INTEGER";
221
+ return "SMALLINT";
222
+ case "bigint":
223
+ if (adapter === "oracle") return "NUMBER(19)";
224
+ if (adapter === "sqlite3") return "INTEGER";
225
+ return "BIGINT";
226
+ case "unsigned":
227
+ if (adapter === "mysql" || adapter === "mariadb") return "INT UNSIGNED";
228
+ if (adapter === "oracle") return "NUMBER(10)";
229
+ // PostgreSQL, SQLite, MSSQL don't support UNSIGNED — use plain INT
230
+ return "INTEGER";
231
+ case "bigint_unsigned":
232
+ if (adapter === "mysql" || adapter === "mariadb")
233
+ return "BIGINT UNSIGNED";
234
+ if (adapter === "oracle") return "NUMBER(19)";
235
+ if (adapter === "sqlite3") return "INTEGER";
236
+ return "BIGINT";
237
+ default:
238
+ if (adapter === "oracle") return "NUMBER(10)";
239
+ return "INTEGER";
240
+ }
88
241
  }
89
242
 
90
- function numericType(adapter) {
91
- if (adapter === "oracle") return "NUMBER(12,2)";
92
- if (adapter === "mssql") return "DECIMAL(12,2)";
93
- return "DECIMAL(12,2)";
243
+ function numericType(adapter, subType) {
244
+ if (!subType) {
245
+ if (adapter === "oracle") return "NUMBER(12,2)";
246
+ if (adapter === "mssql") return "DECIMAL(12,2)";
247
+ return "DECIMAL(12,2)";
248
+ }
249
+
250
+ // decimal(P,S) with explicit precision/scale
251
+ const decimalMatch = subType.match(/^decimal\((\d+),(\d+)\)$/i);
252
+ if (decimalMatch) {
253
+ const p = decimalMatch[1];
254
+ const s = decimalMatch[2];
255
+ if (adapter === "oracle") return `NUMBER(${p},${s})`;
256
+ return `DECIMAL(${p},${s})`;
257
+ }
258
+
259
+ switch (subType.toLowerCase()) {
260
+ case "float":
261
+ if (adapter === "oracle") return "FLOAT";
262
+ if (adapter === "postgres" || adapter === "cockroachdb") return "REAL";
263
+ if (adapter === "sqlite3") return "REAL";
264
+ return "FLOAT";
265
+ case "double":
266
+ if (adapter === "oracle") return "BINARY_DOUBLE";
267
+ if (adapter === "postgres" || adapter === "cockroachdb")
268
+ return "DOUBLE PRECISION";
269
+ if (adapter === "sqlite3") return "REAL";
270
+ if (adapter === "mssql") return "FLOAT";
271
+ return "DOUBLE";
272
+ case "money":
273
+ if (adapter === "postgres" || adapter === "cockroachdb") return "MONEY";
274
+ if (adapter === "mssql") return "MONEY";
275
+ if (adapter === "oracle") return "NUMBER(19,4)";
276
+ return "DECIMAL(19,4)";
277
+ default:
278
+ if (adapter === "oracle") return "NUMBER(12,2)";
279
+ return "DECIMAL(12,2)";
280
+ }
94
281
  }
95
282
 
96
283
  function booleanType(adapter) {
@@ -353,4 +540,5 @@ module.exports = {
353
540
  generateMigrationFiles,
354
541
  generateCreateTableSQL,
355
542
  mapColumnType,
543
+ parseColumnRule,
356
544
  };
@@ -40,7 +40,7 @@ export default router;
40
40
 
41
41
  /**
42
42
  * Generate a parent route file that includes its own CRUD and mounts child routes.
43
- * e.g., routes/orders/index.js mounts order_items under /:order_id/items
43
+ * e.g., routes/users.js mounts posts under /:user_id/posts
44
44
  *
45
45
  * @param {string} tableName - Parent table name
46
46
  * @param {Array<{child, foreignKey}>} children - Child relationships for this parent
@@ -56,7 +56,7 @@ import { ${varName} } from "#models";
56
56
  // Import child routes
57
57
  for (const child of children) {
58
58
  const childVar = safeVarName(child.child);
59
- code += `import ${childVar}Route from "./${child.child}/index.js";\n`;
59
+ code += `import ${childVar}Route from "./${tableName}/${child.child}.js";\n`;
60
60
  }
61
61
 
62
62
  code += `
@@ -129,7 +129,7 @@ function generateRoutesIndexFile(tableNames, relationships = [], options = {}) {
129
129
  for (const table of tableNames) {
130
130
  if (nestedChildren.has(table)) continue;
131
131
  const varName = safeVarName(table);
132
- imports += `import ${varName}Route from "./${table}/index.js";\n`;
132
+ imports += `import ${varName}Route from "./${table}.js";\n`;
133
133
  }
134
134
 
135
135
  // Import docs route if openapi is generated
@@ -166,18 +166,22 @@ function generateSimpleRoutesIndexFile(tableNames) {
166
166
  * Generate a test file for a route covering all CRUD methods.
167
167
  * Uses supertest + the app's express setup.
168
168
  */
169
- function generateTestFile(tableName, pk) {
169
+ function generateTestFile(tableName, pk, structure) {
170
170
  const varName = safeVarName(tableName);
171
+ const fakerFields = generateFakerFields(structure || {});
172
+ const fakerImport = `import { faker } from "@faker-js/faker";`;
173
+
171
174
  return `import assert from "assert";
172
175
  import express from "express";
173
176
  import request from "supertest";
177
+ import "dotenv/config";
178
+ import "../commons/db.js";
174
179
  import dbModelRouter from "db-model-router";
180
+ import { ${varName} } from "../models/index.js";
181
+ ${fakerImport}
175
182
 
176
183
  const { route } = dbModelRouter;
177
184
 
178
- // Adjust the path to your model file as needed
179
- import ${varName} from "../models/${tableName}.js";
180
-
181
185
  function createApp() {
182
186
  const app = express();
183
187
  app.use(express.json());
@@ -185,92 +189,186 @@ function createApp() {
185
189
  return app;
186
190
  }
187
191
 
192
+ ${fakerFields.helperFn}
193
+
188
194
  describe("${tableName} routes", function () {
189
195
  let app;
196
+ let createdId;
190
197
 
191
198
  before(function () {
192
199
  app = createApp();
193
200
  });
194
201
 
195
- describe("GET /${tableName}/", function () {
196
- it("should list records", async function () {
202
+ describe("CRUD lifecycle", function () {
203
+ it("POST /${tableName}/add — should insert a record", async function () {
204
+ const data = generateFakeData();
205
+ const res = await request(app)
206
+ .post("/${tableName}/add")
207
+ .send(data);
208
+ assert.strictEqual(res.status, 200, \`Expected 200, got \${res.status}: \${JSON.stringify(res.body)}\`);
209
+ createdId = res.body.${pk} || res.body.id;
210
+ assert.ok(createdId, "Response should contain the created record ID");
211
+ });
212
+
213
+ it("GET /${tableName}/ — should list records including created one", async function () {
197
214
  const res = await request(app).get("/${tableName}/");
198
215
  assert.strictEqual(res.status, 200);
199
216
  assert.ok(Array.isArray(res.body.data));
217
+ assert.ok(res.body.data.length > 0, "Should have at least one record");
200
218
  });
201
- });
202
219
 
203
- describe("POST /${tableName}/add", function () {
204
- it("should insert a single record", async function () {
205
- const res = await request(app)
206
- .post("/${tableName}/add")
207
- .send({});
208
- assert.ok([200, 201, 400].includes(res.status));
220
+ it("GET /${tableName}/:${pk} — should get the created record", async function () {
221
+ const res = await request(app).get(\`/${tableName}/\${createdId}\`);
222
+ assert.strictEqual(res.status, 200, \`Expected 200, got \${res.status}\`);
223
+ assert.strictEqual(String(res.body.${pk} || res.body.id), String(createdId));
209
224
  });
210
- });
211
225
 
212
- describe("POST /${tableName}/", function () {
213
- it("should bulk insert records", async function () {
226
+ it("PUT /${tableName}/:${pk} — should update the record", async function () {
227
+ const data = { ...generateFakeData(), ${pk}: createdId };
214
228
  const res = await request(app)
215
- .post("/${tableName}/")
216
- .send({ data: [] });
217
- assert.ok([200, 201, 400].includes(res.status));
229
+ .put(\`/${tableName}/\${createdId}\`)
230
+ .send(data);
231
+ assert.strictEqual(res.status, 200, \`Expected 200, got \${res.status}: \${JSON.stringify(res.body)}\`);
218
232
  });
219
- });
220
233
 
221
- describe("GET /${tableName}/:${pk}", function () {
222
- it("should get a record by ID", async function () {
223
- const res = await request(app).get("/${tableName}/1");
224
- assert.ok([200, 404].includes(res.status));
225
- });
226
- });
227
-
228
- describe("PUT /${tableName}/:${pk}", function () {
229
- it("should update a record", async function () {
234
+ it("PATCH /${tableName}/:${pk} — should partially update the record", async function () {
230
235
  const res = await request(app)
231
- .put("/${tableName}/1")
232
- .send({});
233
- assert.ok([200, 400, 404].includes(res.status));
236
+ .patch(\`/${tableName}/\${createdId}\`)
237
+ .send(${fakerFields.patchPayload});
238
+ assert.strictEqual(res.status, 200, \`Expected 200, got \${res.status}: \${JSON.stringify(res.body)}\`);
234
239
  });
235
- });
236
240
 
237
- describe("PATCH /${tableName}/:${pk}", function () {
238
- it("should partially update a record", async function () {
239
- const res = await request(app)
240
- .patch("/${tableName}/1")
241
- .send({});
242
- assert.ok([200, 400, 404].includes(res.status));
241
+ it("DELETE /${tableName}/:${pk} — should delete the record", async function () {
242
+ const res = await request(app).delete(\`/${tableName}/\${createdId}\`);
243
+ assert.ok([200, 204].includes(res.status), \`Expected 200/204, got \${res.status}\`);
243
244
  });
244
- });
245
245
 
246
- describe("DELETE /${tableName}/:${pk}", function () {
247
- it("should delete a record", async function () {
248
- const res = await request(app).delete("/${tableName}/1");
249
- assert.ok([200, 204, 404].includes(res.status));
246
+ it("GET /${tableName}/:${pk} — should return 404 after deletion", async function () {
247
+ const res = await request(app).get(\`/${tableName}/\${createdId}\`);
248
+ assert.strictEqual(res.status, 404);
250
249
  });
251
250
  });
252
251
 
253
- describe("PUT /${tableName}/", function () {
254
- it("should bulk update records", async function () {
252
+ describe("Bulk operations", function () {
253
+ it("POST /${tableName}/ — should bulk insert records", async function () {
254
+ const data = [generateFakeData(), generateFakeData()];
255
255
  const res = await request(app)
256
- .put("/${tableName}/")
257
- .send({ data: [] });
258
- assert.ok([200, 400].includes(res.status));
256
+ .post("/${tableName}/")
257
+ .send({ data });
258
+ assert.strictEqual(res.status, 200, \`Expected 200, got \${res.status}: \${JSON.stringify(res.body)}\`);
259
259
  });
260
260
  });
261
261
 
262
- describe("DELETE /${tableName}/", function () {
263
- it("should bulk delete records", async function () {
264
- const res = await request(app)
265
- .delete("/${tableName}/")
266
- .send({});
267
- assert.ok([200, 204, 400].includes(res.status));
262
+ describe("Error handling", function () {
263
+ it("GET /${tableName}/:${pk} — should return 404 for non-existent ID", async function () {
264
+ const res = await request(app).get("/${tableName}/999999");
265
+ assert.strictEqual(res.status, 404);
268
266
  });
269
267
  });
270
268
  });
271
269
  `;
272
270
  }
273
271
 
272
+ /**
273
+ * Generate a faker helper function string and patch payload based on model structure.
274
+ *
275
+ * @param {object} structure - Model structure { colName: "rule" }
276
+ * @returns {{ helperFn: string, patchPayload: string }}
277
+ */
278
+ function generateFakerFields(structure) {
279
+ const lines = [];
280
+ let firstStringCol = null;
281
+
282
+ for (const [col, rule] of Object.entries(structure)) {
283
+ const fakerCall = columnToFaker(col, rule);
284
+ lines.push(` ${col}: ${fakerCall},`);
285
+ if (!firstStringCol && rule.includes("string")) {
286
+ firstStringCol = col;
287
+ }
288
+ }
289
+
290
+ const helperFn = `function generateFakeData() {
291
+ return {
292
+ ${lines.join("\n")}
293
+ };
294
+ }`;
295
+
296
+ const patchPayload = firstStringCol
297
+ ? `{ ${firstStringCol}: faker.lorem.word() }`
298
+ : `{ ${Object.keys(structure)[0] || "name"}: faker.lorem.word() }`;
299
+
300
+ return { helperFn, patchPayload };
301
+ }
302
+
303
+ /**
304
+ * Map a column name + rule to an appropriate faker call.
305
+ */
306
+ function columnToFaker(col, rule) {
307
+ const lowerCol = col.toLowerCase();
308
+
309
+ // Name-based heuristics first
310
+ if (lowerCol === "email") return "faker.internet.email()";
311
+ if (lowerCol === "phone") return "faker.phone.number()";
312
+ if (lowerCol === "name" || lowerCol.endsWith("_name"))
313
+ return "faker.person.fullName()";
314
+ if (lowerCol === "url" || lowerCol.endsWith("_url"))
315
+ return "faker.internet.url()";
316
+ if (lowerCol === "slug") return "faker.helpers.slugify(faker.lorem.words(2))";
317
+ if (lowerCol === "password" || lowerCol === "password_hash")
318
+ return "faker.internet.password()";
319
+ if (lowerCol === "secret" || lowerCol === "key" || lowerCol === "token")
320
+ return "faker.string.alphanumeric(32)";
321
+ if (lowerCol === "title") return "faker.lorem.sentence()";
322
+ if (
323
+ lowerCol === "description" ||
324
+ lowerCol === "body" ||
325
+ lowerCol === "content"
326
+ )
327
+ return "faker.lorem.paragraph()";
328
+ if (lowerCol === "status")
329
+ return "faker.helpers.arrayElement(['active', 'inactive', 'pending'])";
330
+ if (lowerCol === "event_type")
331
+ return "faker.helpers.arrayElement(['user.created', 'order.placed', 'payment.received'])";
332
+ if (lowerCol === "currency") return "faker.finance.currencyCode()";
333
+ if (
334
+ lowerCol.includes("amount") ||
335
+ lowerCol.includes("price") ||
336
+ lowerCol.includes("total") ||
337
+ lowerCol.includes("subtotal")
338
+ )
339
+ return "parseFloat(faker.finance.amount())";
340
+ if (lowerCol.includes("quantity") || lowerCol.includes("count"))
341
+ return "faker.number.int({ min: 1, max: 100 })";
342
+ if (lowerCol === "unique_attribute") return "faker.string.uuid()";
343
+ if (lowerCol === "attributes") return "{ custom: faker.lorem.word() }";
344
+ if (lowerCol === "permission")
345
+ return "{ module: 'users', action: 'read', scope: 'tenant' }";
346
+ if (lowerCol === "response_body") return "faker.lorem.sentence()";
347
+ if (lowerCol === "response_status_code")
348
+ return "faker.helpers.arrayElement([200, 201, 400, 500])";
349
+
350
+ // Type-based fallback
351
+ const parts = rule.split("|");
352
+ const baseType = parts.filter((p) => p !== "required")[0] || "string";
353
+
354
+ switch (baseType) {
355
+ case "integer":
356
+ if (lowerCol.endsWith("_id"))
357
+ return "faker.number.int({ min: 1, max: 100 })";
358
+ return "faker.number.int({ min: 1, max: 1000 })";
359
+ case "numeric":
360
+ return "parseFloat(faker.finance.amount())";
361
+ case "boolean":
362
+ return "faker.datatype.boolean()";
363
+ case "object":
364
+ return "{ key: faker.lorem.word() }";
365
+ case "datetime":
366
+ return "faker.date.recent().toISOString()";
367
+ default:
368
+ return "faker.lorem.word()";
369
+ }
370
+ }
371
+
274
372
  /**
275
373
  * Generate a child route test file that tests the nested parent/:fk/child endpoints.
276
374
  */
@@ -21,6 +21,7 @@ const {
21
21
  generateModulesUtil,
22
22
  generateWebhookUtil,
23
23
  } = require("./saas/generate-saas-utils");
24
+ const { generateSaasTests } = require("./saas/generate-saas-tests");
24
25
 
25
26
  /**
26
27
  * Read the existing .gitignore file and append `credentials.md` if not already present.
@@ -113,7 +114,13 @@ function generateSaasStructure(adapter, options) {
113
114
  content: generateWebhookUtil(),
114
115
  });
115
116
 
116
- // 7. .gitignore update (add credentials.md)
117
+ // 7. Tests
118
+ const tests = generateSaasTests();
119
+ for (const entry of tests) {
120
+ planned.push(entry);
121
+ }
122
+
123
+ // 8. .gitignore update (add credentials.md)
117
124
  planned.push({ relPath: ".gitignore", content: getGitignoreContent() });
118
125
 
119
126
  return planned;
@@ -62,6 +62,10 @@ function collectDependencies(answers) {
62
62
 
63
63
  // Dev dependencies
64
64
  devDependencies["nodemon"] = "latest";
65
+ devDependencies["mocha"] = "latest";
66
+ devDependencies["supertest"] = "latest";
67
+ devDependencies["dotenv-cli"] = "latest";
68
+ devDependencies["@faker-js/faker"] = "latest";
65
69
 
66
70
  // Swagger UI for API documentation
67
71
  dependencies["swagger-ui-express"] = "latest";
@@ -79,7 +83,7 @@ function getScripts(outputDir) {
79
83
  return {
80
84
  start: "node app.js",
81
85
  dev: "nodemon app.js",
82
- test: 'echo "Error: no test specified" && exit 1',
86
+ test: "dotenv -- mocha --exit",
83
87
  migrate: `node ${prefix}commons/migrate.js`,
84
88
  add_migration: `node ${prefix}commons/add_migration.js`,
85
89
  "docker:build": "docker build -t app .",