@galadrim/galadmin 0.2.12 → 0.3.0-alpha.2.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/.agents/report/blockers.json +1 -0
- package/.agents/report/failures/2026-09-23-api-user-filter-contract.md +31 -0
- package/.agents/report/failures/2026-09-23-api-values-contract.md +25 -0
- package/.agents/report/failures/2026-10-01-cycle-02-create-callback-contract.md +17 -0
- package/.agents/report/failures/2026-10-01-cycle-06-delete-callback-id-contract.md +17 -0
- package/.agents/report/failures/2026-10-01-cycle-07-panel-delete-callback-id-contract.md +24 -0
- package/.agents/report/failures/2026-10-01-cycle-13-update-callback-id-contract.md +17 -0
- package/.agents/report/failures/2026-10-01-cycle-15-rich-text-upload-boundary-contract.md +24 -0
- package/.agents/report/failures/2026-10-03-cycle-06-record-import-parser-contract.md +11 -0
- package/.agents/report/failures/2026-10-03-cycle-07-panel-import-parser-contract.md +24 -0
- package/.agents/report/failures/2026-10-07-resume-cycle-02-api-assembly-contract.md +46 -0
- package/.agents/report/port/2026-09-23-api-config-enrichment.md +13 -0
- package/.agents/report/port/2026-09-23-api-file-upload.md +11 -0
- package/.agents/report/port/2026-09-23-api-query-authorization.md +14 -0
- package/.agents/report/port/2026-09-23-api-request-configuration.md +11 -0
- package/.agents/report/port/2026-09-23-database-manager.md +13 -0
- package/.agents/report/port/2026-10-01-api-adjacent-record-id.md +9 -0
- package/.agents/report/port/2026-10-01-cycle-01-api-config-route.md +9 -0
- package/.agents/report/port/2026-10-01-cycle-03-custom-action.md +9 -0
- package/.agents/report/port/2026-10-01-cycle-04-custom-component.md +7 -0
- package/.agents/report/port/2026-10-01-cycle-05-custom-query.md +7 -0
- package/.agents/report/port/2026-10-01-cycle-08-bulk-delete.md +9 -0
- package/.agents/report/port/2026-10-01-cycle-09-duplicate-record.md +9 -0
- package/.agents/report/port/2026-10-01-cycle-10-notification-badges.md +9 -0
- package/.agents/report/port/2026-10-01-cycle-11-record.md +7 -0
- package/.agents/report/port/2026-10-01-cycle-12-search-companies.md +11 -0
- package/.agents/report/port/2026-10-01-cycle-14-upload-file.md +7 -0
- package/.agents/report/port/2026-10-03-cycle-01-create-record.md +11 -0
- package/.agents/report/port/2026-10-03-cycle-02-delete-record.md +11 -0
- package/.agents/report/port/2026-10-03-cycle-03-panel-delete-record.md +11 -0
- package/.agents/report/port/2026-10-03-cycle-04-update-record.md +11 -0
- package/.agents/report/port/2026-10-03-cycle-05-api-values.md +11 -0
- package/.agents/report/port/2026-10-06-cycle-01-record-import.md +9 -0
- package/.agents/report/port/2026-10-06-cycle-03-rich-text-upload.md +7 -0
- package/.agents/report/port/2026-10-06-cycle-04-user-filters.md +11 -0
- package/.agents/report/port/2026-10-06-resume-cycle-01-panel-import.md +9 -0
- package/.agents/report/port/2026-10-07-cycle-01-api-assembly.md +16 -0
- package/.agents/report/port/2026-10-07-cycle-02-index-entry-point.md +13 -0
- package/.agents/report/remediation/2026-10-02-api-user-filter-remediation.md +71 -0
- package/.agents/report/remediation/2026-10-02-api-values-remediation.md +17 -0
- package/.agents/report/remediation/2026-10-03-callback-id-contract-remediation.md +36 -0
- package/.agents/report/remediation/2026-10-06-import-parser-remediation.md +41 -0
- package/.agents/report/remediation/2026-10-06-rich-text-upload-remediation.md +33 -0
- package/.agents/report/remediation/2026-10-07-api-assembly-remediation.md +32 -0
- package/.agents/skills/port-javascript-to-typescript/SKILL.md +42 -0
- package/.agents/skills/port-javascript-to-typescript/agents/openai.yaml +3 -0
- package/.agents/skills/port-javascript-to-typescript/references/blockers.md +31 -0
- package/.agents/skills/port-javascript-to-typescript/references/planning.md +43 -0
- package/.agents/skills/port-javascript-to-typescript/references/porting.md +9 -0
- package/.agents/skills/port-javascript-to-typescript/references/reporting.md +24 -0
- package/.agents/skills/port-javascript-to-typescript/references/rules.md +8 -0
- package/.agents/skills/port-javascript-to-typescript/references/selection.md +16 -0
- package/.agents/skills/port-javascript-to-typescript/references/tools.md +20 -0
- package/.agents/skills/port-javascript-to-typescript/scripts/check.sh +17 -0
- package/.agents/skills/port-javascript-to-typescript/scripts/graph.sh +30 -0
- package/.agents/skills/port-javascript-to-typescript/scripts/madge.json +12 -0
- package/.agents/skills/port-javascript-to-typescript/scripts/oxlint.json +14 -0
- package/.agents/skills/port-javascript-to-typescript/scripts/test-tools.cjs +76 -0
- package/AGENTS.md +5 -0
- package/dist/api/authorization.d.ts +38 -0
- package/dist/api/authorization.js +74 -0
- package/dist/api/authorization.js.map +1 -0
- package/dist/api/config.d.ts +42 -0
- package/dist/api/config.js +105 -0
- package/dist/api/config.js.map +1 -0
- package/dist/api/filterUser.d.ts +2 -0
- package/dist/api/filterUser.js +24 -0
- package/dist/api/filterUser.js.map +1 -0
- package/dist/api/importRecords.d.ts +1 -0
- package/dist/api/importRecords.js +16 -0
- package/dist/api/importRecords.js.map +1 -0
- package/dist/api/queries.d.ts +48 -0
- package/dist/api/queries.js +139 -0
- package/dist/api/queries.js.map +1 -0
- package/dist/api/request.d.ts +29 -0
- package/dist/api/request.js +3 -0
- package/dist/api/request.js.map +1 -0
- package/dist/api/routes/adjacentRecordId.d.ts +10 -0
- package/dist/api/routes/adjacentRecordId.js +13 -0
- package/dist/api/routes/adjacentRecordId.js.map +1 -0
- package/dist/api/routes/config.d.ts +3 -0
- package/dist/api/routes/config.js +27 -0
- package/dist/api/routes/config.js.map +1 -0
- package/dist/api/routes/createRecord.d.ts +19 -0
- package/dist/api/routes/createRecord.js +93 -0
- package/dist/api/routes/createRecord.js.map +1 -0
- package/dist/api/routes/customAction.d.ts +17 -0
- package/dist/api/routes/customAction.js +35 -0
- package/dist/api/routes/customAction.js.map +1 -0
- package/dist/api/routes/customComponent.d.ts +3 -0
- package/dist/api/routes/customComponent.js +34 -0
- package/dist/api/routes/customComponent.js.map +1 -0
- package/dist/api/routes/customQuery.d.ts +7 -0
- package/dist/api/routes/customQuery.js +33 -0
- package/dist/api/routes/customQuery.js.map +1 -0
- package/dist/api/routes/deleteRecord.d.ts +9 -0
- package/dist/api/routes/deleteRecord.js +46 -0
- package/dist/api/routes/deleteRecord.js.map +1 -0
- package/dist/api/routes/deleteRecordFromPanel.d.ts +10 -0
- package/dist/api/routes/deleteRecordFromPanel.js +36 -0
- package/dist/api/routes/deleteRecordFromPanel.js.map +1 -0
- package/dist/api/routes/deleteRecords.d.ts +9 -0
- package/dist/api/routes/deleteRecords.js +12 -0
- package/dist/api/routes/deleteRecords.js.map +1 -0
- package/dist/api/routes/duplicateRecord.d.ts +17 -0
- package/dist/api/routes/duplicateRecord.js +55 -0
- package/dist/api/routes/duplicateRecord.js.map +1 -0
- package/dist/api/routes/foreignSelectRecords.d.ts +10 -0
- package/dist/api/routes/foreignSelectRecords.js +61 -0
- package/dist/api/routes/foreignSelectRecords.js.map +1 -0
- package/dist/api/routes/notificationBadges.d.ts +6 -0
- package/dist/api/routes/notificationBadges.js +31 -0
- package/dist/api/routes/notificationBadges.js.map +1 -0
- package/dist/api/routes/record.d.ts +7 -0
- package/dist/api/routes/record.js +32 -0
- package/dist/api/routes/record.js.map +1 -0
- package/dist/api/routes/records.d.ts +14 -0
- package/dist/api/routes/records.js +57 -0
- package/dist/api/routes/records.js.map +1 -0
- package/dist/api/routes/recordsExport.d.ts +13 -0
- package/dist/api/routes/recordsExport.js +67 -0
- package/dist/api/routes/recordsExport.js.map +1 -0
- package/dist/api/routes/recordsImport.d.ts +4 -0
- package/dist/api/routes/recordsImport.js +100 -0
- package/dist/api/routes/recordsImport.js.map +1 -0
- package/dist/api/routes/searchCompanies.d.ts +17 -0
- package/dist/api/routes/searchCompanies.js +72 -0
- package/dist/api/routes/searchCompanies.js.map +1 -0
- package/dist/api/routes/subrecordsImport.d.ts +4 -0
- package/dist/api/routes/subrecordsImport.js +94 -0
- package/dist/api/routes/subrecordsImport.js.map +1 -0
- package/dist/api/routes/updateRecord.d.ts +13 -0
- package/dist/api/routes/updateRecord.js +55 -0
- package/dist/api/routes/updateRecord.js.map +1 -0
- package/dist/api/routes/uploadFile.d.ts +3 -0
- package/dist/api/routes/uploadFile.js +13 -0
- package/dist/api/routes/uploadFile.js.map +1 -0
- package/dist/api/routes/uploadFileFromBetterRichTextInput.d.ts +3 -0
- package/dist/api/routes/uploadFileFromBetterRichTextInput.js +33 -0
- package/dist/api/routes/uploadFileFromBetterRichTextInput.js.map +1 -0
- package/dist/api/uploadFile.d.ts +4 -0
- package/dist/api/uploadFile.js +209 -0
- package/dist/api/uploadFile.js.map +1 -0
- package/dist/api/values.d.ts +6 -0
- package/dist/api/values.js +88 -0
- package/dist/api/values.js.map +1 -0
- package/dist/api.d.ts +8 -27
- package/dist/api.js +80 -1387
- package/dist/api.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.strict.tsbuildinfo +1 -0
- package/dist/types/config.d.ts +15 -5
- package/dist/types/config.js +4 -0
- package/dist/types/config.js.map +1 -1
- package/dist/types/database.d.ts +1 -0
- package/dist/types/database.js +3 -0
- package/dist/types/database.js.map +1 -0
- package/dist/utils/dbManager.d.ts +32 -7
- package/dist/utils/dbManager.js +59 -7
- package/dist/utils/dbManager.js.map +1 -1
- package/package.json +12 -3
- package/src/api/authorization.ts +129 -0
- package/src/api/config.ts +152 -0
- package/src/api/filterUser.ts +28 -0
- package/src/api/importRecords.ts +16 -0
- package/src/api/queries.ts +194 -0
- package/src/api/request.ts +31 -0
- package/src/api/routes/adjacentRecordId.ts +20 -0
- package/src/api/routes/config.ts +15 -0
- package/src/api/routes/createRecord.ts +131 -0
- package/src/api/routes/customAction.ts +60 -0
- package/src/api/routes/customComponent.ts +44 -0
- package/src/api/routes/customQuery.ts +53 -0
- package/src/api/routes/deleteRecord.ts +59 -0
- package/src/api/routes/deleteRecordFromPanel.ts +58 -0
- package/src/api/routes/deleteRecords.ts +28 -0
- package/src/api/routes/duplicateRecord.ts +73 -0
- package/src/api/routes/foreignSelectRecords.ts +80 -0
- package/src/api/routes/notificationBadges.ts +47 -0
- package/src/api/routes/record.ts +48 -0
- package/src/api/routes/records.ts +91 -0
- package/src/api/routes/recordsExport.ts +92 -0
- package/src/api/routes/recordsImport.ts +115 -0
- package/src/api/routes/searchCompanies.ts +96 -0
- package/src/api/routes/subrecordsImport.ts +114 -0
- package/src/api/routes/updateRecord.ts +74 -0
- package/src/api/routes/uploadFile.ts +16 -0
- package/src/api/routes/uploadFileFromBetterRichTextInput.ts +41 -0
- package/src/api/uploadFile.ts +249 -0
- package/src/api/values.ts +96 -0
- package/src/api.ts +94 -1580
- package/src/index.ts +42 -25
- package/src/types/config.ts +15 -5
- package/src/types/database.ts +2 -0
- package/src/utils/dbManager.ts +99 -14
- package/tests/api-helpers.test.ts +59 -0
- package/tests/dbManager.test.ts +16 -1
- package/tests/helpers/fakeDb.ts +9 -7
- package/tests/integration/authorizationBoundary.integration.test.ts +57 -0
- package/tests/integration/configEnrichment.integration.test.ts +79 -0
- package/tests/integration/customComponentBoundary.integration.test.ts +58 -0
- package/tests/integration/dbManager.integration.test.ts +105 -3
- package/tests/integration/importParserChild.cjs +55 -0
- package/tests/integration/indexRuntime.integration.test.ts +22 -0
- package/tests/integration/indexRuntimeChild.cjs +110 -0
- package/tests/integration/queries.integration.test.ts +88 -0
- package/tests/integration/requestConfig.integration.test.ts +118 -0
- package/tests/integration/routes.authorization.integration.test.ts +1 -0
- package/tests/integration/routes.config.integration.test.ts +39 -1
- package/tests/integration/routes.crud.integration.test.ts +233 -1
- package/tests/integration/routes.custom.integration.test.ts +119 -0
- package/tests/integration/routes.foreign.integration.test.ts +3 -2
- package/tests/integration/routes.importExport.integration.test.ts +308 -6
- package/tests/integration/routes.integration.test.ts +4 -0
- package/tests/integration/routes.records.integration.test.ts +82 -6
- package/tests/integration/routes.upload.integration.test.ts +123 -0
- package/tests/integration/userFilters.integration.test.ts +225 -0
- package/tsconfig.json +1 -1
- package/tsconfig.strict.json +45 -0
- package/dist/front/ckeditor5-build/src/ckeditor.d.ts +0 -43
- package/dist/front/ckeditor5-build/src/ckeditor.js +0 -130
- package/dist/front/ckeditor5-build/src/ckeditor.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -83,6 +83,7 @@ describeIntegration("authorization against real databases", () => {
|
|
|
83
83
|
|
|
84
84
|
const fruitCount = async () => {
|
|
85
85
|
const { rows } = await harness.query("SELECT * FROM fruits");
|
|
86
|
+
if (!Array.isArray(rows)) throw new Error("Expected a row set");
|
|
86
87
|
return rows.length;
|
|
87
88
|
};
|
|
88
89
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import * as request from "supertest";
|
|
2
|
+
import * as express from "express";
|
|
3
|
+
import configRoute from "../../src/api/routes/config";
|
|
2
4
|
import { buildAppForManager } from "../helpers/app";
|
|
3
5
|
import { baseConfig, fruitView, VIEWER } from "../helpers/fixtures";
|
|
4
6
|
import {
|
|
@@ -13,6 +15,41 @@ import {
|
|
|
13
15
|
|
|
14
16
|
jest.mock("axios");
|
|
15
17
|
|
|
18
|
+
describeIntegration("config route error handling", () => {
|
|
19
|
+
afterEach(() => jest.restoreAllMocks());
|
|
20
|
+
|
|
21
|
+
it("logs the destructuring error and sends 500 when config middleware is absent", async () => {
|
|
22
|
+
const errorLog = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
23
|
+
const app = express();
|
|
24
|
+
app.get("/config", configRoute());
|
|
25
|
+
|
|
26
|
+
const response = await request(app).get("/config").expect(500);
|
|
27
|
+
|
|
28
|
+
expect(response.text).toBe("Internal Server Error");
|
|
29
|
+
expect(errorLog).toHaveBeenCalledTimes(1);
|
|
30
|
+
expect(errorLog).toHaveBeenCalledWith(expect.any(TypeError));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("logs serialization failures and sends 500", async () => {
|
|
34
|
+
const errorLog = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
35
|
+
const app = express();
|
|
36
|
+
app.use((req, _res, next) => {
|
|
37
|
+
req.config = baseConfig({
|
|
38
|
+
views: [fruitView({ filtersFromUser: String })],
|
|
39
|
+
});
|
|
40
|
+
next();
|
|
41
|
+
});
|
|
42
|
+
app.get("/config", configRoute());
|
|
43
|
+
|
|
44
|
+
const response = await request(app).get("/config").expect(500);
|
|
45
|
+
|
|
46
|
+
expect(response.text).toBe("Internal Server Error");
|
|
47
|
+
expect(errorLog).toHaveBeenCalledTimes(1);
|
|
48
|
+
expect(errorLog).toHaveBeenCalledWith(expect.any(TypeError));
|
|
49
|
+
expect(errorLog.mock.calls[0][0].message).toContain("Serializing native function");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
16
53
|
/**
|
|
17
54
|
* GET /api/config against real engines. Serialization and role filtering are
|
|
18
55
|
* pure JS; the real database is what feeds enrichConfig (information_schema
|
|
@@ -95,7 +132,8 @@ describeIntegration("config route against real databases", () => {
|
|
|
95
132
|
label: "Clients only",
|
|
96
133
|
tableName: "orders",
|
|
97
134
|
roles: ["client"],
|
|
98
|
-
filtersFromUser: (
|
|
135
|
+
filtersFromUser: (user: { role: string; userId?: string | number }) =>
|
|
136
|
+
user.role === "root" ? "" : typeof user.userId === "number" ? `client_id = ${user.userId}` : "1 = 0",
|
|
99
137
|
columns: [],
|
|
100
138
|
},
|
|
101
139
|
fruitView(),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as request from "supertest";
|
|
2
|
+
import type { DatabaseRow } from "../../src/utils/dbManager";
|
|
2
3
|
import { generateUniqueIdForObject } from "../../src/api";
|
|
3
4
|
import { buildAppForManager } from "../helpers/app";
|
|
4
5
|
import { baseConfig, fruitView, EDITOR, VIEWER } from "../helpers/fixtures";
|
|
@@ -68,13 +69,48 @@ describeIntegration("crud routes against real databases", () => {
|
|
|
68
69
|
return res;
|
|
69
70
|
};
|
|
70
71
|
|
|
71
|
-
const fruits = async () =>
|
|
72
|
+
const fruits = async () => {
|
|
73
|
+
const { rows } = await harness.query("SELECT * FROM fruits");
|
|
74
|
+
if (!Array.isArray(rows) || !rows.every((row): row is DatabaseRow => !Array.isArray(row))) {
|
|
75
|
+
throw new Error("Expected fruit rows");
|
|
76
|
+
}
|
|
77
|
+
return rows;
|
|
78
|
+
};
|
|
72
79
|
|
|
73
80
|
// MySQL/mysql2 drivers expose the AUTO_INCREMENT id; pg results carry no
|
|
74
81
|
// insertId, so created ids stay undefined in responses (GAP 11).
|
|
75
82
|
const insertIdIsExposed = () => harness.dbType !== "POSTGRESQL";
|
|
76
83
|
|
|
77
84
|
describe("POST /api/updateRecord", () => {
|
|
85
|
+
it("preserves UUID callback IDs, normalized data and HTTP previousData", async () => {
|
|
86
|
+
const id = "00000000-0000-4000-8000-000000000003";
|
|
87
|
+
expect((await harness.query("INSERT INTO uuid_fruits (id, fruitName) VALUES (?, ?)", [id, "pomme"])).err).toBeNull();
|
|
88
|
+
const received: { id: DatabaseRow[string]; data: DatabaseRow }[] = [];
|
|
89
|
+
const receivers: string[] = [];
|
|
90
|
+
const instance = app({
|
|
91
|
+
tableName: "uuid_fruits",
|
|
92
|
+
editCallback: "https://api.example.com/edited",
|
|
93
|
+
editCallbackFunction: function (
|
|
94
|
+
this: { tableName: string },
|
|
95
|
+
payload: { id: DatabaseRow[string]; data: DatabaseRow },
|
|
96
|
+
) {
|
|
97
|
+
receivers.push(this.tableName);
|
|
98
|
+
received.push(payload);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
mockedAxios.post.mockResolvedValue({});
|
|
102
|
+
await request(instance).post("/api/updateRecord").send({
|
|
103
|
+
viewIndex: "0", primaryValue: id, data: { fruitName: "" },
|
|
104
|
+
previousData: { id, fruitName: "pomme" },
|
|
105
|
+
}).expect(200);
|
|
106
|
+
expect(mockedAxios.post).toHaveBeenCalledWith("https://api.example.com/edited", {
|
|
107
|
+
id, data: { fruitName: null }, previousData: { id, fruitName: "pomme" },
|
|
108
|
+
});
|
|
109
|
+
expect(received).toEqual([{ id, data: { fruitName: null } }]);
|
|
110
|
+
expect(receivers).toEqual(["uuid_fruits"]);
|
|
111
|
+
expect((await harness.query("SELECT * FROM uuid_fruits")).rows).toEqual([{ id, fruitName: null }]);
|
|
112
|
+
});
|
|
113
|
+
|
|
78
114
|
it("updates the record and persists the new values", async () => {
|
|
79
115
|
await createFruit({ fruitName: "pomme", acidity: 3 });
|
|
80
116
|
await request(app())
|
|
@@ -455,9 +491,70 @@ describeIntegration("crud routes against real databases", () => {
|
|
|
455
491
|
});
|
|
456
492
|
expect(await fruits()).toHaveLength(1);
|
|
457
493
|
});
|
|
494
|
+
|
|
495
|
+
it("keeps a panel insert committed when its callback rejects and passes the unfiltered data", async () => {
|
|
496
|
+
const received: { id: number | undefined; data: DatabaseRow }[] = [];
|
|
497
|
+
const config = panelConfig();
|
|
498
|
+
const panel = config.views[0].recordViewPanels[0];
|
|
499
|
+
Object.assign(panel.createConfig, {
|
|
500
|
+
columns: [...panel.createConfig.columns, {
|
|
501
|
+
label: "Excluded",
|
|
502
|
+
name: "excluded",
|
|
503
|
+
includedInCreatePayload: false,
|
|
504
|
+
}],
|
|
505
|
+
});
|
|
506
|
+
Object.assign(panel, {
|
|
507
|
+
createCallbackFunction: async (payload: { id: number | undefined; data: DatabaseRow }) => {
|
|
508
|
+
received.push(payload);
|
|
509
|
+
throw new Error("panel callback rejected after insertion");
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
const res = await request(buildAppForManager(harness.manager, baseConfig(config)))
|
|
513
|
+
.post("/api/createRecord")
|
|
514
|
+
.send({
|
|
515
|
+
viewIndex: 0,
|
|
516
|
+
panelIndex: 0,
|
|
517
|
+
recordId: 7,
|
|
518
|
+
data: { text: "hello", excluded: "callback only" },
|
|
519
|
+
})
|
|
520
|
+
.expect(200);
|
|
521
|
+
expect(received).toEqual([{
|
|
522
|
+
id: insertIdIsExposed() ? 1 : undefined,
|
|
523
|
+
data: { text: "hello", excluded: "callback only", fruitId: 7 },
|
|
524
|
+
}]);
|
|
525
|
+
expect(res.body).toEqual(insertIdIsExposed() ? { id: 1 } : {});
|
|
526
|
+
const { rows } = await harness.query("SELECT * FROM fruit_notes");
|
|
527
|
+
expect(rows).toEqual([{ id: 1, text: "hello", fruitId: 7 }]);
|
|
528
|
+
expect(await fruits()).toHaveLength(0);
|
|
529
|
+
});
|
|
458
530
|
});
|
|
459
531
|
|
|
460
532
|
describe("POST /api/deleteRecord", () => {
|
|
533
|
+
it("preserves UUID callback IDs and the deleted row when the HTTP callback fails", async () => {
|
|
534
|
+
const id = "00000000-0000-4000-8000-000000000001";
|
|
535
|
+
expect((await harness.query("INSERT INTO uuid_fruits (id, fruitName) VALUES (?, ?)", [id, "pomme"])).err).toBeNull();
|
|
536
|
+
const received: { id: DatabaseRow[string]; deletedRecord?: DatabaseRow }[] = [];
|
|
537
|
+
const receivers: string[] = [];
|
|
538
|
+
const instance = app({
|
|
539
|
+
tableName: "uuid_fruits",
|
|
540
|
+
deleteCallback: "https://api.example.com/deleted",
|
|
541
|
+
deleteCallbackFunction: function (
|
|
542
|
+
this: { tableName: string },
|
|
543
|
+
payload: { id: DatabaseRow[string]; deletedRecord?: DatabaseRow },
|
|
544
|
+
) {
|
|
545
|
+
receivers.push(this.tableName);
|
|
546
|
+
received.push(payload);
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
mockedAxios.post.mockRejectedValue(new Error("delete callback unavailable"));
|
|
550
|
+
await request(instance).post("/api/deleteRecord")
|
|
551
|
+
.send({ viewIndex: "0", primaryValue: id }).expect(200);
|
|
552
|
+
expect(mockedAxios.post).toHaveBeenCalledWith("https://api.example.com/deleted", { id });
|
|
553
|
+
expect(received).toEqual([{ id, deletedRecord: { id, fruitName: "pomme" } }]);
|
|
554
|
+
expect(receivers).toEqual(["uuid_fruits"]);
|
|
555
|
+
expect((await harness.query("SELECT * FROM uuid_fruits")).rows).toEqual([]);
|
|
556
|
+
});
|
|
557
|
+
|
|
461
558
|
it("selects then deletes the record, passing deletedRecord to callbacks", async () => {
|
|
462
559
|
await createFruit({ fruitName: "pomme", acidity: 3 });
|
|
463
560
|
const callbackFn = jest.fn();
|
|
@@ -576,9 +673,63 @@ describeIntegration("crud routes against real databases", () => {
|
|
|
576
673
|
deletedRecord: expect.objectContaining({ text: "hello" }),
|
|
577
674
|
});
|
|
578
675
|
});
|
|
676
|
+
|
|
677
|
+
it.each(["table", "gallery"])("preserves UUID and missing-row callback payloads for %s panels", async (type) => {
|
|
678
|
+
const id = "00000000-0000-4000-8000-000000000002";
|
|
679
|
+
expect((await harness.query("INSERT INTO uuid_fruits (id, fruitName) VALUES (?, ?)", [id, "poire"])).err).toBeNull();
|
|
680
|
+
const received: { id: DatabaseRow[string]; deletedRecord?: DatabaseRow }[] = [];
|
|
681
|
+
const receivers: string[] = [];
|
|
682
|
+
const config = panelView({
|
|
683
|
+
type,
|
|
684
|
+
tableName: "uuid_fruits",
|
|
685
|
+
deleteCallback: "https://api.example.com/panel-deleted",
|
|
686
|
+
deleteCallbackFunction: function (
|
|
687
|
+
this: { tableName: string },
|
|
688
|
+
payload: { id: DatabaseRow[string]; deletedRecord?: DatabaseRow },
|
|
689
|
+
) {
|
|
690
|
+
receivers.push(this.tableName);
|
|
691
|
+
received.push(payload);
|
|
692
|
+
},
|
|
693
|
+
});
|
|
694
|
+
const panelId = generateUniqueIdForObject(config.views[0].recordViewPanels[0]);
|
|
695
|
+
const instance = buildAppForManager(harness.manager, baseConfig(config));
|
|
696
|
+
mockedAxios.post.mockRejectedValue(new Error("panel callback unavailable"));
|
|
697
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
698
|
+
await request(instance).post("/api/deleteRecordFromPanel")
|
|
699
|
+
.send({ viewIndex: "0", panelId, primaryValue: id }).expect(200);
|
|
700
|
+
}
|
|
701
|
+
expect(mockedAxios.post).toHaveBeenLastCalledWith("https://api.example.com/panel-deleted", { id });
|
|
702
|
+
expect(received).toEqual([
|
|
703
|
+
{ id, deletedRecord: { id, fruitName: "poire" } },
|
|
704
|
+
{ id, deletedRecord: undefined },
|
|
705
|
+
]);
|
|
706
|
+
expect(receivers).toEqual(["uuid_fruits", "uuid_fruits"]);
|
|
707
|
+
expect((await harness.query("SELECT * FROM uuid_fruits")).rows).toEqual([]);
|
|
708
|
+
});
|
|
579
709
|
});
|
|
580
710
|
|
|
581
711
|
describe("POST /api/deleteRecords", () => {
|
|
712
|
+
it("preserves the engine error for an empty selection", async () => {
|
|
713
|
+
await createFruit({ fruitName: "survivor" });
|
|
714
|
+
await request(app()).post("/api/deleteRecords")
|
|
715
|
+
.send({ viewIndex: 0, primaryValues: [] }).expect(500);
|
|
716
|
+
expect((await fruits()).map((row: DatabaseRow) => row.fruitName)).toEqual(["survivor"]);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("binds UUID values including duplicates and leaves unselected rows", async () => {
|
|
720
|
+
const ids = [
|
|
721
|
+
"d28c64a3-f0e8-4df8-a3c8-a78655d212fd",
|
|
722
|
+
"d28c64a3-f0e8-4df8-a3c8-a78655d212fe",
|
|
723
|
+
"d28c64a3-f0e8-4df8-a3c8-a78655d212ff",
|
|
724
|
+
];
|
|
725
|
+
for (const id of ids) {
|
|
726
|
+
expect((await harness.query("INSERT INTO uuid_fruits (id, fruitName) VALUES (?, ?)", [id, "fruit"])).err).toBeFalsy();
|
|
727
|
+
}
|
|
728
|
+
await request(app({ tableName: "uuid_fruits" })).post("/api/deleteRecords")
|
|
729
|
+
.send({ viewIndex: "0", primaryValues: [ids[1], ids[0], ids[1]] }).expect(200);
|
|
730
|
+
expect((await harness.query("SELECT id FROM uuid_fruits")).rows).toEqual([{ id: ids[2] }]);
|
|
731
|
+
});
|
|
732
|
+
|
|
582
733
|
it("bulk-deletes by primary values", async () => {
|
|
583
734
|
const instance = app();
|
|
584
735
|
for (const name of ["a", "b", "c"]) {
|
|
@@ -621,6 +772,46 @@ describeIntegration("crud routes against real databases", () => {
|
|
|
621
772
|
});
|
|
622
773
|
});
|
|
623
774
|
|
|
775
|
+
describe("POST /api/duplicateRecord boundaries", () => {
|
|
776
|
+
it("sends the original null error when insertion fails", async () => {
|
|
777
|
+
await createFruit({ fruitName: "original" });
|
|
778
|
+
const result = await request(app()).post("/api/duplicateRecord").send({
|
|
779
|
+
table: "fruits", primaryValue: 1,
|
|
780
|
+
duplicateConfig: { primaryId: "id", fields: ["missing_column"], foreignTables: [] },
|
|
781
|
+
}).expect(500);
|
|
782
|
+
expect(result.text).toBe("");
|
|
783
|
+
expect(await fruits()).toHaveLength(1);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("preserves foreign copy traversal and the PostgreSQL INSERT IGNORE failure", async () => {
|
|
787
|
+
await createFruit({ fruitName: "original" });
|
|
788
|
+
expect((await harness.query("INSERT INTO fruit_tags (tag, fruitId) VALUES (?, ?)", ["tag", 1])).err).toBeFalsy();
|
|
789
|
+
const result = await request(app({ recordViewPanels: [{ label: "Tags", tableName: "fruit_tags" }] }))
|
|
790
|
+
.post("/api/duplicateRecord").send({
|
|
791
|
+
table: "fruits", primaryValue: 1,
|
|
792
|
+
duplicateConfig: {
|
|
793
|
+
primaryId: "id", fields: ["fruitName"],
|
|
794
|
+
foreignTables: [
|
|
795
|
+
{ table: "fruit_tags", fields: ["missing_column"] },
|
|
796
|
+
{ table: "fruit_tags", fields: ["tag", "fruitId"] },
|
|
797
|
+
],
|
|
798
|
+
},
|
|
799
|
+
}).expect(harness.dbType === "POSTGRESQL" ? 500 : 200);
|
|
800
|
+
const { rows } = await harness.query("SELECT * FROM fruit_tags ORDER BY id");
|
|
801
|
+
if (harness.dbType === "POSTGRESQL") {
|
|
802
|
+
expect(result.text).toBe("");
|
|
803
|
+
expect(await fruits()).toHaveLength(1);
|
|
804
|
+
expect(rows).toEqual([{ id: 1, tag: "tag", fruitId: 1 }]);
|
|
805
|
+
} else {
|
|
806
|
+
expect(await fruits()).toHaveLength(2);
|
|
807
|
+
expect(rows).toEqual([
|
|
808
|
+
{ id: 1, tag: "tag", fruitId: 1 },
|
|
809
|
+
{ id: 2, tag: "tag", fruitId: 1 },
|
|
810
|
+
]);
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
|
|
624
815
|
// INSERT IGNORE ... SET ? is MySQL-specific syntax; the PG conversion
|
|
625
816
|
// pipeline does not translate it, so duplicateRecord only works on the
|
|
626
817
|
// MySQL backends today.
|
|
@@ -723,6 +914,47 @@ describeIntegration("crud routes against real databases", () => {
|
|
|
723
914
|
);
|
|
724
915
|
|
|
725
916
|
describe("POST /api/customAction", () => {
|
|
917
|
+
it("uses the configured message without invoking an action for no selected ids", async () => {
|
|
918
|
+
const action = jest.fn();
|
|
919
|
+
const res = await request(app({
|
|
920
|
+
customActions: [{ label: "Run", action, successMessage: "nothing selected" }],
|
|
921
|
+
})).post("/api/customAction")
|
|
922
|
+
.send({ viewIndex: "0", actionIndex: "0", selectedRecordIds: [] })
|
|
923
|
+
.expect(200);
|
|
924
|
+
expect(res.body).toEqual({ success: true, message: "nothing selected" });
|
|
925
|
+
expect(action).not.toHaveBeenCalled();
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it("passes absent records and root user ids through and continues after an empty result", async () => {
|
|
929
|
+
const action = jest.fn().mockResolvedValue("");
|
|
930
|
+
const instance = app({ customActions: [{ label: "Run", action }] }, { role: "root" });
|
|
931
|
+
const res = await request(instance).post("/api/customAction")
|
|
932
|
+
.send({ viewIndex: 0, actionIndex: 0, selectedRecordIds: [98, 99] })
|
|
933
|
+
.expect(200);
|
|
934
|
+
expect(res.body).toEqual({ success: true });
|
|
935
|
+
expect(action).toHaveBeenCalledTimes(2);
|
|
936
|
+
expect(action).toHaveBeenNthCalledWith(1, undefined, undefined);
|
|
937
|
+
expect(action).toHaveBeenNthCalledWith(2, undefined, undefined);
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it("catches the missing-row-result error after a failed query and stops actions", async () => {
|
|
941
|
+
const action = jest.fn();
|
|
942
|
+
const errorLog = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
943
|
+
try {
|
|
944
|
+
const res = await request(app({
|
|
945
|
+
selectExpression: "column_that_does_not_exist",
|
|
946
|
+
customActions: [{ label: "Run", action }],
|
|
947
|
+
})).post("/api/customAction")
|
|
948
|
+
.send({ viewIndex: 0, actionIndex: 0, selectedRecordIds: [1, 2] })
|
|
949
|
+
.expect(200);
|
|
950
|
+
expect(res.body).toEqual({ success: false, error: {} });
|
|
951
|
+
expect(action).not.toHaveBeenCalled();
|
|
952
|
+
expect(errorLog).toHaveBeenCalledWith(expect.any(TypeError));
|
|
953
|
+
} finally {
|
|
954
|
+
errorLog.mockRestore();
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
|
|
726
958
|
it("runs the action for every selected record", async () => {
|
|
727
959
|
const action = jest.fn().mockResolvedValue(null);
|
|
728
960
|
const config = {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as request from "supertest";
|
|
2
|
+
import type { DatabaseRow } from "../../src/utils/dbManager";
|
|
2
3
|
import { buildAppForManager } from "../helpers/app";
|
|
3
4
|
import { baseConfig, fruitView } from "../helpers/fixtures";
|
|
4
5
|
import {
|
|
@@ -208,6 +209,33 @@ describeIntegration("custom routes against real databases", () => {
|
|
|
208
209
|
expect(res.body[0].text).toBe("HELLO");
|
|
209
210
|
});
|
|
210
211
|
|
|
212
|
+
it("binds every placeholder and formats all rows in key order with the formatter receiver", async () => {
|
|
213
|
+
await seedNote(7, "first");
|
|
214
|
+
await seedNote(7, "second");
|
|
215
|
+
const query = "SELECT * FROM fruit_notes WHERE fruitId = ? OR fruitId = ? ORDER BY id";
|
|
216
|
+
const formatValue = {
|
|
217
|
+
text: async (row: DatabaseRow) => String(row.text).toUpperCase(),
|
|
218
|
+
summary(row: DatabaseRow) {
|
|
219
|
+
return `${row.text}:${this === formatValue ? "receiver" : "wrong"}`;
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
const instance = app({ recordViewPanels: [{ label: "Notes", query, formatValue }] });
|
|
223
|
+
const result = await request(instance).get("/api/customQuery")
|
|
224
|
+
.query({ query, primaryValue: 7, viewIndex: "0", panelIndex: "0" }).expect(200);
|
|
225
|
+
expect(result.body.map((row: { text: string; summary: string }) => [row.text, row.summary]))
|
|
226
|
+
.toEqual([["FIRST", "FIRST:receiver"], ["SECOND", "SECOND:receiver"]]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("returns unformatted rows when panel selection is omitted", async () => {
|
|
230
|
+
await seedNote(7, "hello");
|
|
231
|
+
const formatValue = { text: jest.fn(() => "formatted") };
|
|
232
|
+
const instance = app({ recordViewPanels: [{ label: "Notes", query: PANEL_QUERY, formatValue }] });
|
|
233
|
+
const result = await request(instance).get("/api/customQuery")
|
|
234
|
+
.query({ query: PANEL_QUERY, primaryValue: 7, viewIndex: "0" }).expect(200);
|
|
235
|
+
expect(result.body[0].text).toBe("hello");
|
|
236
|
+
expect(formatValue.text).not.toHaveBeenCalled();
|
|
237
|
+
});
|
|
238
|
+
|
|
211
239
|
it("returns 500 on engine error", async () => {
|
|
212
240
|
const config = baseConfig({
|
|
213
241
|
views: [
|
|
@@ -224,6 +252,57 @@ describeIntegration("custom routes against real databases", () => {
|
|
|
224
252
|
});
|
|
225
253
|
|
|
226
254
|
describe("GET /api/notificationBadges", () => {
|
|
255
|
+
it("retains PostgreSQL's truthy string zero count", async () => {
|
|
256
|
+
const result = await request(app({ notificationBadge: {
|
|
257
|
+
query: "SELECT COUNT(*) AS count FROM fruits", attribute: "count",
|
|
258
|
+
} })).get("/api/notificationBadges").expect(200);
|
|
259
|
+
expect(Object.values(result.body)).toEqual(harness.dbType === "POSTGRESQL" ? ["0"] : []);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("continues after a mutation query and overwrites missing ids in view/subview order", async () => {
|
|
263
|
+
const config = baseConfig({ enriched: true, views: [
|
|
264
|
+
fruitView({
|
|
265
|
+
notificationBadge: { query: "UPDATE fruits SET note = 'badge'", attribute: "affectedRows" },
|
|
266
|
+
subviews: [{ label: "First", filters: "", notificationBadge: { query: "SELECT 2 AS count", attribute: "count" } }],
|
|
267
|
+
}),
|
|
268
|
+
fruitView({ notificationBadge: { query: "SELECT 3 AS count", attribute: "count" } }),
|
|
269
|
+
] });
|
|
270
|
+
const result = await request(buildAppForManager(harness.manager, config))
|
|
271
|
+
.get("/api/notificationBadges").expect(200);
|
|
272
|
+
expect(result.body).toEqual({ undefined: 3 });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("retains repeated badge property reads and the condition receiver", async () => {
|
|
276
|
+
let attributeReads = 0;
|
|
277
|
+
let conditionReads = 0;
|
|
278
|
+
let receivedBadge: DatabaseRow[string] | undefined;
|
|
279
|
+
let receivedOwner: object | undefined;
|
|
280
|
+
const badge = {
|
|
281
|
+
query: "SELECT 1 AS firstBadge, 7 AS secondBadge",
|
|
282
|
+
get attribute() { return ++attributeReads === 1 ? "firstBadge" : "secondBadge"; },
|
|
283
|
+
get displayCondition() {
|
|
284
|
+
conditionReads++;
|
|
285
|
+
return function (this: object, value: DatabaseRow[string]) {
|
|
286
|
+
receivedBadge = value;
|
|
287
|
+
receivedOwner = this;
|
|
288
|
+
return true;
|
|
289
|
+
};
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
const config = baseConfig({ enriched: true, views: [fruitView({ notificationBadge: badge })] });
|
|
293
|
+
const instance = buildAppForManager(harness.manager, config);
|
|
294
|
+
// API construction scans configured query properties before requests.
|
|
295
|
+
attributeReads = 0;
|
|
296
|
+
conditionReads = 0;
|
|
297
|
+
const result = await request(instance)
|
|
298
|
+
.get("/api/notificationBadges").expect(200);
|
|
299
|
+
expect(result.body).toEqual({ undefined: 7 });
|
|
300
|
+
expect(attributeReads).toBe(2);
|
|
301
|
+
expect(conditionReads).toBe(2);
|
|
302
|
+
expect(receivedBadge).toBe(7);
|
|
303
|
+
expect(receivedOwner).toBe(badge);
|
|
304
|
+
});
|
|
305
|
+
|
|
227
306
|
it("computes badges per view and subview", async () => {
|
|
228
307
|
const view = fruitView({
|
|
229
308
|
notificationBadge: {
|
|
@@ -283,6 +362,46 @@ describeIntegration("custom routes against real databases", () => {
|
|
|
283
362
|
const opendatasoft = () =>
|
|
284
363
|
jest.requireMock("axios") as { get: jest.Mock; post: jest.Mock };
|
|
285
364
|
|
|
365
|
+
it("ignores primitive entries and preserves raw optional field values and address coercion", async () => {
|
|
366
|
+
opendatasoft().get.mockResolvedValueOnce({ data: { results: [
|
|
367
|
+
0, false, "primitive", [],
|
|
368
|
+
{ etatadministratifetablissement: "Actif", denominationunitelegale: null,
|
|
369
|
+
siret: 123, numerovoieetablissement: 0, typevoieetablissement: false,
|
|
370
|
+
libellevoieetablissement: null },
|
|
371
|
+
] } });
|
|
372
|
+
const result = await request(app()).get("/api/searchCompanies").query({ query: "test" }).expect(200);
|
|
373
|
+
expect(result.body).toEqual([{ id: expect.any(String), companyName: null, siret: 123, address: " " }]);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("preserves active results with deeply nested unused JSON fields", async () => {
|
|
377
|
+
const depth = 10_000;
|
|
378
|
+
const data: unknown = JSON.parse(
|
|
379
|
+
'{"results":[{"etatadministratifetablissement":"Actif","denominationunitelegale":"keep","unused":' +
|
|
380
|
+
'{"nested":'.repeat(depth) + '0' + '}'.repeat(depth) + '}]}'
|
|
381
|
+
);
|
|
382
|
+
opendatasoft().get.mockResolvedValueOnce({ data });
|
|
383
|
+
const result = await request(app()).get("/api/searchCompanies").query({ query: "test" }).expect(200);
|
|
384
|
+
expect(result.body).toEqual([{ id: expect.any(String), companyName: "keep", address: " " }]);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it.each([{ results: [null] }, { results: {} }, {}])("returns [] for malformed results %j", async (data) => {
|
|
388
|
+
opendatasoft().get.mockResolvedValueOnce({ data });
|
|
389
|
+
const result = await request(app()).get("/api/searchCompanies").query({ query: "test" }).expect(200);
|
|
390
|
+
expect(result.body).toEqual([]);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("keeps the first thirty active establishments in original order", async () => {
|
|
394
|
+
opendatasoft().get.mockResolvedValueOnce({ data: { results: [
|
|
395
|
+
{ etatadministratifetablissement: "Fermé", denominationunitelegale: "closed" },
|
|
396
|
+
...Array.from({ length: 35 }, (_, index) => ({
|
|
397
|
+
etatadministratifetablissement: "Actif", denominationunitelegale: `company-${index}`,
|
|
398
|
+
})),
|
|
399
|
+
] } });
|
|
400
|
+
const result = await request(app()).get("/api/searchCompanies").query({ query: "test" }).expect(200);
|
|
401
|
+
expect(result.body.map((company: { companyName: string }) => company.companyName))
|
|
402
|
+
.toEqual(Array.from({ length: 30 }, (_, index) => `company-${index}`));
|
|
403
|
+
});
|
|
404
|
+
|
|
286
405
|
it("proxies the SIRENE search and maps active establishments", async () => {
|
|
287
406
|
const axiosMock = opendatasoft();
|
|
288
407
|
axiosMock.get.mockResolvedValueOnce({
|
|
@@ -188,7 +188,7 @@ describeIntegration("foreignSelectRecords against real databases", () => {
|
|
|
188
188
|
expect(limited.body).toHaveLength(5);
|
|
189
189
|
});
|
|
190
190
|
|
|
191
|
-
it("applies column.filters and column.filtersFromUser", async () => {
|
|
191
|
+
it("FILTER USER POLICY: applies column.filters and column.filtersFromUser", async () => {
|
|
192
192
|
await insertFamilies([
|
|
193
193
|
{ name: "rose", familyId: 21, active: 1, ownerId: 2 },
|
|
194
194
|
{ name: "apple", familyId: 22, active: 0, ownerId: 2 },
|
|
@@ -196,7 +196,8 @@ describeIntegration("foreignSelectRecords against real databases", () => {
|
|
|
196
196
|
]);
|
|
197
197
|
const overrides = {
|
|
198
198
|
filters: "active = 1",
|
|
199
|
-
filtersFromUser: (
|
|
199
|
+
filtersFromUser: (user: { role: string; userId?: string | number }) =>
|
|
200
|
+
user.role === "root" ? "" : typeof user.userId === "number" ? `ownerId = ${user.userId}` : "1 = 0",
|
|
200
201
|
};
|
|
201
202
|
// EDITOR.userId === 2: only the active family owned by user 2.
|
|
202
203
|
const res = await postForeign(
|