data-primals-engine 1.0.8 → 1.0.10
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/node.js.yml +59 -0
- package/.github/workflows/npm-publish.yml +23 -0
- package/Dockerfile +15 -0
- package/DockerfileTest +15 -0
- package/README.md +217 -0
- package/package.json +4 -7
- package/src/engine.js +2 -2
- package/src/i18n.js +2274 -0
- package/src/index.js +1 -1
- package/src/migrate.js +1 -1
- package/src/modules/data.js +124 -88
- package/src/modules/mongodb.js +0 -7
- package/src/setenv.js +39 -0
- package/test/data.backup.integration.test.js +141 -0
- package/test/data.integration.test.js +796 -0
- package/test/globalSetup.js +15 -0
- package/test/globalTeardown.js +8 -0
- package/test/import_export.integration.test.js +200 -0
- package/test/model.integration.test.js +219 -0
- package/test/workflow.integration.test.js +314 -0
- package/test/workflow.robustness.test.js +195 -0
- package/vitest.config.js +16 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// __tests__/workflow.integration.test.js
|
|
2
|
+
import { expect, describe, it, beforeEach,afterEach, beforeAll, afterAll, vi } from 'vitest';
|
|
3
|
+
import { Config } from "data-primals-engine/config";
|
|
4
|
+
|
|
5
|
+
import {insertData, editData, deleteData, patchData} from 'data-primals-engine/modules/data';
|
|
6
|
+
import { modelsCollection as getAppModelsCollection, getCollectionForUser } from 'data-primals-engine/modules/mongodb';
|
|
7
|
+
import * as workflowModule from 'data-primals-engine/modules/workflow';
|
|
8
|
+
import {getUniquePort, initEngine} from "../src/setenv.js";
|
|
9
|
+
import process from "process";
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
beforeAll(async () =>{
|
|
13
|
+
Config.Set("modules", ["mongodb", "data", "file", "bucket", "workflow","user", "assistant"]);
|
|
14
|
+
await initEngine();
|
|
15
|
+
})
|
|
16
|
+
vi.mock('data-primals-engine/modules/workflow', { spy: true })
|
|
17
|
+
// --- Données Mock pour les tests ---
|
|
18
|
+
const mockUser = {
|
|
19
|
+
username: 'testuserWorkflow',
|
|
20
|
+
_user: 'testuserWorkflow',
|
|
21
|
+
userPlan: 'free',
|
|
22
|
+
email: 'testWorkflow@example.com'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// --- Définitions des modèles ---
|
|
26
|
+
|
|
27
|
+
// Le modèle de données qui déclenchera les workflows
|
|
28
|
+
const targetDataModel = {
|
|
29
|
+
name: 'project',
|
|
30
|
+
_user: mockUser.username,
|
|
31
|
+
description: 'Un modèle pour les projets qui déclencheront des workflows',
|
|
32
|
+
fields: [
|
|
33
|
+
{ name: 'projectName', type: 'string', required: true },
|
|
34
|
+
{ name: 'status', type: 'string', required: true }, // ex: 'new', 'active', 'archived'
|
|
35
|
+
{ name: 'budget', type: 'number' }
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Les modèles qui définissent le système de workflow lui-même.
|
|
40
|
+
// Pour les tests, nous devons insérer leurs définitions dans la collection 'models',
|
|
41
|
+
// puis leurs instances (ex: un trigger spécifique) dans la collection 'datas' de l'utilisateur.
|
|
42
|
+
// Les modèles qui définissent le système de workflow lui-me.
|
|
43
|
+
// Pour les tests, nous devons insérer leurs définitions dans la collection 'models',
|
|
44
|
+
// puis leurs instances (ex: un trigger spécifique) dans la collection 'datas' de l'utilisateur.
|
|
45
|
+
const workflowMetaModels = [
|
|
46
|
+
{
|
|
47
|
+
name: 'workflow',
|
|
48
|
+
_user: mockUser.username,
|
|
49
|
+
description:'',
|
|
50
|
+
fields: [
|
|
51
|
+
{ name: 'name', type: 'string' },
|
|
52
|
+
{ name: 'startStep', type: 'relation', relation: 'workflowStep' }
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'workflowStep',
|
|
57
|
+
_user: mockUser.username,
|
|
58
|
+
description:'',
|
|
59
|
+
fields: [
|
|
60
|
+
{ name: 'name', type: 'string' },
|
|
61
|
+
{ name: 'onSuccessStep', type: 'relation', relation: 'workflowStep' },
|
|
62
|
+
{ name: 'onFailureStep', type: 'relation', relation: 'workflowStep' },
|
|
63
|
+
{ name: 'isTerminal', type: 'boolean' },
|
|
64
|
+
{ name: 'actions', type: 'array', itemsType: 'relation', relation: 'workflowAction' },
|
|
65
|
+
{ name: 'conditions', type: 'object' }
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'workflowTrigger',
|
|
70
|
+
_user: mockUser.username,
|
|
71
|
+
description:'',
|
|
72
|
+
fields: [
|
|
73
|
+
{ name: 'name', type: 'string' },
|
|
74
|
+
{ name: 'targetModel', type: 'model' },
|
|
75
|
+
{ name: 'onEvent', type: 'enum', items: ['DataAdded', 'DataEdited', 'DataDeleted', 'ModelAdded', 'ModelEdited', 'ModelDeleted'] },
|
|
76
|
+
{ name: 'isActive', type: 'boolean' },
|
|
77
|
+
{ name: 'dataFilter', type: 'object' },
|
|
78
|
+
{ name: 'workflow', type: 'relation', relation: 'workflow' },
|
|
79
|
+
{ name: 'cronExpression', type: 'cronSchedule' },
|
|
80
|
+
{ name: 'lockDurationMinutes', type: 'number' }
|
|
81
|
+
]
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'workflowRun',
|
|
85
|
+
_user: mockUser.username,
|
|
86
|
+
description:'',
|
|
87
|
+
fields: [
|
|
88
|
+
{ name: 'status', type: 'enum', items: ['pending', 'running', 'completed', 'failed', 'cancelled'] },
|
|
89
|
+
{ name: 'workflow', type: 'relation', relation: 'workflow' },
|
|
90
|
+
{ name: 'contextData', type: 'object' },
|
|
91
|
+
{ name: 'currentStep', type: 'relation', relation: 'workflowStep' },
|
|
92
|
+
{ name: 'error', type: 'string' },
|
|
93
|
+
{ name: 'startedAt', type: 'datetime' },
|
|
94
|
+
{ name: 'completedAt', type: 'datetime' },
|
|
95
|
+
{ name: 'stepExecutionsCount', type: 'object' }
|
|
96
|
+
]
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'workflowAction',
|
|
100
|
+
_user: mockUser.username,
|
|
101
|
+
description:'',
|
|
102
|
+
fields: [
|
|
103
|
+
// Common
|
|
104
|
+
{ name: 'name', type: 'string' },
|
|
105
|
+
{ name: 'type', type: 'enum', items: ['Webhook', 'CreateData', 'UpdateData', 'DeleteData', 'GenerateAIContent', 'SendEmail'] },
|
|
106
|
+
// Webhook
|
|
107
|
+
{ name: 'url', type: 'url' },
|
|
108
|
+
{ name: 'method', type: 'enum', items: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
|
|
109
|
+
{ name: 'headers', type: 'code', language: 'json' },
|
|
110
|
+
{ name: 'body', type: 'code', language: 'json' },
|
|
111
|
+
// Create/Update/Delete
|
|
112
|
+
{ name: 'targetModel', type: 'model' },
|
|
113
|
+
{ name: 'dataToCreate', type: 'code', language: 'json' },
|
|
114
|
+
{ name: 'targetSelector', type: 'code', language: 'json' },
|
|
115
|
+
{ name: 'fieldsToUpdate', type: 'code', language: 'json' },
|
|
116
|
+
{ name: 'updateMultiple', type: 'boolean' },
|
|
117
|
+
{ name: 'deleteMultiple', type: 'boolean' },
|
|
118
|
+
// GenerateAIContent
|
|
119
|
+
{ name: 'aiProvider', type: 'enum', items: ['OpenAI', 'GoogleGemini'] },
|
|
120
|
+
{ name: 'aiModel', type: 'string' },
|
|
121
|
+
{ name: 'prompt', type: 'richtext' },
|
|
122
|
+
// SendEmail
|
|
123
|
+
{ name: 'emailRecipients', type: 'array', itemsType: 'string' },
|
|
124
|
+
{ name: 'emailSubject', type: 'string' },
|
|
125
|
+
{ name: 'emailContent', type: 'richtext' }
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
let testModelsColInstance;
|
|
132
|
+
let testDatasColInstance;
|
|
133
|
+
let engineInstance;
|
|
134
|
+
|
|
135
|
+
const port = process.env.PORT || getUniquePort(); // Port différent
|
|
136
|
+
|
|
137
|
+
// On "espionne" `c` pour vérifier qu'elle est appelée sans l'exécuter réellement.
|
|
138
|
+
// Cela nous permet de tester uniquement la logique de déclenchement.
|
|
139
|
+
const processWorkflowRunSpy = vi.spyOn(workflowModule, 'processWorkflowRun');
|
|
140
|
+
|
|
141
|
+
beforeEach(async () => {
|
|
142
|
+
testModelsColInstance = getAppModelsCollection;
|
|
143
|
+
testDatasColInstance = getCollectionForUser(mockUser);
|
|
144
|
+
// Nettoyer les données avant chaque test
|
|
145
|
+
await testDatasColInstance.deleteMany({_user: "testuserWorkflow"});
|
|
146
|
+
|
|
147
|
+
// Réinitialiser l'espion
|
|
148
|
+
processWorkflowRunSpy.mockClear();
|
|
149
|
+
|
|
150
|
+
const mods = await testModelsColInstance.find({ $and: [{ _user: mockUser.username}, { $or: [{name: targetDataModel.name}, ...workflowMetaModels.map(m =>({name: m.name}))]}]}).toArray();
|
|
151
|
+
if( mods.length === 0 ) {
|
|
152
|
+
// Insérer les définitions de modèles nécessaires
|
|
153
|
+
await testModelsColInstance.insertMany([
|
|
154
|
+
{...targetDataModel},
|
|
155
|
+
...workflowMetaModels.map(m => ({...m})) // Copie pour éviter les mutations
|
|
156
|
+
]);
|
|
157
|
+
}
|
|
158
|
+
console.log({mods})
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('Intégration des Workflows - triggerWorkflows', () => {
|
|
162
|
+
|
|
163
|
+
let testWorkflow;
|
|
164
|
+
let testStep;
|
|
165
|
+
|
|
166
|
+
// Avant chaque test de ce bloc, on crée un workflow et une étape de base
|
|
167
|
+
const initTest = async () => {
|
|
168
|
+
const workflowInsertResult = await insertData('workflow', { name: 'Test Workflow' }, {}, mockUser, false);
|
|
169
|
+
testWorkflow = { _id: workflowInsertResult.insertedIds[0] };
|
|
170
|
+
|
|
171
|
+
const stepInsertResult = await insertData('workflowStep', { name: 'Start Step', isTerminal: true }, {}, mockUser, false);
|
|
172
|
+
testStep = { _id: stepInsertResult.insertedIds[0] };
|
|
173
|
+
|
|
174
|
+
// Lier l'étape au workflow
|
|
175
|
+
await editData('workflow', testWorkflow._id, { startStep: testStep._id.toString() }, {}, mockUser, false);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
it('devrait créer un workflowRun lors de l\'ajout de données correspondant à un trigger "DataAdded"', async () => {
|
|
179
|
+
await initTest();
|
|
180
|
+
// 1. Arrange: Créer un déclencheur actif pour 'DataAdded' sur le modèle 'project'
|
|
181
|
+
await insertData('workflowTrigger', {
|
|
182
|
+
name: 'Trigger on Project Add',
|
|
183
|
+
targetModel: targetDataModel.name,
|
|
184
|
+
onEvent: 'DataAdded',
|
|
185
|
+
isActive: true,
|
|
186
|
+
workflow: testWorkflow._id.toString()
|
|
187
|
+
}, {}, mockUser, false);
|
|
188
|
+
|
|
189
|
+
// 2. Act: Insérer une donnée qui doit déclencher le workflow
|
|
190
|
+
const projectData = { projectName: 'New Corp Website', status: 'new', budget: 50000 };
|
|
191
|
+
const insertResult = await insertData(targetDataModel.name, projectData, {}, mockUser, true, true);
|
|
192
|
+
|
|
193
|
+
expect(insertResult.success).toBe(true);
|
|
194
|
+
|
|
195
|
+
const workflowRun = await testDatasColInstance.findOne({ _model: 'workflowRun' });
|
|
196
|
+
|
|
197
|
+
expect(workflowRun).not.toBeNull();
|
|
198
|
+
expect(workflowRun.status).toBe('completed');
|
|
199
|
+
expect(workflowRun.workflow.toString()).toBe(testWorkflow._id.toString());
|
|
200
|
+
expect(workflowRun.contextData.triggerData._id.toString()).toBe(insertResult.insertedIds[0].toString());
|
|
201
|
+
expect(workflowRun.contextData.triggerData.projectName).toBe('New Corp Website');
|
|
202
|
+
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('ne devrait PAS créer de workflowRun si le trigger est inactif', async () => {
|
|
206
|
+
await initTest();
|
|
207
|
+
// 1. Arrange: Créer un déclencheur INACTIF
|
|
208
|
+
await insertData('workflowTrigger', {
|
|
209
|
+
name: 'Inactive Trigger',
|
|
210
|
+
targetModel: targetDataModel.name,
|
|
211
|
+
onEvent: 'DataAdded',
|
|
212
|
+
isActive: false, // Inactif
|
|
213
|
+
workflow: testWorkflow._id.toString()
|
|
214
|
+
}, {}, mockUser, false);
|
|
215
|
+
|
|
216
|
+
// 2. Act: Insérer des données
|
|
217
|
+
await insertData(targetDataModel.name, { projectName: 'Secret Project', status: 'new' }, {}, mockUser, true);
|
|
218
|
+
|
|
219
|
+
// 3. Assert: Aucun `workflowRun` ne doit être créé
|
|
220
|
+
const workflowRunCount = await testDatasColInstance.countDocuments({ _model: 'workflowRun' });
|
|
221
|
+
expect(workflowRunCount).toBe(0);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('devrait créer un workflowRun si le dataFilter est satisfait', async () => {
|
|
225
|
+
await initTest();
|
|
226
|
+
// 1. Arrange: Créer un déclencheur avec un `dataFilter`
|
|
227
|
+
await insertData('workflowTrigger', {
|
|
228
|
+
name: 'Trigger for Active Projects',
|
|
229
|
+
targetModel: targetDataModel.name,
|
|
230
|
+
onEvent: 'DataAdded',
|
|
231
|
+
isActive: true,
|
|
232
|
+
dataFilter: { $eq: ['$status', 'active'] }, // Filtre pour status = 'active'
|
|
233
|
+
workflow: testWorkflow._id.toString()
|
|
234
|
+
}, {}, mockUser, false);
|
|
235
|
+
|
|
236
|
+
// 2. Act: Insérer des données qui correspondent au filtre
|
|
237
|
+
await insertData(targetDataModel.name, { projectName: 'Go-Live Project', status: 'active' }, {}, mockUser, true, true);
|
|
238
|
+
|
|
239
|
+
// 3. Assert: Un `workflowRun` doit être créé
|
|
240
|
+
const workflowRunCount = await testDatasColInstance.countDocuments({ _model: 'workflowRun' });
|
|
241
|
+
expect(workflowRunCount).toBe(1);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('ne devrait PAS créer de workflowRun si le dataFilter n\'est PAS satisfait', async () => {
|
|
245
|
+
await initTest();
|
|
246
|
+
// 1. Arrange: Créer un déclencheur avec un `dataFilter`
|
|
247
|
+
await insertData('workflowTrigger', {
|
|
248
|
+
name: 'Trigger for Archived Projects',
|
|
249
|
+
targetModel: targetDataModel.name,
|
|
250
|
+
onEvent: 'DataAdded',
|
|
251
|
+
isActive: true,
|
|
252
|
+
dataFilter: { $eq: ['$status', 'archived'] }, // Filtre pour status = 'archived'
|
|
253
|
+
workflow: testWorkflow._id.toString()
|
|
254
|
+
}, {}, mockUser, false);
|
|
255
|
+
|
|
256
|
+
// 2. Act: Insérer des données qui NE correspondent PAS au filtre
|
|
257
|
+
await insertData(targetDataModel.name, { projectName: 'Ongoing Project', status: 'active' }, {}, mockUser, true, true);
|
|
258
|
+
|
|
259
|
+
// 3. Assert: Aucun `workflowRun` ne doit être créé
|
|
260
|
+
const workflowRunCount = await testDatasColInstance.countDocuments({ _model: 'workflowRun' });
|
|
261
|
+
expect(workflowRunCount).toBe(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('devrait déclencher un workflow "DataEdited" lors de la modification de données', async () => {
|
|
265
|
+
await initTest();
|
|
266
|
+
// 1. Arrange: Insérer une donnée initiale et un déclencheur pour 'DataEdited'
|
|
267
|
+
const insertResult = await insertData(targetDataModel.name, { projectName: 'To Edit', status: 'initial' }, {}, mockUser, false);
|
|
268
|
+
const projectId = insertResult.insertedIds[0];
|
|
269
|
+
|
|
270
|
+
await insertData('workflowTrigger', {
|
|
271
|
+
name: 'Trigger on Project Edit',
|
|
272
|
+
targetModel: targetDataModel.name,
|
|
273
|
+
onEvent: 'DataEdited',
|
|
274
|
+
isActive: true,
|
|
275
|
+
workflow: testWorkflow._id.toString()
|
|
276
|
+
}, {}, mockUser, false);
|
|
277
|
+
|
|
278
|
+
// 2. Act: Modifier la donnée
|
|
279
|
+
const editResult = await patchData(targetDataModel.name, projectId, { status: 'edited' }, {}, mockUser, true, true);
|
|
280
|
+
expect(editResult.success).toBe(true);
|
|
281
|
+
|
|
282
|
+
// 2. Act: Modifier la donnée
|
|
283
|
+
const editResult2 = await editData(targetDataModel.name, projectId, { projectName: 'test', status: 'edited' }, {}, mockUser, true, true);
|
|
284
|
+
expect(editResult2.success).toBe(true);
|
|
285
|
+
|
|
286
|
+
const workflows = await testDatasColInstance.countDocuments({ _model: 'workflowRun' });
|
|
287
|
+
expect(workflows).toBe(2);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('devrait déclencher un workflow "DataDeleted" lors de la suppression de données', async () => {
|
|
291
|
+
await initTest();
|
|
292
|
+
// 1. Arrange: Insérer une donnée et un déclencheur pour 'DataDeleted'
|
|
293
|
+
const insertResult = await insertData(targetDataModel.name, { projectName: 'To Delete', status: 'temp' }, {}, mockUser, false);
|
|
294
|
+
const projectId = insertResult.insertedIds[0];
|
|
295
|
+
|
|
296
|
+
await insertData('workflowTrigger', {
|
|
297
|
+
name: 'Trigger on Project Delete',
|
|
298
|
+
targetModel: targetDataModel.name,
|
|
299
|
+
onEvent: 'DataDeleted',
|
|
300
|
+
isActive: true,
|
|
301
|
+
workflow: testWorkflow._id.toString()
|
|
302
|
+
}, {}, mockUser, false);
|
|
303
|
+
|
|
304
|
+
// 2. Act: Supprimer la donnée
|
|
305
|
+
const deleteResult = await deleteData(targetDataModel.name, [projectId], {}, mockUser, true, true);
|
|
306
|
+
expect(deleteResult.success).toBe(true);
|
|
307
|
+
|
|
308
|
+
// 3. Assert: Un `workflowRun` doit être créé
|
|
309
|
+
const workflowRun = await testDatasColInstance.findOne({ _model: 'workflowRun' });
|
|
310
|
+
expect(workflowRun).not.toBeNull();
|
|
311
|
+
// Le `triggerData` est le document *avant* sa suppression
|
|
312
|
+
expect(workflowRun.contextData.triggerData.projectName).toBe('To Delete');
|
|
313
|
+
});
|
|
314
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { ObjectId } from 'mongodb';
|
|
2
|
+
import { expect, describe, it, beforeEach, afterAll, beforeAll, vi } from 'vitest';
|
|
3
|
+
import { Config } from "data-primals-engine/config";
|
|
4
|
+
|
|
5
|
+
// --- Configuration initiale (similaire à workflow.integration.test.js) ---
|
|
6
|
+
let mongod;
|
|
7
|
+
let testDbUri;
|
|
8
|
+
const testDbName = 'testRobustnessDbHO_Workflow';
|
|
9
|
+
|
|
10
|
+
// --- Importations des modules de l'application ---
|
|
11
|
+
import { Engine } from "data-primals-engine/engine";
|
|
12
|
+
import { insertData, editData } from 'data-primals-engine/modules/data';
|
|
13
|
+
import { modelsCollection as getAppModelsCollection, getCollectionForUser, getCollection } from 'data-primals-engine/modules/mongodb';
|
|
14
|
+
import * as workflowModule from 'data-primals-engine/modules/workflow';
|
|
15
|
+
import {getUniquePort, initEngine} from "../src/setenv.js";
|
|
16
|
+
import {maxExecutionsByStep} from "data-primals-engine/constants";
|
|
17
|
+
|
|
18
|
+
vi.mock('data-primals-engine/modules/workflow', { spy: true })
|
|
19
|
+
|
|
20
|
+
const mockUser = {
|
|
21
|
+
username: 'robustnessUser',
|
|
22
|
+
_user: 'robustnessUser',
|
|
23
|
+
email: 'robustness@example.com'
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Recopie des méta-modèles pour la lisibilité
|
|
27
|
+
const workflowMetaModels = [
|
|
28
|
+
{ name: 'workflow', 'description': '', _user: mockUser.username, fields: [{ name: 'name', type: 'string' }, { name: 'startStep', type: 'relation', relation: 'workflowStep' }] },
|
|
29
|
+
{ name: 'workflowStep', 'description': '', _user: mockUser.username, fields: [{ name: 'name', type: 'string' }, { name: 'onSuccessStep', type: 'relation', relation: 'workflowStep' }, { name: 'onFailureStep', type: 'relation', relation: 'workflowStep' }, { name: 'isTerminal', type: 'boolean' }, { name: 'actions', type: 'array', itemsType: 'relation', relation: 'workflowAction' }, { name: 'conditions', type: 'object' }] },
|
|
30
|
+
{ name: 'workflowTrigger', 'description': '', _user: mockUser.username, fields: [{ name: 'name', type: 'string' }, { name: 'targetModel', type: 'model' }, { name: 'onEvent', type: 'enum', items: ['DataAdded'] }, { name: 'isActive', type: 'boolean' }, { name: 'dataFilter', type: 'object' }, { name: 'workflow', type: 'relation', relation: 'workflow' }] },
|
|
31
|
+
{ name: 'workflowRun', 'description': '',_user: mockUser.username, fields: [{ name: 'status', type: 'enum', items: ['pending', 'running', 'completed', 'failed', 'cancelled'] }, { name: 'workflow', type: 'relation', relation: 'workflow' }, { name: 'contextData', type: 'object' }, { name: 'currentStep', type: 'relation', relation: 'workflowStep' }, { name: 'error', type: 'string' }, { name: 'completedAt', type: 'datetime' }, { name: 'stepExecutionsCount', type: 'object' }] },
|
|
32
|
+
{ name: 'workflowAction', 'description': '', _user: mockUser.username, fields: [{ name: 'name', type: 'string' }, { name: 'type', type: 'enum', items: ['Log'] }] }
|
|
33
|
+
];
|
|
34
|
+
const targetDataModel = { name: 'project', 'description': '',_user: mockUser.username, fields: [{ name: 'projectName', type: 'string' }, { name: 'status', type: 'string' }] };
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
let testModelsColInstance;
|
|
38
|
+
let testDatasColInstance;
|
|
39
|
+
|
|
40
|
+
beforeAll(async () =>{
|
|
41
|
+
Config.Set("modules", ["mongodb", "data", "file", "bucket", "workflow","user", "assistant"]);
|
|
42
|
+
await initEngine();
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
beforeEach(async () => {
|
|
46
|
+
testModelsColInstance = getAppModelsCollection;
|
|
47
|
+
testDatasColInstance = getCollectionForUser(mockUser);
|
|
48
|
+
await testDatasColInstance.deleteMany({ _user: mockUser.username });
|
|
49
|
+
await getCollection('job_locks').deleteMany({}); // Nettoyer les verrous
|
|
50
|
+
const mods = await testModelsColInstance.find({ $and: [{_user: mockUser.username}, {$or: [{name: targetDataModel.name}, ...workflowMetaModels.map(m =>({name: m.name}))] }]}).toArray();
|
|
51
|
+
console.log({mods})
|
|
52
|
+
if( mods.length === 0){
|
|
53
|
+
await testModelsColInstance.insertMany([targetDataModel, ...workflowMetaModels]);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ====================================================================================
|
|
58
|
+
// =================== DÉBUT DES TESTS DE ROBUSTESSE ==================================
|
|
59
|
+
// ====================================================================================
|
|
60
|
+
|
|
61
|
+
describe('Tests de robustesse et des cas limites du module Workflow', () => {
|
|
62
|
+
|
|
63
|
+
describe('Verrouillage distribué (runScheduledJobWithDbLock)', () => {
|
|
64
|
+
|
|
65
|
+
it('ne devrait pas exécuter la fonction si un verrou est déjà actif', async () => {
|
|
66
|
+
const jobsCollection = getCollection('job_locks');
|
|
67
|
+
const jobId = 'concurrent-job-1';
|
|
68
|
+
const jobFunctionSpy = vi.fn();
|
|
69
|
+
|
|
70
|
+
// 1. Arrange: Insérer manuellement un verrou actif dans la DB
|
|
71
|
+
const now = new Date();
|
|
72
|
+
await jobsCollection.insertOne({
|
|
73
|
+
jobId: jobId,
|
|
74
|
+
lockedUntil: new Date(now.getTime() + 10 * 60 * 1000), // Verrouillé pour 10 min
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// 2. Act: Tenter de lancer le job
|
|
78
|
+
await workflowModule.runScheduledJobWithDbLock(jobId, jobFunctionSpy);
|
|
79
|
+
|
|
80
|
+
// 3. Assert: La fonction ne doit JAMAIS avoir été appelée
|
|
81
|
+
expect(jobFunctionSpy).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('devrait acquérir un verrou expiré et exécuter la fonction', async () => {
|
|
85
|
+
const jobsCollection = getCollection('job_locks');
|
|
86
|
+
const jobId = 'expired-lock-job-1';
|
|
87
|
+
const jobFunctionSpy = vi.fn();
|
|
88
|
+
|
|
89
|
+
// 1. Arrange: Insérer un verrou qui a déjà expiré (simule un crash)
|
|
90
|
+
await jobsCollection.insertOne({
|
|
91
|
+
jobId: jobId,
|
|
92
|
+
lockedUntil: new Date(Date.now() - 1000), // Expiré depuis 1 seconde
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// 2. Act: Lancer le job
|
|
96
|
+
await workflowModule.runScheduledJobWithDbLock(jobId, jobFunctionSpy);
|
|
97
|
+
|
|
98
|
+
// 3. Assert: La fonction a bien été exécutée car le verrou a été repris
|
|
99
|
+
expect(jobFunctionSpy).toHaveBeenCalledTimes(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('devrait libérer le verrou même si la jobFunction lve une exception', async () => {
|
|
103
|
+
const jobsCollection = getCollection('job_locks');
|
|
104
|
+
const jobId = 'job-with-exception';
|
|
105
|
+
// 1. Arrange: Créer une fonction qui échoue systématiquement
|
|
106
|
+
const failingJobFunction = vi.fn().mockRejectedValue(new Error('Erreur de traitement interne'));
|
|
107
|
+
|
|
108
|
+
// 2. Act: Exécuter la fonction. On s'attend à ce qu'elle gère l'erreur en interne.
|
|
109
|
+
await expect(workflowModule.runScheduledJobWithDbLock(jobId, failingJobFunction)).resolves.not.toThrow();
|
|
110
|
+
|
|
111
|
+
// 3. Assert:
|
|
112
|
+
// - La fonction a bien été tentée
|
|
113
|
+
expect(failingJobFunction).toHaveBeenCalledTimes(1);
|
|
114
|
+
// - Le verrou a quand même été libéré (c'est le test le plus important ici)
|
|
115
|
+
const lockInDb = await jobsCollection.findOne({ jobId: jobId });
|
|
116
|
+
expect(lockInDb).not.toBeNull();
|
|
117
|
+
// La date d'expiration est mise à une date passée (ici, l'époque UNIX 0)
|
|
118
|
+
expect(lockInDb.lockedUntil.getTime()).toBe(new Date(0).getTime());
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('Moteur de Workflow (processWorkflowRun)', () => {
|
|
123
|
+
|
|
124
|
+
it('devrait arrêter un workflow en boucle et le marquer comme "failed"', async () => {
|
|
125
|
+
// 1. Arrange: Créer un workflow qui boucle sur lui-même
|
|
126
|
+
const action = (await insertData('workflowAction', { name: 'Log Action', type: 'Log' }, {}, mockUser)).insertedIds[0];
|
|
127
|
+
const step1 = (await insertData('workflowStep', { name: 'Step 1', actions: [action] }, {}, mockUser)).insertedIds[0];
|
|
128
|
+
const step2 = (await insertData('workflowStep', { name: 'Step 2', onSuccessStep: step1.toString() }, {}, mockUser)).insertedIds[0];
|
|
129
|
+
await editData('workflowStep', step1, { onSuccessStep: step2.toString() }, {}, mockUser);
|
|
130
|
+
|
|
131
|
+
const workflow = (await insertData('workflow', { name: 'Looping Workflow', startStep: step1.toString() }, {}, mockUser)).insertedIds[0];
|
|
132
|
+
const run = (await insertData('workflowRun', { workflow: workflow, status: 'pending', contextData: {} }, {}, mockUser)).insertedIds[0];
|
|
133
|
+
|
|
134
|
+
// 2. Act: Lancer le traitement du workflow
|
|
135
|
+
await workflowModule.processWorkflowRun(run, mockUser);
|
|
136
|
+
|
|
137
|
+
// 3. Assert: Le workflow a été arrêté et marqué comme échoué
|
|
138
|
+
const finalRun = await testDatasColInstance.findOne({ _id: new ObjectId(run) });
|
|
139
|
+
expect(finalRun.status).toBe('failed');
|
|
140
|
+
expect(finalRun.error).toContain("Maximum executions ("+maxExecutionsByStep+") exceed");
|
|
141
|
+
// Vérifier que le compteur de steps a bien augmenté
|
|
142
|
+
expect(Object.keys(finalRun.stepExecutionsCount).length).toBeGreaterThan(1);
|
|
143
|
+
}, 10000); // Timeout un peu plus long pour ce test
|
|
144
|
+
|
|
145
|
+
it('devrait suivre le chemin "onFailureStep" si les conditions ne sont pas remplies', async () => {
|
|
146
|
+
// 1. Arrange:
|
|
147
|
+
const successAction = (await insertData('workflowAction', { name: 'Success Action', type: 'Log' }, {}, mockUser)).insertedIds[0];
|
|
148
|
+
const failureAction = (await insertData('workflowAction', { name: 'Failure Action', type: 'Log' }, {}, mockUser)).insertedIds[0];
|
|
149
|
+
|
|
150
|
+
const successStep = (await insertData('workflowStep', { name: 'Success Step', actions: [successAction], isTerminal: true }, {}, mockUser)).insertedIds[0];
|
|
151
|
+
const failureStep = (await insertData('workflowStep', { name: 'Failure Step', actions: [failureAction], isTerminal: true }, {}, mockUser)).insertedIds[0];
|
|
152
|
+
|
|
153
|
+
const startStepDef = {
|
|
154
|
+
name: 'Conditional Step',
|
|
155
|
+
// Cette condition est conçue pour échouer car la donnée n'existera pas
|
|
156
|
+
conditions: { status: 'must-be-this-to-succeed' },
|
|
157
|
+
onSuccessStep: successStep.toString(),
|
|
158
|
+
onFailureStep: failureStep.toString(),
|
|
159
|
+
};
|
|
160
|
+
const startStep = (await insertData('workflowStep', startStepDef, {}, mockUser)).insertedIds[0];
|
|
161
|
+
const workflow = (await insertData('workflow', { name: 'Failure Path Workflow', startStep: startStep.toString() }, {}, mockUser)).insertedIds[0];
|
|
162
|
+
const run = (await insertData('workflowRun', { workflow: workflow, status: 'pending', contextData: { triggerDataModel: 'project' } }, {}, mockUser)).insertedIds[0];
|
|
163
|
+
|
|
164
|
+
// 2. Act
|
|
165
|
+
await workflowModule.processWorkflowRun(run, mockUser);
|
|
166
|
+
|
|
167
|
+
// 3. Assert
|
|
168
|
+
const finalRun = await testDatasColInstance.findOne({ _id: new ObjectId(run) });
|
|
169
|
+
|
|
170
|
+
// Le workflow doit se terminer avec le statut 'completed', car une condition
|
|
171
|
+
// non remplie est une branche valide, pas une erreur système.
|
|
172
|
+
expect(finalRun.status).toBe('completed');
|
|
173
|
+
expect(finalRun.error).toBeNull(); // Aucune erreur ne doit être enregistrée
|
|
174
|
+
expect(finalRun.stepExecutionsCount[startStep]).toBe(1); // Aucune erreur ne doit être enregistrée
|
|
175
|
+
expect(finalRun.stepExecutionsCount[failureStep]).toBe(1); // Aucune erreur ne doit être enregistrée
|
|
176
|
+
|
|
177
|
+
}, 10000);
|
|
178
|
+
|
|
179
|
+
it('devrait échouer proprement si une définition d\'étape est introuvable', async () => {
|
|
180
|
+
// 1. Arrange: Créer un workflow qui pointe vers un step ID invalide
|
|
181
|
+
const nonExistentStepId = new ObjectId().toString();
|
|
182
|
+
const workflow = (await insertData('workflow', { name: 'Broken Workflow', startStep: nonExistentStepId }, {}, mockUser)).insertedIds[0];
|
|
183
|
+
const run = (await insertData('workflowRun', { workflow: workflow, status: 'pending' }, {}, mockUser)).insertedIds[0];
|
|
184
|
+
|
|
185
|
+
// 2. Act
|
|
186
|
+
await workflowModule.processWorkflowRun(run, mockUser);
|
|
187
|
+
|
|
188
|
+
// 3. Assert
|
|
189
|
+
const finalRun = await testDatasColInstance.findOne({ _id: new ObjectId(run) });
|
|
190
|
+
expect(finalRun.status).toBe('failed');
|
|
191
|
+
expect(finalRun.error).toContain(`Step definition ID: ${nonExistentStepId} not found`);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
});
|
package/vitest.config.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// vitest.config.js
|
|
2
|
+
import { defineConfig } from 'vitest/config';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
test: {
|
|
6
|
+
// Set a generous timeout for the global setup/teardown hooks themselves.
|
|
7
|
+
// This is important because starting a server can be slow.
|
|
8
|
+
hookTimeout: 60000, // 60 seconds
|
|
9
|
+
|
|
10
|
+
// A default timeout for individual tests
|
|
11
|
+
testTimeout: 25000, // 20 seconds
|
|
12
|
+
|
|
13
|
+
// Tell Vitest where to find your global setup and teardown files
|
|
14
|
+
globalTeardown: './test/globalTeardown.js',
|
|
15
|
+
},
|
|
16
|
+
});
|