data-primals-engine 1.0.9 → 1.0.11

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/src/index.js CHANGED
@@ -10,6 +10,6 @@ export { UserProvider } from './providers.js';
10
10
 
11
11
  // --- Database & Data Modules ---
12
12
  export { datasCollection, filesCollection, modelsCollection, packsCollection } from './modules/mongodb.js';
13
- export { searchData, insertData, editData, loadFromDump, dumpUserData, validateRestoreRequest, patchData, deleteData, createModel, getModel, getModels } from './modules/data.js';
13
+ export { searchData, insertData, editData, exportData, importData, scheduleAlerts, cancelAlerts, validateModelStructure, installPack, jobDumpUserData, loadFromDump, dumpUserData, validateRestoreRequest, patchData, deleteData, createModel, editModel, deleteModels, getModel, getModels } from './modules/data.js';
14
14
 
15
15
 
@@ -1113,6 +1113,111 @@ function applyCronMask(cronString, mask, defaults) {
1113
1113
  return newParts.join(' ');
1114
1114
  }
1115
1115
 
1116
+
1117
+ export const editModel = async (user, id, data) => {
1118
+
1119
+ if( !(isDemoUser(user) && Config.Get("useDemoAccounts")) && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_EDIT_MODEL"], user)){
1120
+ return ({success: false, error: i18n.t('api.permission.editModel', 'Cannot edit models from the API')})
1121
+ }
1122
+
1123
+ const dataModel = data;
1124
+ try {
1125
+ const collection = getCollectionForUser(user);
1126
+ validateModelStructure(dataModel);
1127
+
1128
+ const el = await modelsCollection.findOne({ $and: [
1129
+ {_user: {$exists: true}},
1130
+ { _id: new ObjectId(id) },
1131
+ {$and: [{_user: {$exists: true}}, {$or: [{_user: user._user}, {_user: user.username}]}]
1132
+ }
1133
+ ]});
1134
+
1135
+ if( !el ){
1136
+ return ({success: false, statusCode: 404, error: i18n.t("api.model.notFound", { model: dataModel.name })});
1137
+ }
1138
+
1139
+ // renommage du modèle
1140
+ if (typeof (data.name)==='string'&&el.name !== data.name && data.name ){
1141
+ await collection.updateMany({ _model: el.name }, { $set: { _model: data.name }});
1142
+ await modelsCollection.updateMany({ 'fields' : {
1143
+ '$elemMatch' : { relation: el.name }
1144
+ }}, {
1145
+ $set : {
1146
+ 'fields.$.relation' : data.name
1147
+ }
1148
+ })
1149
+ }
1150
+
1151
+ const coll = getCollectionForUser(user);
1152
+ // Update indexes
1153
+ // Update indexes
1154
+ if (user.userPlan === 'premium') {
1155
+ let indexes = [];
1156
+ try {
1157
+ // On essaie de récupérer les index existants
1158
+ indexes = await coll.indexes();
1159
+ } catch (e) {
1160
+ // Si la collection n'existe pas, c'est normal.
1161
+ // createIndex la créera. Il n'y a juste pas d'index à supprimer.
1162
+ if (e.codeName !== 'NamespaceNotFound') {
1163
+ throw e; // On relance les autres erreurs
1164
+ }
1165
+ }
1166
+
1167
+ // Le reste de votre logique de gestion d'index peut maintenant s'exécuter en toute sécurité
1168
+ for (const field of data.fields) {
1169
+ const elField = el.fields.find(f => f.name === field.name);
1170
+ if (!elField) continue;
1171
+
1172
+ const index = indexes.find(i => i.key[field.name] === 1 &&
1173
+ i.partialFilterExpression?._model === el.name &&
1174
+ i.partialFilterExpression?._user === user.username);
1175
+
1176
+ if (elField.index !== field.index && !field.index) {
1177
+ if (index) {
1178
+ await coll.dropIndex(index.name);
1179
+ }
1180
+ } else if (elField.index !== field.index && field.index) {
1181
+ if (!index) {
1182
+ await coll.createIndex({ [field.name]: 1 }, {
1183
+ partialFilterExpression: {
1184
+ _model: data.name,
1185
+ _user: user.username
1186
+ }
1187
+ });
1188
+ }
1189
+ }
1190
+ }
1191
+ }
1192
+ // suppression des données à la suppression des champs
1193
+ const unset = {};
1194
+ el.fields.filter(f=> !dataModel.fields.some(dt => dt.name === f.name)).map(f => f.name).forEach(f => {
1195
+ unset[f] = 1;
1196
+ });
1197
+ await collection.updateMany({ _model: el.name }, { $unset: unset });
1198
+
1199
+ // sauvegarde du modele
1200
+ const set = {...data};
1201
+ delete set['_id'];
1202
+
1203
+ const oid = new ObjectId(id);
1204
+ await modelsCollection.updateOne({_id: oid}, {$set: set});
1205
+
1206
+ modelsCache.del(user.username+'@@'+el.name);
1207
+
1208
+ const model = await modelsCollection.findOne({_id: oid });
1209
+ triggerWorkflows(model, user, 'ModelEdited').catch(workflowError => {
1210
+ logger.error(`Erreur asynchrone lors du déclenchement des workflows pour ${model._model} ID ${model._id}:`, workflowError);
1211
+ });
1212
+
1213
+ return ({ success: true, data: await modelsCollection.findOne({_id : oid}) });
1214
+ } catch (e) {
1215
+ logger.error(e);
1216
+ return ({ success: false, error: e.message, statusCode: 500 });
1217
+ }
1218
+ };
1219
+
1220
+
1116
1221
  export async function onInit(defaultEngine) {
1117
1222
  engine = defaultEngine;
1118
1223
  logger = engine.getComponent(Logger);
@@ -1459,95 +1564,17 @@ export async function onInit(defaultEngine) {
1459
1564
  });
