data-primals-engine 1.6.2 → 1.6.5
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/README.md +56 -911
- package/client/package-lock.json +64 -50
- package/client/package.json +6 -0
- package/client/src/App.scss +29 -0
- package/client/src/Dashboard.jsx +1 -1
- package/client/src/WorkflowEditor.jsx +101 -0
- package/client/src/WorkflowEditor.scss +29 -4
- package/client/src/_variables.scss +3 -0
- package/doc/automation-workflows.md +102 -0
- package/doc/core-concepts.md +33 -0
- package/doc/custom-api-endpoints.md +40 -0
- package/doc/dashboards-kpis-charts.md +49 -0
- package/doc/data-management.md +120 -0
- package/doc/data-models.md +75 -0
- package/doc/index.md +14 -0
- package/doc/roles-permissions.md +43 -0
- package/doc/users.md +30 -0
- package/package.json +7 -5
- package/src/client.js +6 -4
- package/src/core.js +31 -12
- package/src/defaultModels +1628 -0
- package/src/defaultModels.js +14 -0
- package/src/filter.js +110 -42
- package/src/modules/data/data.history.js +5 -1
- package/src/modules/data/data.js +2 -1
- package/src/modules/data/data.operations.js +116 -72
- package/src/modules/data/data.relations.js +1 -1
- package/src/modules/mongodb.js +2 -1
- package/src/modules/swagger.js +25 -5
- package/src/modules/user.js +138 -76
- package/src/modules/workflow.js +187 -177
- package/src/providers.js +1 -1
- package/swagger-en.yml +2308 -472
- package/swagger-fr.yml +487 -3
- package/test/core.test.js +341 -0
- package/test/data.history.integration.test.js +140 -16
- package/test/data.integration.test.js +137 -2
- package/test/user.test.js +201 -280
- package/test/workflow.integration.test.js +8 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
sleep,
|
|
6
|
+
escapeRegex,
|
|
7
|
+
sequential,
|
|
8
|
+
isValidRegex,
|
|
9
|
+
escapeHtml,
|
|
10
|
+
isUnsecureKey,
|
|
11
|
+
parseSafeJSON,
|
|
12
|
+
isDate,
|
|
13
|
+
safeAssignObject,
|
|
14
|
+
debounce,
|
|
15
|
+
uuidv4,
|
|
16
|
+
cssProps,
|
|
17
|
+
removeDir,
|
|
18
|
+
wordWrap,
|
|
19
|
+
getObjectHash,
|
|
20
|
+
isPathRelativeTo,
|
|
21
|
+
isGUID,
|
|
22
|
+
isPlainObject,
|
|
23
|
+
escapeRegExp,
|
|
24
|
+
shuffle,
|
|
25
|
+
setSeed,
|
|
26
|
+
getRand,
|
|
27
|
+
getRandom,
|
|
28
|
+
randomDate,
|
|
29
|
+
isLightColor,
|
|
30
|
+
tryParseJson,
|
|
31
|
+
isIsoDate,
|
|
32
|
+
event_trigger,
|
|
33
|
+
event_on,
|
|
34
|
+
event_off,
|
|
35
|
+
slugify,
|
|
36
|
+
getFileExtension,
|
|
37
|
+
object_equals,
|
|
38
|
+
isValidPath,
|
|
39
|
+
stringToHslColor,
|
|
40
|
+
countKeys
|
|
41
|
+
} from '../src/core.js';
|
|
42
|
+
|
|
43
|
+
describe('Core Utility Functions', () => {
|
|
44
|
+
|
|
45
|
+
describe('isPlainObject', () => {
|
|
46
|
+
it('should return true for plain objects', () => {
|
|
47
|
+
expect(isPlainObject({})).toBe(true);
|
|
48
|
+
expect(isPlainObject({ a: 1 })).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should return false for non-plain objects or other types', () => {
|
|
52
|
+
expect(isPlainObject([])).toBe(false);
|
|
53
|
+
expect(isPlainObject(null)).toBe(false);
|
|
54
|
+
expect(isPlainObject(new Date())).toBe(false);
|
|
55
|
+
expect(isPlainObject("string")).toBe(false);
|
|
56
|
+
expect(isPlainObject(123)).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('slugify', () => {
|
|
61
|
+
it('should convert string to a slug', () => {
|
|
62
|
+
expect(slugify('Hello World!')).toBe('hello-world');
|
|
63
|
+
expect(slugify(' Test avec des espaces ')).toBe('test-avec-des-espaces');
|
|
64
|
+
expect(slugify('CaractèresSpéciaux-123', '-', true)).toBe('caracteresspeciaux-123');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('sleep', () => {
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
vi.useFakeTimers();
|
|
71
|
+
});
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
vi.useRealTimers();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should resolve after the specified duration', async () => {
|
|
77
|
+
const sleepPromise = sleep(1000);
|
|
78
|
+
vi.advanceTimersByTime(1000);
|
|
79
|
+
await expect(sleepPromise).resolves.toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
describe('getObjectHash', () => {
|
|
85
|
+
it('should generate a consistent hash for the same object', () => {
|
|
86
|
+
const obj1 = { name: 'test', value: 1 };
|
|
87
|
+
const obj2 = { value: 1, name: 'test' }; // Ordre différent
|
|
88
|
+
expect(getObjectHash(obj1)).toBe(getObjectHash(obj2));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should generate a different hash for different objects', () => {
|
|
92
|
+
const obj1 = { name: 'test', value: 1 };
|
|
93
|
+
const obj2 = { name: 'test', value: 2 };
|
|
94
|
+
expect(getObjectHash(obj1)).not.toBe(getObjectHash(obj2));
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('escapeRegex and escapeRegExp', () => {
|
|
99
|
+
it('should escape special regex characters', () => {
|
|
100
|
+
const specialString = '.*+?^${}()|[]\\';
|
|
101
|
+
const expected = '\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\';
|
|
102
|
+
expect(escapeRegex(specialString)).toBe(expected);
|
|
103
|
+
expect(escapeRegExp(specialString)).toBe(expected);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('sequential', () => {
|
|
108
|
+
it('should execute async tasks sequentially', async () => {
|
|
109
|
+
const order = [];
|
|
110
|
+
const task1 = async () => { await sleep(20); order.push(1); return 'a'; };
|
|
111
|
+
const task2 = async () => { await sleep(10); order.push(2); return 'b'; };
|
|
112
|
+
|
|
113
|
+
const results = await sequential([task1, task2]);
|
|
114
|
+
|
|
115
|
+
expect(order).toEqual([1, 2]);
|
|
116
|
+
expect(results).toEqual(['a', 'b']);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('isValidRegex', () => {
|
|
121
|
+
it('should validate correct regex strings', () => {
|
|
122
|
+
expect(isValidRegex('/hello/g')).toBe(true);
|
|
123
|
+
expect(isValidRegex('#[0-9]+#')).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should invalidate incorrect regex strings', () => {
|
|
127
|
+
expect(isValidRegex('/[a-z/')).toBe(false); // Unmatched bracket
|
|
128
|
+
expect(isValidRegex('hello')).toBe(false); // No delimiters
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('escapeHtml', () => {
|
|
133
|
+
it('should escape HTML tags and dangerous protocols', () => {
|
|
134
|
+
const input = '<script>alert("xss")</script><a href="javascript:void(0)">link</a>'; // Le protocole "javascript:" sera supprimé
|
|
135
|
+
const expected = '<script>alert("xss")</script><a href="void(0)">link</a>';
|
|
136
|
+
expect(escapeHtml(input)).toBe(expected);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('isUnsecureKey', () => {
|
|
141
|
+
it('should identify unsecure keys', () => {
|
|
142
|
+
expect(isUnsecureKey('__proto__')).toBe(true);
|
|
143
|
+
expect(isUnsecureKey('constructor')).toBe(true);
|
|
144
|
+
expect(isUnsecureKey('prototype')).toBe(true);
|
|
145
|
+
expect(isUnsecureKey('safeKey')).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('parseSafeJSON', () => {
|
|
150
|
+
it('should parse valid JSON', () => {
|
|
151
|
+
expect(parseSafeJSON('{"a":1}')).toEqual({ a: 1 });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should prevent prototype pollution', () => {
|
|
155
|
+
const polluted = parseSafeJSON('{"__proto__":{"polluted":true}}');
|
|
156
|
+
expect(polluted.polluted).toBeUndefined();
|
|
157
|
+
const obj = {};
|
|
158
|
+
expect(obj.polluted).toBeUndefined();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('isDate', () => {
|
|
163
|
+
it('should return true for valid date strings and objects', () => {
|
|
164
|
+
expect(isDate(new Date())).toBe(true);
|
|
165
|
+
expect(isDate('2023-01-01')).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should return false for invalid dates', () => {
|
|
169
|
+
expect(isDate('not a date')).toBe(false);
|
|
170
|
+
expect(isDate(null)).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('safeAssignObject', () => {
|
|
175
|
+
it('should assign property for a safe key', () => {
|
|
176
|
+
const obj = {};
|
|
177
|
+
safeAssignObject(obj, 'a', 1);
|
|
178
|
+
expect(obj.a).toBe(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should not assign property for an unsecure key', () => {
|
|
182
|
+
const obj = {};
|
|
183
|
+
safeAssignObject(obj, '__proto__', { polluted: true });
|
|
184
|
+
expect(obj.polluted).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('debounce', () => {
|
|
189
|
+
beforeEach(() => { vi.useFakeTimers(); });
|
|
190
|
+
afterEach(() => { vi.useRealTimers(); });
|
|
191
|
+
|
|
192
|
+
it('should call the function only once after the delay', () => {
|
|
193
|
+
const spy = vi.fn();
|
|
194
|
+
const debounced = debounce(spy, 500);
|
|
195
|
+
|
|
196
|
+
debounced();
|
|
197
|
+
debounced();
|
|
198
|
+
debounced();
|
|
199
|
+
|
|
200
|
+
vi.advanceTimersByTime(500);
|
|
201
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('uuidv4', () => {
|
|
206
|
+
it('should generate a valid v4 UUID', () => {
|
|
207
|
+
const uuid = uuidv4();
|
|
208
|
+
const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
209
|
+
expect(uuid).toMatch(uuidV4Regex);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('cssProps', () => {
|
|
214
|
+
it('should convert a CSS string to a style object', () => {
|
|
215
|
+
const css = 'background-color: red; font-size: 16px;';
|
|
216
|
+
const expected = { backgroundColor: 'red', fontSize: '16px' };
|
|
217
|
+
expect(cssProps(css)).toEqual(expected);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('wordWrap', () => {
|
|
222
|
+
it('should wrap a long string at a specified width', () => {
|
|
223
|
+
const str = 'this is a long string to be wrapped';
|
|
224
|
+
const wrapped = wordWrap(str, 10);
|
|
225
|
+
expect(wrapped).toBe('this is a\nlong\nstring to\nbe wrapped');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('isPathRelativeTo', () => {
|
|
230
|
+
it('should correctly identify relative paths', () => {
|
|
231
|
+
expect(isPathRelativeTo('/a/b/c', '/a/b')).toBe(true);
|
|
232
|
+
expect(isPathRelativeTo('/a/d', '/a/b')).toBe(false);
|
|
233
|
+
expect(isPathRelativeTo('/a/b', '/a/b/c')).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('isGUID', () => {
|
|
238
|
+
it('should validate correct GUIDs', () => {
|
|
239
|
+
expect(isGUID('123e4567-e89b-12d3-a456-426614174000')).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
it('should invalidate incorrect GUIDs', () => {
|
|
242
|
+
expect(isGUID('not-a-guid')).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('shuffle', () => {
|
|
247
|
+
it('should contain the same elements after shuffling', () => {
|
|
248
|
+
const original = [1, 2, 3, 4, 5];
|
|
249
|
+
const shuffled = [...original];
|
|
250
|
+
shuffle(shuffled);
|
|
251
|
+
expect(shuffled).toHaveLength(original.length);
|
|
252
|
+
expect(shuffled.sort()).toEqual(original.sort());
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe('Seeded Random', () => {
|
|
257
|
+
it('should produce consistent random numbers for the same seed', () => {
|
|
258
|
+
setSeed(12345);
|
|
259
|
+
const rand1 = getRand();
|
|
260
|
+
const rand2 = getRand();
|
|
261
|
+
|
|
262
|
+
setSeed(12345);
|
|
263
|
+
const rand3 = getRand();
|
|
264
|
+
const rand4 = getRand();
|
|
265
|
+
|
|
266
|
+
expect(rand1).toBe(rand3);
|
|
267
|
+
expect(rand2).toBe(rand4);
|
|
268
|
+
expect(rand1).not.toBe(rand2);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('isLightColor', () => {
|
|
273
|
+
it('should identify light and dark colors', () => {
|
|
274
|
+
expect(isLightColor('#FFFFFF')).toBe(true);
|
|
275
|
+
expect(isLightColor('#000000')).toBe(false);
|
|
276
|
+
expect(isLightColor('#f0f0f0')).toBe(true);
|
|
277
|
+
expect(isLightColor('#3498db')).toBe(false); // primary-color from scss
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('tryParseJson', () => {
|
|
282
|
+
it('should parse valid JSON strings', () => {
|
|
283
|
+
expect(tryParseJson('{"a": 1}')).toEqual({ a: 1 });
|
|
284
|
+
});
|
|
285
|
+
it('should return null for invalid JSON strings', () => {
|
|
286
|
+
expect(tryParseJson('{"a": 1')).toBeNull();
|
|
287
|
+
expect(tryParseJson('not json')).toBeNull();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe('isIsoDate', () => {
|
|
292
|
+
it('should validate correct ISO date strings', () => {
|
|
293
|
+
expect(isIsoDate('2023-10-26T10:00:00.000Z')).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
it('should invalidate incorrect ISO date strings', () => {
|
|
296
|
+
expect(isIsoDate('2023-10-26')).toBe(false);
|
|
297
|
+
expect(isIsoDate(new Date().toString())).toBe(false);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe('event system', () => {
|
|
302
|
+
beforeEach(() => {
|
|
303
|
+
event_off('test');
|
|
304
|
+
});
|
|
305
|
+
it('should trigger a registered event', () => {
|
|
306
|
+
const spy = vi.fn();
|
|
307
|
+
event_on('test', spy);
|
|
308
|
+
event_trigger('test', 'arg1');
|
|
309
|
+
expect(spy).toHaveBeenCalledWith('arg1');
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe('getFileExtension', () => {
|
|
314
|
+
it('should return the file extension', () => {
|
|
315
|
+
expect(getFileExtension('file.txt')).toBe('txt');
|
|
316
|
+
expect(getFileExtension('archive.tar.gz')).toBe('gz');
|
|
317
|
+
expect(getFileExtension('noextension')).toBe('');
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe('object_equals', () => {
|
|
322
|
+
it('should correctly compare objects', () => {
|
|
323
|
+
expect(object_equals({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })).toBe(true);
|
|
324
|
+
expect(object_equals({ a: 1 }, { a: 2 })).toBe(false);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('stringToHslColor', () => {
|
|
329
|
+
it('should generate a consistent HSL color string', () => {
|
|
330
|
+
expect(stringToHslColor('test')).toBe('hsl(58, 70%, 55%)');
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe('countKeys', () => {
|
|
335
|
+
it('should count keys in nested objects and arrays', () => {
|
|
336
|
+
const obj = { a: 1, b: { c: 2, d: [1, { e: 3 }] } };
|
|
337
|
+
expect(countKeys(obj)).toBe(5);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// __tests__/data.history.integration.test.js
|
|
2
2
|
import { ObjectId } from 'mongodb';
|
|
3
3
|
import { expect, describe, it, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
4
|
-
import { handleGetHistoryRequest } from '../src/modules/data/data.history.js';
|
|
4
|
+
import { handleGetHistoryRequest, handleGetRevisionRequest, handleRevertToRevisionRequest } from '../src/modules/data/data.history.js';
|
|
5
5
|
import { Config } from '../src/config.js';
|
|
6
6
|
import { sleep } from '../src/core.js';
|
|
7
|
-
import { insertData, editData,
|
|
7
|
+
import { insertData, editData, deleteData } from '../src/index.js';
|
|
8
8
|
import { getCollection, getCollectionForUser } from '../src/modules/mongodb.js';
|
|
9
9
|
import { generateUniqueName, initEngine } from "../src/setenv.js";
|
|
10
10
|
import {purgeData} from "../src/modules/data/data.history.js";
|
|
@@ -16,6 +16,20 @@ let historyCollection;
|
|
|
16
16
|
let datasCollection;
|
|
17
17
|
let modelsCollection;
|
|
18
18
|
|
|
19
|
+
// Mock Express req/res objects for testing API handlers
|
|
20
|
+
const mockReq = (params = {}, query = {}, body = {}, user = testUser) => ({
|
|
21
|
+
params,
|
|
22
|
+
query,
|
|
23
|
+
body,
|
|
24
|
+
me: user
|
|
25
|
+
});
|
|
26
|
+
const mockRes = () => {
|
|
27
|
+
const res = {};
|
|
28
|
+
res.status = (code) => { res.statusCode = code; return res; };
|
|
29
|
+
res.json = (data) => { res.body = data; return res; };
|
|
30
|
+
return res;
|
|
31
|
+
};
|
|
32
|
+
|
|
19
33
|
describe('Data History Module Integration Tests', () => {
|
|
20
34
|
|
|
21
35
|
beforeAll(async () => {
|
|
@@ -192,6 +206,126 @@ describe('Data History Module Integration Tests', () => {
|
|
|
192
206
|
expect(historyCount).toBe(1);
|
|
193
207
|
});
|
|
194
208
|
|
|
209
|
+
it('should create a snapshot history record on document deletion', async () => {
|
|
210
|
+
// 1. Setup model
|
|
211
|
+
const modelName = generateUniqueName('productHistoryDelete');
|
|
212
|
+
const productModelDef = {
|
|
213
|
+
name: modelName,
|
|
214
|
+
description:'',
|
|
215
|
+
_user: testUser.username,
|
|
216
|
+
history: { enabled: true },
|
|
217
|
+
fields: [{ name: 'name', type: 'string' }]
|
|
218
|
+
};
|
|
219
|
+
await modelsCollection.insertOne(productModelDef);
|
|
220
|
+
|
|
221
|
+
// 2. Insert a document
|
|
222
|
+
const insertResult = await insertData(modelName, { name: 'Document to be deleted' }, {}, testUser);
|
|
223
|
+
const docId = new ObjectId(insertResult.data._id);
|
|
224
|
+
|
|
225
|
+
// 3. Delete the document
|
|
226
|
+
await deleteData(modelName, [docId.toString()], testUser);
|
|
227
|
+
|
|
228
|
+
// 4. Verify the new history record (v2)
|
|
229
|
+
const historyRecord = await historyCollection.findOne({ documentId: docId, version: 2 });
|
|
230
|
+
|
|
231
|
+
expect(historyRecord).not.toBeNull();
|
|
232
|
+
expect(historyRecord.operation).toBe('delete');
|
|
233
|
+
expect(historyRecord.snapshot).not.toBeNull();
|
|
234
|
+
expect(historyRecord.snapshot.name).toBe('Document to be deleted');
|
|
235
|
+
expect(historyRecord.changes).toBeUndefined();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should correctly reconstruct a document at a specific version', async () => {
|
|
239
|
+
// 1. Setup model
|
|
240
|
+
const modelName = generateUniqueName('productHistoryReconstruct');
|
|
241
|
+
const productModelDef = {
|
|
242
|
+
name: modelName,
|
|
243
|
+
_user: testUser.username,
|
|
244
|
+
description:'',
|
|
245
|
+
history: { enabled: true },
|
|
246
|
+
fields: [
|
|
247
|
+
{ name: 'name', type: 'string' },
|
|
248
|
+
{ name: 'status', type: 'string' },
|
|
249
|
+
{ name: 'count', type: 'number' }
|
|
250
|
+
]
|
|
251
|
+
};
|
|
252
|
+
await modelsCollection.insertOne(productModelDef);
|
|
253
|
+
|
|
254
|
+
// 2. Create and update document to generate history
|
|
255
|
+
const insertResult = await insertData(modelName, { name: 'Reconstruct', status: 'initial', count: 0 }, {}, testUser); // v1
|
|
256
|
+
const docId = new ObjectId(insertResult.data._id);
|
|
257
|
+
|
|
258
|
+
await editData(modelName, { _id: docId }, { status: 'updated' }, {}, testUser); // v2
|
|
259
|
+
await editData(modelName, { _id: docId }, { count: 10 }, {}, testUser); // v3
|
|
260
|
+
|
|
261
|
+
// 3. Test reconstruction at each version via the API handler
|
|
262
|
+
// Version 1 (creation)
|
|
263
|
+
let req = mockReq({ modelName, recordId: docId.toString(), version: '1' });
|
|
264
|
+
let res = mockRes();
|
|
265
|
+
await handleGetRevisionRequest(req, res);
|
|
266
|
+
expect(res.body.success).toBe(true);
|
|
267
|
+
expect(res.body.data.name).toBe('Reconstruct');
|
|
268
|
+
expect(res.body.data.status).toBe('initial');
|
|
269
|
+
expect(res.body.data.count).toBe(0);
|
|
270
|
+
|
|
271
|
+
// Version 2 (status updated)
|
|
272
|
+
req = mockReq({ modelName, recordId: docId.toString(), version: '2' });
|
|
273
|
+
res = mockRes();
|
|
274
|
+
await handleGetRevisionRequest(req, res);
|
|
275
|
+
expect(res.body.success).toBe(true);
|
|
276
|
+
expect(res.body.data.status).toBe('updated');
|
|
277
|
+
expect(res.body.data.count).toBe(0); // count is still 0
|
|
278
|
+
|
|
279
|
+
// Version 3 (count updated)
|
|
280
|
+
req = mockReq({ modelName, recordId: docId.toString(), version: '3' });
|
|
281
|
+
res = mockRes();
|
|
282
|
+
await handleGetRevisionRequest(req, res);
|
|
283
|
+
expect(res.body.success).toBe(true);
|
|
284
|
+
expect(res.body.data.status).toBe('updated');
|
|
285
|
+
expect(res.body.data.count).toBe(10);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should revert a document to a previous version and create a new history entry', async () => {
|
|
289
|
+
// 1. Setup model
|
|
290
|
+
const modelName = generateUniqueName('productHistoryRevert');
|
|
291
|
+
const productModelDef = {
|
|
292
|
+
name: modelName,
|
|
293
|
+
_user: testUser.username,
|
|
294
|
+
description:'',
|
|
295
|
+
history: { enabled: true },
|
|
296
|
+
fields: [{ name: 'name', type: 'string' }, { name: 'status', type: 'string' }]
|
|
297
|
+
};
|
|
298
|
+
await modelsCollection.insertOne(productModelDef);
|
|
299
|
+
|
|
300
|
+
// 2. Create and update document
|
|
301
|
+
const insertResult = await insertData(modelName, { name: 'Revert Test', status: 'v1' }, {}, testUser); // v1
|
|
302
|
+
const docId = new ObjectId(insertResult.data._id);
|
|
303
|
+
await editData(modelName, { _id: docId }, { status: 'v2' }, {}, testUser); // v2
|
|
304
|
+
|
|
305
|
+
// 3. Revert the document to version 1
|
|
306
|
+
const req = mockReq({ modelName, recordId: docId.toString(), version: '1' });
|
|
307
|
+
const res = mockRes();
|
|
308
|
+
await handleRevertToRevisionRequest(req, res);
|
|
309
|
+
|
|
310
|
+
expect(res.body.success).toBe(true);
|
|
311
|
+
expect(res.body.message).toBe("Document successfully reverted.");
|
|
312
|
+
|
|
313
|
+
// 4. Verify the current state of the document
|
|
314
|
+
const revertedDoc = await datasCollection.findOne({ _id: docId });
|
|
315
|
+
expect(revertedDoc.status).toBe('v1');
|
|
316
|
+
|
|
317
|
+
// 5. Verify that a new history entry (v3) was created for the revert action
|
|
318
|
+
const historyRecords = await historyCollection.find({ documentId: docId }).sort({ version: 1 }).toArray();
|
|
319
|
+
expect(historyRecords).toHaveLength(3);
|
|
320
|
+
|
|
321
|
+
const revertHistoryEntry = historyRecords[2]; // v3
|
|
322
|
+
expect(revertHistoryEntry.version).toBe(3);
|
|
323
|
+
expect(revertHistoryEntry.operation).toBe('update'); // A revert is an update
|
|
324
|
+
expect(revertHistoryEntry.changes).not.toBeNull();
|
|
325
|
+
expect(revertHistoryEntry.changes.status.from).toBe('v2');
|
|
326
|
+
expect(revertHistoryEntry.changes.status.to).toBe('v1');
|
|
327
|
+
});
|
|
328
|
+
|
|
195
329
|
it('should filter history records by date range', async () => {
|
|
196
330
|
// 1. Setup model
|
|
197
331
|
const modelName = generateUniqueName('productHistoryDateFilter');
|
|
@@ -227,21 +361,11 @@ describe('Data History Module Integration Tests', () => {
|
|
|
227
361
|
});
|
|
228
362
|
|
|
229
363
|
// Mock Express req/res objects
|
|
230
|
-
|
|
231
|
-
params: { modelName, recordId: docId.toString() },
|
|
232
|
-
query,
|
|
233
|
-
me: testUser
|
|
234
|
-
});
|
|
235
|
-
const mockRes = () => {
|
|
236
|
-
const res = {};
|
|
237
|
-
res.status = (code) => { res.statusCode = code; return res; };
|
|
238
|
-
res.json = (data) => { res.body = data; return res; };
|
|
239
|
-
return res;
|
|
240
|
-
};
|
|
364
|
+
// Using the global mockReq and mockRes helpers now
|
|
241
365
|
|
|
242
366
|
// 4. Test cases
|
|
243
367
|
// Case A: Filter for February
|
|
244
|
-
let req = mockReq({ startDate: '2023-02-01', endDate: '2023-02-28' });
|
|
368
|
+
let req = mockReq({ modelName, recordId: docId.toString() }, { startDate: '2023-02-01', endDate: '2023-02-28' });
|
|
245
369
|
let res = mockRes();
|
|
246
370
|
await handleGetHistoryRequest(req, res);
|
|
247
371
|
expect(res.body.success).toBe(true);
|
|
@@ -249,14 +373,14 @@ describe('Data History Module Integration Tests', () => {
|
|
|
249
373
|
expect(res.body.data.map(d => d._v).sort()).toEqual([2, 3]);
|
|
250
374
|
|
|
251
375
|
// Case B: Filter starting from Feb 15th
|
|
252
|
-
req = mockReq({ startDate: '2023-02-15' });
|
|
376
|
+
req = mockReq({ modelName, recordId: docId.toString() }, { startDate: '2023-02-15' });
|
|
253
377
|
res = mockRes();
|
|
254
378
|
await handleGetHistoryRequest(req, res);
|
|
255
379
|
expect(res.body.success).toBe(true);
|
|
256
380
|
expect(res.body.count).toBe(3); // v2, v3, v4
|
|
257
381
|
|
|
258
382
|
// Case C: Filter up to Feb 15th (inclusive)
|
|
259
|
-
req = mockReq({ endDate: '2023-02-15' });
|
|
383
|
+
req = mockReq({ modelName, recordId: docId.toString() }, { endDate: '2023-02-15' });
|
|
260
384
|
res = mockRes();
|
|
261
385
|
await handleGetHistoryRequest(req, res);
|
|
262
386
|
expect(res.body.success).toBe(true);
|