bulltrackers-module 1.0.1100 → 1.0.1102
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/functions/computation-system-v3/framework/adapters/StorageManager.js +12 -2
- package/functions/computation-system-v3/shared/AlertSubscriptionMatcher.js +30 -2
- package/functions/computation-system-v4/examples/event-driven-triggers/event-driven-triggered-computation.js +39 -0
- package/functions/computation-system-v4/plugins/index.ts +1 -1
- package/functions/computation-system-v4/plugins/manifest/ScheduleValidator.ts +2 -1
- package/functions/computation-system-v4/plugins/planner/DefaultPlannerPlugin.ts +7 -0
- package/functions/computation-system-v4/plugins/pubsub/DefaultPubSubPlugin.ts +114 -12
- package/functions/version.js +1 -1
- package/package.json +1 -1
- package/functions/computation-system-v3/shared/AlertSubscriptionMatcher.test.js +0 -165
|
@@ -300,15 +300,25 @@ class StorageManager {
|
|
|
300
300
|
* these up and handle delivery.
|
|
301
301
|
*/
|
|
302
302
|
async _writeToOutboxes(results) {
|
|
303
|
+
this.logger.log('INFO', `[StorageMgr] Checking for outbox payloads...`);
|
|
303
304
|
const outbox = this._extractOutboxPayloads(results);
|
|
304
|
-
|
|
305
|
+
|
|
306
|
+
if (!outbox) {
|
|
307
|
+
this.logger.log('INFO', `[StorageMgr] No outbox payloads found.`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
this.logger.log('INFO', `[StorageMgr] Extracted outbox payloads: email=${outbox.emailPayloads?.length || 0}, sms=${outbox.smsPayloads?.length || 0}, fcm=${outbox.fcmPayloads?.length || 0}`);
|
|
305
312
|
|
|
306
313
|
const allPayloads = [
|
|
307
314
|
...(outbox.emailPayloads || []).map(p => ({ collection: 'outbox_emails', data: p })),
|
|
308
315
|
...(outbox.smsPayloads || []).map(p => ({ collection: 'outbox_sms', data: p })),
|
|
309
316
|
...(outbox.fcmPayloads || []).map(p => ({ collection: 'outbox_fcm', data: p }))
|
|
310
317
|
];
|
|
311
|
-
if (allPayloads.length === 0)
|
|
318
|
+
if (allPayloads.length === 0) {
|
|
319
|
+
this.logger.log('INFO', `[StorageMgr] All payload groups are empty, nothing to write.`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
312
322
|
|
|
313
323
|
const BATCH_LIMIT = 400;
|
|
314
324
|
const batches = [];
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
* @param {Object} [params.piMasterList] - { piCid: { username } }
|
|
25
25
|
* @returns {{ notifications, outbox, totalMatched, totalEvaluated }}
|
|
26
26
|
*/
|
|
27
|
+
const GLOBAL_SINK_USER_ID = 'GLOBAL_SINK_USER_FOR_TESTING';
|
|
28
|
+
|
|
27
29
|
function matchSubscriptions({
|
|
28
30
|
parentResults = [],
|
|
29
31
|
subscriptions = [],
|
|
@@ -74,6 +76,17 @@ function matchSubscriptions({
|
|
|
74
76
|
phoneNumber: s.phone_number || null
|
|
75
77
|
};
|
|
76
78
|
}
|
|
79
|
+
// Add global sink user's preferences (always on for all channels if settings are provided)
|
|
80
|
+
if (GLOBAL_SINK_USER_ID && !userPrefs[GLOBAL_SINK_USER_ID]) {
|
|
81
|
+
const sinkUserSettings = userSettings.find(s => String(s.user_id) === GLOBAL_SINK_USER_ID);
|
|
82
|
+
userPrefs[GLOBAL_SINK_USER_ID] = {
|
|
83
|
+
email: sinkUserSettings?.channel_email === 'true' || sinkUserSettings?.channel_email === true,
|
|
84
|
+
sms: sinkUserSettings?.channel_sms === 'true' || sinkUserSettings?.channel_sms === true,
|
|
85
|
+
push: sinkUserSettings?.channel_push === 'true' || sinkUserSettings?.channel_push === true,
|
|
86
|
+
emailAddress: sinkUserSettings?.email_address || 'global-sink@example.com', // Default fallback
|
|
87
|
+
phoneNumber: sinkUserSettings?.phone_number || null
|
|
88
|
+
};
|
|
89
|
+
}
|
|
77
90
|
|
|
78
91
|
// FCM tokens index: userId → [token, ...]
|
|
79
92
|
const userFcmTokens = {};
|
|
@@ -95,6 +108,22 @@ function matchSubscriptions({
|
|
|
95
108
|
const piCid = String(result.entityId || result.piCid || result.cid || result.user_id);
|
|
96
109
|
const watchers = piToWatchers[piCid] || [];
|
|
97
110
|
|
|
111
|
+
// --- Start Global Sink Logic ---
|
|
112
|
+
if (GLOBAL_SINK_USER_ID) {
|
|
113
|
+
const sinkWatcher = {
|
|
114
|
+
userId: GLOBAL_SINK_USER_ID,
|
|
115
|
+
watchlistId: 'GLOBAL_SINK',
|
|
116
|
+
watchlistName: 'Global Sink'
|
|
117
|
+
};
|
|
118
|
+
// Ensure the global sink user is processed like a normal watcher
|
|
119
|
+
watchers.push(sinkWatcher);
|
|
120
|
+
// Ensure the sink user is considered "subscribed"
|
|
121
|
+
if (!userSubSettings[`${GLOBAL_SINK_USER_ID}::${piCid}`]) {
|
|
122
|
+
userSubSettings[`${GLOBAL_SINK_USER_ID}::${piCid}`] = { [configKey]: true };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// --- End Global Sink Logic ---
|
|
126
|
+
|
|
98
127
|
for (const watcher of watchers) {
|
|
99
128
|
totalEvaluated++;
|
|
100
129
|
|
|
@@ -102,7 +131,6 @@ function matchSubscriptions({
|
|
|
102
131
|
const settings = userSubSettings[subKey];
|
|
103
132
|
if (!settings) continue;
|
|
104
133
|
|
|
105
|
-
// Boolean check: is this alert type toggled on?
|
|
106
134
|
const isEnabled = settings[configKey] === true
|
|
107
135
|
|| (settings.alertConfig && settings.alertConfig[configKey] === true);
|
|
108
136
|
if (!isEnabled) continue;
|
|
@@ -126,7 +154,7 @@ function matchSubscriptions({
|
|
|
126
154
|
metadata: extractMetadata(result)
|
|
127
155
|
});
|
|
128
156
|
|
|
129
|
-
// Build outbox payloads based on user delivery preferences
|
|
157
|
+
// Build outbox payloads based on user delivery preferences
|
|
130
158
|
const prefs = userPrefs[watcher.userId] || { email: false, sms: false, push: true };
|
|
131
159
|
|
|
132
160
|
if (prefs.email && prefs.emailAddress) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Example: Inbound PubSub Triggered Computation
|
|
3
|
+
* * This computation waits entirely on an external service. It is NOT scheduled
|
|
4
|
+
* by the daily planner. When the external service publishes a message to
|
|
5
|
+
* 'external-ml-completed', this DAG kicks off, processes data natively in WASM,
|
|
6
|
+
* and then optionally responds back to the external tool.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
config: {
|
|
11
|
+
name: 'PostProcess_ML_Results',
|
|
12
|
+
type: 'standard', // Runs normally in the WASM sandbox
|
|
13
|
+
|
|
14
|
+
schedule: {
|
|
15
|
+
frequency: 'event-driven' // Excludes it from automated daily sweeps
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
pubsubTrigger: {
|
|
19
|
+
topic: 'external-ml-completed', // The topic the external tool publishes TO
|
|
20
|
+
payloadMatch: { event: 'training_done' }, // (Optional) only trigger if payload matches this
|
|
21
|
+
respondTo: 'ml-orchestrator-ack' // (Optional) Topic to reply to when this computation finishes
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
requires: {
|
|
25
|
+
mlData: { type: 'bigquery', table: 'computation_results_v3' }
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
process: function (ctx) {
|
|
30
|
+
ctx.log.info("Processing ML results natively after being triggered by external event!");
|
|
31
|
+
|
|
32
|
+
// ... standard WASM processing logic ...
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
status: "processed",
|
|
36
|
+
result: { success: true }
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
};
|
|
@@ -19,4 +19,4 @@ export { WasmRuntimePlugin } from './runtime/WasmRuntimePlugin';
|
|
|
19
19
|
export { DefaultDispatchPlugin } from './dispatch/DefaultDispatchPlugin';
|
|
20
20
|
export { PythonDispatchPlugin } from './dispatch/PythonDispatchPlugin';
|
|
21
21
|
export { DefaultPlannerPlugin } from './planner/DefaultPlannerPlugin';
|
|
22
|
-
export {
|
|
22
|
+
export { GcpExecutionLogSinkPlugin } from './logging/GcpExecutionLogSinkPlugin';
|
|
@@ -138,7 +138,8 @@ export class ScheduleValidator {
|
|
|
138
138
|
const schedule = entry.schedule as ScheduleLike | undefined;
|
|
139
139
|
if (!schedule || typeof schedule === 'string') return errors;
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
// Add 'event-driven' to the allowed list
|
|
142
|
+
const validFreqs = ['hourly', 'daily', 'weekly', 'monthly', 'event-driven'];
|
|
142
143
|
if (schedule.frequency && !validFreqs.includes(schedule.frequency)) {
|
|
143
144
|
errors.push({ computation: entry.name, message: `Invalid frequency: ${schedule.frequency}` });
|
|
144
145
|
}
|
|
@@ -297,6 +297,13 @@ export class DefaultPlannerPlugin implements IPlannerPlugin {
|
|
|
297
297
|
allStatuses: Map<string, RunStatus>,
|
|
298
298
|
manifestNames: Set<string>
|
|
299
299
|
): 'schedule' | 'skip' | 'blocked-dependency' | 'blocked-attempts' {
|
|
300
|
+
|
|
301
|
+
// NEW: Ignore event-driven computations during automated planner sweeps
|
|
302
|
+
const scheduleFreq = (entry.recipe?.config?.schedule as any)?.frequency;
|
|
303
|
+
if (scheduleFreq === 'event-driven') {
|
|
304
|
+
return 'skip';
|
|
305
|
+
}
|
|
306
|
+
|
|
300
307
|
// Already completed with correct hash
|
|
301
308
|
if (current && current.status === 'completed' && current.hash === entry.hash) {
|
|
302
309
|
return 'skip';
|
|
@@ -5,6 +5,7 @@ import type { IHookRegistry } from '../../types/IHook';
|
|
|
5
5
|
import type { HookContext } from '../../types/IHookContext';
|
|
6
6
|
import type { HealthStatus } from '../../types/shared';
|
|
7
7
|
import type { IBillingPlugin } from '../../types/IBillingPlugin';
|
|
8
|
+
import type { ISchedulerPlugin } from '../../types/ISchedulerPlugin';
|
|
8
9
|
|
|
9
10
|
export class DefaultPubSubPlugin implements IPlugin {
|
|
10
11
|
readonly name = 'default-pubsub';
|
|
@@ -20,6 +21,9 @@ export class DefaultPubSubPlugin implements IPlugin {
|
|
|
20
21
|
private config: any;
|
|
21
22
|
|
|
22
23
|
registerHooks(hooks: IHookRegistry): void {
|
|
24
|
+
// -------------------------------------------------------------------------
|
|
25
|
+
// OUTBOUND: Bridge to External Services (from 'batch.execute')
|
|
26
|
+
// -------------------------------------------------------------------------
|
|
23
27
|
hooks.get('batch.execute').tap({
|
|
24
28
|
name: this.name,
|
|
25
29
|
priority: 50, // Run before WasmRuntimePlugin
|
|
@@ -102,6 +106,37 @@ export class DefaultPubSubPlugin implements IPlugin {
|
|
|
102
106
|
return ctx;
|
|
103
107
|
}
|
|
104
108
|
});
|
|
109
|
+
|
|
110
|
+
// -------------------------------------------------------------------------
|
|
111
|
+
// OUTBOUND ACK: Respond to External Service upon native completion
|
|
112
|
+
// -------------------------------------------------------------------------
|
|
113
|
+
hooks.get('computation.complete').tap({
|
|
114
|
+
name: `${this.name}-responder`,
|
|
115
|
+
priority: 50,
|
|
116
|
+
handler: async (ctx: HookContext) => {
|
|
117
|
+
const entry = ctx.entry as any;
|
|
118
|
+
const triggerCfg = entry.recipe?.config?.pubsubTrigger;
|
|
119
|
+
|
|
120
|
+
// If this computation requested we respond to an external topic upon completion
|
|
121
|
+
if (triggerCfg && triggerCfg.respondTo) {
|
|
122
|
+
try {
|
|
123
|
+
const topic = this.pubsubClient.topic(triggerCfg.respondTo);
|
|
124
|
+
await topic.publishMessage({
|
|
125
|
+
json: {
|
|
126
|
+
computation: entry.name,
|
|
127
|
+
date: ctx.date,
|
|
128
|
+
status: (ctx as any).status || 'completed',
|
|
129
|
+
ownerId: entry.ownerId
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
this.logger.log(`[PubSub] Sent completion acknowledgment to ${triggerCfg.respondTo} for ${entry.name}`);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
this.logger.error(`[PubSub] Failed to send completion response: ${err}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return ctx;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
105
140
|
}
|
|
106
141
|
|
|
107
142
|
async initialize(context: PluginInitContext): Promise<void> {
|
|
@@ -117,7 +152,12 @@ export class DefaultPubSubPlugin implements IPlugin {
|
|
|
117
152
|
|
|
118
153
|
context.kernel.onEvent('kernel.manifest.built', async (payload: any) => {
|
|
119
154
|
const entries = payload.manifest as any[];
|
|
155
|
+
|
|
156
|
+
// Validate rules for outbound 'type: pubsub' computations
|
|
120
157
|
this.validatePubSubBoundaries(entries);
|
|
158
|
+
|
|
159
|
+
// Register background listeners for inbound 'event-driven' triggers
|
|
160
|
+
await this.setupInboundTriggers(entries);
|
|
121
161
|
});
|
|
122
162
|
|
|
123
163
|
this.logger.log(`[PubSub] Initialized (Prefix: ${this.mailboxPrefix}). Listening for Distributed Manifest Builds.`);
|
|
@@ -128,12 +168,13 @@ export class DefaultPubSubPlugin implements IPlugin {
|
|
|
128
168
|
}
|
|
129
169
|
|
|
130
170
|
async shutdown(): Promise<void> {
|
|
131
|
-
// Optional teardown
|
|
171
|
+
// Optional teardown of subscriptions could go here if graceful shutdown is needed
|
|
132
172
|
}
|
|
133
173
|
|
|
134
174
|
/**
|
|
135
|
-
* Phase 2
|
|
136
|
-
* Invoked externally by a web-facing function parsing inbound Task Tokens
|
|
175
|
+
* Phase 2 Webhook Verifier & Resumption.
|
|
176
|
+
* Invoked externally by a web-facing function parsing inbound Task Tokens
|
|
177
|
+
* for computations that suspended themselves via waitForResponse: true.
|
|
137
178
|
*/
|
|
138
179
|
async processCallback(taskToken: string, resultData: any): Promise<void> {
|
|
139
180
|
const firestore = this.config.clients?.firestore;
|
|
@@ -155,15 +196,76 @@ export class DefaultPubSubPlugin implements IPlugin {
|
|
|
155
196
|
});
|
|
156
197
|
|
|
157
198
|
// Write final output using storage plugins and trigger the cascade
|
|
158
|
-
const scheduler = this.kernel.
|
|
159
|
-
if (scheduler && typeof scheduler.
|
|
160
|
-
|
|
161
|
-
|
|
199
|
+
const scheduler = this.kernel.getPluginByCapability<ISchedulerPlugin>('scheduler.enqueue');
|
|
200
|
+
if (scheduler && typeof scheduler.scheduleRootTrigger === 'function') {
|
|
201
|
+
// Here we enqueue a cascade trigger to wake up dependents
|
|
202
|
+
await scheduler.scheduleRootTrigger({
|
|
203
|
+
taskId: `cascade-${data.computation}-${data.date}-${Date.now()}`,
|
|
162
204
|
computation: data.computation,
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
205
|
+
name: data.computation,
|
|
206
|
+
targetDate: data.date,
|
|
207
|
+
configHash: '',
|
|
208
|
+
runAtSeconds: Math.floor(Date.now() / 1000),
|
|
209
|
+
reason: 'PUBSUB_WEBHOOK_RESUME',
|
|
210
|
+
ownerId: data.ownerId
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// -------------------------------------------------------------------------
|
|
216
|
+
// INBOUND: Listeners for External Triggers
|
|
217
|
+
// -------------------------------------------------------------------------
|
|
218
|
+
private async setupInboundTriggers(entries: any[]): Promise<void> {
|
|
219
|
+
const scheduler = this.kernel.getPluginByCapability<ISchedulerPlugin>('scheduler.enqueue');
|
|
220
|
+
|
|
221
|
+
for (const entry of entries) {
|
|
222
|
+
const triggerCfg = entry.recipe?.config?.pubsubTrigger;
|
|
223
|
+
|
|
224
|
+
if (triggerCfg && triggerCfg.topic) {
|
|
225
|
+
// Create a predictable subscription name for this worker/environment
|
|
226
|
+
const subName = `${triggerCfg.topic}-sub-${this.config.project?.id || 'bulltrackers'}`;
|
|
227
|
+
const subscription = this.pubsubClient.subscription(subName);
|
|
228
|
+
|
|
229
|
+
this.logger.log(`[PubSub] Listening for external triggers on ${triggerCfg.topic} for ${entry.name}`);
|
|
230
|
+
|
|
231
|
+
subscription.on('message', async (message) => {
|
|
232
|
+
try {
|
|
233
|
+
const data = JSON.parse(message.data.toString());
|
|
234
|
+
|
|
235
|
+
// Optional: Check payload Match (e.g. { "action": "run_model" })
|
|
236
|
+
if (triggerCfg.payloadMatch) {
|
|
237
|
+
const isMatch = Object.entries(triggerCfg.payloadMatch).every(([k, v]) => data[k] === v);
|
|
238
|
+
if (!isMatch) {
|
|
239
|
+
message.ack(); // Not meant for this computation; silently ack
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this.logger.log(`[PubSub] Inbound trigger received for ${entry.name}. Enqueueing task.`);
|
|
245
|
+
|
|
246
|
+
// Use the date from the payload, or default to today's UTC date
|
|
247
|
+
const targetDate = data.date || new Date().toISOString().split('T')[0];
|
|
248
|
+
const taskId = `event-${entry.name}__${targetDate}__${Date.now()}`;
|
|
249
|
+
|
|
250
|
+
// Dispatch directly to the Cloud Tasks Queue via standard root trigger
|
|
251
|
+
await scheduler.scheduleRootTrigger({
|
|
252
|
+
taskId,
|
|
253
|
+
computation: entry.name,
|
|
254
|
+
name: entry.originalName || entry.name,
|
|
255
|
+
targetDate,
|
|
256
|
+
configHash: entry.hash,
|
|
257
|
+
runAtSeconds: Math.floor(Date.now() / 1000), // Run immediately
|
|
258
|
+
reason: 'PUBSUB_EVENT_TRIGGER',
|
|
259
|
+
ownerId: data.ownerId || entry.ownerId,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
message.ack(); // Successfully dispatched to our internal durable queue
|
|
263
|
+
} catch (err) {
|
|
264
|
+
this.logger.error(`[PubSub] Failed to process inbound trigger for ${entry.name}: ${err}`);
|
|
265
|
+
message.nack(); // Retry processing the pubsub message later
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
167
269
|
}
|
|
168
270
|
}
|
|
169
271
|
|
|
@@ -204,4 +306,4 @@ export class DefaultPubSubPlugin implements IPlugin {
|
|
|
204
306
|
}
|
|
205
307
|
}
|
|
206
308
|
}
|
|
207
|
-
}
|
|
309
|
+
}
|
package/functions/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const version =
|
|
1
|
+
const version = 44.0
|
package/package.json
CHANGED
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const {
|
|
4
|
-
matchSubscriptions,
|
|
5
|
-
generateAlertMessage,
|
|
6
|
-
_safeJsonParse,
|
|
7
|
-
_extractMetadata
|
|
8
|
-
} = require('./AlertSubscriptionMatcher');
|
|
9
|
-
|
|
10
|
-
// ─── generateAlertMessage ───────────────────────────────────────────────────
|
|
11
|
-
|
|
12
|
-
describe('generateAlertMessage', () => {
|
|
13
|
-
test('resolves piUsername and change fields', () => {
|
|
14
|
-
const config = { messageTemplate: "{piUsername}'s risk score increased by {change} points (from {previous} to {current})" };
|
|
15
|
-
const msg = generateAlertMessage(config, 'MockGuru', { change: 4, previous: 3, current: 7 });
|
|
16
|
-
expect(msg).toBe("MockGuru's risk score increased by 4 points (from 3 to 7)");
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test('resolves title for social post', () => {
|
|
20
|
-
const config = { messageTemplate: '{piUsername} posted a new update: {title}' };
|
|
21
|
-
const msg = generateAlertMessage(config, 'TestTrader', { title: 'Market Analysis Q1' });
|
|
22
|
-
expect(msg).toBe('TestTrader posted a new update: Market Analysis Q1');
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test('handles missing fields gracefully', () => {
|
|
26
|
-
const config = { messageTemplate: '{piUsername} changed {change}' };
|
|
27
|
-
const msg = generateAlertMessage(config, undefined, {});
|
|
28
|
-
expect(msg).toContain('Unknown');
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
// ─── matchSubscriptions (MVP boolean-only) ───────────────────────────────────
|
|
33
|
-
|
|
34
|
-
describe('matchSubscriptions', () => {
|
|
35
|
-
const alertConfig = {
|
|
36
|
-
id: 'increasedRisk',
|
|
37
|
-
frontendName: 'Increased Risk',
|
|
38
|
-
configKey: 'increasedRisk',
|
|
39
|
-
severity: 'high',
|
|
40
|
-
isDynamic: false,
|
|
41
|
-
messageTemplate: "{piUsername}'s risk score increased"
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const parentResults = [
|
|
45
|
-
{ entityId: '100', triggered: true, currentRisk: 7, previousRisk: 3, change: 4, username: 'MockGuru' },
|
|
46
|
-
{ entityId: '200', triggered: false, currentRisk: 2, previousRisk: 2, change: 0 }
|
|
47
|
-
];
|
|
48
|
-
|
|
49
|
-
const watchlists = [
|
|
50
|
-
{ user_id: 'user1', watchlist_id: 'wl-1', name: 'My Watchlist', items: JSON.stringify([{ cid: '100' }]) },
|
|
51
|
-
{ user_id: 'user2', watchlist_id: 'wl-2', name: 'Other', items: JSON.stringify([{ cid: '200' }]) }
|
|
52
|
-
];
|
|
53
|
-
|
|
54
|
-
const subscriptions = [
|
|
55
|
-
{ user_id: 'user1', pi_cid: '100', settings: JSON.stringify({ alertConfig: { increasedRisk: true } }) }
|
|
56
|
-
];
|
|
57
|
-
|
|
58
|
-
test('produces notifications for triggered entities with matching subscriptions', () => {
|
|
59
|
-
const result = matchSubscriptions({
|
|
60
|
-
parentResults,
|
|
61
|
-
subscriptions,
|
|
62
|
-
watchlists,
|
|
63
|
-
alertConfig,
|
|
64
|
-
computationName: 'RiskScoreIncrease',
|
|
65
|
-
date: '2026-03-07'
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
expect(result.notifications).toHaveLength(1);
|
|
69
|
-
expect(result.notifications[0]).toMatchObject({
|
|
70
|
-
userCid: 'user1',
|
|
71
|
-
piCid: 100,
|
|
72
|
-
alertTypeId: 'increasedRisk',
|
|
73
|
-
computationName: 'RiskScoreIncrease',
|
|
74
|
-
computationDate: '2026-03-07',
|
|
75
|
-
severity: 'high',
|
|
76
|
-
watchlistId: 'wl-1',
|
|
77
|
-
watchlistName: 'My Watchlist'
|
|
78
|
-
});
|
|
79
|
-
expect(result.outbox).toBeDefined();
|
|
80
|
-
expect(result.outbox.emailPayloads).toBeDefined();
|
|
81
|
-
expect(result.outbox.smsPayloads).toBeDefined();
|
|
82
|
-
expect(result.outbox.fcmPayloads).toBeDefined();
|
|
83
|
-
expect(result.totalMatched).toBe(1);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test('accepts boolean toggle directly on subscription (no alertConfig wrapper)', () => {
|
|
87
|
-
const subsDirect = [
|
|
88
|
-
{ user_id: 'user1', pi_cid: '100', settings: JSON.stringify({ increasedRisk: true }) }
|
|
89
|
-
];
|
|
90
|
-
const result = matchSubscriptions({
|
|
91
|
-
parentResults,
|
|
92
|
-
subscriptions: subsDirect,
|
|
93
|
-
watchlists,
|
|
94
|
-
alertConfig,
|
|
95
|
-
computationName: 'RiskScoreIncrease',
|
|
96
|
-
date: '2026-03-07'
|
|
97
|
-
});
|
|
98
|
-
expect(result.notifications).toHaveLength(1);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test('skips non-triggered entities', () => {
|
|
102
|
-
const result = matchSubscriptions({
|
|
103
|
-
parentResults: [{ entityId: '100', triggered: false }],
|
|
104
|
-
subscriptions,
|
|
105
|
-
watchlists,
|
|
106
|
-
alertConfig,
|
|
107
|
-
computationName: 'Test',
|
|
108
|
-
date: '2026-03-07'
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
expect(result.notifications).toHaveLength(0);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test('skips users without subscriptions', () => {
|
|
115
|
-
const result = matchSubscriptions({
|
|
116
|
-
parentResults,
|
|
117
|
-
subscriptions: [],
|
|
118
|
-
watchlists,
|
|
119
|
-
alertConfig,
|
|
120
|
-
computationName: 'Test',
|
|
121
|
-
date: '2026-03-07'
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
expect(result.notifications).toHaveLength(0);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test('returns standardised output shape', () => {
|
|
128
|
-
const result = matchSubscriptions({
|
|
129
|
-
parentResults: [],
|
|
130
|
-
subscriptions: [],
|
|
131
|
-
watchlists: [],
|
|
132
|
-
alertConfig,
|
|
133
|
-
computationName: 'RiskScoreIncrease',
|
|
134
|
-
date: '2026-03-07'
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
expect(result).toHaveProperty('notifications');
|
|
138
|
-
expect(result).toHaveProperty('outbox');
|
|
139
|
-
expect(result.outbox).toHaveProperty('emailPayloads');
|
|
140
|
-
expect(result.outbox).toHaveProperty('smsPayloads');
|
|
141
|
-
expect(result.outbox).toHaveProperty('fcmPayloads');
|
|
142
|
-
expect(result).toHaveProperty('totalMatched');
|
|
143
|
-
expect(result).toHaveProperty('totalEvaluated');
|
|
144
|
-
expect(Array.isArray(result.notifications)).toBe(true);
|
|
145
|
-
});
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
149
|
-
|
|
150
|
-
describe('helpers', () => {
|
|
151
|
-
test('_safeJsonParse returns parsed value', () => {
|
|
152
|
-
expect(_safeJsonParse('{"a":1}', {})).toEqual({ a: 1 });
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
test('_safeJsonParse returns fallback on invalid JSON', () => {
|
|
156
|
-
expect(_safeJsonParse('not-json', [])).toEqual([]);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test('_extractMetadata removes internal fields', () => {
|
|
160
|
-
const meta = _extractMetadata({ triggered: true, entityId: '100', currentRisk: 7 });
|
|
161
|
-
expect(meta).not.toHaveProperty('triggered');
|
|
162
|
-
expect(meta).not.toHaveProperty('entityId');
|
|
163
|
-
expect(meta).toHaveProperty('currentRisk', 7);
|
|
164
|
-
});
|
|
165
|
-
});
|