bulltrackers-module 1.0.778 → 1.0.779

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.
Files changed (23) hide show
  1. package/functions/alert-system/helpers/alert_helpers.js +114 -90
  2. package/functions/alert-system/helpers/alert_manifest_loader.js +88 -99
  3. package/functions/alert-system/index.js +81 -138
  4. package/functions/alert-system/tests/stage1-alert-manifest.test.js +94 -0
  5. package/functions/alert-system/tests/stage2-alert-metadata.test.js +93 -0
  6. package/functions/alert-system/tests/stage3-alert-handler.test.js +79 -0
  7. package/functions/api-v2/helpers/data-fetchers/firestore.js +613 -478
  8. package/functions/api-v2/routes/popular_investors.js +7 -7
  9. package/functions/api-v2/routes/profile.js +2 -1
  10. package/functions/api-v2/tests/stage4-profile-paths.test.js +52 -0
  11. package/functions/api-v2/tests/stage5-aum-bigquery.test.js +81 -0
  12. package/functions/api-v2/tests/stage7-pi-page-views.test.js +55 -0
  13. package/functions/api-v2/tests/stage8-watchlist-membership.test.js +49 -0
  14. package/functions/api-v2/tests/stage9-user-alert-settings.test.js +81 -0
  15. package/functions/computation-system-v2/computations/BehavioralAnomaly.js +104 -81
  16. package/functions/computation-system-v2/computations/NewSectorExposure.js +7 -7
  17. package/functions/computation-system-v2/computations/NewSocialPost.js +6 -6
  18. package/functions/computation-system-v2/computations/PositionInvestedIncrease.js +11 -11
  19. package/functions/computation-system-v2/computations/SignedInUserPIProfileMetrics.js +1 -1
  20. package/functions/computation-system-v2/config/bulltrackers.config.js +8 -0
  21. package/functions/computation-system-v2/framework/core/Manifest.js +1 -0
  22. package/functions/core/utils/bigquery_utils.js +32 -0
  23. package/package.json +1 -1
@@ -23,13 +23,13 @@ let cachedAlertTypes = null;
23
23
  */