1460
1565
 
1461
1566
  engine.put('/api/model/:id', [middlewareAuthenticator, userInitiator, setTimeoutMiddleware(15000)], async (req, res) => {
1462
-
1463
- if( !(isDemoUser(req.me) && Config.Get("useDemoAccounts")) && isLocalUser(req.me) && !await hasPermission(["API_ADMIN", "API_EDIT_MODEL"], req.me)){
1464
- return res.status(403).json({success: false, error: i18n.t('api.permission.editModel', 'Cannot edit models from the API')})
1465
- }
1466
-
1467
- const dataModel = req.fields;
1468
- try {
1469
- const collection = getCollectionForUser(req.me);
1470
- validateModelStructure(dataModel);
1471
-
1472
- const el = await modelsCollection.findOne({ $and: [
1473
- {_user: {$exists: true}},
1474
- { _id: new ObjectId(req.params.id) },
1475
- {$and: [{_user: {$exists: true}}, {$or: [{_user: req.me._user}, {_user: req.me.username}]}]
1476
- }
1477
- ]});
1478
-
1479
- if( !el ){
1480
- return res.status(404).json({error: i18n.t("api.model.notFound", { model: dataModel.name })});
1481
- }
1482
-
1483
- // renommage du modèle
1484
- if (typeof (req.fields.name)==='string'&&el.name !== req.fields.name && req.fields.name ){
1485
- await collection.updateMany({ _model: el.name }, { $set: { _model: req.fields.name }});
1486
- await modelsCollection.updateMany({ 'fields' : {
1487
- '$elemMatch' : { relation: el.name }
1488
- }}, {
1489
- $set : {
1490
- 'fields.$.relation' : req.fields.name
1491
- }
1492
- })
1493
- }
1494
-
1495
- // Update indexes
1496
- if( req.me.userPlan === 'premium') {
1497
- const indexes = await datasCollection.indexes();
1498
- for (const field of dataModel.fields) {
1499
- const elField = el.fields.find(f => f.name === field.name);
1500
- if (!elField)
1501
- continue;
1502
- const index = indexes.find(i => i.key[field.name] === 1 &&
1503
- i.partialFilterExpression?._model === el.name &&
1504
- i.partialFilterExpression?._user === req.me.username);
1505
- if (elField.index !== field.index && !field.index) {
1506
- if (index) {
1507
- await datasCollection.dropIndex(index.name);
1508
- }
1509
- } else if (elField.index !== field.index && field.index) {
1510
- if (!index) {
1511
- await datasCollection.createIndex({[field.name]: 1}, {
1512
- partialFilterExpression: {
1513
- _model: dataModel.name,
1514
- _user: req.me.username
1515
- }
1516
- });
1517
- }
1518
- }
1519
- }
1520
- }
1521
-
1522
- // suppression des données à la suppression des champs
1523
- const unset = {};
1524
- el.fields.filter(f=> !dataModel.fields.some(dt => dt.name === f.name)).map(f => f.name).forEach(f => {
1525
- unset[f] = 1;
1526
- });
1527
- await collection.updateMany({ _model: el.name }, { $unset: unset });
1528
-
1529
- // sauvegarde du modele
1530
- const set = {...req.fields};
1531
- delete set['_id'];
1532
- await modelsCollection.updateOne({_id: new ObjectId(req.params.id)}, {$set: set});
1533
-
1534
- modelsCache.del(req.me.username+'@@'+el.name);
1535
-
1536
- const model = await modelsCollection.findOne({_id: new ObjectId(req.params.id) });
1537
- triggerWorkflows(model, req.me, 'ModelEdited').catch(workflowError => {
1538
- logger.error(`Erreur asynchrone lors du déclenchement des workflows pour ${model._model} ID ${model._id}:`, workflowError);
1539
- });
1540
-
1541
- res.json({ success: true, data: await modelsCollection.findOne({_id : new ObjectId(req.params.id)}) });
1542
- } catch (e) {
1543
- logger.error(e);
1544
- res.json({ success: false });
1567
+ const result = await editModel(req.me, req.params.id, req.fields);
1568
+ if( result.success){
1569
+ return res.status(result.statusCode || '200').json(result);
1570
+ }else{
1571
+ return res.status(result.statusCode || '500').json(result);
1545
1572
  }
1546
- })
1573
+ });
1547
1574
 
