@galadrim/galadmin 0.1.236 → 0.2.7
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 +34 -3
- 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 +25 -3
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/config.d.ts +6 -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 +26 -3
- package/src/types/config.ts +13 -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 +412 -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,366 @@
|
|
|
1
|
+
import * as request from "supertest";
|
|
2
|
+
import { buildAppForManager } from "../helpers/app";
|
|
3
|
+
import { baseConfig, fruitView } from "../helpers/fixtures";
|
|
4
|
+
import {
|
|
5
|
+
DbHarness,
|
|
6
|
+
describeIntegration,
|
|
7
|
+
getMysql2Harness,
|
|
8
|
+
getMysqlHarness,
|
|
9
|
+
getPgHarness,
|
|
10
|
+
resetTables,
|
|
11
|
+
} from "./dbHarness";
|
|
12
|
+
|
|
13
|
+
jest.mock("axios");
|
|
14
|
+
|
|
15
|
+
const CONTAINER_TIMEOUT = 300000;
|
|
16
|
+
|
|
17
|
+
// The primaryId is declared explicitly in every view config below because
|
|
18
|
+
// config enrichment silently no-ops on PostgreSQL (see the enrichment
|
|
19
|
+
// characterization test at the bottom — GAP 10).
|
|
20
|
+
|
|
21
|
+
describeIntegration("routes against real databases", () => {
|
|
22
|
+
let mysqlHarness: DbHarness;
|
|
23
|
+
let mysql2Harness: DbHarness;
|
|
24
|
+
let pgHarness: DbHarness;
|
|
25
|
+
const harnesses: Record<string, DbHarness> = {};
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
mysqlHarness = await getMysqlHarness();
|
|
29
|
+
harnesses.mysql = mysqlHarness;
|
|
30
|
+
mysql2Harness = await getMysql2Harness();
|
|
31
|
+
harnesses.mysql2 = mysql2Harness;
|
|
32
|
+
pgHarness = await getPgHarness();
|
|
33
|
+
harnesses.postgresql = pgHarness;
|
|
34
|
+
}, CONTAINER_TIMEOUT);
|
|
35
|
+
|
|
36
|
+
afterAll(async () => {
|
|
37
|
+
// Close the pools; the containers themselves live until globalTeardown.
|
|
38
|
+
await mysqlHarness?.stop();
|
|
39
|
+
await mysql2Harness?.stop();
|
|
40
|
+
await pgHarness?.stop();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe.each([
|
|
44
|
+
["mysql", () => harnesses.mysql],
|
|
45
|
+
["mysql2", () => harnesses.mysql2],
|
|
46
|
+
["postgresql", () => harnesses.postgresql],
|
|
47
|
+
])("backend: %s", (_backend, getHarness) => {
|
|
48
|
+
let harness: DbHarness;
|
|
49
|
+
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
harness = getHarness();
|
|
52
|
+
await resetTables(harness);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const app = () =>
|
|
56
|
+
buildAppForManager(
|
|
57
|
+
harness.manager,
|
|
58
|
+
baseConfig({ views: [fruitView({ primaryId: "id" })] }),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
it("POST /createRecord persists the row", async () => {
|
|
62
|
+
const res = await request(app())
|
|
63
|
+
.post("/api/createRecord")
|
|
64
|
+
.send({ viewIndex: 0, data: { fruitName: "pomme", acidity: 3 } })
|
|
65
|
+
.expect(200);
|
|
66
|
+
if (harness.dbType === "MYSQL") {
|
|
67
|
+
expect(res.body.id).toBe(1);
|
|
68
|
+
} else {
|
|
69
|
+
// CHARACTERIZATION (GAP 11): pg results carry no insertId, so the
|
|
70
|
+
// created id is undefined in the response on PostgreSQL.
|
|
71
|
+
expect(res.body.id).toBeUndefined();
|
|
72
|
+
}
|
|
73
|
+
const { err, rows } = await harness.query("SELECT * FROM fruits");
|
|
74
|
+
expect(err ?? null).toBeNull();
|
|
75
|
+
expect(rows).toHaveLength(1);
|
|
76
|
+
expect(rows[0].fruitName ?? rows[0].fruitname).toBe("pomme");
|
|
77
|
+
expect(Number(rows[0].acidity)).toBe(3);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("POST /updateRecord updates the persisted row", async () => {
|
|
81
|
+
await request(app())
|
|
82
|
+
.post("/api/createRecord")
|
|
83
|
+
.send({ viewIndex: 0, data: { fruitName: "pomme", acidity: 3 } })
|
|
84
|
+
.expect(200);
|
|
85
|
+
await request(app())
|
|
86
|
+
.post("/api/updateRecord")
|
|
87
|
+
.send({ viewIndex: 0, primaryValue: 1, data: { fruitName: "poire" } })
|
|
88
|
+
.expect(200);
|
|
89
|
+
const { rows } = await harness.query("SELECT * FROM fruits");
|
|
90
|
+
expect(rows).toHaveLength(1);
|
|
91
|
+
expect(rows[0].fruitName ?? rows[0].fruitname).toBe("poire");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("POST /deleteRecord removes the row, then answers 404 once gone", async () => {
|
|
95
|
+
await request(app())
|
|
96
|
+
.post("/api/createRecord")
|
|
97
|
+
.send({ viewIndex: 0, data: { fruitName: "pomme" } })
|
|
98
|
+
.expect(200);
|
|
99
|
+
await request(app())
|
|
100
|
+
.post("/api/deleteRecord")
|
|
101
|
+
.send({ viewIndex: 0, primaryValue: 1 })
|
|
102
|
+
.expect(200);
|
|
103
|
+
const { rows } = await harness.query("SELECT * FROM fruits");
|
|
104
|
+
expect(rows).toHaveLength(0);
|
|
105
|
+
await request(app())
|
|
106
|
+
.post("/api/deleteRecord")
|
|
107
|
+
.send({ viewIndex: 0, primaryValue: 1 })
|
|
108
|
+
.expect(404);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("POST /deleteRecords removes several rows by primary value", async () => {
|
|
112
|
+
const appInstance = app();
|
|
113
|
+
await request(appInstance)
|
|
114
|
+
.post("/api/createRecord")
|
|
115
|
+
.send({ viewIndex: 0, data: { fruitName: "pomme" } })
|
|
116
|
+
.expect(200);
|
|
117
|
+
await request(appInstance)
|
|
118
|
+
.post("/api/createRecord")
|
|
119
|
+
.send({ viewIndex: 0, data: { fruitName: "poire" } })
|
|
120
|
+
.expect(200);
|
|
121
|
+
await request(appInstance)
|
|
122
|
+
.post("/api/deleteRecords")
|
|
123
|
+
.send({ viewIndex: 0, primaryValues: ["1", "2"] })
|
|
124
|
+
.expect(200);
|
|
125
|
+
const { rows } = await harness.query("SELECT * FROM fruits");
|
|
126
|
+
expect(rows).toHaveLength(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("POST /records lists rows with pagination, search and count", async () => {
|
|
130
|
+
const appInstance = app();
|
|
131
|
+
for (const name of ["pomme", "poire", "banane"]) {
|
|
132
|
+
await request(appInstance)
|
|
133
|
+
.post("/api/createRecord")
|
|
134
|
+
.send({ viewIndex: 0, data: { fruitName: name } })
|
|
135
|
+
.expect(200);
|
|
136
|
+
}
|
|
137
|
+
const res = await request(appInstance)
|
|
138
|
+
.post("/api/records")
|
|
139
|
+
.send({ viewIndex: 0, page: 1, filters: [] })
|
|
140
|
+
.expect(200);
|
|
141
|
+
expect(res.body.records).toHaveLength(3);
|
|
142
|
+
// PG returns COUNT(*) as a bigint, which node-pg surfaces as a string.
|
|
143
|
+
expect(String(res.body.count)).toBe("3");
|
|
144
|
+
|
|
145
|
+
// The LIKE -> ::text ILIKE rewrite must actually run on the engine.
|
|
146
|
+
const search = await request(appInstance)
|
|
147
|
+
.post("/api/records")
|
|
148
|
+
.send({ viewIndex: 0, page: 1, filters: [], query: "pomme" })
|
|
149
|
+
.expect(200);
|
|
150
|
+
expect(search.body.records).toHaveLength(1);
|
|
151
|
+
expect(search.body.records[0].fruitName).toBe("pomme");
|
|
152
|
+
|
|
153
|
+
const noMatch = await request(appInstance)
|
|
154
|
+
.post("/api/records")
|
|
155
|
+
.send({ viewIndex: 0, page: 1, filters: [], query: "cerise" })
|
|
156
|
+
.expect(200);
|
|
157
|
+
expect(noMatch.body.records).toHaveLength(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("GET /record returns the persisted record", async () => {
|
|
161
|
+
const appInstance = app();
|
|
162
|
+
await request(appInstance)
|
|
163
|
+
.post("/api/createRecord")
|
|
164
|
+
.send({ viewIndex: 0, data: { fruitName: "pomme", acidity: 3 } })
|
|
165
|
+
.expect(200);
|
|
166
|
+
const res = await request(appInstance)
|
|
167
|
+
.get("/api/record")
|
|
168
|
+
.query({ viewIndex: 0, recordId: 1 })
|
|
169
|
+
.expect(200);
|
|
170
|
+
expect(res.body.fruitName).toBe("pomme");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("a read-only role cannot write, and the row stays untouched", async () => {
|
|
174
|
+
const viewerApp = buildAppForManager(
|
|
175
|
+
harness.manager,
|
|
176
|
+
baseConfig({
|
|
177
|
+
views: [
|
|
178
|
+
fruitView({
|
|
179
|
+
primaryId: "id",
|
|
180
|
+
roles: ["editor"],
|
|
181
|
+
readRoles: ["viewer"],
|
|
182
|
+
}),
|
|
183
|
+
],
|
|
184
|
+
}),
|
|
185
|
+
{ role: "viewer", userId: 3 },
|
|
186
|
+
);
|
|
187
|
+
await request(app())
|
|
188
|
+
.post("/api/createRecord")
|
|
189
|
+
.send({ viewIndex: 0, data: { fruitName: "pomme" } })
|
|
190
|
+
.expect(200);
|
|
191
|
+
await request(viewerApp)
|
|
192
|
+
.post("/api/updateRecord")
|
|
193
|
+
.send({ viewIndex: 0, primaryValue: 1, data: { fruitName: "hack" } })
|
|
194
|
+
.expect(403);
|
|
195
|
+
const { rows } = await harness.query("SELECT * FROM fruits");
|
|
196
|
+
expect(rows[0].fruitName ?? rows[0].fruitname).toBe("pomme");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("POST /recordsImport upserts rows (idempotent re-import)", async () => {
|
|
200
|
+
const parseImportFile = jest
|
|
201
|
+
.fn()
|
|
202
|
+
.mockResolvedValueOnce([
|
|
203
|
+
{ id: 1, fruitName: "pomme" },
|
|
204
|
+
{ id: 2, fruitName: "poire" },
|
|
205
|
+
])
|
|
206
|
+
.mockResolvedValueOnce([{ id: 1, fruitName: "POMME-IMPORTED" }]);
|
|
207
|
+
const appInstance = buildAppForManager(
|
|
208
|
+
harness.manager,
|
|
209
|
+
baseConfig({
|
|
210
|
+
views: [
|
|
211
|
+
fruitView({
|
|
212
|
+
primaryId: "id",
|
|
213
|
+
importFileUniqueField: "id",
|
|
214
|
+
parseImportFile,
|
|
215
|
+
}),
|
|
216
|
+
],
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
if (harness.dbType === "POSTGRESQL") {
|
|
221
|
+
// quotePostgresQuery now knows the ON CONFLICT / EXCLUDED keywords,
|
|
222
|
+
// so the upsert runs on a real engine too (GAP 12 fixed): same
|
|
223
|
+
// assertions as the MySQL branch.
|
|
224
|
+
await request(appInstance)
|
|
225
|
+
.post("/api/recordsImport")
|
|
226
|
+
.field("viewIndex", "0")
|
|
227
|
+
.attach("data", Buffer.from("csv"), "import.csv")
|
|
228
|
+
.expect(200);
|
|
229
|
+
let { rows } = await harness.query(
|
|
230
|
+
"SELECT * FROM fruits ORDER BY id",
|
|
231
|
+
);
|
|
232
|
+
expect(rows).toHaveLength(2);
|
|
233
|
+
|
|
234
|
+
// Re-import the same id: upsert, not duplicate.
|
|
235
|
+
await request(appInstance)
|
|
236
|
+
.post("/api/recordsImport")
|
|
237
|
+
.field("viewIndex", "0")
|
|
238
|
+
.attach("data", Buffer.from("csv"), "import.csv")
|
|
239
|
+
.expect(200);
|
|
240
|
+
({ rows } = await harness.query("SELECT * FROM fruits ORDER BY id"));
|
|
241
|
+
expect(rows).toHaveLength(2);
|
|
242
|
+
expect(rows[0].fruitName ?? rows[0].fruitname).toBe("POMME-IMPORTED");
|
|
243
|
+
} else {
|
|
244
|
+
await request(appInstance)
|
|
245
|
+
.post("/api/recordsImport")
|
|
246
|
+
.field("viewIndex", "0")
|
|
247
|
+
.attach("data", Buffer.from("csv"), "import.csv")
|
|
248
|
+
.expect(200);
|
|
249
|
+
let { rows } = await harness.query(
|
|
250
|
+
"SELECT * FROM fruits ORDER BY id",
|
|
251
|
+
);
|
|
252
|
+
expect(rows).toHaveLength(2);
|
|
253
|
+
|
|
254
|
+
// Re-import the same id: upsert, not duplicate.
|
|
255
|
+
await request(appInstance)
|
|
256
|
+
.post("/api/recordsImport")
|
|
257
|
+
.field("viewIndex", "0")
|
|
258
|
+
.attach("data", Buffer.from("csv"), "import.csv")
|
|
259
|
+
.expect(200);
|
|
260
|
+
({ rows } = await harness.query("SELECT * FROM fruits ORDER BY id"));
|
|
261
|
+
expect(rows).toHaveLength(2);
|
|
262
|
+
expect(rows[0].fruitName ?? rows[0].fruitname).toBe("POMME-IMPORTED");
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("POST /foreignSelectRecords searches with a quoted term", async () => {
|
|
267
|
+
const searchColumn = {
|
|
268
|
+
label: "Nom",
|
|
269
|
+
name: "fruitName",
|
|
270
|
+
table: "fruits",
|
|
271
|
+
searchableName: "fruitName",
|
|
272
|
+
__id__: "foreign-col",
|
|
273
|
+
};
|
|
274
|
+
const appInstance = buildAppForManager(
|
|
275
|
+
harness.manager,
|
|
276
|
+
baseConfig({
|
|
277
|
+
views: [
|
|
278
|
+
fruitView({
|
|
279
|
+
primaryId: "id",
|
|
280
|
+
columns: [
|
|
281
|
+
searchColumn,
|
|
282
|
+
{ label: "Acidité", name: "acidity" },
|
|
283
|
+
],
|
|
284
|
+
}),
|
|
285
|
+
],
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
await request(appInstance)
|
|
289
|
+
.post("/api/createRecord")
|
|
290
|
+
.send({ viewIndex: 0, data: { fruitName: "l'orange" } })
|
|
291
|
+
.expect(200);
|
|
292
|
+
if (harness.dbType === "POSTGRESQL") {
|
|
293
|
+
// The PG branch of the route escapes quotes with doubling (''),
|
|
294
|
+
// which is valid under standard_conforming_strings — the search
|
|
295
|
+
// works on a real engine (unlike the /records search, GAP 7).
|
|
296
|
+
const res = await request(appInstance)
|
|
297
|
+
.post("/api/foreignSelectRecords")
|
|
298
|
+
.send({ viewIndex: 0, columnId: "foreign-col", query: "l'orange" })
|
|
299
|
+
.expect(200);
|
|
300
|
+
expect(res.body).toHaveLength(1);
|
|
301
|
+
expect(res.body[0].value).toBe("l'orange");
|
|
302
|
+
} else {
|
|
303
|
+
const res = await request(appInstance)
|
|
304
|
+
.post("/api/foreignSelectRecords")
|
|
305
|
+
.send({ viewIndex: 0, columnId: "foreign-col", query: "l'orange" })
|
|
306
|
+
.expect(200);
|
|
307
|
+
expect(res.body).toHaveLength(1);
|
|
308
|
+
// The label/value default to the column's own value.
|
|
309
|
+
expect(res.body[0].value).toBe("l'orange");
|
|
310
|
+
expect(res.body[0].label).toBe("l'orange");
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describeIntegration("config enrichment against real information_schema", () => {
|
|
317
|
+
let mysqlHarness: DbHarness;
|
|
318
|
+
let pgHarness: DbHarness;
|
|
319
|
+
|
|
320
|
+
beforeAll(async () => {
|
|
321
|
+
mysqlHarness = await getMysqlHarness();
|
|
322
|
+
pgHarness = await getPgHarness();
|
|
323
|
+
}, CONTAINER_TIMEOUT);
|
|
324
|
+
|
|
325
|
+
afterAll(async () => {
|
|
326
|
+
await mysqlHarness?.stop();
|
|
327
|
+
await pgHarness?.stop();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("resolves the primary key on MySQL without a declared primaryId", async () => {
|
|
331
|
+
const { rows } = await mysqlHarness.query(
|
|
332
|
+
"SELECT * FROM information_schema.columns WHERE table_schema = ?",
|
|
333
|
+
["galadmin_test"],
|
|
334
|
+
);
|
|
335
|
+
const pri = rows.find(
|
|
336
|
+
(r: any) => r.TABLE_NAME === "fruits" && r.COLUMN_KEY === "PRI",
|
|
337
|
+
);
|
|
338
|
+
expect(pri.COLUMN_NAME).toBe("id");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("CHARACTERIZATION (GAP 10): pg information_schema returns lowercase keys, so enrichment no-ops", async () => {
|
|
342
|
+
// 1) The probe enrichConfig actually runs filters by DATABASE name —
|
|
343
|
+
// correct on MySQL, but on PostgreSQL table_schema is the SCHEMA
|
|
344
|
+
// ("public"), so the probe matches nothing at all.
|
|
345
|
+
const { rows: probeRows } = await pgHarness.query(
|
|
346
|
+
"SELECT * FROM information_schema.columns WHERE table_schema = ?",
|
|
347
|
+
["galadmin_test"],
|
|
348
|
+
);
|
|
349
|
+
expect(probeRows).toHaveLength(0);
|
|
350
|
+
|
|
351
|
+
// 2) Even probing the right schema, the keys are lowercase and there is
|
|
352
|
+
// no COLUMN_KEY — enrichConfig only looks for TABLE_NAME/COLUMN_KEY/
|
|
353
|
+
// COLUMN_NAME/COLUMN_TYPE, so primaryId detection, `required` flags
|
|
354
|
+
// and enum->select mapping silently never fire on PostgreSQL.
|
|
355
|
+
const { err, rows } = await pgHarness.query(
|
|
356
|
+
"SELECT * FROM information_schema.columns WHERE table_schema = ?",
|
|
357
|
+
["public"],
|
|
358
|
+
);
|
|
359
|
+
if (err) throw err;
|
|
360
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
361
|
+
const first = rows[0];
|
|
362
|
+
expect(first.TABLE_NAME).toBeUndefined();
|
|
363
|
+
expect(first.table_name).toBeDefined();
|
|
364
|
+
expect(first.column_key).toBeUndefined();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as request from "supertest";
|
|
2
|
+
import { buildAppForManager } from "../helpers/app";
|
|
3
|
+
import { baseConfig, fruitView, EDITOR } from "../helpers/fixtures";
|
|
4
|
+
import {
|
|
5
|
+
BackendName,
|
|
6
|
+
DbHarness,
|
|
7
|
+
describeIntegration,
|
|
8
|
+
getMysql2Harness,
|
|
9
|
+
getMysqlHarness,
|
|
10
|
+
getPgHarness,
|
|
11
|
+
resetTables,
|
|
12
|
+
} from "./dbHarness";
|
|
13
|
+
|
|
14
|
+
jest.mock("axios");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The MySQL -> PostgreSQL conversion pipeline (convertQuery ->
|
|
18
|
+
* quotePostgresQuery) checked against the real engines. Its other behaviors —
|
|
19
|
+
* identifier quoting, $N placeholders, NULL normalization, doubled-quote
|
|
20
|
+
* escaping, ILIKE rewriting, the count subquery — are pinned behaviorally by
|
|
21
|
+
* the crud/records/authorization suites running on the PostgreSQL backend.
|
|
22
|
+
* This suite keeps only what nothing else exercises.
|
|
23
|
+
*/
|
|
24
|
+
describeIntegration("SQL conversion pipeline against real databases", () => {
|
|
25
|
+
const harnesses: Record<BackendName, DbHarness> = {} as any;
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
harnesses.mysql = await getMysqlHarness();
|
|
29
|
+
harnesses.mysql2 = await getMysql2Harness();
|
|
30
|
+
harnesses.postgresql = await getPgHarness();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterAll(async () => {
|
|
34
|
+
await harnesses.mysql?.stop();
|
|
35
|
+
await harnesses.mysql2?.stop();
|
|
36
|
+
await harnesses.postgresql?.stop();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe.each([
|
|
40
|
+
["mysql", () => harnesses.mysql],
|
|
41
|
+
["mysql2", () => harnesses.mysql2],
|
|
42
|
+
["postgresql", () => harnesses.postgresql],
|
|
43
|
+
])("backend: %s", (_backend, getHarness) => {
|
|
44
|
+
let harness: DbHarness;
|
|
45
|
+
|
|
46
|
+
beforeEach(async () => {
|
|
47
|
+
harness = getHarness();
|
|
48
|
+
await resetTables(harness);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const app = () =>
|
|
52
|
+
buildAppForManager(
|
|
53
|
+
harness.manager,
|
|
54
|
+
baseConfig({ views: [fruitView({ primaryId: "id" })] }),
|
|
55
|
+
EDITOR,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const postRecords = (instance: any, body: any) =>
|
|
59
|
+
request(instance).post("/api/records").send({
|
|
60
|
+
viewIndex: 0,
|
|
61
|
+
page: 1,
|
|
62
|
+
filters: [],
|
|
63
|
+
...body,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const idsOf = (records: any[]) => records.map((r: any) => Number(r.id));
|
|
67
|
+
|
|
68
|
+
it("keeps a literal '?' in the search out of the placeholder conversion", async () => {
|
|
69
|
+
// The search term is interpolated as a string literal BEFORE the
|
|
70
|
+
// MySQL -> PostgreSQL conversion runs; a '?' inside it must survive as
|
|
71
|
+
// text instead of becoming a $N placeholder that consumes a parameter
|
|
72
|
+
// slot and shifts every real parameter (PG would fail the statement).
|
|
73
|
+
const instance = app();
|
|
74
|
+
await request(instance)
|
|
75
|
+
.post("/api/createRecord")
|
|
76
|
+
.send({ viewIndex: 0, data: { fruitName: "what? pomme" } })
|
|
77
|
+
.expect(200);
|
|
78
|
+
await request(instance)
|
|
79
|
+
.post("/api/createRecord")
|
|
80
|
+
.send({ viewIndex: 0, data: { fruitName: "pomme" } })
|
|
81
|
+
.expect(200);
|
|
82
|
+
const res = await postRecords(instance, { query: "what?" }).expect(200);
|
|
83
|
+
expect(idsOf(res.body.records)).toEqual([1]);
|
|
84
|
+
// The count subquery wraps the same literal-bearing query.
|
|
85
|
+
expect(String(res.body.count)).toBe("1");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|