24
24
  async function handleAlertTrigger(message, context, config, dependencies) {
25
25
  const { db, logger } = dependencies;
26
-
26
+
27
27
  try {
28
28
  // [NEW] Load alert types from manifest (cached)
29
29
  if (!cachedAlertTypes) {
30
30
  cachedAlertTypes = await loadAlertTypesFromManifest(logger);
31
31
  }
32
-
32
+
33
33
  // Parse Pub/Sub message
34
34
  let payload;
35
35
  if (message.data) {
@@ -38,28 +38,28 @@ async function handleAlertTrigger(message, context, config, dependencies) {
38
38
  } else {
39
39
  payload = message;
40
40
  }
41
-
41
+
42
42
  const { date, computationName, documentPath } = payload;
43
-
43
+
44
44
  if (!date || !computationName) {
45
45
  logger.log('WARN', '[AlertTrigger] Missing required fields: date or computationName');
46
46
  return;
47
47
  }
48
-
48
+
49
49
  // 1. Check if this is an alert computation (using dynamic manifest)
50
50
  if (!isAlertComputation(cachedAlertTypes, computationName)) {
51
51
  logger.log('DEBUG', `[AlertTrigger] Not an alert computation: ${computationName}`);
52
52
  return;
53
53
  }
54
-
54
+
55
55
  const alertType = getAlertTypeByComputation(cachedAlertTypes, computationName);
56
56
  if (!alertType) {
57
57
  logger.log('WARN', `[AlertTrigger] Alert type not found for computation: ${computationName}`);
58
58
  return;
59
59
  }
60
-
60
+
61
61
  logger.log('INFO', `[AlertTrigger] Processing alert computation: ${computationName} for date ${date}`);
62
-
62
+
63
63
  // 2. Read computation results from Firestore
64
64
  let docRef;
65
65
  if (documentPath) {
@@ -75,36 +75,36 @@ async function handleAlertTrigger(message, context, config, dependencies) {
75
75
  .collection('computations')
76
76
  .doc(computationName);
77
77
  }
78
-
78
+
79
79
  const docSnapshot = await docRef.get();
80
-
80
+
81
81
  if (!docSnapshot.exists) {
82
82
  logger.log('WARN', `[AlertTrigger] Document not found: ${docRef.path}`);
83
83
  return;
84
84
  }
85
-
85
+
86
86
  // 3. Read and decompress computation results (handling GCS, shards, and compression)
87
87
  const docData = docSnapshot.data();
88
88
  const results = await readComputationResultsWithShards(db, docData, docRef, logger);
89
-
89
+
90
90
  if (!results.cids || results.cids.length === 0) {
91
91
  logger.log('INFO', `[AlertTrigger] No PIs found in computation results for ${computationName}`);
92
92
  return;
93
93
  }
94
-
94
+
95
95
  logger.log('INFO', `[AlertTrigger] Found ${results.cids.length} PIs in ${computationName} results`);
96
-
96
+
97
97
  // 4. Process alerts for each PI
98
98
  let processedCount = 0;
99
99
  let errorCount = 0;
100
-
100
+
101
101
  for (const piCid of results.cids) {
102
102
  try {
103
103
  // Extract PI-specific metadata if available, otherwise use global metadata
104
- const piMetadata = results.perUserData && results.perUserData[piCid]
104
+ const piMetadata = results.perUserData && results.perUserData[piCid]
105
105
  ? results.perUserData[piCid]
106
106
  : results.metadata || {};
107
-
107
+
108
108
  await processAlertForPI(
109
109
  db,
110
110
  logger,
@@ -121,16 +121,16 @@ async function handleAlertTrigger(message, context, config, dependencies) {
121
121
  // Continue processing other PIs even if one fails
122
122
  }
123
123
  }
124
-
124
+
125
125
  logger.log('SUCCESS', `[AlertTrigger] Completed processing ${computationName}: ${processedCount} successful, ${errorCount} errors`);
126
-
126
+
127
127
  // After processing alerts, check for "all clear" notifications for static watchlists
128
128
  // This runs after all alert computations are processed
129
129
  if (processedCount > 0 || results.cids.length > 0) {
130
130
  // Check if any PIs were processed but didn't trigger alerts
131
131
  await checkAndSendAllClearNotifications(db, logger, results.cids, date, config, dependencies);
132
132
  }
133
-
133
+
134
134
  } catch (error) {
135
135
  logger.log('ERROR', '[AlertTrigger] Fatal error processing alert trigger', error);
136
136
  throw error; // Re-throw for Pub/Sub retry
@@ -138,140 +138,83 @@ async function handleAlertTrigger(message, context, config, dependencies) {
138
138
  }
139
139
 
140
140
  /**
141
- * Firestore trigger function for alert generation
141
+ * Firestore trigger function for alert generation (V2 paths only).
142
142
  * Triggered when computation results are written to:
143
- * unified_insights/{date}/results/{category}/computations/{computationName}
144
- * * Handles sharded data by checking _shards subcollection if _sharded === true
143
+ * - Per-entity: alerts/{date}/{computationName}/{entityId}
144
+ * - Single-doc: alerts/{date}/{computationName} (doc contains cids and per-entity data)
145
+ * context.params must include date, computationName; entityId present only for per-entity path.
145
146
  */
146
147
  async function handleComputationResultWrite(change, context, config, dependencies) {
147
148
  const { db, logger } = dependencies;
148
-
149
- // Declare variables outside try block for error logging
150
- let date, category, computationName;
149
+
150
+ let date, computationName, entityId;
151
151
 
152
152
  try {
153
- // [NEW] Load alert types from manifest (cached)
154
153
  if (!cachedAlertTypes) {
155
154
  cachedAlertTypes = await loadAlertTypesFromManifest(logger);
156
155
  }
157
156
 
158
- // --- PATH RESOLUTION LOGIC ---
159
- // 1. Try to use context.params (Preferred for Gen 2 & Wildcards)
160
- // The 'contextShim' in your root index.js passes 'event.params' into here.
161
- ({ date, category, computationName } = context.params || {});
157
+ ({ date, computationName, entityId } = context.params || {});
162
158
 
163
- // 2. Fallback: Parse from resource string (Legacy / Backup)
164
159
  if (!date || !computationName) {
165
- const resource = context.resource || (change.after && change.after.ref.path) || '';
166
-
167
- // Simple split logic can fail on full resource URIs (e.g. //firestore.googleapis.com/...)
168
- // so we look for specific keywords to anchor the split.
169
- const pathParts = resource.split('/');
170
-
171
- const dateIndex = pathParts.indexOf('unified_insights') + 1;
172
- const categoryIndex = pathParts.indexOf('results') + 1;
173
- const computationIndex = pathParts.indexOf('computations') + 1;
174
-
175
- if (dateIndex > 0 && categoryIndex > 0 && computationIndex > 0) {
176
- date = pathParts[dateIndex];
177
- category = pathParts[categoryIndex];
178
- computationName = pathParts[computationIndex];
179
- }
160
+ logger.log('WARN', `[AlertTrigger] Missing path params. Params: ${JSON.stringify(context.params)}`);
161
+ return;
180
162
  }
181
163
 
182
- // 3. Validation
183
- if (!date || !category || !computationName) {
184
- logger.log('WARN', `[AlertTrigger] Could not resolve path params. Resource: ${context.resource}`);
185
- return;
186
- }
187
-
188
- // Only process if document was created or updated (not deleted)
189
164
  if (!change.after.exists) {
190
165
  logger.log('INFO', `[AlertTrigger] Document deleted, skipping: ${computationName} for ${date}`);
191
166
  return;
192
167
  }
193
-
194
- // 1. Check if this is an alert computation OR PopularInvestorProfileMetrics (using dynamic manifest)
195
- const isProfileMetrics = computationName === 'PopularInvestorProfileMetrics';
196
- if (!isAlertComputation(cachedAlertTypes, computationName) && !isProfileMetrics) {
197
- logger.log('DEBUG', `[AlertTrigger] Not an alert computation or profile metrics: ${computationName}`);
198
- return;
199
- }
200
-
201
- // [FIX] Allow both 'popular-investor' (for profile metrics) AND 'alerts' (for actual alerts)
202
- if (category !== 'popular-investor' && category !== 'alerts') {
203
- logger.log('DEBUG', `[AlertTrigger] Skipping irrelevant category: ${category}`);
168
+
169
+ if (!isAlertComputation(cachedAlertTypes, computationName)) {
170
+ logger.log('DEBUG', `[AlertTrigger] Not an alert computation: ${computationName}`);
204
171
  return;
205
172
  }
206
-
207
- // If it's PopularInvestorProfileMetrics, check for all-clear notifications only
208
- if (isProfileMetrics) {
209
- const docData = change.after.data();
210
- const results = await readComputationResultsWithShards(db, docData, change.after.ref, logger);
211
- if (results.cids && results.cids.length > 0) {
212
- await checkAndSendAllClearNotifications(db, logger, results.cids, date, config, dependencies);
213
- }
214
- return; // Don't process as alert computation
215
- }
216
-
173
+
217
174
  const alertType = getAlertTypeByComputation(cachedAlertTypes, computationName);
218
175
  if (!alertType) {
219
176
  logger.log('WARN', `[AlertTrigger] Alert type not found for computation: ${computationName}`);
220
177
  return;
221
178
  }
222
-
223
- // [NEW] Filter test alerts - only send to developers if isTest === true
179
+
224
180
  if (alertType.isTest) {
225
- logger.log('INFO', `[AlertTrigger] Processing TEST alert computation: ${computationName} for date ${date} (will only send to developers)`);
181
+ logger.log('INFO', `[AlertTrigger] Processing TEST alert: ${computationName} for date ${date}`);
226
182
  } else {
227
- logger.log('INFO', `[AlertTrigger] Processing alert computation: ${computationName} for date ${date}`);
183
+ logger.log('INFO', `[AlertTrigger] Processing alert: ${computationName} for date ${date}`);
228
184
  }
229
-
230
- // 2. Read and decompress computation results (handling GCS, shards, and compression)
185
+
231
186
  const docData = change.after.data();
232
- const results = await readComputationResultsWithShards(db, docData, change.after.ref, logger);
233
-
234
- if (!results.cids || results.cids.length === 0) {
235
- logger.log('INFO', `[AlertTrigger] No PIs found in computation results for ${computationName}`);
187
+ const deps = { ...dependencies, alertType };
188
+
189
+ if (entityId != null && entityId !== '') {
190
+ // Per-entity path: one doc per PI; doc body is the single PI result
191
+ await processAlertForPI(db, logger, entityId, alertType, docData, date, deps);
192
+ logger.log('SUCCESS', `[AlertTrigger] Completed ${computationName} for PI ${entityId}`);
236
193
  return;
237
194
  }
238
-
239
- logger.log('INFO', `[AlertTrigger] Found ${results.cids.length} PIs in ${computationName} results`);
240
-
241
- // 3. Process alerts for each PI
242
- // Use computationName as the alertTypeId (it maps to alertConfig keys)
195
+
196
+ // Single-doc path: doc contains cids and per-entity keys
197
+ const cids = Array.isArray(docData.cids) ? docData.cids : Object.keys(docData).filter(k => /^\d+$/.test(k)).map(Number);
198
+ if (!cids.length) {
199
+ logger.log('INFO', `[AlertTrigger] No PIs in document for ${computationName}`);
200
+ return;
201
+ }
202
+
243
203
  let processedCount = 0;
244
204
  let errorCount = 0;
245
-
246
- for (const piCid of results.cids) {
205
+ for (const piCid of cids) {
247
206
  try {
248
- // Extract PI-specific metadata if available, otherwise use global metadata
249
- const piMetadata = results.perUserData && results.perUserData[piCid]
250
- ? results.perUserData[piCid]
251
- : results.metadata || results.globalMetadata || {};
252
-
253
- await processAlertForPI(
254
- db,
255
- logger,
256
- piCid,
257
- alertType,
258
- piMetadata,
259
- date,
260
- { ...dependencies, alertType } // Pass alertType in dependencies for isTest check
261
- );
207
+ const piMetadata = docData[String(piCid)] ?? docData[piCid] ?? {};
208
+ await processAlertForPI(db, logger, piCid, alertType, piMetadata, date, deps);
262
209
  processedCount++;
263
210
  } catch (error) {
264
211
  errorCount++;
265
212
  logger.log('ERROR', `[AlertTrigger] Error processing alert for PI ${piCid}`, error);
266
- // Continue processing other PIs even if one fails
267
213
  }
268
214
  }
269
-
270
- logger.log('SUCCESS', `[AlertTrigger] Completed processing ${computationName}: ${processedCount} successful, ${errorCount} errors`);
271
-
215
+ logger.log('SUCCESS', `[AlertTrigger] Completed ${computationName}: ${processedCount} successful, ${errorCount} errors`);
272
216
  } catch (error) {
273
217
  logger.log('ERROR', `[AlertTrigger] Fatal error processing ${computationName}`, error);
274
- // Don't throw - we don't want to retry the entire computation write
275
218
  }
276
219
  }
277
220
 
@@ -283,36 +226,36 @@ async function handleComputationResultWrite(change, context, config, dependencie
283
226
  async function checkAndSendAllClearNotifications(db, logger, processedPICids, computationDate, config, dependencies = {}) {
284
227
  try {
285
228
  const today = computationDate || new Date().toISOString().split('T')[0];
286
-
229
+
287
230
  if (!processedPICids || processedPICids.length === 0) {
288
231
  return; // No PIs to check
289
232
  }
290
-
233
+
291
234
  // Get all static watchlists that contain any of the processed PIs
292
235
  const watchlistsCollection = config.watchlistsCollection || 'watchlists';
293
236
  const watchlistsSnapshot = await db.collection(watchlistsCollection).get();
294
-
237
+
295
238
  const usersToNotify = new Map(); // userCid -> Map of piCid -> {username, ...}
296
-
239
+
297
240
  for (const userDoc of watchlistsSnapshot.docs) {
298
241
  const userCid = Number(userDoc.id);
299
242
  const userListsSnapshot = await userDoc.ref.collection('lists').get();
300
-
243
+
301
244
  for (const listDoc of userListsSnapshot.docs) {
302
245
  const listData = listDoc.data();
303
-
246
+
304
247
  // Only check static watchlists
305
248
  if (listData.type !== 'static' || !listData.items || !Array.isArray(listData.items)) {
306
249
  continue;
307
250
  }
308
-
251
+
309
252
  // Check if any processed PI is in this watchlist
310
253
  for (const item of listData.items) {
311
254
  const piCid = Number(item.cid);
312
255
  if (processedPICids.includes(piCid)) {
313
256
  // Check if this PI triggered any alerts today for this user
314
257
  const hasAlerts = await checkIfPIHasAlertsToday(db, logger, userCid, piCid, today, dependencies);
315
-
258
+
316
259
  if (!hasAlerts) {
317
260
  // No alerts triggered - send "all clear" notification
318
261
  if (!usersToNotify.has(userCid)) {
@@ -327,7 +270,7 @@ async function checkAndSendAllClearNotifications(db, logger, processedPICids, co
327
270
  }
328
271
  }
329
272
  }
330
-
273
+
331
274
  // Send notifications to all users
332
275
  let totalNotifications = 0;
333
276
  for (const [userCid, pisMap] of usersToNotify.entries()) {
@@ -336,11 +279,11 @@ async function checkAndSendAllClearNotifications(db, logger, processedPICids, co
336
279
  totalNotifications++;
337
280
  }
338
281
  }
339
-
282
+
340
283
  if (totalNotifications > 0) {
341
284
  logger.log('INFO', `[checkAndSendAllClearNotifications] Sent ${totalNotifications} all-clear notifications to ${usersToNotify.size} users`);
342
285
  }
343
-
286
+
344
287
  } catch (error) {
345
288
  logger.log('ERROR', `[checkAndSendAllClearNotifications] Error checking all-clear notifications`, error);
346
289
  // Don't throw - this is a non-critical feature
@@ -355,7 +298,7 @@ async function checkIfPIHasAlertsToday(db, logger, userCid, piCid, date, depende
355
298
  try {
356
299
  const { collectionRegistry } = dependencies;
357
300
  const config = dependencies.config || {};
358
-
301
+
359
302
  // Use collection registry to get notifications path
360
303
  let notificationsPath;
361
304
  if (collectionRegistry && collectionRegistry.getCollectionPath) {
@@ -364,9 +307,9 @@ async function checkIfPIHasAlertsToday(db, logger, userCid, piCid, date, depende
364
307
  // Fallback to legacy path
365
308
  notificationsPath = `user_notifications/${userCid}/notifications`;
366
309
  }
367
-
310
+
368
311
  const notificationsRef = db.collection(notificationsPath);
369
-
312
+
370
313
  // Check alerts collection instead of notifications
371
314
  // Use collection registry to get alerts path
372
315
  let alertsPath;
@@ -376,16 +319,16 @@ async function checkIfPIHasAlertsToday(db, logger, userCid, piCid, date, depende
376
319
  // Fallback to legacy path
377
320
  alertsPath = `user_alerts/${userCid}/alerts`;
378
321
  }
379
-
322
+
380
323
  const alertsRef = db.collection(alertsPath);
381
-
324
+
382
325
  // Check if there are any alerts for this PI today
383
326
  const snapshot = await alertsRef
384
327
  .where('piCid', '==', Number(piCid))
385
328
  .where('computationDate', '==', date)
386
329
  .limit(1)
387
330
  .get();
388
-
331
+
389
332
  return !snapshot.empty;
390
333
  } catch (error) {
391
334
  if (logger) {
@@ -404,7 +347,7 @@ async function sendAllClearNotification(db, logger, userCid, piCid, piUsername,
404
347
  const { FieldValue } = require('@google-cloud/firestore');
405
348
  const { collectionRegistry } = dependencies;
406
349
  const config = dependencies.config || {};
407
-
350
+
408
351
  // Use collection registry to get notifications path
409
352
  let notificationsPath;
410
353
  if (collectionRegistry && collectionRegistry.getCollectionPath) {
@@ -413,7 +356,7 @@ async function sendAllClearNotification(db, logger, userCid, piCid, piUsername,
413
356
  // Fallback to legacy path
414
357
  notificationsPath = `user_notifications/${userCid}/notifications`;
415
358
  }
416
-
359
+
417
360
  // Check if we already sent an all-clear notification for this PI today
418
361
  const existingSnapshot = await db.collection(notificationsPath)
419
362
  .where('type', '==', 'watchlistAlerts')
@@ -422,12 +365,12 @@ async function sendAllClearNotification(db, logger, userCid, piCid, piUsername,
422
365
  .where('metadata.computationDate', '==', date)
423
366
  .limit(1)
424
367
  .get();
425
-
368
+
426
369
  if (!existingSnapshot.empty) {
427
370
  // Already sent notification for this PI today
428
371
  return;
429
372
  }
430
-
373
+
431
374
  // Create the notification using writeWithMigration
432
375
  const notificationId = `all_clear_${Date.now()}_${userCid}_${piCid}_${Math.random().toString(36).substring(2, 9)}`;
433
376
  const notificationData = {
@@ -449,7 +392,7 @@ async function sendAllClearNotification(db, logger, userCid, piCid, piUsername,
449
392
  message: 'User was processed, all clear today!'
450
393
  }
451
394
  };
452
-
395
+
453
396
  // Write to alerts collection (not notifications) - alerts are separate from system notifications
454
397
  // Write directly to new path: SignedInUsers/{userCid}/alerts/{alertId}
455
398
  const alertData = {
@@ -464,14 +407,14 @@ async function sendAllClearNotification(db, logger, userCid, piCid, piUsername,
464
407
  createdAt: FieldValue.serverTimestamp(),
465
408
  computationDate: date
466
409
  };
467
-
410
+
468
411
  // 1. Write to Firestore (source of truth)
469
412
  await db.collection('SignedInUsers')
470
413
  .doc(String(userCid))
471
414
  .collection('alerts')
472
415
  .doc(notificationId)
473
416
  .set(alertData);
474
-
417
+
475
418
  // 2. Send FCM push notification (non-blocking)
476
419
  try {
477
420
  await sendAlertPushNotification(db, userCid, alertData, logger);
@@ -479,9 +422,9 @@ async function sendAllClearNotification(db, logger, userCid, piCid, piUsername,
479
422
  // Log but don't throw - FCM failure shouldn't fail the alert
480
423
  logger.log('WARN', `[sendAllClearNotification] FCM push failed for user ${userCid}: ${fcmError.message}`);
481
424
  }
482
-
425
+
483
426
  logger.log('INFO', `[sendAllClearNotification] Sent all-clear notification to user ${userCid} for PI ${piCid}`);
484
-
427
+
485
428
  } catch (error) {
486
429
  logger.log('ERROR', `[sendAllClearNotification] Error sending all-clear notification`, error);
487
430
  // Don't throw - this is non-critical
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @fileoverview Stage 1 unit test: Mark alert computations and derive triggers from V2 config.
3
+ * Uses real config/computations (read-only). Retained for inspection and debugging.
4
+ */
5
+
6
+ const path = require('path');
7
+ const assert = require('assert');
8
+
9
+ const EXPECTED_ALERT_COMPUTATIONS = [
10
+ 'RiskScoreIncrease',
11
+ 'BehavioralAnomaly',
12
+ 'NewSectorExposure',
13
+ 'NewSocialPost',
14
+ 'PositionInvestedIncrease'
15
+ ];
16
+
17
+ async function runStage1Tests() {
18
+ let passed = 0;
19
+ let failed = 0;
20
+
21
+ // (a) Manifest: exactly the five alert computations have alert in manifest
22
+ try {
23
+ const v2ConfigPath = path.join(__dirname, '../../computation-system-v2/config/bulltrackers.config.js');
24
+ const v2Config = require(v2ConfigPath);
25
+ const { ManifestBuilder } = require('../../computation-system-v2/framework/core/Manifest.js');
26
+ const builder = new ManifestBuilder(v2Config, null);
27
+ const manifest = builder.build(v2Config.computations);
28
+ const withAlert = manifest.filter(e => e.alert != null);
29
+ const namesWithAlert = withAlert.map(e => e.originalName).sort();
30
+ const expectedSorted = [...EXPECTED_ALERT_COMPUTATIONS].sort();
31
+ assert.deepStrictEqual(
32
+ namesWithAlert,
33
+ expectedSorted,
34
+ `Manifest alert computations: expected ${expectedSorted.join(', ')}, got ${namesWithAlert.join(', ')}`
35
+ );
36
+ passed++;
37
+ console.log('[Stage1] (a) Manifest: exactly five alert computations have alert');
38
+ } catch (e) {
39
+ failed++;
40
+ console.error('[Stage1] (a) FAIL:', e.message);
41
+ }
42
+
43
+ // (b) loadAlertTypesFromManifest returns five alert types with correct computationName and configKey
44
+ try {
45
+ const { loadAlertTypesFromManifest } = require('../helpers/alert_manifest_loader.js');
46
+ const logger = { log: () => {} };
47
+ const alertTypes = await loadAlertTypesFromManifest(logger);
48
+ assert.strictEqual(alertTypes.length, 5, `Expected 5 alert types, got ${alertTypes.length}`);
49
+ const computationNames = alertTypes.map(t => t.computationName).sort();
50
+ assert.deepStrictEqual(
51
+ computationNames,
52
+ [...EXPECTED_ALERT_COMPUTATIONS].sort(),
53
+ `Alert types computationName: expected ${EXPECTED_ALERT_COMPUTATIONS.join(', ')}, got ${computationNames.join(', ')}`
54
+ );
55
+ for (const t of alertTypes) {
56
+ assert(t.configKey != null && t.configKey !== '', `Alert type ${t.computationName} must have configKey`);
57
+ }
58
+ passed++;
59
+ console.log('[Stage1] (b) loadAlertTypesFromManifest returns five alert types with computationName and configKey');
60
+ } catch (e) {
61
+ failed++;
62
+ console.error('[Stage1] (b) FAIL:', e.message);
63
+ }
64
+
65
+ // (c) Derived alertComputations list matches entries with storage.firestore.enabled and alert
66
+ try {
67
+ const v2ConfigPath = path.join(__dirname, '../../computation-system-v2/config/bulltrackers.config.js');
68
+ const v2Config = require(v2ConfigPath);
69
+ const { ManifestBuilder } = require('../../computation-system-v2/framework/core/Manifest.js');
70
+ const builder = new ManifestBuilder(v2Config, null);
71
+ const manifest = builder.build(v2Config.computations);
72
+ const alertComputations = manifest.filter(e => e.storage?.firestore?.enabled && e.alert);
73
+ const alertNames = alertComputations.map(e => e.originalName).sort();
74
+ assert.deepStrictEqual(
75
+ alertNames,
76
+ [...EXPECTED_ALERT_COMPUTATIONS].sort(),
77
+ `Derived alertComputations: expected ${EXPECTED_ALERT_COMPUTATIONS.join(', ')}, got ${alertNames.join(', ')}`
78
+ );
79
+ passed++;
80
+ console.log('[Stage1] (c) Derived alertComputations matches storage.firestore.enabled && alert');
81
+ } catch (e) {
82
+ failed++;
83
+ console.error('[Stage1] (c) FAIL:', e.message);
84
+ }
85
+
86
+ console.log(`[Stage1] Done: ${passed} passed, ${failed} failed`);
87
+ return failed === 0;
88
+ }
89
+
90
+ // Allow async test (b)
91
+ (async () => {
92
+ const ok = await runStage1Tests();
93
+ process.exit(ok ? 0 : 1);
94
+ })();
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @fileoverview Stage 2 unit test: Complete alert metadata on all five alert computations.
3
+ * Asserts each computation has full alert metadata and generateAlertMessage uses only messageTemplate + metadata.
4
+ * Retained for inspection and debugging.
5
+ */
6
+
7
+ const path = require('path');
8
+ const assert = require('assert');
9
+
10
+ const EXPECTED_ALERT_COMPUTATIONS = [
11
+ 'RiskScoreIncrease',
12
+ 'BehavioralAnomaly',
13
+ 'NewSectorExposure',
14
+ 'NewSocialPost',
15
+ 'PositionInvestedIncrease'
16
+ ];
17
+
18
+ async function runStage2Tests() {
19
+ let passed = 0;
20
+ let failed = 0;
21
+
22
+ const v2ConfigPath = path.join(__dirname, '../../computation-system-v2/config/bulltrackers.config.js');
23
+ const v2Config = require(v2ConfigPath);
24
+ const computations = v2Config.computations || [];
25
+ const { generateAlertMessage, loadAlertTypesFromManifest } = require('../helpers/alert_manifest_loader.js');
26
+ const logger = { log: () => {} };
27
+ const alertTypes = await loadAlertTypesFromManifest(logger);
28
+
29
+ for (const ComputationClass of computations) {
30
+ if (!ComputationClass?.getConfig) continue;
31
+ const config = ComputationClass.getConfig();
32
+ if (!config?.alert) continue;
33
+
34
+ const name = config.name;
35
+ const alert = config.alert;
36
+
37
+ try {
38
+ assert(alert.id != null && alert.id !== '', `${name}: alert.id required`);
39
+ assert(alert.frontendName != null && alert.frontendName !== '', `${name}: alert.frontendName required`);
40
+ assert(alert.description != null, `${name}: alert.description required`);
41
+ assert(alert.messageTemplate != null && alert.messageTemplate !== '', `${name}: alert.messageTemplate required`);
42
+ assert(alert.configKey != null && alert.configKey !== '', `${name}: alert.configKey required`);
43
+
44
+ if (alert.isDynamic && alert.dynamicConfig) {
45
+ const hasThresholds = Array.isArray(alert.dynamicConfig.thresholds) && alert.dynamicConfig.thresholds.length > 0;
46
+ const hasConditions = Array.isArray(alert.dynamicConfig.conditions) && alert.dynamicConfig.conditions.length > 0;
47
+ assert(hasThresholds || hasConditions, `${name}: dynamic alert should have thresholds or conditions`);
48
+ if (alert.dynamicConfig.resultFields) {
49
+ assert(typeof alert.dynamicConfig.resultFields === 'object', `${name}: resultFields should be object`);
50
+ }
51
+ }
52
+
53
+ const alertType = alertTypes.find(t => t.computationName === name);
54
+ assert(alertType != null, `${name}: should be in loaded alert types`);
55
+
56
+ const sampleResult = name === 'RiskScoreIncrease'
57
+ ? { change: 1.5, previousRisk: 3, currentRisk: 4.5 }
58
+ : name === 'BehavioralAnomaly'
59
+ ? { driver: 'Sector HHI', score: 4.2, driverValue: 'elevated' }
60
+ : name === 'NewSocialPost'
61
+ ? { title: 'Market update' }
62
+ : name === 'NewSectorExposure'
63
+ ? { newExposures: ['Technology'] }
64
+ : { moves: [{ symbol: 'AAPL', diff: 5, prev: 10, curr: 15 }] };
65
+
66
+ const msg = generateAlertMessage(alertType, 'TestPI', sampleResult);
67
+ assert(typeof msg === 'string' && msg.length > 0, `${name}: generateAlertMessage must return non-empty string`);
68
+ assert(msg.includes('TestPI') || msg.includes('Unknown'), `${name}: message should include piUsername or fallback`);
69
+
70
+ passed++;
71
+ console.log(`[Stage2] ${name}: alert metadata and generateAlertMessage OK`);
72
+ } catch (e) {
73
+ failed++;
74
+ console.error(`[Stage2] ${name} FAIL:`, e.message);
75
+ }
76
+ }
77
+
78
+ assert.strictEqual(
79
+ passed,
80
+ EXPECTED_ALERT_COMPUTATIONS.length,
81
+ `Expected ${EXPECTED_ALERT_COMPUTATIONS.length} alert computations to pass, got ${passed} passed, ${failed} failed`
82
+ );
83
+
84
+ console.log(`[Stage2] Done: ${passed} passed, ${failed} failed`);
85
+ return failed === 0;
86
+ }
87
+
88
+ runStage2Tests()
89
+ .then(ok => process.exit(ok ? 0 : 1))
90
+ .catch(err => {
91
+ console.error(err);
92
+ process.exit(1);
93
+ });