@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.
Files changed (133) hide show
  1. package/package.json +40 -0
  2. package/src/actions/action.test.ts +108 -0
  3. package/src/actions/action.ts +60 -0
  4. package/src/actions/loader.ts +47 -0
  5. package/src/api/actions.ts +124 -0
  6. package/src/api/meta-mutations.ts +922 -0
  7. package/src/api/meta.ts +222 -0
  8. package/src/api/reports.ts +98 -0
  9. package/src/api/server.ts +24 -0
  10. package/src/api/tables.ts +108 -0
  11. package/src/api/views.ts +44 -0
  12. package/src/boot.ts +206 -0
  13. package/src/cli/ai-commands.ts +220 -0
  14. package/src/cli/check.ts +169 -0
  15. package/src/cli/cli-utils.test.ts +313 -0
  16. package/src/cli/describe.test.ts +151 -0
  17. package/src/cli/describe.ts +88 -0
  18. package/src/cli/emit-result.test.ts +160 -0
  19. package/src/cli/format.ts +150 -0
  20. package/src/cli/http-client.ts +55 -0
  21. package/src/cli/index.ts +162 -0
  22. package/src/cli/init.ts +35 -0
  23. package/src/cli/project-context.ts +38 -0
  24. package/src/cli/request.ts +146 -0
  25. package/src/cli/routes.ts +418 -0
  26. package/src/cli/rows-insert-master-detail.test.ts +124 -0
  27. package/src/cli/rows-insert-master-detail.ts +186 -0
  28. package/src/cli/rows-insert.test.ts +137 -0
  29. package/src/cli/rows-insert.ts +97 -0
  30. package/src/cli/serve-single.ts +49 -0
  31. package/src/create-project.ts +81 -0
  32. package/src/data/count.ts +62 -0
  33. package/src/data/crud.test.ts +188 -0
  34. package/src/data/crud.ts +242 -0
  35. package/src/data/lookup.test.ts +96 -0
  36. package/src/data/lookup.ts +104 -0
  37. package/src/data/query-parser.test.ts +67 -0
  38. package/src/data/query-parser.ts +106 -0
  39. package/src/data/sanitize.test.ts +57 -0
  40. package/src/data/sanitize.ts +25 -0
  41. package/src/data/save-pipeline.test.ts +115 -0
  42. package/src/data/save-pipeline.ts +93 -0
  43. package/src/data/validate.test.ts +110 -0
  44. package/src/data/validate.ts +98 -0
  45. package/src/db/errors.ts +20 -0
  46. package/src/db/logger.ts +63 -0
  47. package/src/db/sqlite-connection.test.ts +59 -0
  48. package/src/db/sqlite-connection.ts +79 -0
  49. package/src/index.ts +111 -0
  50. package/src/integration/api-actions.test.ts +60 -0
  51. package/src/integration/api-global.test.ts +21 -0
  52. package/src/integration/api-meta.test.ts +252 -0
  53. package/src/integration/api-reports.test.ts +77 -0
  54. package/src/integration/api-tables.test.ts +238 -0
  55. package/src/integration/api-views.test.ts +39 -0
  56. package/src/integration/cli-routes.test.ts +167 -0
  57. package/src/integration/fixtures/actions/create-account.ts +23 -0
  58. package/src/integration/fixtures/reports/account-list.ts +25 -0
  59. package/src/integration/fixtures/schema/accounts.ts +21 -0
  60. package/src/integration/fixtures/schema/audit-log.ts +19 -0
  61. package/src/integration/fixtures/schema/journal-entries.ts +20 -0
  62. package/src/integration/fixtures/views/dashboard.tsx +4 -0
  63. package/src/integration/fixtures/views/settings.tsx +3 -0
  64. package/src/integration/setup.ts +72 -0
  65. package/src/introspect/db-helpers.ts +109 -0
  66. package/src/introspect/describe-all.test.ts +73 -0
  67. package/src/introspect/describe-all.ts +80 -0
  68. package/src/introspect/describe.test.ts +65 -0
  69. package/src/introspect/describe.ts +184 -0
  70. package/src/introspect/exec.test.ts +103 -0
  71. package/src/introspect/exec.ts +57 -0
  72. package/src/introspect/indexes.test.ts +41 -0
  73. package/src/introspect/indexes.ts +95 -0
  74. package/src/introspect/inference.ts +98 -0
  75. package/src/introspect/list-tables.test.ts +40 -0
  76. package/src/introspect/list-tables.ts +62 -0
  77. package/src/introspect/query.test.ts +77 -0
  78. package/src/introspect/query.ts +47 -0
  79. package/src/introspect/sample.test.ts +67 -0
  80. package/src/introspect/sample.ts +50 -0
  81. package/src/introspect/sql-safety.ts +76 -0
  82. package/src/introspect/sqlite/db-helpers.test.ts +79 -0
  83. package/src/introspect/sqlite/db-helpers.ts +56 -0
  84. package/src/introspect/sqlite/describe-all.ts +21 -0
  85. package/src/introspect/sqlite/describe.test.ts +160 -0
  86. package/src/introspect/sqlite/describe.ts +185 -0
  87. package/src/introspect/sqlite/exec.ts +57 -0
  88. package/src/introspect/sqlite/indexes.test.ts +60 -0
  89. package/src/introspect/sqlite/indexes.ts +96 -0
  90. package/src/introspect/sqlite/list-tables.test.ts +100 -0
  91. package/src/introspect/sqlite/list-tables.ts +67 -0
  92. package/src/introspect/sqlite/query.ts +49 -0
  93. package/src/introspect/sqlite/sample.ts +50 -0
  94. package/src/introspect/table-rename.test.ts +235 -0
  95. package/src/introspect/table-rename.ts +115 -0
  96. package/src/introspect/types.ts +95 -0
  97. package/src/reports/check.test.ts +499 -0
  98. package/src/reports/check.ts +208 -0
  99. package/src/reports/engine.test.ts +1465 -0
  100. package/src/reports/engine.ts +678 -0
  101. package/src/reports/loader.ts +55 -0
  102. package/src/reports/report.ts +308 -0
  103. package/src/reports/sql-bind.ts +161 -0
  104. package/src/reports/sqlite-bind.test.ts +98 -0
  105. package/src/reports/sqlite-bind.ts +58 -0
  106. package/src/reports/sqlite-sql-client.ts +42 -0
  107. package/src/runtime.ts +3 -0
  108. package/src/schema/check.ts +90 -0
  109. package/src/schema/ddl.test.ts +210 -0
  110. package/src/schema/ddl.ts +180 -0
  111. package/src/schema/dynamic-builder.ts +297 -0
  112. package/src/schema/extract.test.ts +261 -0
  113. package/src/schema/extract.ts +285 -0
  114. package/src/schema/loader.test.ts +31 -0
  115. package/src/schema/loader.ts +60 -0
  116. package/src/schema/metadata-io.test.ts +261 -0
  117. package/src/schema/metadata-io.ts +161 -0
  118. package/src/schema/metadata-tables.test.ts +737 -0
  119. package/src/schema/metadata-tables.ts +341 -0
  120. package/src/schema/migrate.ts +195 -0
  121. package/src/schema/normalize-datatype.test.ts +58 -0
  122. package/src/schema/normalize-datatype.ts +99 -0
  123. package/src/schema/registry.test.ts +174 -0
  124. package/src/schema/registry.ts +139 -0
  125. package/src/schema/reserved.ts +227 -0
  126. package/src/schema/table.ts +135 -0
  127. package/src/test-fixtures/schema/accounts.ts +24 -0
  128. package/src/test-fixtures/schema/not-a-table.ts +6 -0
  129. package/src/testing/test-utils.ts +44 -0
  130. package/src/views/loader.test.ts +70 -0
  131. package/src/views/loader.ts +38 -0
  132. package/src/views/view.test.ts +121 -0
  133. package/src/views/view.ts +16 -0
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Integration tests for the /tables namespace (CRUD operations, single-project mode).
3
+ *
4
+ * Tests the full create → read → update → delete cycle against real
5
+ * fixture schemas backed by in-memory SQLite. Tests within this file are
6
+ * ordered intentionally — later tests depend on rows created by earlier tests.
7
+ */
8
+ import { describe, it, expect, beforeAll } from "vitest";
9
+ import { createIntegrationApp, request, postJson, putJson, del } from "./setup.js";
10
+
11
+ beforeAll(async () => {
12
+ await createIntegrationApp();
13
+ });
14
+
15
+ describe("/tables CRUD", () => {
16
+ // ── Create → Read → Update → Delete cycle ──────────────────────────
17
+
18
+ describe("accounts CRUD cycle", () => {
19
+ let createdId: number;
20
+
21
+ it("POST /tables/accounts creates a row", async () => {
22
+ const res = await postJson("/tables/accounts", {
23
+ name: "Cash",
24
+ type: "asset",
25
+ balance: 1000,
26
+ });
27
+ expect(res.status).toBe(201);
28
+
29
+ const body = await res.json();
30
+ expect(body.data).toBeDefined();
31
+ expect(body.data.name).toBe("Cash");
32
+ expect(body.data.type).toBe("asset");
33
+ expect(body.data.balance).toBe(1000);
34
+ expect(body.data.id).toBeGreaterThan(0);
35
+
36
+ createdId = body.data.id;
37
+ });
38
+
39
+ it("GET /tables/accounts lists rows", async () => {
40
+ const res = await request("/tables/accounts");
41
+ expect(res.status).toBe(200);
42
+
43
+ const body = await res.json();
44
+ expect(body.data.length).toBeGreaterThanOrEqual(1);
45
+ expect(body.meta).toBeDefined();
46
+ expect(body.meta.total).toBeGreaterThanOrEqual(1);
47
+ });
48
+
49
+ it("GET /tables/accounts/:id returns a single row", async () => {
50
+ const res = await request(`/tables/accounts/${createdId}`);
51
+ expect(res.status).toBe(200);
52
+
53
+ const body = await res.json();
54
+ expect(body.data.id).toBe(createdId);
55
+ expect(body.data.name).toBe("Cash");
56
+ });
57
+
58
+ it("PUT /tables/accounts/:id updates a row", async () => {
59
+ const res = await putJson(`/tables/accounts/${createdId}`, {
60
+ name: "Petty Cash",
61
+ type: "asset",
62
+ balance: 500,
63
+ });
64
+ expect(res.status).toBe(200);
65
+
66
+ const body = await res.json();
67
+ expect(body.data.name).toBe("Petty Cash");
68
+ expect(body.data.balance).toBe(500);
69
+ });
70
+
71
+ it("DELETE /tables/accounts/:id deletes a row", async () => {
72
+ const res = await del(`/tables/accounts/${createdId}`);
73
+ expect(res.status).toBe(200);
74
+
75
+ const body = await res.json();
76
+ expect(body.data.id).toBe(createdId);
77
+
78
+ const check = await request(`/tables/accounts/${createdId}`);
79
+ expect(check.status).toBe(404);
80
+ });
81
+ });
82
+
83
+ // ── Pagination ──────────────────────────────────────────────────────
84
+
85
+ describe("pagination", () => {
86
+ it("returns paginated results with meta.total", async () => {
87
+ for (const name of ["Alpha", "Bravo", "Charlie"]) {
88
+ await postJson("/tables/accounts", { name, type: "asset" });
89
+ }
90
+
91
+ const res = await request("/tables/accounts?limit=2&page=1");
92
+ expect(res.status).toBe(200);
93
+
94
+ const body = await res.json();
95
+ expect(body.data).toHaveLength(2);
96
+ expect(body.meta.total).toBe(3);
97
+ expect(body.meta.page).toBe(1);
98
+ expect(body.meta.limit).toBe(2);
99
+ expect(body.meta.pages).toBe(2);
100
+ });
101
+ });
102
+
103
+ // ── Sorting ─────────────────────────────────────────────────────────
104
+
105
+ describe("sorting", () => {
106
+ it("sorts by name asc", async () => {
107
+ const res = await request("/tables/accounts?sort=name");
108
+ expect(res.status).toBe(200);
109
+
110
+ const body = await res.json();
111
+ const names = body.data.map((r: any) => r.name);
112
+ const sorted = [...names].sort();
113
+ expect(names).toEqual(sorted);
114
+ });
115
+
116
+ it("sorts by name desc", async () => {
117
+ const res = await request("/tables/accounts?sort=-name");
118
+ expect(res.status).toBe(200);
119
+
120
+ const body = await res.json();
121
+ const names = body.data.map((r: any) => r.name);
122
+ const sorted = [...names].sort().reverse();
123
+ expect(names).toEqual(sorted);
124
+ });
125
+ });
126
+
127
+ // ── Filtering ───────────────────────────────────────────────────────
128
+
129
+ describe("filtering", () => {
130
+ it("filters by filter[type]=asset", async () => {
131
+ await postJson("/tables/accounts", {
132
+ name: "Revenue Account",
133
+ type: "revenue",
134
+ });
135
+
136
+ const res = await request("/tables/accounts?filter[type]=asset");
137
+ expect(res.status).toBe(200);
138
+
139
+ const body = await res.json();
140
+ for (const row of body.data) {
141
+ expect(row.type).toBe("asset");
142
+ }
143
+ expect(body.data.some((r: any) => r.type === "revenue")).toBe(false);
144
+ });
145
+ });
146
+
147
+ // ── Validation ──────────────────────────────────────────────────────
148
+
149
+ describe("validation", () => {
150
+ it("rejects invalid select value with 422", async () => {
151
+ const res = await postJson("/tables/accounts", {
152
+ name: "Bad Account",
153
+ type: "invalid_type",
154
+ });
155
+ expect(res.status).toBe(422);
156
+
157
+ const body = await res.json();
158
+ expect(body.error).toBe("Validation failed");
159
+ expect(body.details).toBeDefined();
160
+ });
161
+
162
+ it("rejects missing required field with 422", async () => {
163
+ const res = await postJson("/tables/accounts", {
164
+ type: "asset",
165
+ });
166
+ expect(res.status).toBe(422);
167
+
168
+ const body = await res.json();
169
+ expect(body.error).toBe("Validation failed");
170
+ });
171
+ });
172
+
173
+ // ── Immutable tables ───────────────────────────────────────────────
174
+
175
+ describe("immutable tables", () => {
176
+ it("POST to audit_log succeeds", async () => {
177
+ const res = await postJson("/tables/audit_log", {
178
+ event: "test_event",
179
+ detail: "some detail",
180
+ });
181
+ expect(res.status).toBe(201);
182
+
183
+ const body = await res.json();
184
+ expect(body.data.event).toBe("test_event");
185
+ });
186
+
187
+ it("PUT to audit_log returns 403", async () => {
188
+ const createRes = await postJson("/tables/audit_log", {
189
+ event: "immutable_test",
190
+ });
191
+ const id = (await createRes.json()).data.id;
192
+
193
+ const res = await putJson(`/tables/audit_log/${id}`, {
194
+ event: "modified",
195
+ });
196
+ expect(res.status).toBe(403);
197
+ });
198
+
199
+ it("DELETE from audit_log returns 403", async () => {
200
+ const res = await del("/tables/audit_log/1");
201
+ expect(res.status).toBe(403);
202
+ });
203
+ });
204
+
205
+ // ── Lookup ──────────────────────────────────────────────────────────
206
+
207
+ describe("lookup", () => {
208
+ it("GET /tables/accounts/_lookup returns display values", async () => {
209
+ const res = await request("/tables/accounts/_lookup");
210
+ expect(res.status).toBe(200);
211
+
212
+ const body = await res.json();
213
+ expect(body.data).toBeDefined();
214
+ expect(typeof body.data).toBe("object");
215
+
216
+ const entries = Object.entries(body.data);
217
+ expect(entries.length).toBeGreaterThan(0);
218
+ for (const [id, display] of entries) {
219
+ expect(typeof id).toBe("string");
220
+ expect(typeof display).toBe("string");
221
+ }
222
+ });
223
+ });
224
+
225
+ // ── Edge cases ──────────────────────────────────────────────────────
226
+
227
+ describe("edge cases", () => {
228
+ it("GET /tables/nonexistent returns 404", async () => {
229
+ const res = await request("/tables/nonexistent");
230
+ expect(res.status).toBe(404);
231
+ });
232
+
233
+ it("GET /tables/accounts/999 returns 404", async () => {
234
+ const res = await request("/tables/accounts/999");
235
+ expect(res.status).toBe(404);
236
+ });
237
+ });
238
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Integration tests for the /views namespace (single-project mode).
3
+ */
4
+ import { describe, it, expect, beforeAll } from "vitest";
5
+ import { createIntegrationApp, request } from "./setup.js";
6
+
7
+ beforeAll(async () => {
8
+ await createIntegrationApp();
9
+ });
10
+
11
+ describe("/views", () => {
12
+ it("GET /views lists all views with name and label", async () => {
13
+ const res = await request("/views");
14
+ expect(res.status).toBe(200);
15
+
16
+ const body = await res.json();
17
+ expect(body.views).toHaveLength(2);
18
+
19
+ const names = body.views.map((v: any) => v.name).sort();
20
+ expect(names).toEqual(["dashboard", "settings"]);
21
+
22
+ const dashboard = body.views.find((v: any) => v.name === "dashboard");
23
+ expect(dashboard.label).toBe("Dashboard");
24
+ });
25
+
26
+ it("GET /views/dashboard returns single view metadata", async () => {
27
+ const res = await request("/views/dashboard");
28
+ expect(res.status).toBe(200);
29
+
30
+ const body = await res.json();
31
+ expect(body.name).toBe("dashboard");
32
+ expect(body.label).toBe("Dashboard");
33
+ });
34
+
35
+ it("GET /views/nonexistent returns 404", async () => {
36
+ const res = await request("/views/nonexistent");
37
+ expect(res.status).toBe(404);
38
+ });
39
+ });
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Tests for CLI route registration and Commander-based routing.
3
+ *
4
+ * The first describe block verifies that ROUTES patterns correctly resolve
5
+ * to the expected HTTP method + path by matching fixed segments.
6
+ *
7
+ * The second describe block does a round-trip test against the single-project
8
+ * app (no /p/:slug prefix).
9
+ */
10
+ import { describe, it, expect, beforeAll } from "vitest";
11
+ import { ROUTES } from "../cli/routes.js";
12
+ import type { CliRoute } from "../cli/routes.js";
13
+ import { createIntegrationApp, request } from "./setup.js";
14
+
15
+ // ── Helper: find a route by its fixed segments ──────────────────────
16
+ function findRoute(fixedSegments: string[]): CliRoute | undefined {
17
+ const key = fixedSegments.join(" ");
18
+ return ROUTES.find((r) => {
19
+ const fixed = r.pattern.filter((t) => !t.startsWith(":")).join(" ");
20
+ return fixed === key;
21
+ });
22
+ }
23
+
24
+ // ── Route table verification (no DB needed) ─────────────────────────
25
+
26
+ describe("CLI route table", () => {
27
+ it("meta tables → GET /meta/tables", () => {
28
+ const route = findRoute(["meta", "tables"]);
29
+ expect(route).toBeDefined();
30
+ expect(route!.path).toBe("/meta/tables");
31
+ expect(route!.method).toBe("GET");
32
+ });
33
+
34
+ it("meta tables indexes → GET /meta/tables/:name/indexes", () => {
35
+ const route = findRoute(["meta", "tables", "indexes"]);
36
+ expect(route).toBeDefined();
37
+ expect(route!.path).toBe("/meta/tables/:name/indexes");
38
+ expect(route!.params).toEqual(["name"]);
39
+ });
40
+
41
+ it("meta tables sample → GET /meta/tables/:name/sample", () => {
42
+ const route = findRoute(["meta", "tables", "sample"]);
43
+ expect(route).toBeDefined();
44
+ expect(route!.path).toBe("/meta/tables/:name/sample");
45
+ expect(route!.params).toEqual(["name"]);
46
+ });
47
+
48
+ it("meta tables show → GET /meta/tables/:name", () => {
49
+ const route = findRoute(["meta", "tables", "show"]);
50
+ expect(route).toBeDefined();
51
+ expect(route!.path).toBe("/meta/tables/:name");
52
+ expect(route!.method).toBe("GET");
53
+ });
54
+
55
+ it("meta sql query → POST /meta/sql/query", () => {
56
+ const route = findRoute(["meta", "sql", "query"]);
57
+ expect(route).toBeDefined();
58
+ expect(route!.path).toBe("/meta/sql/query");
59
+ expect(route!.method).toBe("POST");
60
+ });
61
+
62
+ it("meta sql exec → POST /meta/sql/exec", () => {
63
+ const route = findRoute(["meta", "sql", "exec"]);
64
+ expect(route).toBeDefined();
65
+ expect(route!.path).toBe("/meta/sql/exec");
66
+ expect(route!.method).toBe("POST");
67
+ });
68
+
69
+ it("meta schema sync → POST /meta/schema/sync", () => {
70
+ const route = findRoute(["meta", "schema", "sync"]);
71
+ expect(route).toBeDefined();
72
+ expect(route!.path).toBe("/meta/schema/sync");
73
+ expect(route!.method).toBe("POST");
74
+ });
75
+
76
+ it("tables list → GET /tables/:table", () => {
77
+ const route = findRoute(["tables", "list"]);
78
+ expect(route).toBeDefined();
79
+ expect(route!.path).toBe("/tables/:table");
80
+ expect(route!.method).toBe("GET");
81
+ expect(route!.params).toEqual(["table"]);
82
+ });
83
+
84
+ it("tables create → POST /tables/:table", () => {
85
+ const route = findRoute(["tables", "create"]);
86
+ expect(route).toBeDefined();
87
+ expect(route!.path).toBe("/tables/:table");
88
+ expect(route!.method).toBe("POST");
89
+ expect(route!.params).toEqual(["table"]);
90
+ });
91
+
92
+ it("tables update → PUT /tables/:table/:id", () => {
93
+ const route = findRoute(["tables", "update"]);
94
+ expect(route).toBeDefined();
95
+ expect(route!.path).toBe("/tables/:table/:id");
96
+ expect(route!.method).toBe("PUT");
97
+ expect(route!.params).toEqual(["table", "id"]);
98
+ });
99
+
100
+ it("tables delete → DELETE /tables/:table/:id", () => {
101
+ const route = findRoute(["tables", "delete"]);
102
+ expect(route).toBeDefined();
103
+ expect(route!.path).toBe("/tables/:table/:id");
104
+ expect(route!.method).toBe("DELETE");
105
+ expect(route!.params).toEqual(["table", "id"]);
106
+ });
107
+
108
+ it("reports → GET /reports", () => {
109
+ const route = findRoute(["reports"]);
110
+ expect(route).toBeDefined();
111
+ expect(route!.path).toBe("/reports");
112
+ expect(route!.method).toBe("GET");
113
+ });
114
+
115
+ it("reports run → GET /reports/:name/results", () => {
116
+ const route = findRoute(["reports", "run"]);
117
+ expect(route).toBeDefined();
118
+ expect(route!.path).toBe("/reports/:name/results");
119
+ expect(route!.method).toBe("GET");
120
+ });
121
+
122
+ it("actions → GET /actions", () => {
123
+ const route = findRoute(["actions"]);
124
+ expect(route).toBeDefined();
125
+ expect(route!.path).toBe("/actions");
126
+ expect(route!.method).toBe("GET");
127
+ });
128
+
129
+ it("views → GET /views", () => {
130
+ const route = findRoute(["views"]);
131
+ expect(route).toBeDefined();
132
+ expect(route!.path).toBe("/views");
133
+ expect(route!.method).toBe("GET");
134
+ });
135
+ });
136
+
137
+ // ── CLI route → HTTP round-trip (single-project, no /p/:slug) ───────
138
+
139
+ describe("CLI route → HTTP round-trip", () => {
140
+ beforeAll(async () => {
141
+ await createIntegrationApp();
142
+ });
143
+
144
+ it("meta tables → GET /meta/tables → 200 with table list", async () => {
145
+ const route = findRoute(["meta", "tables"]);
146
+ expect(route).toBeDefined();
147
+
148
+ const res = await request(route!.path);
149
+ expect(res.status).toBe(200);
150
+
151
+ const body = await res.json();
152
+ expect(body.tables).toBeDefined();
153
+ expect(body.tables.length).toBeGreaterThan(0);
154
+ });
155
+
156
+ it("tables list accounts → GET /tables/accounts → 200 with rows", async () => {
157
+ const route = findRoute(["tables", "list"]);
158
+ expect(route).toBeDefined();
159
+
160
+ const path = route!.path.replace(":table", "accounts");
161
+ const res = await request(path);
162
+ expect(res.status).toBe(200);
163
+
164
+ const body = await res.json();
165
+ expect(body.data).toBeDefined();
166
+ });
167
+ });
@@ -0,0 +1,23 @@
1
+ import { action } from "@sapporta/server/action";
2
+ import { z } from "zod";
3
+ import { accounts } from "../schema/accounts.js";
4
+
5
+ export default action({
6
+ name: "create_account",
7
+ label: "Create Account",
8
+ input: z.object({
9
+ name: z.string().min(1),
10
+ type: z.enum(["asset", "liability", "equity", "revenue", "expense"]),
11
+ balance: z.number().optional(),
12
+ }),
13
+ // SQLite transactions are synchronous — run() must not be async.
14
+ // Drizzle's better-sqlite3 operations return values directly.
15
+ run: ({ input, db }) => {
16
+ const result = db
17
+ .insert(accounts.drizzle)
18
+ .values(input)
19
+ .returning()
20
+ .all();
21
+ return result[0];
22
+ },
23
+ });
@@ -0,0 +1,25 @@
1
+ import { report } from "@sapporta/server/report";
2
+
3
+ export default report({
4
+ name: "account-list",
5
+ label: "Account List",
6
+ params: [
7
+ { name: "type", type: "string", required: false, label: "Account Type" },
8
+ ],
9
+ sources: {
10
+ accounts: {
11
+ // SQLite doesn't need type casts — parameters are dynamically typed.
12
+ // COALESCE handles NULL balance values; CAST to INTEGER for consistency.
13
+ query: "SELECT name, type, CAST(COALESCE(balance, 0) AS INTEGER) AS balance FROM accounts WHERE ($type IS NULL OR type = $type) ORDER BY name",
14
+ },
15
+ },
16
+ tree: {
17
+ source: "accounts",
18
+ levelName: "account",
19
+ columns: [
20
+ { name: "name", header: "Name" },
21
+ { name: "type", header: "Type" },
22
+ { name: "balance", header: "Balance" },
23
+ ],
24
+ },
25
+ });
@@ -0,0 +1,21 @@
1
+ import { table, timestamp } from "@sapporta/server/table";
2
+ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
3
+
4
+ export const accounts = table({
5
+ drizzle: sqliteTable("accounts", {
6
+ id: integer("id").primaryKey({ autoIncrement: true }),
7
+ name: text("name").notNull(),
8
+ type: text("type").notNull(),
9
+ balance: integer("balance"),
10
+ created_at: timestamp("created_at"),
11
+ updated_at: timestamp("updated_at"),
12
+ }),
13
+ meta: {
14
+ label: "Accounts",
15
+ selects: [
16
+ { type: "select", column: "type", options: ["asset", "liability", "equity", "revenue", "expense"] },
17
+ ],
18
+ },
19
+ });
20
+
21
+ export default accounts;
@@ -0,0 +1,19 @@
1
+ import { table, timestamp } from "@sapporta/server/table";
2
+ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
3
+
4
+ /**
5
+ * Immutable table fixture. Tests that PUT/DELETE are rejected with 403
6
+ * while POST still succeeds. The `immutable: true` flag is enforced by
7
+ * the CRUD handlers in crud.ts (handleUpdate/handleDelete).
8
+ */
9
+ export const auditLog = table({
10
+ drizzle: sqliteTable("audit_log", {
11
+ id: integer("id").primaryKey({ autoIncrement: true }),
12
+ event: text("event").notNull(),
13
+ detail: text("detail"),
14
+ created_at: timestamp("created_at"),
15
+ }),
16
+ meta: { label: "Audit Log", immutable: true },
17
+ });
18
+
19
+ export default auditLog;
@@ -0,0 +1,20 @@
1
+ import { table, timestamp } from "@sapporta/server/table";
2
+ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
3
+
4
+ /**
5
+ * Journal entries fixture. Deliberately omits Drizzle .references() on account_id
6
+ * to keep the fixture simple — the FK is a logical relationship, not a DB constraint.
7
+ * This matches many real-world Sapporta tables.
8
+ */
9
+ export const journalEntries = table({
10
+ drizzle: sqliteTable("journal_entries", {
11
+ id: integer("id").primaryKey({ autoIncrement: true }),
12
+ account_id: integer("account_id").notNull(),
13
+ description: text("description").notNull(),
14
+ amount: integer("amount").notNull(),
15
+ created_at: timestamp("created_at"),
16
+ }),
17
+ meta: { label: "Journal Entries" },
18
+ });
19
+
20
+ export default journalEntries;
@@ -0,0 +1,4 @@
1
+ // Minimal view fixture. The view loader (view-loader.ts) only reads filenames —
2
+ // it doesn't import the file. But valid TSX is safer in case the loader changes.
3
+ export const meta = { name: "dashboard", label: "Dashboard" };
4
+ export default function Dashboard() { return null; }
@@ -0,0 +1,3 @@
1
+ // Minimal view fixture. See dashboard.tsx for notes on why this is valid TSX.
2
+ export const meta = { name: "settings", label: "Settings" };
3
+ export default function Settings() { return null; }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Shared setup for single-project integration tests.
3
+ *
4
+ * Boots a single project directly (no registry, no multi-project dispatch).
5
+ * Routes are mounted at root — no /p/:slug prefix.
6
+ *
7
+ * Uses in-memory SQLite for complete isolation between test files.
8
+ */
9
+ import { bootProject } from "../boot.js";
10
+ import { createApp } from "../api/server.js";
11
+ import { createTestDb } from "../testing/test-utils.js";
12
+ import type { Hono } from "hono";
13
+ import { resolve } from "node:path";
14
+
15
+ const FIXTURES_DIR = resolve(import.meta.dirname, "fixtures");
16
+
17
+ // Module-level ref so helper functions can access the app without
18
+ // threading it through every call. Set by createIntegrationApp().
19
+ let app: Hono;
20
+
21
+ export async function createIntegrationApp(): Promise<{
22
+ app: Hono;
23
+ }> {
24
+ const conn = createTestDb();
25
+ const projectCtx = await bootProject(
26
+ { slug: "test", databasePath: ":memory:", dir: FIXTURES_DIR },
27
+ conn,
28
+ );
29
+
30
+ app = createApp();
31
+ app.route("/", projectCtx.subApp);
32
+
33
+ return { app };
34
+ }
35
+
36
+ /** Make a GET request to the test app. */
37
+ export function request(path: string, init?: RequestInit) {
38
+ return app.request(path, init);
39
+ }
40
+
41
+ /** POST JSON body to the test app. */
42
+ export function postJson(path: string, body: unknown) {
43
+ return app.request(path, {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify(body),
47
+ });
48
+ }
49
+
50
+ /** PUT JSON body to the test app. */
51
+ export function putJson(path: string, body: unknown) {
52
+ return app.request(path, {
53
+ method: "PUT",
54
+ headers: { "Content-Type": "application/json" },
55
+ body: JSON.stringify(body),
56
+ });
57
+ }
58
+
59
+ /** PATCH JSON body to the test app. */
60
+ export function patchJson(path: string, body: unknown) {
61
+ return app.request(path, {
62
+ method: "PATCH",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify(body),
65
+ });
66
+ }
67
+
68
+ /** DELETE request to the test app. Optionally appends a query string. */
69
+ export function del(path: string, query?: string) {
70
+ const url = query ? `${path}?${query}` : path;
71
+ return app.request(url, { method: "DELETE" });
72
+ }