data-primals-engine 1.2.1 → 1.2.3

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/packs.js CHANGED
@@ -39,6 +39,14 @@ export const getAllPacks = async () => {
39
39
  return perms;
40
40
  }
41
41
 
42
+ const envSmtp = [
43
+ { "name": "SMTP_HOST", "value": "smtp.example.com", "description": "SMTP server host for sending emails." },
44
+ { "name": "SMTP_PORT", "value": "587", "description": "SMTP server port." },
45
+ { "name": "SMTP_USER", "value": "user@example.com", "description": "Username for SMTP authentication." },
46
+ { "name": "SMTP_PASS", "value": "your_smtp_password", "description": "Password for SMTP authentication." },
47
+ { "name": "SMTP_FROM", "value": "\"My Store\" <noreply@example.com>", "description": "The 'From' address for outgoing emails." }
48
+ ];
49
+
42
50
  const categories = [ 'News', 'Blog', 'Products', 'Services', 'Store', 'Events', 'Forums', 'Contact', 'Support'];
43
51
  const tags = [ 'info', 'incident', 'maintenance', 'feature', 'hint', 'bugfix', 'question'];
44
52
 
@@ -48,6 +56,242 @@ export const getAllPacks = async () => {
48
56
  {name: 'visitor', perms: ['API_SEARCH_DATA_webpage','API_SEARCH_DATA_content', 'API_SEARCH_DATA_lang', 'API_SEARCH_DATA_currency', 'API_SEARCH_DATA_taxonomy']}];
49
57
 