1548
1575
  engine.post('/api/data/restore', [throttle, middlewareAuthenticator, userInitiator,myFreePremiumAnonymousLimiter, setTimeoutMiddleware(60000)], async (req, res) => {
1549
1576
 
1550
- if (!((req.me?.roles || []).includes("admin"))) {
1577
+ if (!((user?.roles || []).includes("admin"))) {
1551
1578
  return res.status(403).json({success: false, error: 'Cannot backup data. Contact an administrator to get back your data'})
1552
1579
  }
1553
1580
 
@@ -0,0 +1,219 @@
1
+ // __tests__/data.integration.test.js
2
+ import { ObjectId } from 'mongodb';
3
+ import {expect, describe, it, beforeEach, afterEach, beforeAll, afterAll, vi} from 'vitest';
4
+ import { Config } from '../src/config.js';
5
+
6
+ import {
7
+ modelsCollection as getAppModelsCollection,
8
+ datasCollection // Accès direct pour vérifications
9
+ } from 'data-primals-engine/modules/mongodb';
10
+ import {generateUniqueName, getUniquePort, initEngine} from "../src/setenv.js";
11
+ import {editModel} from "../src/modules/data.js";
12
+ import {getCollectionForUser} from "../src/modules/mongodb.js";
13
+
14
+ let testModelsColInstance;
15
+ let testDatasColInstance;
16
+
17
+ let testModelId;
18
+
19
+ // Cette fonction va remplacer la logique de votre beforeEach pour la création de contexte
20
+ async function setupTestContext() {
21
+
22
+ const currentTestModelName = generateUniqueName('relatedModel');
23
+ const currentRelatedModelName = generateUniqueName('comprehensiveModel');
24
+
25
+ // Créer un utilisateur unique pour ce test
26
+ const currentTestUser = {
27
+ username: generateUniqueName('testuserDataIntegration'),
28
+ userPlan: 'free',
29
+ email: generateUniqueName('test') + '@example.com'
30
+ };
31
+
32
+
33
+ const relatedModelDefinition = {
34
+ name: currentRelatedModelName,
35
+ _user: currentTestUser.username,
36
+ description: 'Model for testing relations',
37
+ fields: [
38
+ {name: 'relatedName', type: 'string', required: true, unique: true},
39
+ {name: 'relatedValue', type: 'number'}
40
+ ],
41
+ maxRequestData: 10,
42
+ };
43
+
44
+ const comprehensiveTestModelDefinition = {
45
+ name: currentTestModelName,
46
+ _user: currentTestUser.username,
47
+ description: 'A model with all field types and constraints for testing',
48
+ fields: [
49
+ // String types
50
+ {name: 'stringRequired', type: 'string', required: true, hint: 'A required string'},
51
+ {name: 'stringUnique', type: 'string', unique: true, hint: 'A unique string'},
52
+ {name: 'stringMaxLength', type: 'string', maxlength: 5, hint: 'String with max length 5'},
53
+ {name: 'stringDefault', type: 'string', default: 'defaultString'},
54
+ {name: 'string_tLang', type: 'string_t', hint: 'Localizable string'},
55
+ {name: 'richtextField', type: 'richtext', maxlength: 200},
56
+ {name: 'passwordField', type: 'password'},
57
+ {name: 'emailField', type: 'email'},
58
+ {name: 'phoneField', type: 'phone'},
59
+ {name: 'urlField', type: 'url'},
60
+ // Number types
61
+ {name: 'number', type: 'number'},
62
+ {name: 'numberMinMax', type: 'number', min: 10, max: 20},
63
+ {name: 'numberStep', type: 'number', step: 0.5},
64
+ {name: 'numberDefault', type: 'number', default: 42},
65
+ // Boolean
66
+ {name: 'booleanField', type: 'boolean'},
67
+ {name: 'booleanDefault', type: 'boolean', default: true},
68
+ // Date & Datetime
69
+ {name: 'dateField', type: 'date'},
70
+ {name: 'datetimeField', type: 'datetime', default: 'now'},
71
+ {name: 'dateMinMax', type: 'date', min: '2023-01-01', max: '2023-12-31'},
72
+ // Enum
73
+ {name: 'enumField', type: 'enum', items: ['alpha', 'beta', 'gamma']},
74
+ {name: 'enumDefault', type: 'enum', items: ['one', 'two'], default: 'one'},
75
+ // Array
76
+ {name: 'arrayString', type: 'array', itemsType: 'string', maxItems: 2},
77
+ {name: 'arrayNumber', type: 'array', itemsType: 'number', minItems: 1},
78
+ {name: 'arrayEnum', type: 'array', itemsType: 'enum', items: ['a', 'b']},
79
+ // Relation
80
+ {name: 'relationSingle', type: 'relation', relation: currentRelatedModelName},
81
+ {name: 'relationMultiple', type: 'relation', relation: currentRelatedModelName, multiple: true},
82
+ // File (metadata validation)
83
+ {name: 'fileField', type: 'file', mimeTypes: ['image/png', 'application/pdf'], maxSize: 1024 * 10}, // 10KB
84
+ // Color
85
+ {name: 'colorField', type: 'color'},
86
+ // Code
87
+ {name: 'codeJsonField', type: 'code', language: 'json'},
88
+ {name: 'codeJsField', type: 'code', language: 'javascript', maxlength: 100},
89
+ // Object (simple validation, structure not deeply validated by default dataTypes.object)
90
+ {name: 'objectField', type: 'object'},
91
+ // Model & ModelField (validation of string format, not existence)
92
+ {name: 'modelNameField', type: 'model'},
93
+ {name: 'modelFieldNameField', type: 'modelField'}, // Note: modelField type expects an object {model: 'modelName', field: 'fieldName'}
94
+ ],
95
+ maxRequestData: 50,
96
+ };
97
+
98
+ // Insérer les modèles en base
99
+ const result= await testModelsColInstance.insertMany([
100
+ comprehensiveTestModelDefinition,
101
+ relatedModelDefinition
102
+ ]);
103
+
104
+ testModelId = result.insertedIds[0];
105
+ await testDatasColInstance.deleteMany({ _user: currentTestUser.username });
106
+ await testDatasColInstance.deleteMany({ _model: { $in: [comprehensiveTestModelDefinition.name, 'renamedTestModel'] } });
107
+ // Retourner toutes les variables nécessaires pour un test
108
+ return {
109
+ currentTestUser,
110
+ comprehensiveTestModelDefinition,
111
+ relatedModelDefinition,
112
+ };
113
+ }
114
+
115
+ describe('CRUD on model definitions and integrity tests', () => {
116
+
117
+ beforeAll(async () =>{
118
+ Config.Set("modules", ["mongodb", "data", "file", "bucket", "workflow","user", "assistant"]);
119
+ await initEngine();
120
+
121
+ // Initialize collection instances after the engine is ready
122
+ testModelsColInstance = getAppModelsCollection;
123
+ testDatasColInstance = datasCollection;
124
+ })
125
+ describe('editModel unit tests', () => {
126
+
127
+ it('should create and drop index when field.index is toggled (premium user)', async () => {
128
+ // --- SETUP ---
129
+ const { currentTestUser, comprehensiveTestModelDefinition } = await setupTestContext();
130
+ const premiumUser = { ...currentTestUser, userPlan: 'premium' };
131
+ const dataCollection = getCollectionForUser(premiumUser);
132
+ const fieldToIndex = 'stringUnique'; // Utiliser un champ qui existe vraiment dans le modèle
133
+
134
+ // --- VERIFICATION INITIALE ---
135
+ // S'assurer qu'aucun index n'existe au départ.
136
+ // On enveloppe l'appel dans un try/catch pour gérer le cas où la collection n'existe pas.
137
+ let initialIndexes = [];
138
+ try {
139
+ initialIndexes = await dataCollection.indexes();
140
+ } catch (e) {
141
+ // C'est normal si la collection n'existe pas encore. On considère qu'il n'y a pas d'index.
142
+ if (e.codeName !== 'NamespaceNotFound') {
143
+ throw e; // Relancer les autres erreurs inattendues
144
+ }
145
+ }
146
+ expect(initialIndexes.some(i => i.key[fieldToIndex] === 1)).toBe(false);
147
+
148
+ // --- ACTION 1 : AJOUTER UN INDEX ---
149
+ const modelWithIndex = {
150
+ ...comprehensiveTestModelDefinition,
151
+ fields: comprehensiveTestModelDefinition.fields.map(f =>
152
+ f.name === fieldToIndex ? { ...f, index: true } : f
153
+ ),
154
+ };
155
+ await editModel(premiumUser, testModelId, modelWithIndex);
156
+
157
+ // --- VERIFICATION 1 ---
158
+ // Maintenant, la collection et l'index doivent exister.
159
+ const indexesAfterCreation = await dataCollection.indexes();
160
+ const newIndex = indexesAfterCreation.find(i => i.key[fieldToIndex] === 1);
161
+
162
+ expect(newIndex).toBeDefined();
163
+ // Le filtre partiel est crucial pour que l'index ne s'applique qu'aux bonnes données
164
+ expect(newIndex.partialFilterExpression).toEqual({
165
+ _model: comprehensiveTestModelDefinition.name,
166
+ _user: premiumUser.username,
167
+ });
168
+
169
+ // --- ACTION 2 : SUPPRIMER L'INDEX ---
170
+ const modelWithoutIndex = {
171
+ ...comprehensiveTestModelDefinition,
172
+ fields: comprehensiveTestModelDefinition.fields.map(f =>
173
+ f.name === fieldToIndex ? { ...f, index: false } : f
174
+ ),
175
+ };
176
+ await editModel(premiumUser, testModelId, modelWithoutIndex);
177
+
178
+ // --- VERIFICATION 2 ---
179
+ const indexesAfterDeletion = await dataCollection.indexes();
180
+ expect(indexesAfterDeletion.some(i => i.key[fieldToIndex] === 1)).toBe(false);
181
+ }, 20000); // Augmenter le timeout si nécessaire pour les opérations d'index
182
+
183
+ it('should not save extra, non-defined fields in the model definition', async () => {
184
+ const { currentTestUser, comprehensiveTestModelDefinition, relatedModelDefinition } = await setupTestContext();
185
+ // 1. Préparer les données avec un champ non sollicité
186
+ const updatedModelData = {
187
+ ...comprehensiveTestModelDefinition,
188
+ description: 'An updated description',
189
+ extraBogusField: 'this should not be saved', // Champ arbitraire
190
+ anotherOne: { nested: true }
191
+ };
192
+ // 2. Appeler la fonction d'édition
193
+ const result = await editModel(currentTestUser, testModelId, updatedModelData);
194
+ expect(result.success).toBe(false);
195
+ });
196
+ it('should return an error if trying to edit a non-existent model', async () => {
197
+ const { currentTestUser, comprehensiveTestModelDefinition, relatedModelDefinition } = await setupTestContext();
198
+ const nonExistentId = new ObjectId();
199
+ const result = await editModel(currentTestUser, nonExistentId, comprehensiveTestModelDefinition);
200
+ expect(result.success).toBe(false);
201
+ expect(result.statusCode).toBe(404);
202
+ expect(result.error).toContain('introuvable');
203
+ });
204
+ it('should return an error if the new model structure is invalid', async () => {
205
+ const { currentTestUser, comprehensiveTestModelDefinition, relatedModelDefinition } = await setupTestContext();
206
+ const invalidModelData = {
207
+ ...comprehensiveTestModelDefinition,
208
+ fields: [
209
+ { name: 'title' } // Le champ 'type' est manquant, ce qui est invalide
210
+ ]
211
+ };
212
+ const result = await editModel(currentTestUser, testModelId, invalidModelData);
213
+ expect(result.success).toBe(false);
214
+ // L'erreur est levée par validateModelStructure, donc le message peut varier
215
+ expect(result.error).toBeDefined();
216
+ });
217
+ });
218
+
219
+ });