data-primals-engine 1.0.7 → 1.0.9
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/package.json +4 -7
- package/server.js +11 -2
- package/src/data.js +15 -5
- package/src/email.js +2 -0
- package/src/engine.js +42 -40
- package/src/gameObject.js +5 -7
- package/src/migrate.js +1 -1
- package/src/modules/data.js +87 -61
- package/src/modules/mongodb.js +1 -8
- package/src/modules/workflow.js +190 -58
- package/src/packs.js +26 -10
- 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/workflow.integration.test.js +314 -0
- package/test/workflow.robustness.test.js +195 -0
- package/vitest.config.js +16 -0
package/src/modules/workflow.js
CHANGED
|
@@ -365,45 +365,40 @@ async function handleCreateDataAction(actionDef, contextData, user, dbCollection
|
|
|
365
365
|
|
|
366
366
|
try {
|
|
367
367
|
// 2. Substitute Variables in the data template
|
|
368
|
-
let substitutedDataString;
|
|
369
368
|
let dataObject;
|
|
370
369
|
|
|
371
|
-
// dataToCreate might be a string (JSON) or already an object from the model definition
|
|
372
370
|
if (typeof dataToCreate === 'string') {
|
|
373
|
-
substitutedDataString = await substituteVariables(dataToCreate, contextData, user);
|
|
374
|
-
|
|
371
|
+
const substitutedDataString = await substituteVariables(dataToCreate, contextData, user);
|
|
372
|
+
try {
|
|
373
|
+
// CORRECTION : Utiliser la bonne variable (substitutedDataString)
|
|
374
|
+
dataObject = JSON.parse(substitutedDataString);
|
|
375
|
+
} catch (parseError) {
|
|
376
|
+
const msg = `Failed to parse substituted JSON string: ${substitutedDataString}. Error: ${parseError.message}`;
|
|
377
|
+
logger.error(`[handleCreateDataAction] ${msg}`);
|
|
378
|
+
return { success: false, message: msg };
|
|
379
|
+
}
|
|
375
380
|
} else if (typeof dataToCreate === 'object') {
|
|
376
|
-
//
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
// Or assume it's meant to be used directly if it came as an object
|
|
380
|
-
// Let's assume direct use for now if it's an object in the definition
|
|
381
|
-
dataObject = substitutedObject;
|
|
382
|
-
|
|
383
|
-
console.log({substitutedObject});
|
|
381
|
+
// CORRECTION : Assigner le résultat de la substitution à dataObject.
|
|
382
|
+
// On passe une copie pour ne pas muter le template original.
|
|
383
|
+
dataObject = await substituteVariables(JSON.parse(JSON.stringify(dataToCreate)), contextData, user);
|
|
384
384
|
} else {
|
|
385
|
-
const msg = `[handleCreateDataAction]
|
|
385
|
+
const msg = `[handleCreateDataAction] 'dataToCreate' has an invalid type (${typeof dataToCreate}). Expected string (JSON) or object.`;
|
|
386
386
|
logger.error(msg);
|
|
387
387
|
return { success: false, message: msg };
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
// Log pour débogage
|
|
391
|
+
logger.debug('Final data object after substitution:', dataObject);
|
|
390
392
|
|
|
391
|
-
// 3.
|
|
392
|
-
|
|
393
|
-
try {
|
|
394
|
-
dataObject = JSON.parse(substitutedDataString);
|
|
395
|
-
if (typeof dataObject !== 'object' || dataObject === null) {
|
|
396
|
-
throw new Error("Parsed data is not a valid object.");
|
|
397
|
-
}
|
|
398
|
-
} catch (parseError) {
|
|
399
|
-
const msg = `[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): Failed to parse substituted 'dataToCreate' JSON. Error: ${parseError.message}. Substituted string: ${substitutedDataString}`;
|
|
400
|
-
logger.error(msg);
|
|
401
|
-
return { success: false, message: msg };
|
|
402
|
-
}
|
|
403
|
-
}
|
|
393
|
+
// 3. Appeler insertData avec l'objet correctement substitué
|
|
394
|
+
const result = await insertData(targetModel, dataObject, [], user, true, true); // On attend la fin du workflow déclenché par cette création
|
|
404
395
|
|
|
405
|
-
|
|
406
|
-
|
|
396
|
+
if (result.success) {
|
|
397
|
+
return { success: true, insertedIds: result.insertedIds };
|
|
398
|
+
} else {
|
|
399
|
+
// Propage l'erreur venant de insertData
|
|
400
|
+
return { success: false, message: result.error || "Insertion failed." };
|
|
401
|
+
}
|
|
407
402
|
|
|
408
403
|
} catch (error) {
|
|
409
404
|
const msg = `[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): Unexpected error during creation for model '${targetModel}'. Error: ${error.message}`;
|
|
@@ -723,9 +718,124 @@ function getNestedValue(obj, path) {
|
|
|
723
718
|
// Retourne la valeur finale trouvée
|
|
724
719
|
return current;
|
|
725
720
|
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Résout un chemin de variable complexe (ex: "triggerData.order.customer.contact.email")
|
|
724
|
+
* en construisant un pipeline d'agrégation dynamique pour tout récupérer en une seule requête.
|
|
725
|
+
*
|
|
726
|
+
* @param {string} pathString - Le chemin de la variable, ex: "triggerData.order.customer.contact.email".
|
|
727
|
+
* @param {object} initialContext - L'objet de départ (le triggerData).
|
|
728
|
+
* @param {object} user - L'objet utilisateur pour les requêtes DB.
|
|
729
|
+
* @returns {Promise<any>} La valeur résolue.
|
|
730
|
+
*/
|
|
731
|
+
async function resolvePathValue(pathString, initialContext, user) {
|
|
732
|
+
const pathParts = pathString.split('.');
|
|
733
|
+
const rootObjectKey = pathParts.shift(); // ex: "triggerData"
|
|
734
|
+
|
|
735
|
+
// Si le chemin ne commence pas par triggerData ou context, essayer de résoudre directement
|
|
736
|
+
if (rootObjectKey !== 'triggerData' && rootObjectKey !== 'context') {
|
|
737
|
+
let current = initialContext;
|
|
738
|
+
for (const part of [rootObjectKey, ...pathParts]) {
|
|
739
|
+
if (current === null || typeof current === 'undefined') return undefined;
|
|
740
|
+
current = current[part];
|
|
741
|
+
}
|
|
742
|
+
return current;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Vérifier si c'est un chemin simple qui peut être résolu sans aggregation
|
|
746
|
+
if (pathParts.length === 1) {
|
|
747
|
+
return initialContext[pathParts[0]];
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
let currentModelName = initialContext._model;
|
|
751
|
+
let currentDocId = new ObjectId(initialContext._id);
|
|
752
|
+
const collection = getCollectionForUser(user);
|
|
753
|
+
|
|
754
|
+
// Construire le pipeline d'agrégation
|
|
755
|
+
const pipeline = [
|
|
756
|
+
{ $match: { _id: currentDocId } }
|
|
757
|
+
];
|
|
758
|
+
|
|
759
|
+
// Itérer sur chaque segment du chemin pour construire les lookups
|
|
760
|
+
for (let i = 0; i < pathParts.length; i++) {
|
|
761
|
+
const segment = pathParts[i];
|
|
762
|
+
|
|
763
|
+
// Si c'est le dernier segment, on n'a pas besoin de faire un lookup
|
|
764
|
+
if (i === pathParts.length - 1) break;
|
|
765
|
+
|
|
766
|
+
const modelDef = await getModel(currentModelName, user);
|
|
767
|
+
const fieldDef = modelDef.fields.find(f => f.name === segment);
|
|
768
|
+
|
|
769
|
+
if (!fieldDef || fieldDef.type !== 'relation') {
|
|
770
|
+
// Si ce n'est pas une relation, on ne peut pas continuer le chemin
|
|
771
|
+
return undefined;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const nextModelName = fieldDef.relation;
|
|
775
|
+
const asField = `__resolved_${segment}`;
|
|
776
|
+
|
|
777
|
+
pipeline.push({
|
|
778
|
+
$lookup: {
|
|
779
|
+
from: collection.collectionName,
|
|
780
|
+
let: { relationId: `$${segment}` },
|
|
781
|
+
pipeline: [
|
|
782
|
+
{
|
|
783
|
+
$match: {
|
|
784
|
+
$expr: {
|
|
785
|
+
$eq: ["$_id", {
|
|
786
|
+
$cond: {
|
|
787
|
+
if: { $eq: [{ $type: "$$relationId" }, "string"] },
|
|
788
|
+
then: { $toObjectId: "$$relationId" },
|
|
789
|
+
else: "$$relationId"
|
|
790
|
+
}
|
|
791
|
+
}]
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
],
|
|
796
|
+
as: asField
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
pipeline.push({
|
|
801
|
+
$unwind: {
|
|
802
|
+
path: `$${asField}`,
|
|
803
|
+
preserveNullAndEmptyArrays: true
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
pipeline.push({
|
|
808
|
+
$addFields: {
|
|
809
|
+
[segment]: `$${asField}`
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
pipeline.push({ $project: { [asField]: 0 } });
|
|
814
|
+
|
|
815
|
+
currentModelName = nextModelName;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const results = await collection.aggregate(pipeline).toArray();
|
|
819
|
+
|
|
820
|
+
if (results.length === 0) {
|
|
821
|
+
return undefined;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Extraire la valeur finale
|
|
825
|
+
let finalValue = results[0];
|
|
826
|
+
for (const part of pathParts) {
|
|
827
|
+
if (finalValue === null || typeof finalValue === 'undefined') {
|
|
828
|
+
return undefined;
|
|
829
|
+
}
|
|
830
|
+
finalValue = finalValue[part];
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
return finalValue;
|
|
834
|
+
}
|
|
835
|
+
|
|
726
836
|
/**
|
|
727
837
|
* Remplace les placeholders dans un template (string, object, array) par des valeurs du contextData.
|
|
728
|
-
*
|
|
838
|
+
* Version améliorée avec support des chemins complexes via resolvePathValue.
|
|
729
839
|
*/
|
|
730
840
|
export async function substituteVariables(template, contextData, user) {
|
|
731
841
|
// 1. Retourner les types non substituables tels quels
|
|
@@ -759,54 +869,76 @@ export async function substituteVariables(template, contextData, user) {
|
|
|
759
869
|
// `contextToSearch` contient toutes les données disponibles à sa racine
|
|
760
870
|
const contextToSearch = { ...contextData, env: userEnv };
|
|
761
871
|
|
|
762
|
-
// 5. Logique de résolution de valeur
|
|
763
|
-
const findValue = (key) => {
|
|
764
|
-
let value;
|
|
872
|
+
// 5. Logique de résolution de valeur améliorée avec resolvePathValue
|
|
873
|
+
const findValue = async (key) => {
|
|
765
874
|
let path = key.trim();
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
875
|
+
if (path.endsWith('._id')) {
|
|
876
|
+
const basePath = path.slice(0, -4);
|
|
877
|
+
const value = await findValue(basePath);
|
|
878
|
+
return value?._id?.toString(); // Convertit l'ObjectId en string
|
|
770
879
|
}
|
|
771
|
-
// Note : Pas de gestion spéciale pour {env.*} car `env` est déjà à la racine de contextToSearch.
|
|
772
880
|
|
|
773
881
|
// Gérer les valeurs dynamiques spéciales
|
|
774
882
|
if (path === 'now') {
|
|
775
|
-
|
|
883
|
+
return new Date().toISOString();
|
|
776
884
|
} else if (path === 'randomUUID') {
|
|
777
|
-
|
|
778
|
-
value = crypto.randomUUID();
|
|
779
|
-
} else {
|
|
780
|
-
// Chercher le chemin dans l'objet de contexte entier
|
|
781
|
-
value = getNestedValue(contextToSearch, path);
|
|
885
|
+
return crypto.randomUUID();
|
|
782
886
|
}
|
|
783
|
-
|
|
887
|
+
|
|
888
|
+
// Détecter si le chemin est complexe (contient plus d'un point)
|
|
889
|
+
if (path.split('.').length > 1) {
|
|
890
|
+
try {
|
|
891
|
+
// Essayer de résoudre le chemin avec resolvePathValue
|
|
892
|
+
const [root, ...rest] = path.split('.');
|
|
893
|
+
// On vérifie si la racine du chemin (ex: 'triggerData') existe dans notre contexte
|
|
894
|
+
if (contextToSearch[root]) {
|
|
895
|
+
const resolvedValue = await resolvePathValue(
|
|
896
|
+
rest.join('.'),
|
|
897
|
+
contextToSearch[root], // On passe le bon objet de départ (ex: l'objet triggerData)
|
|
898
|
+
user
|
|
899
|
+
);
|
|
900
|
+
if (resolvedValue !== undefined) {
|
|
901
|
+
return resolvedValue;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
} catch (error) {
|
|
905
|
+
console.warn(`Erreur lors de la résolution du chemin "${path}":`, error.message);
|
|
906
|
+
// On continue avec la méthode normale si la résolution échoue
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Fallback: chercher le chemin dans l'objet de contexte normal
|
|
911
|
+
return getNestedValue(contextToSearch, path);
|
|
784
912
|
};
|
|
785
913
|
|
|
786
914
|
// CAS A : La chaîne est un unique placeholder (ex: "{context.triggerData.product.price}")
|
|
787
|
-
// Ceci préserve le type de donnée original (nombre, booléen, objet, etc.).
|
|
788
915
|
const singlePlaceholderMatch = template.match(/^\{([^}]+)\}$/);
|
|
789
916
|
if (singlePlaceholderMatch) {
|
|
790
917
|
const key = singlePlaceholderMatch[1];
|
|
791
|
-
const value = findValue(key);
|
|
792
|
-
// Si une valeur est trouvée, la retourner directement. Sinon, retourner le template original.
|
|
918
|
+
const value = await findValue(key);
|
|
793
919
|
return value !== undefined ? value : template;
|
|
794
920
|
}
|
|
795
921
|
|
|
796
|
-
// CAS B : La chaîne
|
|
797
|
-
// Le résultat sera toujours une chaîne de caractères.
|
|
922
|
+
// CAS B : La chaîne contient plusieurs placeholders ou mix texte/variables
|
|
798
923
|
const placeholderRegex = /\{([^}]+)\}/g;
|
|
799
|
-
|
|
800
|
-
const value = findValue(key);
|
|
924
|
+
const placeholders = [...template.matchAll(placeholderRegex)];
|
|
801
925
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
}
|
|
926
|
+
// Si aucun placeholder trouvé, retourner la chaîne telle quelle
|
|
927
|
+
if (placeholders.length === 0) {
|
|
928
|
+
return template;
|
|
929
|
+
}
|
|
807
930
|
|
|
808
|
-
|
|
809
|
-
|
|
931
|
+
// Remplacer chaque placeholder de manière asynchrone
|
|
932
|
+
let result = template;
|
|
933
|
+
for (const [match, key] of placeholders) {
|
|
934
|
+
const value = await findValue(key);
|
|
935
|
+
const replacement = value !== undefined
|
|
936
|
+
? (value === null ? 'null' : typeof value === 'object' ? JSON.stringify(value) : String(value))
|
|
937
|
+
: match;
|
|
938
|
+
result = result.replace(match, replacement);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return result;
|
|
810
942
|
}
|
|
811
943
|
|
|
812
944
|
/**
|
package/src/packs.js
CHANGED
|
@@ -242,20 +242,17 @@ export const getAllPacks = async () => {
|
|
|
242
242
|
},{
|
|
243
243
|
"name": "Data purging",
|
|
244
244
|
"startStep": { "$link": { "name": "Purge execution", "_model": "workflowStep" } }
|
|
245
|
+
},{
|
|
246
|
+
"name": "Shipment Notification",
|
|
247
|
+
"description": "Notifies the customer when their order has been shipped.",
|
|
248
|
+
"startStep": { "$link": { "name": "Send Shipment Email Step", "_model": "workflowStep" } }
|
|
245
249
|
}],
|
|
246
250
|
"workflowAction": [
|
|
247
|
-
{ "name": "Update order status to 'processing'", "type": "UpdateData", "targetModel": "order", "targetSelector": { "_id": "{triggerData._id}" }, "fieldsToUpdate": { "status": "processing" } },
|
|
251
|
+
{ "name": "Update order status to 'processing'", "type": "UpdateData", "targetModel": "order", "targetSelector": { "_id": { $toObjectId: "{triggerData._id}" }}, "fieldsToUpdate": { "status": "processing" } },
|
|
248
252
|
{ "name": "Create Shipment Record", "type": "CreateData",
|
|
249
253
|
"targetModel": "shipment",
|
|
250
254
|
"dataToCreate": { "order": "{triggerData._id}", "status": "preparing" } },
|
|
251
|
-
{ "name": "Update order status to 'shipped'", "type": "UpdateData", "targetModel": "order", "targetSelector": { "_id": "{triggerData._id}" }, "fieldsToUpdate": { "status": "shipped" } },
|
|
252
|
-
{
|
|
253
|
-
"name": "Send Shipping Confirmation",
|
|
254
|
-
"type": "SendEmail",
|
|
255
|
-
"emailRecipients": ["{triggerData.customer.email}"],
|
|
256
|
-
"emailSubject": "Your order #{triggerData.orderId} has been shipped!",
|
|
257
|
-
"emailContent": "Hello {triggerData.customer.firstName},<br><br>Good news! Your order is on its way. You can track it using this number: {context.CreateShipmentRecord.insertedIds[0]}.<br><br>Thank you for your purchase!"
|
|
258
|
-
},
|
|
255
|
+
{ "name": "Update order status to 'shipped'", "type": "UpdateData", "targetModel": "order", "targetSelector": { "_id": { $toObjectId: "{triggerData._id}" }}, "fieldsToUpdate": { "status": "shipped" } },
|
|
259
256
|
{
|
|
260
257
|
name: 'Delete queries older than 30 days',
|
|
261
258
|
type: 'DeleteData',
|
|
@@ -263,6 +260,14 @@ export const getAllPacks = async () => {
|
|
|
263
260
|
targetSelector: {
|
|
264
261
|
"$lt": ["$timestamp", {"$subtract": ["$$NOW", 1000*3600*24*365*5] } ]
|
|
265
262
|
}
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
"name": "Send Shipping Notification Email",
|
|
266
|
+
"type": "SendEmail",
|
|
267
|
+
// C'est ici que la magie opère !
|
|
268
|
+
"emailRecipients": ["{triggerData.order.customer.contact.email}"],
|
|
269
|
+
"emailSubject": "Your order #{triggerData.order.orderId} has been shipped!",
|
|
270
|
+
"emailContent": "Hello {triggerData.order.customer.contact.firstName},<br><br>Good news! Your order #{triggerData.order.orderId} is on its way. You can track it using this number: <strong>{triggerData.trackingNumber}</strong>.<br><br>Thank you for your purchase!"
|
|
266
271
|
}
|
|
267
272
|
],
|
|
268
273
|
"workflowStep": [
|
|
@@ -278,6 +283,12 @@ export const getAllPacks = async () => {
|
|
|
278
283
|
"actions": { "$link": { "name": "Create Shipment Record", "_model": "workflowAction" } },
|
|
279
284
|
"onSuccessStep": { "$link": { "name": "Ship Order", "_model": "workflowStep" } },
|
|
280
285
|
},
|
|
286
|
+
{
|
|
287
|
+
"name": "Send Shipment Email Step",
|
|
288
|
+
"workflow": { "$link": { "name": "Shipment Notification", "_model": "workflow" } },
|
|
289
|
+
"actions": { "$link": { "name": "Send Shipping Notification Email", "_model": "workflowAction" } },
|
|
290
|
+
"isTerminal": true
|
|
291
|
+
},
|
|
281
292
|
{
|
|
282
293
|
"name": "Ship Order",
|
|
283
294
|
"workflow": { "$link": { "name": "Order Fulfillment", "_model": "workflow" }},
|
|
@@ -300,7 +311,12 @@ export const getAllPacks = async () => {
|
|
|
300
311
|
}
|
|
301
312
|
],
|
|
302
313
|
"workflowTrigger": [{
|
|
303
|
-
"name": "New
|
|
314
|
+
"name": "On New Shipment Created",
|
|
315
|
+
"workflow": { "$link": { "name": "Shipment Notification", "_model": "workflow" } },
|
|
316
|
+
"type": "manual", // Déclenché par un événement
|
|
317
|
+
"onEvent": "DataAdded",
|
|
318
|
+
"targetModel": "shipment",
|
|
319
|
+
"isActive": true
|
|
304
320
|
},{
|
|
305
321
|
name: 'Daily data purge',
|
|
306
322
|
type: 'scheduled',
|
package/src/setenv.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {getRandom} from "data-primals-engine/core";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import {Engine} from "./index.js";
|
|
4
|
+
|
|
5
|
+
let ports = [], engineInstance, mongod;
|
|
6
|
+
export const getUniquePort = () =>{
|
|
7
|
+
let d, it=0;
|
|
8
|
+
do{
|
|
9
|
+
d = getRandom(10000, 20000);
|
|
10
|
+
++it;
|
|
11
|
+
} while( ports.includes(d) && it < 10000);
|
|
12
|
+
return d;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// --- Utilitaires pour les tests ---
|
|
16
|
+
export const generateUniqueName = (baseName) => `${baseName}_${getRandom(1000, 9999)}_${Date.now()}`;
|
|
17
|
+
export const initEngine = async () => {
|
|
18
|
+
if( engineInstance )
|
|
19
|
+
return engineInstance;
|
|
20
|
+
|
|
21
|
+
process.env.OPENAI_API_KEY = "O000";
|
|
22
|
+
|
|
23
|
+
const port = process.env.PORT || getUniquePort(); // Different port for this test suite
|
|
24
|
+
engineInstance = await Engine.Create();
|
|
25
|
+
await engineInstance.start(port);
|
|
26
|
+
return engineInstance;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Stops the application engine and the in-memory MongoDB instance.
|
|
32
|
+
*/
|
|
33
|
+
export const stopEngine = async () => {
|
|
34
|
+
if (engineInstance) {
|
|
35
|
+
await engineInstance.stop();
|
|
36
|
+
engineInstance = null;
|
|
37
|
+
console.log("Test engine stopped.");
|
|
38
|
+
}
|
|
39
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// test/data.backup.integration.test.js
|
|
2
|
+
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Config } from '../src/config.js';
|
|
5
|
+
|
|
6
|
+
import { ObjectId } from 'mongodb';
|
|
7
|
+
import {expect, describe, it, beforeAll, afterAll, beforeEach} from 'vitest';
|
|
8
|
+
import { vi } from 'vitest'
|
|
9
|
+
import { Buffer } from 'node:buffer'; // Explicitly import Buffer
|
|
10
|
+
import crypto from 'node:crypto'; //Explicitly import crypto
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
createModel,
|
|
14
|
+
getModel, insertData
|
|
15
|
+
} from 'data-primals-engine/modules/data';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
modelsCollection as getAppModelsCollection,
|
|
19
|
+
getCollectionForUser as getAppUserCollection,
|
|
20
|
+
} from 'data-primals-engine/modules/mongodb';
|
|
21
|
+
import { Engine } from "data-primals-engine/engine";
|
|
22
|
+
import process from "node:process";
|
|
23
|
+
|
|
24
|
+
import { dumpUserData, loadFromDump, getUserHash } from 'data-primals-engine/modules/data';
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import {getRandom} from "data-primals-engine/core";
|
|
27
|
+
import {getUniquePort, initEngine, stopEngine} from "../src/setenv.js";
|
|
28
|
+
|
|
29
|
+
vi.mock('data-primals-engine/engine', async(importOriginal) => {
|
|
30
|
+
const mod = await importOriginal() // type is inferred
|
|
31
|
+
return {
|
|
32
|
+
...mod
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Mock data and settings
|
|
37
|
+
const mockUser = {
|
|
38
|
+
username: 'testuserBackup',
|
|
39
|
+
_user: 'testuserBackup',
|
|
40
|
+
userPlan: 'premium',
|
|
41
|
+
email: 'testBackup@example.com',
|
|
42
|
+
configS3: {
|
|
43
|
+
bucketName: null
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const testDbName = 'testIntegrationDbHO_Backup';
|
|
47
|
+
const testModelDefinition = {
|
|
48
|
+
name: 'backupTestModel',
|
|
49
|
+
_user: mockUser.username,
|
|
50
|
+
description: 'Model for testing backup/restore',
|
|
51
|
+
fields: [
|
|
52
|
+
{ name: 'testField', type: 'string', required: true },
|
|
53
|
+
{ name: 'optionalField', type: 'number' },
|
|
54
|
+
],
|
|
55
|
+
maxRequestData: 10,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
let testModelsColInstance;
|
|
59
|
+
let testDatasColInstance;
|
|
60
|
+
let engineInstance;
|
|
61
|
+
let testDatasApi;
|
|
62
|
+
|
|
63
|
+
const backupDir = path.resolve('./test-backups'); // Use an absolute path
|
|
64
|
+
|
|
65
|
+
beforeAll(async () => {
|
|
66
|
+
|
|
67
|
+
process.env.BACKUP_DIR = backupDir; // Set backup directory
|
|
68
|
+
|
|
69
|
+
// Create the backup directory if it doesn't exist
|
|
70
|
+
if (!fs.existsSync(backupDir)) {
|
|
71
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Delete any existing files in the backup directory
|
|
75
|
+
fs.readdirSync(backupDir).forEach(file => {
|
|
76
|
+
fs.unlinkSync(path.join(backupDir, file));
|
|
77
|
+
});
|
|
78
|
+
vi.stubEnv('S3_CONFIG_ENCRYPTION_KEY', '00000000000000000000000000000000');
|
|
79
|
+
vi.stubEnv('OPENAI_API_KEY', '00000000000000000000000000000000');
|
|
80
|
+
// You might need to create a model first if your dumpUserData requires it
|
|
81
|
+
await createModel(testModelDefinition);
|
|
82
|
+
}, 45000);
|
|
83
|
+
|
|
84
|
+
afterAll(async () => {
|
|
85
|
+
|
|
86
|
+
delete process.env.DB_URL;
|
|
87
|
+
delete process.env.DB_NAME;
|
|
88
|
+
|
|
89
|
+
// Clean up test backups
|
|
90
|
+
if (fs.existsSync(backupDir)) {
|
|
91
|
+
fs.readdirSync(backupDir).forEach(file => {
|
|
92
|
+
fs.unlinkSync(path.join(backupDir, file));
|
|
93
|
+
});
|
|
94
|
+
// Optional: fs.rmdirSync(backupDir); // Remove the directory itself
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
beforeAll(async () =>{
|
|
99
|
+
Config.Set("modules", ["mongodb", "data", "file", "bucket", "workflow","user", "assistant"]);
|
|
100
|
+
await initEngine();
|
|
101
|
+
})
|
|
102
|
+
beforeEach(async () => {
|
|
103
|
+
testModelsColInstance = getAppModelsCollection;
|
|
104
|
+
testDatasColInstance = getAppUserCollection(mockUser);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('Data Backup and Restore Integration', () => {
|
|
108
|
+
it('should dump and restore user data successfully, and verify data integrity', async () => { // Le nom du test est plus précis
|
|
109
|
+
// 1. Insérer des données à sauvegarder
|
|
110
|
+
const initialData = { testField: 'Initial Value', optionalField: 123 };
|
|
111
|
+
const insertResult = await insertData(testModelDefinition.name, initialData, {}, mockUser, false);
|
|
112
|
+
expect(insertResult.success).toBe(true);
|
|
113
|
+
const insertedId = insertResult.insertedIds[0];
|
|
114
|
+
|
|
115
|
+
// Vérifier que les données existent avant la sauvegarde
|
|
116
|
+
let docBeforeBackup = await testDatasColInstance.findOne({ _id: new ObjectId(insertedId) });
|
|
117
|
+
expect(docBeforeBackup).not.toBeNull();
|
|
118
|
+
expect(docBeforeBackup.testField).toBe('Initial Value');
|
|
119
|
+
|
|
120
|
+
// 2. Sauvegarder les données
|
|
121
|
+
await dumpUserData(mockUser);
|
|
122
|
+
|
|
123
|
+
// 3. Simuler une suppression totale des données
|
|
124
|
+
await testDatasColInstance.deleteMany({ _user: mockUser.username });
|
|
125
|
+
let docAfterDelete = await testDatasColInstance.findOne({ _id: new ObjectId(insertedId) });
|
|
126
|
+
expect(docAfterDelete).toBeNull();
|
|
127
|
+
|
|
128
|
+
// 4. Restaurer les données
|
|
129
|
+
await loadFromDump(mockUser);
|
|
130
|
+
|
|
131
|
+
// 5. **VÉRIFICATION FINALE** : S'assurer que les données sont restaurées correctement
|
|
132
|
+
const countAfterRestore = await testDatasColInstance.countDocuments({ _user: mockUser.username });
|
|
133
|
+
expect(countAfterRestore).toBeGreaterThan(0);
|
|
134
|
+
|
|
135
|
+
const docAfterRestore = await testDatasColInstance.findOne({ _user: mockUser.username, _model: testModelDefinition.name });
|
|
136
|
+
expect(docAfterRestore).not.toBeNull();
|
|
137
|
+
expect(docAfterRestore.testField).toBe('Initial Value');
|
|
138
|
+
expect(docAfterRestore.optionalField).toBe(123);
|
|
139
|
+
|
|
140
|
+
}, 15000); // Timeout augmenté pour les opérations de fichiers
|
|
141
|
+
});
|