50
58
  return [
59
+ {
60
+ "name": "Marketing & Campaigning",
61
+ "description": "Launch powerful, personalized, and scalable email campaigns. This pack uses dynamic audiences and a robust workflow to send emails in chunks, ensuring high performance. Depends on the 'Customer Relationship Management (CRM)' pack.",
62
+ "tags": ["marketing", "email", "campaign", "workflow"],
63
+ "models": ["env", "contact", "workflow", "workflowStep", "workflowAction", "workflowRun", "workflowTrigger",
64
+ {
65
+ "name": "campaign",
66
+ "description": "Defines an email marketing campaign.",
67
+ "fields": [
68
+ { "name": "name", "type": "string", "required": true, "asMain": true },
69
+ { "name": "subject", "type": "string", "required": true },
70
+ { "name": "content", "type": "richtext", "required": true },
71
+ { "name": "status", "type": "enum", "items": ["draft", "scheduled", "in_progress", "finished", "cancelled"], "default": "draft" },
72
+ { "name": "audience", "type": "relation", "relation": "audience" },
73
+ { "name": "processedRecipients", "type": "array", "itemsType": "string", "hint": "List of processed contact IDs." }
74
+ ]
75
+ },
76
+ {
77
+ "name": "audience",
78
+ "description": "Defines a dynamic segment of contacts based on a filter.",
79
+ "fields": [
80
+ { "name": "name", "type": "string", "required": true, "asMain": true },
81
+ { "name": "description", "type": "string" },
82
+ { "name": "filter", "type": "code", "language": "json", "required": true, "conditionBuilder": true, "hint": "A MongoDB filter to select contacts. E.g., { \"tags\": \"newsletter\" }" }
83
+ ]
84
+ }
85
+ ],
86
+ "data":{
87
+ "all": {
88
+ "audience": [
89
+ {
90
+ "name": "Example Audience for Campaigning",
91
+ "description": "An example audience targeting specific contacts from the CRM pack.",
92
+ "filter": { "$or": [{ "$eq": ["$email", "alice.martin@innovatech.com"] }, { "$eq": ["$email", "bob.durand@globalexports.com"] }] }
93
+ }
94
+ ],
95
+ "campaign": [
96
+ {
97
+ "name": "Q3 Product Launch",
98
+ "subject": "🚀 Discover Our New Products!",
99
+ "content": "<h1>Hello {recipient.firstName},</h1><p>We are excited to introduce our latest product line. We think you'll love it!</p><p>Best regards,<br>The Team</p>",
100
+ "status": "draft",
101
+ "audience": { "$link": { "name": "Example Audience for Campaigning", "_model": "audience" } }
102
+ }
103
+ ],
104
+ "workflow": [{
105
+ "name": "Campaign Emailing Workflow",
106
+ "description": "A scalable workflow that sends campaign emails in chunks by dynamically querying contacts from an audience.",
107
+ "startStep": { "$link": { "name": "Start Campaign Processing", "_model": "workflowStep" } }
108
+ }],
109
+ "workflowAction": [
110
+ {
111
+ "name": "Set Campaign to 'in_progress'",
112
+ "type": "UpdateData",
113
+ "targetModel": "campaign",
114
+ "targetSelector": { "_id": "{triggerData._id}" },
115
+ "fieldsToUpdate": { "status": "in_progress" }
116
+ },
117
+ {
118
+ "name": "Get Next Recipient Chunk",
119
+ "type": "ExecuteScript",
120
+ "script": `
121
+ const chunkSize = 10; // Process 10 recipients per run
122
+ const campaign = context.triggerData;
123
+ const audience = await db.findOne("audience",{"_id": campaign.audience});
124
+
125
+ if (!audience || !audience.filter) {
126
+ logger.error('Campaign audience or audience filter is not defined.');
127
+ return { chunk: [], message: 'Campaign audience or audience filter is not defined.'}; // Returning an empty chunk will stop the workflow.
128
+ }
129
+
130
+ const processedIds = campaign.processedRecipients || [];
131
+
132
+ const query = {
133
+ '$and': [
134
+ audience.filter,
135
+ { '$not':{ '$in': ["$_id", processedIds] } }
136
+ ]
137
+ };
138
+
139
+ logger.info('Finding next chunk with filter:', JSON.stringify(query));
140
+
141
+ const searchResult = await db.find('contact', query, { limit: chunkSize });
142
+ const chunk = searchResult.data || [];
143
+
144
+ logger.info(\`Found \${chunk.length} recipients for the next chunk.\`);
145
+
146
+ return { chunk }; // This chunk is passed to the next action via context.result
147
+ `
148
+ },
149
+ {
150
+ "name": "Send Email to Chunk",
151
+ "type": "SendEmail",
152
+ "emailRecipients": ["{context.result.chunk}"],
153
+ "emailSubject": "{triggerData.subject}",
154
+ "emailContent": "{triggerData.content}"
155
+ },
156
+ {
157
+ "name": "Update Processed Recipients",
158
+ "type": "ExecuteScript",
159
+ "script": `
160
+ const campaignId = context.triggerData._id;
161
+ const emailResult = context.emailResult;
162
+
163
+ if (!emailResult || !Array.isArray(emailResult.sent) || emailResult.sent.length === 0) {
164
+ logger.info('No recipients were successfully sent an email in this chunk.');
165
+ // Return the original chunk from the first script to allow the condition to check it
166
+ return { processedChunk: context.result.chunk };
167
+ }
168
+
169
+ const processedIds = emailResult.sent.map(recipient => recipient._id.toString());
170
+
171
+ logger.info(\`Updating campaign \${campaignId} with \${processedIds.length} new processed IDs.\`);
172
+
173
+ await db.update(
174
+ 'campaign',
175
+ { _id: campaignId },
176
+ { 'processedRecipients': [...campaign.processedRecipients, ...processedIds] }
177
+ );
178
+
179
+ // Return the original chunk for the condition check step
180
+ return { processedChunk: context.result.chunk };
181
+ `
182
+ },
183
+ {
184
+ "name": "Set Campaign to 'finished'",
185
+ "type": "UpdateData",
186
+ "targetModel": "campaign",
187
+ "targetSelector": { "_id": "{triggerData._id}" },
188
+ "fieldsToUpdate": { "status": "finished" }
189
+ }
190
+ ],
191
+ "workflowStep": [
192
+ {
193
+ "name": "Start Campaign Processing",
194
+ "workflow": { "$link": { "name": "Campaign Emailing Workflow", "_model": "workflow" } },
195
+ "actions": { "$link": { "name": "Set Campaign to 'in_progress'", "_model": "workflowAction" } },
196
+ "onSuccessStep": { "$link": { "name": "Process Recipient Chunk", "_model": "workflowStep" } }
197
+ },
198
+ {
199
+ "name": "Process Recipient Chunk",
200
+ "workflow": { "$link": { "name": "Campaign Emailing Workflow", "_model": "workflow" } },
201
+ "actions":
202
+ { "$link": { "$or": [
203
+ {"$eq": ["$name", "Get Next Recipient Chunk"]},
204
+ {"$eq": ["$name", "Send Email to Chunk"]},
205
+ {"$eq": ["$name", "Update Processed Recipients"]}
206
+ ],"_model": "workflowAction"}},
207
+ "onSuccessStep": { "$link": { "name": "Check if Campaign is Complete", "_model": "workflowStep" } }
208
+ },
209
+ {
210
+ "name": "Check if Campaign is Complete",
211
+ "workflow": { "$link": { "name": "Campaign Emailing Workflow", "_model": "workflow" } },
212
+ "conditions": { "$gt": [{ "$size": {$ifNull:["{context.result.processedChunk}", []]} }, 0] },
213
+ "onSuccessStep": { "$link": { "name": "Process Recipient Chunk", "_model": "workflowStep" } },
214
+ "onFailureStep": { "$link": { "name": "Finish Campaign", "_model": "workflowStep" } }
215
+ },
216
+ {
217
+ "name": "Finish Campaign",
218
+ "workflow": { "$link": { "name": "Campaign Emailing Workflow", "_model": "workflow" } },
219
+ "actions": { "$link": { "name": "Set Campaign to 'finished'", "_model": "workflowAction" } },
220
+ "isTerminal": true
221
+ }
222
+ ],
223
+ "workflowTrigger": [{
224
+ "name": "On Campaign Scheduled",
225
+ "workflow": { "$link": { "name": "Campaign Emailing Workflow", "_model": "workflow" } },
226
+ "type": "manual",
227
+ "onEvent": "DataEdited",
228
+ "targetModel": "campaign",
229
+ "dataFilter": { "$eq": ["$status", "scheduled"] },
230
+ "isActive": true
231
+ }],
232
+ "env": envSmtp
233
+ }
234
+ }
235
+ },
236
+ /*
237
+ {
238
+ "name": "Social Media Publisher",
239
+ "description": "Automate your social media presence. This pack provides a workflow to automatically post new blog articles to Twitter and LinkedIn. Requires the 'Website Starter Pack'.",
240
+ "tags": ["social media", "marketing", "automation", "workflow"],
241
+ "dependencies": ["Website Starter Pack"],
242
+ "models": ["workflow", "workflowStep", "workflowAction", "workflowTrigger", "env"],
243
+ "data": {
244
+ "all": {
245
+ "workflow": [{
246
+ "name": "Publish New Blog Post to Socials",
247
+ "description": "Automatically posts a link to a new blog post on Twitter and LinkedIn.",
248
+ "startStep": {"$link": {"name": "Post on Social Networks", "_model": "workflowStep"}}
249
+ }],
250
+ "workflowAction": [
251
+ {
252
+ "name": "Post to Twitter",
253
+ "type": "PostToSocialMedia",
254
+ "provider": "Twitter",
255
+ "content": "📰 New blog post published: {triggerData.title}! Check it out here: https://your-website.com/blog/{triggerData.slug}"
256
+ },
257
+ {
258
+ "name": "Post to LinkedIn",
259
+ "type": "PostToSocialMedia",
260
+ "provider": "LinkedIn",
261
+ "content": "We've just published a new article: '{triggerData.title}'.\n\n{triggerData.summary}\n\nRead the full post on our blog: https://your-website.com/blog/{triggerData.slug}\n#YourIndustry #BlogPost"
262
+ }
263
+ ],
264
+ "workflowStep": [{
265
+ "name": "Post on Social Networks",
266
+ "workflow": {"$link": {"name": "Publish New Blog Post to Socials", "_model": "workflow"}},
267
+ "actions": {
268
+ "$link": {
269
+ "$or": [
270
+ {"$eq": ["$name", "Post to Twitter"]},
271
+ {"$eq": ["$name", "Post to LinkedIn"]}
272
+ ],
273
+ "_model": "workflowAction"
274
+ }
275
+ },
276
+ "isTerminal": true
277
+ }],
278
+ "workflowTrigger": [{
279
+ "name": "On New Blog Post Added",
280
+ "workflow": {"$link": {"name": "Publish New Blog Post to Socials", "_model": "workflow"}},
281
+ "type": "manual",
282
+ "onEvent": "DataAdded",
283
+ "targetModel": "content",
284
+ "dataFilter": {"category": {"$find": {"name": "Blog"}}},
285
+ "isActive": true
286
+ }],
287
+ "env": [
288
+ {"name": "TWITTER_API_KEY", "value": "your_key_here"},
289
+ {"name": "TWITTER_API_SECRET", "value": "your_secret_here"},
290
+ {"name": "LINKEDIN_ACCESS_TOKEN", "value": "your_token_here"}
291
+ ]
292
+ }
293
+ }
294
+ },*/
51
295
  {
52
296
  "name": "E-commerce Starter Kit",
53
297
  "description": "Launch your online store in just a few clicks. This pack includes templates for products, orders, and customers, as well as sample data, KPIs, alerts and an order fulfillment workflow (with sending email).",
@@ -55,13 +299,7 @@ export const getAllPacks = async () => {
55
299
  "models": ["env", "taxonomy", "product", "productVariant", "brand", "currency", "order", "shipment", "review", "cart", "cartItem", "discount", "workflow", "workflowStep", "workflowAction","workflowRun", "workflowTrigger", "translation", "lang", "kpi", "dashboard", "alert", "return"],
56
300
  "data": {
57
301
  "all": {
58
- "env": [
59
- { "name": "SMTP_HOST", "value": "smtp.example.com", "description": "SMTP server host for sending emails." },
60
- { "name": "SMTP_PORT", "value": "587", "description": "SMTP server port." },
61
- { "name": "SMTP_USER", "value": "user@example.com", "description": "Username for SMTP authentication." },
62
- { "name": "SMTP_PASS", "value": "your_smtp_password", "description": "Password for SMTP authentication." },
63
- { "name": "SMTP_FROM", "value": "\"My Store\" <noreply@example.com>", "description": "The 'From' address for outgoing emails." }
64
- ],
302
+ "env":envSmtp,
65
303
  "taxonomy": [
66
304
  { "name": "E-commerce", "type": "category" },
67
305
  { "name": "Clothes", "type": "category", "parent": { "$find": { "name": "E-commerce" } } },
@@ -14,13 +14,14 @@ import {
14
14
 
15
15
  import {
16
16
  modelsCollection as getAppModelsCollection,
17
- getCollectionForUser as getAppUserCollection
17
+ getCollectionForUser as getAppUserCollection, getCollectionForUser
18
18
  } from 'data-primals-engine/modules/mongodb';
19
19
  import process from "node:process";
20
20
 
21
21
  import { dumpUserData, loadFromDump } from 'data-primals-engine/modules/data';
22
22
  import fs from "node:fs";
23
23
  import {initEngine} from "../src/setenv.js";
24
+ import {getUserCollectionName} from "../src/modules/mongodb.js";
24
25
 
25
26
  vi.mock('data-primals-engine/engine', async(importOriginal) => {
26
27
  const mod = await importOriginal() // type is inferred
@@ -70,7 +71,7 @@ beforeAll(async () => {
70
71
  fs.readdirSync(backupDir).forEach(file => {
71
72
  fs.unlinkSync(path.join(backupDir, file));
72
73
  });
73
- vi.stubEnv('S3_CONFIG_ENCRYPTION_KEY', '00000000000000000000000000000000');
74
+ vi.stubEnv('ENCRYPTION_KEY', '00000000000000000000000000000000');
74
75
  vi.stubEnv('OPENAI_API_KEY', '00000000000000000000000000000000');
75
76
  // You might need to create a model first if your dumpUserData requires it
76
77
  await createModel(testModelDefinition);
@@ -88,6 +89,8 @@ afterAll(async () => {
88
89
  });
89
90
  fs.rmdirSync(backupDir); // Remove the directory itself
90
91
  }
92
+ const coll = await getCollectionForUser(mockUser);
93
+ await coll.drop()
91
94
  });
92
95
 
93
96
  beforeAll(async () =>{
@@ -1,22 +1,25 @@
1
1
  // __tests__/data.integration.test.js
2
2
  import { ObjectId } from 'mongodb';
3
- import {expect, describe, it, beforeEach, beforeAll, afterAll} from 'vitest';
3
+ import {vi, expect, describe, it, beforeEach, beforeAll, afterAll} from 'vitest';
4
4
  import { Config } from '../src/config.js';
5
-
6
5
  import {
7
6
  insertData,
8
7
  editData,
9
8
  deleteData,
10
- searchData, installPack, deleteModels, createModel
9
+ searchData, installPack, deleteModels, createModel, patchData
11
10
  } from 'data-primals-engine/modules/data';
12
11
 
13
12
  import {
14
13
  modelsCollection as getAppModelsCollection,
15
14
  getCollection,
16
- getCollectionForUser as getAppUserCollection
15
+ getCollectionForUser as getAppUserCollection, getCollectionForUser
17
16
  } from 'data-primals-engine/modules/mongodb';
18
17
  import {getRandom} from "../src/core.js";
19
18
  import {generateUniqueName, initEngine} from "../src/setenv.js";
19
+ import {processWorkflowRun} from "data-primals-engine/modules/workflow";
20
+ import {sendEmail} from "../src/email.js";
21
+ import {MongoDatabase} from "../src/engine.js";
22
+ import {getUserCollectionName} from "../src/modules/mongodb.js";
20
23
 
21
24
  let testModelsColInstance;
22
25
  let testDatasColInstance;
@@ -828,6 +831,8 @@ describe('Intégration des fonctions CRUD de données avec validation complète'
828
831
  await deleteModels({ name: orderModel.name, _user: user.username });
829
832
  await deleteData(productModel.name, {}, user);
830
833
  await deleteData(orderModel.name, {}, user);
834
+ const coll = await getCollectionForUser(user);
835
+ await coll.drop();
831
836
  });
832
837
 
833
838
  it('should ALLOW inserting data with a valid relation', async () => {
@@ -871,4 +876,5 @@ describe('Intégration des fonctions CRUD de données avec validation complète'
871
876
  await deleteData(orderModel.name, initialOrder.insertedIds, user);
872
877
  });
873
878
  });
879
+
874
880
  });
@@ -0,0 +1,202 @@
1
+ // events.test.js
2
+ import { Event } from '../src/events.js';
3
+ import {vitest} from "vitest";
4
+ import {expect, describe, it, beforeEach, beforeAll, afterAll} from 'vitest';
5
+
6
+ describe('Event System', () => {
7
+ beforeEach(() => {
8
+ // Clear all events before each test to ensure a clean state
9
+ for (const system in Event) {
10
+ if (Event.hasOwnProperty(system) && typeof Event[system] === 'object') {
11
+ for (const eventName in Event[system]) {
12
+ if (Event[system].hasOwnProperty(eventName)) {
13
+ delete Event[system][eventName];
14
+ }
15
+ }
16
+ }
17
+ }
18
+ });
19
+
20
+ it('should allow listening for and triggering an event', () => {
21
+ const mockCallback = vitest.fn();
22
+ const eventName = 'testEvent';
23
+
24
+ Event.Listen(eventName, mockCallback);
25
+ Event.Trigger(eventName);
26
+
27
+ expect(mockCallback).toHaveBeenCalledTimes(1);
28
+ });
29
+ it('should pass arguments to the event callback', () => {
30
+ const mockCallback = vitest.fn();
31
+ const eventName = 'testEventWithArgs';
32
+ const arg1 = 'hello';
33
+ const arg2 = 123;
34
+
35
+ Event.Listen(eventName, mockCallback, "priority", "medium");
36
+ Event.Trigger(eventName, "priority", "medium", arg1, arg2);
37
+
38
+ expect(mockCallback).toHaveBeenCalledTimes(1);
39
+ expect(mockCallback).toHaveBeenCalledWith(arg1, arg2);
40
+ });
41
+
42
+ it('should allow removing a specific callback', () => {
43
+ const mockCallback1 = vitest.fn();
44
+ const mockCallback2 = vitest.fn();
45
+ const eventName = 'testEventRemoveCallback';
46
+
47
+ Event.Listen(eventName, mockCallback1);
48
+ Event.Listen(eventName, mockCallback2);
49
+ Event.RemoveCallback(eventName, mockCallback1);
50
+ Event.Trigger(eventName);
51
+
52
+ expect(mockCallback1).not.toHaveBeenCalled();
53
+ expect(mockCallback2).toHaveBeenCalledTimes(1);
54
+ });
55
+
56
+ it('should not trigger a callback if the event is not listened to', () => {
57
+ const mockCallback = vitest.fn();
58
+ const eventName = 'nonExistentEvent';
59
+
60
+ Event.Trigger(eventName);
61
+
62
+ expect(mockCallback).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it('should handle different systems and layers', () => {
66
+ const mockCallbackPriority = vitest.fn();
67
+ const mockCallbackLog = vitest.fn();
68
+ const eventName = 'testEventMultiSystem';
69
+
70
+ Event.Listen(eventName, mockCallbackPriority, 'priority', 'high');
71
+ Event.Listen(eventName, mockCallbackLog, 'log', 'info');
72
+
73
+ Event.Trigger(eventName, 'priority', 'high');
74
+ expect(mockCallbackPriority).toHaveBeenCalledTimes(1);
75
+ expect(mockCallbackLog).not.toHaveBeenCalled();
76
+
77
+ vitest.clearAllMocks(); // Reset mock counts
78
+
79
+ Event.Trigger(eventName, 'log', 'info');
80
+ expect(mockCallbackPriority).not.toHaveBeenCalled();
81
+ expect(mockCallbackLog).toHaveBeenCalledTimes(1);
82
+ });
83
+
84
+ it('should throw an error if an invalid system or layer is used with Listen', () => {
85
+ expect(() => {
86
+ Event.Listen('invalidEvent', () => {}, 'invalidSystem', 'invalidLayer');
87
+ }).toThrowError(/System 'invalidSystem' does not exist./);
88
+
89
+ expect(() => {
90
+ Event.Listen('invalidEvent', () => {}, 'priority', 'invalidLayer');
91
+ }).toThrowError(/Layer 'invalidLayer' does not exist/);
92
+ });
93
+
94
+ describe('Event.Trigger result merging', () => {
95
+ it('should merge objects from multiple callbacks using spread operator', () => {
96
+ const eventName = 'mergeObjectEvent';
97
+ const callback1 = vitest.fn().mockReturnValue({ a: 1, b: 2 });
98
+ const callback2 = vitest.fn().mockReturnValue({ b: 3, c: 4 }); // b will be overwritten
99
+
100
+ Event.Listen(eventName, callback1);
101
+ Event.Listen(eventName, callback2);
102
+
103
+ const result = Event.Trigger(eventName);
104
+
105
+ expect(result).toEqual({ a: 1, b: 3, c: 4 });
106
+ });
107
+
108
+ it('should concatenate arrays from multiple callbacks', () => {
109
+ const eventName = 'mergeArrayEvent';
110
+ const callback1 = vitest.fn().mockReturnValue([1, 2]);
111
+ const callback2 = vitest.fn().mockReturnValue([3, 4]);
112
+
113
+ Event.Listen(eventName, callback1);
114
+ Event.Listen(eventName, callback2);
115
+
116
+ const result = Event.Trigger(eventName);
117
+
118
+ expect(result).toEqual([1, 2, 3, 4]);
119
+ });
120
+
121
+ it('should concatenate strings from multiple callbacks', () => {
122
+ const eventName = 'mergeStringEvent';
123
+ const callback1 = vitest.fn().mockReturnValue('Hello ');
124
+ const callback2 = vitest.fn().mockReturnValue('World');
125
+
126
+ Event.Listen(eventName, callback1);
127
+ Event.Listen(eventName, callback2);
128
+
129
+ const result = Event.Trigger(eventName);
130
+
131
+ expect(result).toBe('Hello World');
132
+ });
133
+
134
+ it('should add numbers from multiple callbacks', () => {
135
+ const eventName = 'mergeNumberEvent';
136
+ const callback1 = vitest.fn().mockReturnValue(10);
137
+ const callback2 = vitest.fn().mockReturnValue(20);
138
+
139
+ Event.Listen(eventName, callback1);
140
+ Event.Listen(eventName, callback2);
141
+
142
+ const result = Event.Trigger(eventName);
143
+
144
+ expect(result).toBe(30);
145
+ });
146
+
147
+ it('should perform a logical AND on booleans from multiple callbacks', () => {
148
+ const eventName = 'mergeBooleanEvent';
149
+
150
+ // Case 1: true && true -> true
151
+ const cb_true1 = vitest.fn().mockReturnValue(true);
152
+ const cb_true2 = vitest.fn().mockReturnValue(true);
153
+ Event.Listen(eventName, cb_true1);
154
+ Event.Listen(eventName, cb_true2);
155
+ expect(Event.Trigger(eventName)).toBe(true);
156
+ Event.RemoveAllCallbacks(eventName); // Clean up for next case
157
+
158
+ // Case 2: true && false -> false
159
+ const cb_false1 = vitest.fn().mockReturnValue(false);
160
+ Event.Listen(eventName, cb_true1);
161
+ Event.Listen(eventName, cb_false1);
162
+ expect(Event.Trigger(eventName)).toBe(false);
163
+ Event.RemoveAllCallbacks(eventName);
164
+
165
+ // Case 3: false && true -> false
166
+ Event.Listen(eventName, cb_false1);
167
+ Event.Listen(eventName, cb_true1);
168
+ expect(Event.Trigger(eventName)).toBe(false);
169
+ });
170
+
171
+ it('should handle mixed-type return values', () => {
172
+ const eventName = 'mergeMixedEvent';
173
+ const callback1 = vitest.fn().mockReturnValue(100); // number
174
+ const callback2 = vitest.fn().mockReturnValue({ a: 1 }); // object
175
+
176
+ Event.Listen(eventName, callback1);
177
+ Event.Listen(eventName, callback2);
178
+
179
+ const result = Event.Trigger(eventName);
180
+
181
+ // The logic initializes `ret` with the first value (100).
182
+ // When the second callback returns an object, it re-initializes `ret` to {}
183
+ // and merges the object. The final result is the object.
184
+ expect(result).toEqual({ a: 1 });
185
+ });
186
+
187
+ it('should ignore null and undefined return values while merging', () => {
188
+ const eventName = 'ignoreNullsEvent';
189
+ const callback1 = vitest.fn().mockReturnValue(null);
190
+ const callback2 = vitest.fn().mockReturnValue({ data: 'important' });
191
+ const callback3 = vitest.fn().mockReturnValue(undefined);
192
+
193
+ Event.Listen(eventName, callback1);
194
+ Event.Listen(eventName, callback2);
195
+ Event.Listen(eventName, callback3);
196
+
197
+ const result = Event.Trigger(eventName);
198
+
199
+ expect(result).toEqual({ data: 'important' });
200
+ });
201
+ });
202
+ });