@galadrim/galadmin 0.1.236 → 0.2.6
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/.github/workflows/main.yml +33 -2
- package/README.md +65 -0
- package/dist/api.d.ts +16 -0
- package/dist/api.js +240 -189
- package/dist/api.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utils/dbManager.d.ts +5 -0
- package/dist/utils/dbManager.js +146 -11
- package/dist/utils/dbManager.js.map +1 -1
- package/dist/utils/imageDimensions.d.ts +5 -0
- package/dist/utils/imageDimensions.js +98 -0
- package/dist/utils/imageDimensions.js.map +1 -0
- package/jest.config.js +43 -0
- package/jest.integration.config.js +16 -0
- package/package.json +30 -10
- package/src/api.ts +284 -217
- package/src/front/src/App.test.js +28 -0
- package/src/front/src/components/OutsideClickHandler.test.js +53 -0
- package/src/front/src/components/RedirectToFirstView.test.js +38 -0
- package/src/front/src/utils/checkRequiredColumns.test.js +46 -0
- package/src/front/src/utils/evalExpression.test.js +29 -0
- package/src/index.ts +1 -1
- package/src/utils/dbManager.ts +243 -102
- package/src/utils/imageDimensions.ts +112 -0
- package/tests/__mocks__/@aws-sdk/client-s3.js +6 -0
- package/tests/__mocks__/@aws-sdk/s3-request-presigner.js +3 -0
- package/tests/__mocks__/@google-cloud/storage.js +7 -0
- package/tests/__mocks__/argon2.js +3 -0
- package/tests/__mocks__/axios.js +7 -0
- package/tests/__mocks__/connect-pg-simple.js +24 -0
- package/tests/__mocks__/express-mysql-session.js +24 -0
- package/tests/__mocks__/mysql2/promise.js +3 -0
- package/tests/__mocks__/pg.js +3 -0
- package/tests/__mocks__/rollup.js +5 -0
- package/tests/__mocks__/ssh2-sftp-client.js +10 -0
- package/tests/api-helpers.test.ts +376 -0
- package/tests/dbManager.init.test.ts +129 -0
- package/tests/dbManager.test.ts +265 -0
- package/tests/edge-cases.test.ts +167 -0
- package/tests/helpers/app.ts +69 -0
- package/tests/helpers/fakeDb.ts +66 -0
- package/tests/helpers/fakePool.ts +76 -0
- package/tests/helpers/fixtures.ts +68 -0
- package/tests/imageDimensions.test.ts +111 -0
- package/tests/index.sessionStore.test.ts +19 -0
- package/tests/index.test.ts +363 -0
- package/tests/integration/__mocks__/axios.js +3 -0
- package/tests/integration/dbHarness.ts +381 -0
- package/tests/integration/dbManager.integration.test.ts +69 -0
- package/tests/integration/globalSetup.ts +43 -0
- package/tests/integration/globalTeardown.ts +42 -0
- package/tests/integration/routes.authorization.integration.test.ts +319 -0
- package/tests/integration/routes.config.integration.test.ts +189 -0
- package/tests/integration/routes.crud.integration.test.ts +805 -0
- package/tests/integration/routes.custom.integration.test.ts +376 -0
- package/tests/integration/routes.foreign.integration.test.ts +306 -0
- package/tests/integration/routes.importExport.integration.test.ts +404 -0
- package/tests/integration/routes.integration.test.ts +366 -0
- package/tests/integration/routes.postgresql.integration.test.ts +88 -0
- package/tests/integration/routes.records.integration.test.ts +634 -0
- package/tests/integration/routes.upload.integration.test.ts +425 -0
- package/tests/setup/silence-console.ts +15 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import * as request from "supertest";
|
|
2
|
+
import { generateUniqueIdForObject } from "../../src/api";
|
|
3
|
+
import { buildAppForManager } from "../helpers/app";
|
|
4
|
+
import { baseConfig, fruitView, EDITOR, VIEWER } from "../helpers/fixtures";
|
|
5
|
+
import {
|
|
6
|
+
BackendName,
|
|
7
|
+
DbHarness,
|
|
8
|
+
describeIntegration,
|
|
9
|
+
getMysql2Harness,
|
|
10
|
+
getMysqlHarness,
|
|
11
|
+
getPgHarness,
|
|
12
|
+
resetTables,
|
|
13
|
+
} from "./dbHarness";
|
|
14
|
+
|
|
15
|
+
jest.mock("axios");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* /records, /adjacentRecordId and /record against real engines. Every
|
|
19
|
+
* SQL-composition property the scripted suite asserted (pagination, LIKE
|
|
20
|
+
* search, filter merging, ordering, grouping, custom queries) is verified by
|
|
21
|
+
* what the engine actually returns.
|
|
22
|
+
*/
|
|
23
|
+
describeIntegration("records routes against real databases", () => {
|
|
24
|
+
const harnesses: Record<BackendName, DbHarness> = {} as any;
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
harnesses.mysql = await getMysqlHarness();
|
|
28
|
+
harnesses.mysql2 = await getMysql2Harness();
|
|
29
|
+
harnesses.postgresql = await getPgHarness();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterAll(async () => {
|
|
33
|
+
await harnesses.mysql?.stop();
|
|
34
|
+
await harnesses.mysql2?.stop();
|
|
35
|
+
await harnesses.postgresql?.stop();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe.each([
|
|
39
|
+
["mysql", () => harnesses.mysql],
|
|
40
|
+
["mysql2", () => harnesses.mysql2],
|
|
41
|
+
["postgresql", () => harnesses.postgresql],
|
|
42
|
+
])("backend: %s", (_backend, getHarness) => {
|
|
43
|
+
let harness: DbHarness;
|
|
44
|
+
|
|
45
|
+
beforeEach(async () => {
|
|
46
|
+
harness = getHarness();
|
|
47
|
+
await resetTables(harness);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const app = (overrides: any = {}, user: any = EDITOR) =>
|
|
51
|
+
buildAppForManager(
|
|
52
|
+
harness.manager,
|
|
53
|
+
baseConfig({ views: [fruitView({ primaryId: "id", ...overrides })] }),
|
|
54
|
+
user,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const postRecords = (instance: any, body: any) =>
|
|
58
|
+
request(instance).post("/api/records").send({
|
|
59
|
+
viewIndex: 0,
|
|
60
|
+
page: 1,
|
|
61
|
+
filters: [],
|
|
62
|
+
...body,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const idsOf = (records: any[]) => records.map((r: any) => Number(r.id));
|
|
66
|
+
|
|
67
|
+
describe("POST /api/records", () => {
|
|
68
|
+
it("returns paginated records with a count", async () => {
|
|
69
|
+
const instance = app();
|
|
70
|
+
for (const name of ["pomme", "poire", "banane"]) {
|
|
71
|
+
await request(instance)
|
|
72
|
+
.post("/api/createRecord")
|
|
73
|
+
.send({ viewIndex: 0, data: { fruitName: name } })
|
|
74
|
+
.expect(200);
|
|
75
|
+
}
|
|
76
|
+
const res = await postRecords(instance, { page: 1 }).expect(200);
|
|
77
|
+
expect(res.body.records).toHaveLength(3);
|
|
78
|
+
// PG returns COUNT(*) as a bigint string.
|
|
79
|
+
expect(String(res.body.count)).toBe("3");
|
|
80
|
+
expect(res.body.input.query).toBeDefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("indexes views as the root role sees them", async () => {
|
|
84
|
+
for (const name of ["pomme", "poire"]) {
|
|
85
|
+
await request(app())
|
|
86
|
+
.post("/api/createRecord")
|
|
87
|
+
.send({ viewIndex: 0, data: { fruitName: name } })
|
|
88
|
+
.expect(200);
|
|
89
|
+
}
|
|
90
|
+
// The first view in the raw config is invisible to root; viewIndex
|
|
91
|
+
// must resolve against the root-filtered list, where the fruits view
|
|
92
|
+
// is index 0. Selecting the raw index 0 would hit the (nonexistent)
|
|
93
|
+
// orders table and fail.
|
|
94
|
+
const instance = buildAppForManager(
|
|
95
|
+
harness.manager,
|
|
96
|
+
baseConfig({
|
|
97
|
+
views: [
|
|
98
|
+
{
|
|
99
|
+
type: "table",
|
|
100
|
+
label: "Clients only",
|
|
101
|
+
tableName: "orders",
|
|
102
|
+
roles: ["client"],
|
|
103
|
+
filtersFromUser: (id) => `client_id = ${id}`,
|
|
104
|
+
columns: [{ label: "Id", name: "id" }],
|
|
105
|
+
},
|
|
106
|
+
fruitView({ primaryId: "id" }),
|
|
107
|
+
],
|
|
108
|
+
}),
|
|
109
|
+
{ role: "root", userId: 1 },
|
|
110
|
+
);
|
|
111
|
+
const res = await postRecords(instance, { page: 1 }).expect(200);
|
|
112
|
+
expect(idsOf(res.body.records)).toEqual([1, 2]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("applies view.limit and page-based OFFSET", async () => {
|
|
116
|
+
const instance = app({ limit: 5 });
|
|
117
|
+
for (let i = 1; i <= 7; i++) {
|
|
118
|
+
await request(instance)
|
|
119
|
+
.post("/api/createRecord")
|
|
120
|
+
.send({ viewIndex: 0, data: { fruitName: `fruit${i}` } })
|
|
121
|
+
.expect(200);
|
|
122
|
+
}
|
|
123
|
+
const page1 = await postRecords(instance, { page: 1 }).expect(200);
|
|
124
|
+
expect(page1.body.records).toHaveLength(5);
|
|
125
|
+
expect(idsOf(page1.body.records)).toEqual([1, 2, 3, 4, 5]);
|
|
126
|
+
// The count wraps the query WITHOUT its LIMIT/OFFSET: it is the
|
|
127
|
+
// total matching row count, not the page size.
|
|
128
|
+
expect(String(page1.body.count)).toBe("7");
|
|
129
|
+
const page2 = await postRecords(instance, { page: 2 }).expect(200);
|
|
130
|
+
expect(page2.body.records).toHaveLength(2);
|
|
131
|
+
expect(idsOf(page2.body.records)).toEqual([6, 7]);
|
|
132
|
+
expect(String(page2.body.count)).toBe("7");
|
|
133
|
+
const page3 = await postRecords(instance, { page: 3 }).expect(200);
|
|
134
|
+
expect(page3.body.records).toHaveLength(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("never produces a negative OFFSET", async () => {
|
|
138
|
+
const instance = app();
|
|
139
|
+
for (const name of ["a", "b", "c"]) {
|
|
140
|
+
await request(instance)
|
|
141
|
+
.post("/api/createRecord")
|
|
142
|
+
.send({ viewIndex: 0, data: { fruitName: name } })
|
|
143
|
+
.expect(200);
|
|
144
|
+
}
|
|
145
|
+
const res = await postRecords(instance, { page: 0 }).expect(200);
|
|
146
|
+
expect(res.body.records).toHaveLength(3);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("searches via searchableName expressions and skips searchable:false columns", async () => {
|
|
150
|
+
const config = {
|
|
151
|
+
views: [
|
|
152
|
+
fruitView({
|
|
153
|
+
primaryId: "id",
|
|
154
|
+
columns: [
|
|
155
|
+
{ label: "Nom", name: "fruitName", searchable: false },
|
|
156
|
+
{
|
|
157
|
+
label: "Tout",
|
|
158
|
+
name: "irrelevant",
|
|
159
|
+
searchableName: "CONCAT_WS(' ', fruitName, acidity)",
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
}),
|
|
163
|
+
],
|
|
164
|
+
};
|
|
165
|
+
const instance = buildAppForManager(
|
|
166
|
+
harness.manager,
|
|
167
|
+
baseConfig(config),
|
|
168
|
+
);
|
|
169
|
+
await request(instance)
|
|
170
|
+
.post("/api/createRecord")
|
|
171
|
+
.send({ viewIndex: 0, data: { fruitName: "pomme", acidity: 1 } })
|
|
172
|
+
.expect(200);
|
|
173
|
+
await request(instance)
|
|
174
|
+
.post("/api/createRecord")
|
|
175
|
+
.send({ viewIndex: 0, data: { fruitName: "x", acidity: 42 } })
|
|
176
|
+
.expect(200);
|
|
177
|
+
// "x 42" only matches through the CONCAT_WS expression (the
|
|
178
|
+
// fruitName column is not searchable).
|
|
179
|
+
const res = await postRecords(instance, { query: "x 42" }).expect(200);
|
|
180
|
+
expect(res.body.records).toHaveLength(1);
|
|
181
|
+
expect(res.body.records[0].fruitName).toBe("x");
|
|
182
|
+
const res2 = await postRecords(instance, {
|
|
183
|
+
query: "pomme",
|
|
184
|
+
}).expect(200);
|
|
185
|
+
expect(res2.body.records).toHaveLength(1);
|
|
186
|
+
expect(res2.body.records[0].fruitName).toBe("pomme");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("escapes single quotes in the search term", async () => {
|
|
190
|
+
const instance = app();
|
|
191
|
+
await request(instance)
|
|
192
|
+
.post("/api/createRecord")
|
|
193
|
+
.send({ viewIndex: 0, data: { fruitName: "l'orange" } })
|
|
194
|
+
.expect(200);
|
|
195
|
+
await request(instance)
|
|
196
|
+
.post("/api/createRecord")
|
|
197
|
+
.send({ viewIndex: 0, data: { fruitName: "orange" } })
|
|
198
|
+
.expect(200);
|
|
199
|
+
const res = await postRecords(instance, {
|
|
200
|
+
query: "l'orange",
|
|
201
|
+
}).expect(200);
|
|
202
|
+
expect(res.body.records).toHaveLength(1);
|
|
203
|
+
expect(res.body.records[0].fruitName).toBe("l'orange");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("merges view filters, client filters and filtersFromUser", async () => {
|
|
207
|
+
const config = {
|
|
208
|
+
views: [
|
|
209
|
+
fruitView({
|
|
210
|
+
primaryId: "id",
|
|
211
|
+
filters: "acidity < 10",
|
|
212
|
+
filtersFromUser: (userId: number) => `ownerId = ${userId}`,
|
|
213
|
+
}),
|
|
214
|
+
],
|
|
215
|
+
};
|
|
216
|
+
const instance = buildAppForManager(
|
|
217
|
+
harness.manager,
|
|
218
|
+
baseConfig(config),
|
|
219
|
+
EDITOR,
|
|
220
|
+
);
|
|
221
|
+
// Passes all three conditions.
|
|
222
|
+
await request(instance)
|
|
223
|
+
.post("/api/createRecord")
|
|
224
|
+
.send({ viewIndex: 0, data: { fruitName: "ok", acidity: 5, ownerId: 2 } })
|
|
225
|
+
.expect(200);
|
|
226
|
+
// Rejected by the view filter.
|
|
227
|
+
await request(instance)
|
|
228
|
+
.post("/api/createRecord")
|
|
229
|
+
.send({ viewIndex: 0, data: { fruitName: "acidic", acidity: 15, ownerId: 2 } })
|
|
230
|
+
.expect(200);
|
|
231
|
+
// Rejected by filtersFromUser.
|
|
232
|
+
await request(instance)
|
|
233
|
+
.post("/api/createRecord")
|
|
234
|
+
.send({ viewIndex: 0, data: { fruitName: "foreign", acidity: 5, ownerId: 3 } })
|
|
235
|
+
.expect(200);
|
|
236
|
+
const res = await postRecords(instance, {
|
|
237
|
+
filters: ["acidity > 2"],
|
|
238
|
+
}).expect(200);
|
|
239
|
+
expect(res.body.records).toHaveLength(1);
|
|
240
|
+
expect(res.body.records[0].fruitName).toBe("ok");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("sorts by orderBy DESC with the primaryId as tiebreak", async () => {
|
|
244
|
+
const instance = app();
|
|
245
|
+
await request(instance)
|
|
246
|
+
.post("/api/createRecord")
|
|
247
|
+
.send({ viewIndex: 0, data: { fruitName: "a", acidity: 1 } })
|
|
248
|
+
.expect(200);
|
|
249
|
+
await request(instance)
|
|
250
|
+
.post("/api/createRecord")
|
|
251
|
+
.send({ viewIndex: 0, data: { fruitName: "b", acidity: 3 } })
|
|
252
|
+
.expect(200);
|
|
253
|
+
await request(instance)
|
|
254
|
+
.post("/api/createRecord")
|
|
255
|
+
.send({ viewIndex: 0, data: { fruitName: "c", acidity: 3 } })
|
|
256
|
+
.expect(200);
|
|
257
|
+
const res = await postRecords(instance, {
|
|
258
|
+
orderBy: "acidity",
|
|
259
|
+
isDesc: true,
|
|
260
|
+
}).expect(200);
|
|
261
|
+
// acidity DESC first, then the id tiebreak ASC: row 3 (acidity 3)
|
|
262
|
+
// comes after row 2 (also acidity 3), row 1 (acidity 1) last.
|
|
263
|
+
expect(idsOf(res.body.records)).toEqual([2, 3, 1]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("falls back to view.isDesc when body.isDesc is null", async () => {
|
|
267
|
+
const instance = app({ isDesc: true });
|
|
268
|
+
await request(instance)
|
|
269
|
+
.post("/api/createRecord")
|
|
270
|
+
.send({ viewIndex: 0, data: { fruitName: "a", acidity: 3 } })
|
|
271
|
+
.expect(200);
|
|
272
|
+
await request(instance)
|
|
273
|
+
.post("/api/createRecord")
|
|
274
|
+
.send({ viewIndex: 0, data: { fruitName: "b", acidity: 1 } })
|
|
275
|
+
.expect(200);
|
|
276
|
+
const res = await postRecords(instance, {
|
|
277
|
+
orderBy: "acidity",
|
|
278
|
+
isDesc: null,
|
|
279
|
+
}).expect(200);
|
|
280
|
+
expect(idsOf(res.body.records)).toEqual([1, 2]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("orders by primaryId alone when no orderBy is given", async () => {
|
|
284
|
+
const instance = app();
|
|
285
|
+
for (const name of ["c", "a", "b"]) {
|
|
286
|
+
await request(instance)
|
|
287
|
+
.post("/api/createRecord")
|
|
288
|
+
.send({ viewIndex: 0, data: { fruitName: name } })
|
|
289
|
+
.expect(200);
|
|
290
|
+
}
|
|
291
|
+
const res = await postRecords(instance, {}).expect(200);
|
|
292
|
+
expect(idsOf(res.body.records)).toEqual([1, 2, 3]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("CHARACTERIZATION: grouped subviews run on PG but are rejected by MySQL's ONLY_FULL_GROUP_BY", async () => {
|
|
296
|
+
const config = {
|
|
297
|
+
views: [
|
|
298
|
+
fruitView({
|
|
299
|
+
// No declared primaryId: on MySQL enrichment resolves it and
|
|
300
|
+
// the route appends it to ORDER BY; on PG enrichment no-ops
|
|
301
|
+
// (GAP 10) and it stays undefined.
|
|
302
|
+
// Grouped select: every projected column must be grouped or
|
|
303
|
+
// aggregated (ONLY_FULL_GROUP_BY / standard SQL). The column
|
|
304
|
+
// is table-qualified because the join exposes activity_id on
|
|
305
|
+
// both sides.
|
|
306
|
+
selectExpression: "fruits.activity_id, MAX(age) AS maxAge",
|
|
307
|
+
join:
|
|
308
|
+
"JOIN activities ON fruits.activity_id = activities.activity_id",
|
|
309
|
+
groupBy: "fruits.activity_id",
|
|
310
|
+
subviews: [
|
|
311
|
+
{
|
|
312
|
+
label: "Old",
|
|
313
|
+
filters: "age > 50",
|
|
314
|
+
having: "MAX(age) > 10",
|
|
315
|
+
orderBy: "maxAge",
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
}),
|
|
319
|
+
],
|
|
320
|
+
};
|
|
321
|
+
const instance = buildAppForManager(
|
|
322
|
+
harness.manager,
|
|
323
|
+
baseConfig(config),
|
|
324
|
+
);
|
|
325
|
+
await harness.query(
|
|
326
|
+
"INSERT INTO activities (name) VALUES (?)",
|
|
327
|
+
["running"],
|
|
328
|
+
);
|
|
329
|
+
await harness.query(
|
|
330
|
+
"INSERT INTO activities (name) VALUES (?)",
|
|
331
|
+
["chess"],
|
|
332
|
+
);
|
|
333
|
+
await harness.query(
|
|
334
|
+
"INSERT INTO fruits (fruitName, age, activity_id) VALUES (?, ?, ?)",
|
|
335
|
+
["young-pomme", 30, 1],
|
|
336
|
+
);
|
|
337
|
+
await harness.query(
|
|
338
|
+
"INSERT INTO fruits (fruitName, age, activity_id) VALUES (?, ?, ?)",
|
|
339
|
+
["old-pomme", 60, 1],
|
|
340
|
+
);
|
|
341
|
+
await harness.query(
|
|
342
|
+
"INSERT INTO fruits (fruitName, age, activity_id) VALUES (?, ?, ?)",
|
|
343
|
+
["chess-fruit", 20, 2],
|
|
344
|
+
);
|
|
345
|
+
const res = await request(instance)
|
|
346
|
+
.post("/api/records")
|
|
347
|
+
.send({ viewIndex: 0, page: 1, subviewIndex: 0, filters: [] });
|
|
348
|
+
if (harness.dbType === "POSTGRESQL") {
|
|
349
|
+
// Without the appended primaryId tiebreak the grouped query is
|
|
350
|
+
// valid: only activity 1 survives age>50 + MAX(age)>10.
|
|
351
|
+
expect(res.status).toBe(200);
|
|
352
|
+
expect(res.body.records).toHaveLength(1);
|
|
353
|
+
expect(Number(res.body.records[0].activity_id)).toBe(1);
|
|
354
|
+
expect(Number(res.body.records[0].maxAge)).toBe(60);
|
|
355
|
+
expect(String(res.body.count)).toBe("1");
|
|
356
|
+
} else {
|
|
357
|
+
// GAP: the route always appends the bare primaryId (fruits.id) as
|
|
358
|
+
// an ORDER BY tiebreak. fruits.id is neither grouped nor
|
|
359
|
+
// aggregated, so MySQL's ONLY_FULL_GROUP_BY (the 8.x default)
|
|
360
|
+
// rejects every grouped view.
|
|
361
|
+
expect(res.status).toBe(500);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("uses customQuery when set", async () => {
|
|
366
|
+
const config = {
|
|
367
|
+
views: [
|
|
368
|
+
fruitView({
|
|
369
|
+
primaryId: "id",
|
|
370
|
+
customQuery: "SELECT * FROM fruits WHERE acidity > 5",
|
|
371
|
+
}),
|
|
372
|
+
],
|
|
373
|
+
};
|
|
374
|
+
const instance = buildAppForManager(
|
|
375
|
+
harness.manager,
|
|
376
|
+
baseConfig(config),
|
|
377
|
+
);
|
|
378
|
+
await request(instance)
|
|
379
|
+
.post("/api/createRecord")
|
|
380
|
+
.send({ viewIndex: 0, data: { fruitName: "sweet", acidity: 9 } })
|
|
381
|
+
.expect(200);
|
|
382
|
+
await request(instance)
|
|
383
|
+
.post("/api/createRecord")
|
|
384
|
+
.send({ viewIndex: 0, data: { fruitName: "bland", acidity: 1 } })
|
|
385
|
+
.expect(200);
|
|
386
|
+
const res = await postRecords(instance, {}).expect(200);
|
|
387
|
+
expect(res.body.records).toHaveLength(1);
|
|
388
|
+
expect(res.body.records[0].fruitName).toBe("sweet");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("enriches records with __formatValue-*__ fields", async () => {
|
|
392
|
+
const formatter = (record: any) => `${record.fruitName} kg`;
|
|
393
|
+
const config = {
|
|
394
|
+
views: [
|
|
395
|
+
fruitView({
|
|
396
|
+
primaryId: "id",
|
|
397
|
+
columns: [
|
|
398
|
+
{ label: "Nom", name: "fruitName", formatValue: formatter },
|
|
399
|
+
{ label: "Acidité", name: "acidity" },
|
|
400
|
+
],
|
|
401
|
+
}),
|
|
402
|
+
],
|
|
403
|
+
};
|
|
404
|
+
const instance = buildAppForManager(
|
|
405
|
+
harness.manager,
|
|
406
|
+
baseConfig(config),
|
|
407
|
+
);
|
|
408
|
+
await request(instance)
|
|
409
|
+
.post("/api/createRecord")
|
|
410
|
+
.send({ viewIndex: 0, data: { fruitName: "pomme" } })
|
|
411
|
+
.expect(200);
|
|
412
|
+
const res = await postRecords(instance, {}).expect(200);
|
|
413
|
+
// Enrichment hashes the pristine column object (before it gains
|
|
414
|
+
// required/dataType), so the id is the hash of the initial literal.
|
|
415
|
+
const colId = generateUniqueIdForObject({
|
|
416
|
+
label: "Nom",
|
|
417
|
+
name: "fruitName",
|
|
418
|
+
});
|
|
419
|
+
expect(res.body.records[0][`__formatValue-${colId}__`]).toBe(
|
|
420
|
+
"pomme kg",
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("returns 500 when the engine rejects the query", async () => {
|
|
425
|
+
const config = {
|
|
426
|
+
views: [
|
|
427
|
+
fruitView({ tableName: "table_does_not_exist", primaryId: "id" }),
|
|
428
|
+
],
|
|
429
|
+
};
|
|
430
|
+
await request(buildAppForManager(harness.manager, baseConfig(config)))
|
|
431
|
+
.post("/api/records")
|
|
432
|
+
.send({ viewIndex: 0, page: 1, filters: [] })
|
|
433
|
+
.expect(500);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// NOTE: the unit suite also covered a "falls back to counting fetched
|
|
437
|
+
// rows when the count query fails" branch. That defensive path is not
|
|
438
|
+
// reachable on a healthy engine — when the wrapped query runs, its
|
|
439
|
+
// COUNT(*) projection runs too — so it has no real-engine equivalent.
|
|
440
|
+
|
|
441
|
+
it("serves rows scoped per-user via filtersFromUser(userId, role)", async () => {
|
|
442
|
+
const config = {
|
|
443
|
+
views: [
|
|
444
|
+
fruitView({
|
|
445
|
+
primaryId: "id",
|
|
446
|
+
filtersFromUser: (userId: number, role?: string) =>
|
|
447
|
+
`ownerId = ${userId} AND role = '${role}'`,
|
|
448
|
+
}),
|
|
449
|
+
],
|
|
450
|
+
};
|
|
451
|
+
const instance = buildAppForManager(
|
|
452
|
+
harness.manager,
|
|
453
|
+
baseConfig(config),
|
|
454
|
+
EDITOR,
|
|
455
|
+
);
|
|
456
|
+
await request(instance)
|
|
457
|
+
.post("/api/createRecord")
|
|
458
|
+
.send({
|
|
459
|
+
viewIndex: 0,
|
|
460
|
+
data: { fruitName: "mine", ownerId: 2, role: "editor" },
|
|
461
|
+
})
|
|
462
|
+
.expect(200);
|
|
463
|
+
await request(instance)
|
|
464
|
+
.post("/api/createRecord")
|
|
465
|
+
.send({
|
|
466
|
+
viewIndex: 0,
|
|
467
|
+
data: { fruitName: "root-fruit", ownerId: 2, role: "root" },
|
|
468
|
+
})
|
|
469
|
+
.expect(200);
|
|
470
|
+
const res = await postRecords(instance, {}).expect(200);
|
|
471
|
+
expect(res.body.records).toHaveLength(1);
|
|
472
|
+
expect(res.body.records[0].fruitName).toBe("mine");
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
describe("POST /api/adjacentRecordId", () => {
|
|
477
|
+
const seedFruits = async (instance: any) => {
|
|
478
|
+
for (const name of ["pomme", "poire", "banane", "cerise", "datte"]) {
|
|
479
|
+
await request(instance)
|
|
480
|
+
.post("/api/createRecord")
|
|
481
|
+
.send({ viewIndex: 0, data: { fruitName: name } })
|
|
482
|
+
.expect(200);
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
it("returns the id of the record at position + offset", async () => {
|
|
487
|
+
const instance = app();
|
|
488
|
+
await seedFruits(instance);
|
|
489
|
+
const res = await request(instance)
|
|
490
|
+
.post("/api/adjacentRecordId")
|
|
491
|
+
.send({ position: "2", offset: "-1", primaryId: "id", table: "fruits" })
|
|
492
|
+
.expect(200);
|
|
493
|
+
// page = position + offset = 1 record per page → the first record.
|
|
494
|
+
expect(res.body).toEqual({ recordId: 1 });
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("returns null when no record exists at that position", async () => {
|
|
498
|
+
const instance = app();
|
|
499
|
+
await seedFruits(instance);
|
|
500
|
+
const res = await request(instance)
|
|
501
|
+
.post("/api/adjacentRecordId")
|
|
502
|
+
.send({ position: "99", offset: "0", primaryId: "id", table: "fruits" })
|
|
503
|
+
.expect(200);
|
|
504
|
+
expect(res.body).toEqual({ recordId: null });
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
describe("GET /api/record", () => {
|
|
509
|
+
it("returns a single record by primary id", async () => {
|
|
510
|
+
const instance = app();
|
|
511
|
+
await request(instance)
|
|
512
|
+
.post("/api/createRecord")
|
|
513
|
+
.send({ viewIndex: 0, data: { fruitName: "pomme", acidity: 3 } })
|
|
514
|
+
.expect(200);
|
|
515
|
+
const res = await request(instance)
|
|
516
|
+
.get("/api/record")
|
|
517
|
+
.query({ viewIndex: 0, recordId: 1 })
|
|
518
|
+
.expect(200);
|
|
519
|
+
expect(res.body.fruitName).toBe("pomme");
|
|
520
|
+
expect(Number(res.body.acidity)).toBe(3);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("applies selectExpression and join from the view", async () => {
|
|
524
|
+
const config = {
|
|
525
|
+
views: [
|
|
526
|
+
fruitView({
|
|
527
|
+
primaryId: "id",
|
|
528
|
+
selectExpression: "*, a.name AS activityName",
|
|
529
|
+
join:
|
|
530
|
+
"JOIN activities a ON fruits.activity_id = a.activity_id",
|
|
531
|
+
}),
|
|
532
|
+
],
|
|
533
|
+
};
|
|
534
|
+
const instance = buildAppForManager(
|
|
535
|
+
harness.manager,
|
|
536
|
+
baseConfig(config),
|
|
537
|
+
);
|
|
538
|
+
await harness.query(
|
|
539
|
+
"INSERT INTO activities (name) VALUES (?)",
|
|
540
|
+
["running"],
|
|
541
|
+
);
|
|
542
|
+
// A fruit attached to an activity joins through.
|
|
543
|
+
await harness.query(
|
|
544
|
+
"INSERT INTO fruits (fruitName, activity_id) VALUES (?, ?)",
|
|
545
|
+
["pomme", 1],
|
|
546
|
+
);
|
|
547
|
+
// A fruit without an activity disappears through the inner join.
|
|
548
|
+
await harness.query(
|
|
549
|
+
"INSERT INTO fruits (fruitName, activity_id) VALUES (?, ?)",
|
|
550
|
+
["lonely", null],
|
|
551
|
+
);
|
|
552
|
+
const found = await request(instance)
|
|
553
|
+
.get("/api/record")
|
|
554
|
+
.query({ viewIndex: 0, recordId: 1 })
|
|
555
|
+
.expect(200);
|
|
556
|
+
expect(found.body.fruitName).toBe("pomme");
|
|
557
|
+
expect(found.body.activityName).toBe("running");
|
|
558
|
+
await request(instance)
|
|
559
|
+
.get("/api/record")
|
|
560
|
+
.query({ viewIndex: 0, recordId: 2 })
|
|
561
|
+
.expect(404);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("returns 404 when the record does not exist", async () => {
|
|
565
|
+
await request(app())
|
|
566
|
+
.get("/api/record")
|
|
567
|
+
.query({ viewIndex: 0, recordId: 999 })
|
|
568
|
+
.expect(404);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it("returns 500 on db error", async () => {
|
|
572
|
+
const config = {
|
|
573
|
+
views: [
|
|
574
|
+
fruitView({ tableName: "table_does_not_exist", primaryId: "id" }),
|
|
575
|
+
],
|
|
576
|
+
};
|
|
577
|
+
await request(buildAppForManager(harness.manager, baseConfig(config)))
|
|
578
|
+
.get("/api/record")
|
|
579
|
+
.query({ viewIndex: 0, recordId: 5 })
|
|
580
|
+
.expect(500);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it("enriches the record with formatValue columns", async () => {
|
|
584
|
+
const config = {
|
|
585
|
+
views: [
|
|
586
|
+
fruitView({
|
|
587
|
+
primaryId: "id",
|
|
588
|
+
columns: [
|
|
589
|
+
{
|
|
590
|
+
label: "Nom",
|
|
591
|
+
name: "fruitName",
|
|
592
|
+
formatValue: (r: any) => r.fruitName.toUpperCase(),
|
|
593
|
+
},
|
|
594
|
+
{ label: "Acidité", name: "acidity" },
|
|
595
|
+
],
|
|
596
|
+
}),
|
|
597
|
+
],
|
|
598
|
+
};
|
|
599
|
+
const instance = buildAppForManager(
|
|
600
|
+
harness.manager,
|
|
601
|
+
baseConfig(config),
|
|
602
|
+
);
|
|
603
|
+
await request(instance)
|
|
604
|
+
.post("/api/createRecord")
|
|
605
|
+
.send({ viewIndex: 0, data: { fruitName: "pomme" } })
|
|
606
|
+
.expect(200);
|
|
607
|
+
const res = await request(instance)
|
|
608
|
+
.get("/api/record")
|
|
609
|
+
.query({ viewIndex: 0, recordId: 1 })
|
|
610
|
+
.expect(200);
|
|
611
|
+
const colId = generateUniqueIdForObject({
|
|
612
|
+
label: "Nom",
|
|
613
|
+
name: "fruitName",
|
|
614
|
+
});
|
|
615
|
+
expect(res.body[`__formatValue-${colId}__`]).toBe("POMME");
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("rejects writes for views hidden to the role (403)", async () => {
|
|
619
|
+
const config = {
|
|
620
|
+
views: [fruitView({ primaryId: "id", roles: ["admin"] })],
|
|
621
|
+
};
|
|
622
|
+
const instance = buildAppForManager(
|
|
623
|
+
harness.manager,
|
|
624
|
+
baseConfig(config),
|
|
625
|
+
VIEWER,
|
|
626
|
+
);
|
|
627
|
+
await request(instance)
|
|
628
|
+
.post("/api/updateRecord")
|
|
629
|
+
.send({ viewIndex: 0, data: {}, primaryValue: 1 })
|
|
630
|
+
.expect(403);
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
});
|