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.
Files changed (39) hide show
  1. package/README.md +56 -911
  2. package/client/package-lock.json +64 -50
  3. package/client/package.json +6 -0
  4. package/client/src/App.scss +29 -0
  5. package/client/src/Dashboard.jsx +1 -1
  6. package/client/src/WorkflowEditor.jsx +101 -0
  7. package/client/src/WorkflowEditor.scss +29 -4
  8. package/client/src/_variables.scss +3 -0
  9. package/doc/automation-workflows.md +102 -0
  10. package/doc/core-concepts.md +33 -0
  11. package/doc/custom-api-endpoints.md +40 -0
  12. package/doc/dashboards-kpis-charts.md +49 -0
  13. package/doc/data-management.md +120 -0
  14. package/doc/data-models.md +75 -0
  15. package/doc/index.md +14 -0
  16. package/doc/roles-permissions.md +43 -0
  17. package/doc/users.md +30 -0
  18. package/package.json +7 -5
  19. package/src/client.js +6 -4
  20. package/src/core.js +31 -12
  21. package/src/defaultModels +1628 -0
  22. package/src/defaultModels.js +14 -0
  23. package/src/filter.js +110 -42
  24. package/src/modules/data/data.history.js +5 -1
  25. package/src/modules/data/data.js +2 -1
  26. package/src/modules/data/data.operations.js +116 -72
  27. package/src/modules/data/data.relations.js +1 -1
  28. package/src/modules/mongodb.js +2 -1
  29. package/src/modules/swagger.js +25 -5
  30. package/src/modules/user.js +138 -76
  31. package/src/modules/workflow.js +187 -177
  32. package/src/providers.js +1 -1
  33. package/swagger-en.yml +2308 -472
  34. package/swagger-fr.yml +487 -3
  35. package/test/core.test.js +341 -0
  36. package/test/data.history.integration.test.js +140 -16
  37. package/test/data.integration.test.js +137 -2
  38. package/test/user.test.js +201 -280
  39. 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 = '&#60;script&#62;alert&#40;&#34;xss&#34;&#41;&#60;&#47;script&#62;&#60;a href&#61;&#34;void&#40;0&#41;&#34;&#62;link&#60;&#47;a&#62;';
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, deleteModels } from '../src/index.js';
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
- const mockReq = (query) => ({
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);