@sapporta/server 0.0.1
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/package.json +40 -0
- package/src/actions/action.test.ts +108 -0
- package/src/actions/action.ts +60 -0
- package/src/actions/loader.ts +47 -0
- package/src/api/actions.ts +124 -0
- package/src/api/meta-mutations.ts +922 -0
- package/src/api/meta.ts +222 -0
- package/src/api/reports.ts +98 -0
- package/src/api/server.ts +24 -0
- package/src/api/tables.ts +108 -0
- package/src/api/views.ts +44 -0
- package/src/boot.ts +206 -0
- package/src/cli/ai-commands.ts +220 -0
- package/src/cli/check.ts +169 -0
- package/src/cli/cli-utils.test.ts +313 -0
- package/src/cli/describe.test.ts +151 -0
- package/src/cli/describe.ts +88 -0
- package/src/cli/emit-result.test.ts +160 -0
- package/src/cli/format.ts +150 -0
- package/src/cli/http-client.ts +55 -0
- package/src/cli/index.ts +162 -0
- package/src/cli/init.ts +35 -0
- package/src/cli/project-context.ts +38 -0
- package/src/cli/request.ts +146 -0
- package/src/cli/routes.ts +418 -0
- package/src/cli/rows-insert-master-detail.test.ts +124 -0
- package/src/cli/rows-insert-master-detail.ts +186 -0
- package/src/cli/rows-insert.test.ts +137 -0
- package/src/cli/rows-insert.ts +97 -0
- package/src/cli/serve-single.ts +49 -0
- package/src/create-project.ts +81 -0
- package/src/data/count.ts +62 -0
- package/src/data/crud.test.ts +188 -0
- package/src/data/crud.ts +242 -0
- package/src/data/lookup.test.ts +96 -0
- package/src/data/lookup.ts +104 -0
- package/src/data/query-parser.test.ts +67 -0
- package/src/data/query-parser.ts +106 -0
- package/src/data/sanitize.test.ts +57 -0
- package/src/data/sanitize.ts +25 -0
- package/src/data/save-pipeline.test.ts +115 -0
- package/src/data/save-pipeline.ts +93 -0
- package/src/data/validate.test.ts +110 -0
- package/src/data/validate.ts +98 -0
- package/src/db/errors.ts +20 -0
- package/src/db/logger.ts +63 -0
- package/src/db/sqlite-connection.test.ts +59 -0
- package/src/db/sqlite-connection.ts +79 -0
- package/src/index.ts +111 -0
- package/src/integration/api-actions.test.ts +60 -0
- package/src/integration/api-global.test.ts +21 -0
- package/src/integration/api-meta.test.ts +252 -0
- package/src/integration/api-reports.test.ts +77 -0
- package/src/integration/api-tables.test.ts +238 -0
- package/src/integration/api-views.test.ts +39 -0
- package/src/integration/cli-routes.test.ts +167 -0
- package/src/integration/fixtures/actions/create-account.ts +23 -0
- package/src/integration/fixtures/reports/account-list.ts +25 -0
- package/src/integration/fixtures/schema/accounts.ts +21 -0
- package/src/integration/fixtures/schema/audit-log.ts +19 -0
- package/src/integration/fixtures/schema/journal-entries.ts +20 -0
- package/src/integration/fixtures/views/dashboard.tsx +4 -0
- package/src/integration/fixtures/views/settings.tsx +3 -0
- package/src/integration/setup.ts +72 -0
- package/src/introspect/db-helpers.ts +109 -0
- package/src/introspect/describe-all.test.ts +73 -0
- package/src/introspect/describe-all.ts +80 -0
- package/src/introspect/describe.test.ts +65 -0
- package/src/introspect/describe.ts +184 -0
- package/src/introspect/exec.test.ts +103 -0
- package/src/introspect/exec.ts +57 -0
- package/src/introspect/indexes.test.ts +41 -0
- package/src/introspect/indexes.ts +95 -0
- package/src/introspect/inference.ts +98 -0
- package/src/introspect/list-tables.test.ts +40 -0
- package/src/introspect/list-tables.ts +62 -0
- package/src/introspect/query.test.ts +77 -0
- package/src/introspect/query.ts +47 -0
- package/src/introspect/sample.test.ts +67 -0
- package/src/introspect/sample.ts +50 -0
- package/src/introspect/sql-safety.ts +76 -0
- package/src/introspect/sqlite/db-helpers.test.ts +79 -0
- package/src/introspect/sqlite/db-helpers.ts +56 -0
- package/src/introspect/sqlite/describe-all.ts +21 -0
- package/src/introspect/sqlite/describe.test.ts +160 -0
- package/src/introspect/sqlite/describe.ts +185 -0
- package/src/introspect/sqlite/exec.ts +57 -0
- package/src/introspect/sqlite/indexes.test.ts +60 -0
- package/src/introspect/sqlite/indexes.ts +96 -0
- package/src/introspect/sqlite/list-tables.test.ts +100 -0
- package/src/introspect/sqlite/list-tables.ts +67 -0
- package/src/introspect/sqlite/query.ts +49 -0
- package/src/introspect/sqlite/sample.ts +50 -0
- package/src/introspect/table-rename.test.ts +235 -0
- package/src/introspect/table-rename.ts +115 -0
- package/src/introspect/types.ts +95 -0
- package/src/reports/check.test.ts +499 -0
- package/src/reports/check.ts +208 -0
- package/src/reports/engine.test.ts +1465 -0
- package/src/reports/engine.ts +678 -0
- package/src/reports/loader.ts +55 -0
- package/src/reports/report.ts +308 -0
- package/src/reports/sql-bind.ts +161 -0
- package/src/reports/sqlite-bind.test.ts +98 -0
- package/src/reports/sqlite-bind.ts +58 -0
- package/src/reports/sqlite-sql-client.ts +42 -0
- package/src/runtime.ts +3 -0
- package/src/schema/check.ts +90 -0
- package/src/schema/ddl.test.ts +210 -0
- package/src/schema/ddl.ts +180 -0
- package/src/schema/dynamic-builder.ts +297 -0
- package/src/schema/extract.test.ts +261 -0
- package/src/schema/extract.ts +285 -0
- package/src/schema/loader.test.ts +31 -0
- package/src/schema/loader.ts +60 -0
- package/src/schema/metadata-io.test.ts +261 -0
- package/src/schema/metadata-io.ts +161 -0
- package/src/schema/metadata-tables.test.ts +737 -0
- package/src/schema/metadata-tables.ts +341 -0
- package/src/schema/migrate.ts +195 -0
- package/src/schema/normalize-datatype.test.ts +58 -0
- package/src/schema/normalize-datatype.ts +99 -0
- package/src/schema/registry.test.ts +174 -0
- package/src/schema/registry.ts +139 -0
- package/src/schema/reserved.ts +227 -0
- package/src/schema/table.ts +135 -0
- package/src/test-fixtures/schema/accounts.ts +24 -0
- package/src/test-fixtures/schema/not-a-table.ts +6 -0
- package/src/testing/test-utils.ts +44 -0
- package/src/views/loader.test.ts +70 -0
- package/src/views/loader.ts +38 -0
- package/src/views/view.test.ts +121 -0
- package/src/views/view.ts +16 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { parseFlags, formatTable, truncateValues } from "./format.js";
|
|
4
|
+
import { requireSelect, rejectDangerousSQL, validateTableName, validateColumnNames, rejectControlChars } from "../introspect/sql-safety.js";
|
|
5
|
+
import { buildInsertQuery, assertTableExists, getTableColumns, validatePayloadColumns } from "../introspect/db-helpers.js";
|
|
6
|
+
|
|
7
|
+
describe("parseFlags", () => {
|
|
8
|
+
it("parses --key value pairs", () => {
|
|
9
|
+
const result = parseFlags(["--table", "accounts", "--limit", "10"]);
|
|
10
|
+
expect(result.table).toBe("accounts");
|
|
11
|
+
expect(result.limit).toBe("10");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("parses boolean flags (no value)", () => {
|
|
15
|
+
const result = parseFlags(["--verbose", "--table", "accounts"]);
|
|
16
|
+
expect(result.verbose).toBe("true");
|
|
17
|
+
expect(result.table).toBe("accounts");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("collects positional args under _", () => {
|
|
21
|
+
const result = parseFlags(["accounts", "--limit", "5"]);
|
|
22
|
+
expect(result._).toEqual(["accounts"]);
|
|
23
|
+
expect(result.limit).toBe("5");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns empty for no args", () => {
|
|
27
|
+
const result = parseFlags([]);
|
|
28
|
+
expect(result._).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("requireSelect", () => {
|
|
33
|
+
it("allows SELECT queries", () => {
|
|
34
|
+
expect(() => requireSelect("SELECT * FROM accounts")).not.toThrow();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("allows WITH (CTE) queries", () => {
|
|
38
|
+
expect(() =>
|
|
39
|
+
requireSelect("WITH cte AS (SELECT 1) SELECT * FROM cte"),
|
|
40
|
+
).not.toThrow();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("allows case-insensitive", () => {
|
|
44
|
+
expect(() => requireSelect("select * from accounts")).not.toThrow();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rejects INSERT", () => {
|
|
48
|
+
expect(() =>
|
|
49
|
+
requireSelect("INSERT INTO accounts (name) VALUES ('x')"),
|
|
50
|
+
).toThrow("Only SELECT");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects UPDATE", () => {
|
|
54
|
+
expect(() =>
|
|
55
|
+
requireSelect("UPDATE accounts SET name = 'x'"),
|
|
56
|
+
).toThrow("Only SELECT");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("rejects DELETE", () => {
|
|
60
|
+
expect(() => requireSelect("DELETE FROM accounts")).toThrow("Only SELECT");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("rejectDangerousSQL", () => {
|
|
65
|
+
it("allows normal SELECT", () => {
|
|
66
|
+
expect(() => rejectDangerousSQL("SELECT * FROM accounts")).not.toThrow();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("allows normal INSERT", () => {
|
|
70
|
+
expect(() =>
|
|
71
|
+
rejectDangerousSQL("INSERT INTO accounts (name) VALUES ('x')"),
|
|
72
|
+
).not.toThrow();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("rejects DROP DATABASE", () => {
|
|
76
|
+
expect(() => rejectDangerousSQL("DROP DATABASE sapporta")).toThrow(
|
|
77
|
+
"DROP DATABASE",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("rejects TRUNCATE", () => {
|
|
82
|
+
expect(() => rejectDangerousSQL("TRUNCATE accounts")).toThrow("TRUNCATE");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("rejects DROP SCHEMA", () => {
|
|
86
|
+
expect(() => rejectDangerousSQL("DROP SCHEMA public CASCADE")).toThrow(
|
|
87
|
+
"DROP SCHEMA",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("validateTableName", () => {
|
|
93
|
+
it("accepts valid table names", () => {
|
|
94
|
+
expect(() => validateTableName("accounts")).not.toThrow();
|
|
95
|
+
expect(() => validateTableName("_private")).not.toThrow();
|
|
96
|
+
expect(() => validateTableName("order_items")).not.toThrow();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("rejects names with special characters", () => {
|
|
100
|
+
expect(() => validateTableName("users; DROP TABLE")).toThrow("Invalid table name");
|
|
101
|
+
expect(() => validateTableName("my-table")).toThrow("Invalid table name");
|
|
102
|
+
expect(() => validateTableName("my table")).toThrow("Invalid table name");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("rejects names starting with numbers", () => {
|
|
106
|
+
expect(() => validateTableName("1table")).toThrow("Invalid table name");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("rejects empty string", () => {
|
|
110
|
+
expect(() => validateTableName("")).toThrow("Invalid table name");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("validateColumnNames", () => {
|
|
115
|
+
it("accepts valid column names", () => {
|
|
116
|
+
expect(() => validateColumnNames(["id", "first_name", "AccountType"])).not.toThrow();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("rejects column names with semicolons", () => {
|
|
120
|
+
expect(() => validateColumnNames(["id; DROP TABLE users"])).toThrow("Invalid column name");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("rejects column names with special characters", () => {
|
|
124
|
+
expect(() => validateColumnNames(["name?"])).toThrow("Invalid column name");
|
|
125
|
+
expect(() => validateColumnNames(["col'name"])).toThrow("Invalid column name");
|
|
126
|
+
expect(() => validateColumnNames(["col name"])).toThrow("Invalid column name");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("rejects column names starting with numbers", () => {
|
|
130
|
+
expect(() => validateColumnNames(["1column"])).toThrow("Invalid column name");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("accepts empty array", () => {
|
|
134
|
+
expect(() => validateColumnNames([])).not.toThrow();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("buildInsertQuery", () => {
|
|
139
|
+
it("builds a parameterized INSERT with RETURNING *", () => {
|
|
140
|
+
const { query, values } = buildInsertQuery("accounts", { name: "Cash", type: "asset" });
|
|
141
|
+
expect(query).toBe('INSERT INTO "accounts" ("name", "type") VALUES (?, ?) RETURNING *');
|
|
142
|
+
expect(values).toEqual(["Cash", "asset"]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("double-quotes column names to handle reserved words", () => {
|
|
146
|
+
const { query } = buildInsertQuery("items", { order: 1, group: "a" });
|
|
147
|
+
expect(query).toContain('"order"');
|
|
148
|
+
expect(query).toContain('"group"');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("handles single-column insert", () => {
|
|
152
|
+
const { query, values } = buildInsertQuery("tags", { label: "urgent" });
|
|
153
|
+
expect(query).toBe('INSERT INTO "tags" ("label") VALUES (?) RETURNING *');
|
|
154
|
+
expect(values).toEqual(["urgent"]);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("rejectControlChars", () => {
|
|
159
|
+
it("accepts normal text", () => {
|
|
160
|
+
expect(() => rejectControlChars('{"name":"Cash","amount":100}')).not.toThrow();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("accepts whitespace characters (tab, newline, carriage return)", () => {
|
|
164
|
+
expect(() => rejectControlChars('{\n\t"name": "Cash"\r\n}')).not.toThrow();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("rejects null byte", () => {
|
|
168
|
+
expect(() => rejectControlChars('{"name":"Cash\x00"}')).toThrow("control characters");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("rejects bell character", () => {
|
|
172
|
+
expect(() => rejectControlChars('{"name":"Cash\x07"}')).toThrow("control characters");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("rejects backspace", () => {
|
|
176
|
+
expect(() => rejectControlChars('{"name":"Cash\x08"}')).toThrow("control characters");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("rejects form feed within restricted range", () => {
|
|
180
|
+
expect(() => rejectControlChars('{"name":"Cash\x0e"}')).toThrow("control characters");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("truncateValues", () => {
|
|
185
|
+
it("truncates long strings", () => {
|
|
186
|
+
const rows = [{ id: 1, text: "a".repeat(300) }];
|
|
187
|
+
const result = truncateValues(rows, 200);
|
|
188
|
+
expect((result[0].text as string).length).toBe(203); // 200 + "..."
|
|
189
|
+
expect((result[0].text as string).endsWith("...")).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("leaves short strings untouched", () => {
|
|
193
|
+
const rows = [{ id: 1, text: "hello" }];
|
|
194
|
+
const result = truncateValues(rows, 200);
|
|
195
|
+
expect(result[0].text).toBe("hello");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("leaves non-string values untouched", () => {
|
|
199
|
+
const rows = [{ id: 1, amount: 99999 }];
|
|
200
|
+
const result = truncateValues(rows);
|
|
201
|
+
expect(result[0].amount).toBe(99999);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("does not mutate original rows", () => {
|
|
205
|
+
const rows = [{ id: 1, text: "a".repeat(300) }];
|
|
206
|
+
truncateValues(rows, 200);
|
|
207
|
+
expect((rows[0].text as string).length).toBe(300);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("formatTable", () => {
|
|
212
|
+
it("formats rows as aligned table", () => {
|
|
213
|
+
const rows = [
|
|
214
|
+
{ id: 1, name: "Cash" },
|
|
215
|
+
{ id: 2, name: "Revenue" },
|
|
216
|
+
];
|
|
217
|
+
const output = formatTable(rows);
|
|
218
|
+
expect(output).toContain("id");
|
|
219
|
+
expect(output).toContain("name");
|
|
220
|
+
expect(output).toContain("Cash");
|
|
221
|
+
expect(output).toContain("Revenue");
|
|
222
|
+
// Check alignment (header + separator + 2 rows = 4 lines)
|
|
223
|
+
expect(output.split("\n")).toHaveLength(4);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("returns (empty) for no rows", () => {
|
|
227
|
+
expect(formatTable([])).toBe("(empty)");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("shows NULL for null/undefined values", () => {
|
|
231
|
+
const rows = [{ id: 1, name: null }];
|
|
232
|
+
const output = formatTable(rows);
|
|
233
|
+
expect(output).toContain("NULL");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// SQLite-based validation primitives
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// These tests use a real in-memory SQLite database. The db-helpers functions
|
|
241
|
+
// are now synchronous and operate on better-sqlite3 directly.
|
|
242
|
+
|
|
243
|
+
describe("assertTableExists", () => {
|
|
244
|
+
let sqlite: Database.Database;
|
|
245
|
+
|
|
246
|
+
beforeEach(() => {
|
|
247
|
+
sqlite = new Database(":memory:");
|
|
248
|
+
sqlite.pragma("foreign_keys = ON");
|
|
249
|
+
sqlite.exec(`CREATE TABLE accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, type TEXT)`);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
afterEach(() => {
|
|
253
|
+
sqlite.close();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("passes when table exists", () => {
|
|
257
|
+
expect(() => assertTableExists(sqlite, "accounts")).not.toThrow();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("throws TABLE_NOT_FOUND when table does not exist", () => {
|
|
261
|
+
expect(() => assertTableExists(sqlite, "missing")).toThrow("not found");
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("getTableColumns", () => {
|
|
266
|
+
let sqlite: Database.Database;
|
|
267
|
+
|
|
268
|
+
beforeEach(() => {
|
|
269
|
+
sqlite = new Database(":memory:");
|
|
270
|
+
sqlite.pragma("foreign_keys = ON");
|
|
271
|
+
sqlite.exec(`CREATE TABLE accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, type TEXT)`);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
afterEach(() => {
|
|
275
|
+
sqlite.close();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("returns column names as a Set", () => {
|
|
279
|
+
const cols = getTableColumns(sqlite, "accounts");
|
|
280
|
+
expect(cols).toEqual(new Set(["id", "name", "type"]));
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("returns empty Set for unknown table", () => {
|
|
284
|
+
const cols = getTableColumns(sqlite, "missing");
|
|
285
|
+
expect(cols).toEqual(new Set());
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe("validatePayloadColumns", () => {
|
|
290
|
+
let sqlite: Database.Database;
|
|
291
|
+
|
|
292
|
+
beforeEach(() => {
|
|
293
|
+
sqlite = new Database(":memory:");
|
|
294
|
+
sqlite.pragma("foreign_keys = ON");
|
|
295
|
+
sqlite.exec(`CREATE TABLE accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, type TEXT)`);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
afterEach(() => {
|
|
299
|
+
sqlite.close();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("passes when all columns exist", () => {
|
|
303
|
+
expect(() => validatePayloadColumns(sqlite, "accounts", ["name", "type"])).not.toThrow();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("throws for unknown columns", () => {
|
|
307
|
+
expect(() => validatePayloadColumns(sqlite, "accounts", ["name", "bogus"])).toThrow("Unknown column");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("throws TABLE_NOT_FOUND if table missing", () => {
|
|
311
|
+
expect(() => validatePayloadColumns(sqlite, "missing", ["x"])).toThrow("not found");
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { describeAll, describeOne } from "./describe.js";
|
|
4
|
+
import { ROUTES } from "./routes.js";
|
|
5
|
+
import { AI_COMMANDS } from "./ai-commands.js";
|
|
6
|
+
import type { CliRoute } from "./routes.js";
|
|
7
|
+
|
|
8
|
+
// Minimal route table for focused tests
|
|
9
|
+
const testRoutes: CliRoute[] = [
|
|
10
|
+
{
|
|
11
|
+
pattern: ["meta", "sql", "query"],
|
|
12
|
+
description: "Run a read-only SQL query",
|
|
13
|
+
method: "POST",
|
|
14
|
+
path: "/meta/sql/query",
|
|
15
|
+
params: [],
|
|
16
|
+
inputSchema: z.object({
|
|
17
|
+
sql: z.string().describe("The SQL query"),
|
|
18
|
+
limit: z.number().optional().describe("Max rows"),
|
|
19
|
+
}),
|
|
20
|
+
extractData: (res) => res.data ?? [],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
pattern: ["meta", "tables"],
|
|
24
|
+
description: "List all tables",
|
|
25
|
+
method: "GET",
|
|
26
|
+
path: "/meta/tables",
|
|
27
|
+
params: [],
|
|
28
|
+
extractData: (res) => res.tables ?? [],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
pattern: ["meta", "schema", "sync"],
|
|
32
|
+
description: "Sync schema files to database",
|
|
33
|
+
method: "POST",
|
|
34
|
+
path: "/meta/schema/sync",
|
|
35
|
+
params: [],
|
|
36
|
+
mutating: true,
|
|
37
|
+
extractData: (res) => [res],
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
describe("describeAll", () => {
|
|
42
|
+
it("returns all routes", () => {
|
|
43
|
+
const result = describeAll(testRoutes);
|
|
44
|
+
expect(result.ok).toBe(true);
|
|
45
|
+
if (!result.ok) return;
|
|
46
|
+
expect(result.data).toHaveLength(3);
|
|
47
|
+
const names = result.data.map((d) => d.command);
|
|
48
|
+
expect(names).toContain("meta sql query");
|
|
49
|
+
expect(names).toContain("meta tables");
|
|
50
|
+
expect(names).toContain("meta schema sync");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("sorts commands alphabetically", () => {
|
|
54
|
+
const result = describeAll(testRoutes);
|
|
55
|
+
if (!result.ok) return;
|
|
56
|
+
const names = result.data.map((d) => d.command);
|
|
57
|
+
expect(names).toEqual([...names].sort());
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("includes HTTP method and path", () => {
|
|
61
|
+
const result = describeAll(testRoutes);
|
|
62
|
+
if (!result.ok) return;
|
|
63
|
+
const sqlCmd = result.data.find((d) => d.command === "meta sql query");
|
|
64
|
+
expect(sqlCmd?.method).toBe("POST");
|
|
65
|
+
expect(sqlCmd?.path).toBe("/meta/sql/query");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("describeOne", () => {
|
|
70
|
+
it("returns details for a route", () => {
|
|
71
|
+
const result = describeOne("meta sql query", testRoutes);
|
|
72
|
+
expect(result.ok).toBe(true);
|
|
73
|
+
if (!result.ok) return;
|
|
74
|
+
expect(result.data).toHaveLength(1);
|
|
75
|
+
const cmd = result.data[0];
|
|
76
|
+
expect(cmd.command).toBe("meta sql query");
|
|
77
|
+
expect(cmd.method).toBe("POST");
|
|
78
|
+
expect(cmd.path).toBe("/meta/sql/query");
|
|
79
|
+
expect(cmd.inputSchema).toBeDefined();
|
|
80
|
+
const schema = cmd.inputSchema as any;
|
|
81
|
+
expect(schema.type).toBe("object");
|
|
82
|
+
expect(schema.properties).toHaveProperty("sql");
|
|
83
|
+
expect(schema.properties).toHaveProperty("limit");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns mutating flag", () => {
|
|
87
|
+
const result = describeOne("meta schema sync", testRoutes);
|
|
88
|
+
expect(result.ok).toBe(true);
|
|
89
|
+
if (!result.ok) return;
|
|
90
|
+
expect(result.data[0].mutating).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns error for unknown command", () => {
|
|
94
|
+
const result = describeOne("nonexistent", testRoutes);
|
|
95
|
+
expect(result.ok).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("ROUTES integrity", () => {
|
|
100
|
+
it("every route has a non-empty description", () => {
|
|
101
|
+
for (const route of ROUTES) {
|
|
102
|
+
expect(route.description, `${route.pattern.join(" ")} should have a description`).toBeTruthy();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("every route has a valid method", () => {
|
|
107
|
+
const validMethods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
|
|
108
|
+
for (const route of ROUTES) {
|
|
109
|
+
expect(validMethods.has(route.method), `${route.pattern.join(" ")} has invalid method ${route.method}`).toBe(true);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("every route has a path starting with /", () => {
|
|
114
|
+
for (const route of ROUTES) {
|
|
115
|
+
expect(route.path.startsWith("/"), `${route.pattern.join(" ")} path should start with /`).toBe(true);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("route params match path placeholders", () => {
|
|
120
|
+
for (const route of ROUTES) {
|
|
121
|
+
const pathParams = (route.path.match(/:\w+/g) ?? []).map((p) => p.slice(1));
|
|
122
|
+
expect(route.params.sort(), `${route.pattern.join(" ")} params should match path`).toEqual(pathParams.sort());
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("routes with inputSchema produce valid JSON Schema", () => {
|
|
127
|
+
for (const route of ROUTES) {
|
|
128
|
+
if (route.inputSchema) {
|
|
129
|
+
const jsonSchema = z.toJSONSchema(route.inputSchema);
|
|
130
|
+
expect(jsonSchema, `${route.pattern.join(" ")} should produce valid JSON Schema`).toHaveProperty("type");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Keep AI_COMMANDS integrity tests — the old registry still exists for server-side use
|
|
137
|
+
describe("AI_COMMANDS registry integrity", () => {
|
|
138
|
+
it("every command has a non-empty description", () => {
|
|
139
|
+
for (const [name, cmd] of Object.entries(AI_COMMANDS)) {
|
|
140
|
+
expect(cmd.description, `${name} should have a description`).toBeTruthy();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("every command has a valid inputSchema", () => {
|
|
145
|
+
for (const [name, cmd] of Object.entries(AI_COMMANDS)) {
|
|
146
|
+
expect(cmd.inputSchema, `${name} should have an inputSchema`).toBeDefined();
|
|
147
|
+
const jsonSchema = z.toJSONSchema(cmd.inputSchema);
|
|
148
|
+
expect(jsonSchema, `${name} should produce valid JSON Schema`).toHaveProperty("type");
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { OperationResult } from "../introspect/types.js";
|
|
3
|
+
import type { CliRoute } from "./routes.js";
|
|
4
|
+
|
|
5
|
+
/** Format a route pattern for user-facing display: ":name" → "<name>" */
|
|
6
|
+
function formatCommand(route: CliRoute): string {
|
|
7
|
+
return route.pattern
|
|
8
|
+
.map((t) => (t.startsWith(":") ? `<${t.slice(1)}>` : t))
|
|
9
|
+
.join(" ");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Extract only the fixed (non-param) segments as a match key. */
|
|
13
|
+
function fixedKey(route: CliRoute): string {
|
|
14
|
+
return route.pattern.filter((t) => !t.startsWith(":")).join(" ");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* List all commands with summary info (from route table).
|
|
19
|
+
*/
|
|
20
|
+
export function describeAll(routes: CliRoute[]): OperationResult {
|
|
21
|
+
const commands = routes.map((r) => ({
|
|
22
|
+
command: formatCommand(r),
|
|
23
|
+
description: r.description,
|
|
24
|
+
method: r.method,
|
|
25
|
+
path: r.path,
|
|
26
|
+
mutating: r.mutating ?? false,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
commands.sort((a, b) => a.command.localeCompare(b.command));
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
ok: true,
|
|
33
|
+
data: commands as Record<string, unknown>[],
|
|
34
|
+
meta: { tableOutputHandled: true, message: formatCommandList(commands) },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Describe a single command by name (from route table).
|
|
40
|
+
* Matches against fixed segments (e.g. "tables create" matches
|
|
41
|
+
* pattern ["tables", "create", ":table"]).
|
|
42
|
+
*/
|
|
43
|
+
export function describeOne(name: string, routes: CliRoute[]): OperationResult {
|
|
44
|
+
const route = routes.find((r) => fixedKey(r) === name);
|
|
45
|
+
if (!route) {
|
|
46
|
+
return { ok: false, error: `Unknown command: ${name}`, code: "MISSING_ARGUMENT" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const inputSchema = route.inputSchema ? z.toJSONSchema(route.inputSchema) : null;
|
|
50
|
+
const data = [{
|
|
51
|
+
command: formatCommand(route),
|
|
52
|
+
description: route.description,
|
|
53
|
+
method: route.method,
|
|
54
|
+
path: route.path,
|
|
55
|
+
mutating: route.mutating ?? false,
|
|
56
|
+
inputSchema,
|
|
57
|
+
}];
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
ok: true,
|
|
61
|
+
data,
|
|
62
|
+
meta: {
|
|
63
|
+
tableOutputHandled: true,
|
|
64
|
+
message: formatRouteDetail(route, inputSchema),
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatCommandList(commands: { command: string; description: string; method: string; path: string; mutating: boolean }[]): string {
|
|
70
|
+
const maxCmd = Math.max(...commands.map((c) => c.command.length));
|
|
71
|
+
const maxMethod = Math.max(...commands.map((c) => c.method.length));
|
|
72
|
+
return commands
|
|
73
|
+
.map((c) => ` ${c.command.padEnd(maxCmd)} ${c.method.padEnd(maxMethod)} ${c.path.padEnd(30)} ${c.description}`)
|
|
74
|
+
.join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatRouteDetail(route: CliRoute, inputSchema: unknown): string {
|
|
78
|
+
const lines: string[] = [
|
|
79
|
+
`Command: ${formatCommand(route)}`,
|
|
80
|
+
`Description: ${route.description}`,
|
|
81
|
+
`HTTP: ${route.method} ${route.path}`,
|
|
82
|
+
`Mutating: ${route.mutating ?? false}`,
|
|
83
|
+
];
|
|
84
|
+
if (inputSchema) {
|
|
85
|
+
lines.push(``, `Input Schema:`, JSON.stringify(inputSchema, null, 2));
|
|
86
|
+
}
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|