bulltrackers-module 1.0.892 → 1.0.894

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.
@@ -16,6 +16,11 @@ process.env.COMPUTATION_VERIFICATION_URL = '';
16
16
  // Mock Redis so sync route uses Firestore rate-limit path (path relative to Core where Jest runs)
17
17
  jest.mock('bulltrackers-module/functions/shared/upstashRedis', () => ({ getRedis: () => null }));
18
18
 
19
+ // Force in-process verification in tests (no sandbox HTTP). Use same path as route require() so Jest applies the mock.
20
+ jest.mock('../services/verificationSandboxClient', () => ({
21
+ callVerificationSandbox: () => Promise.resolve(null),
22
+ }));
23
+
19
24
  const request = require('supertest');
20
25
  const { createTestApp } = require('./test-app-factory');
21
26
 
@@ -983,9 +988,10 @@ describe('Cross-Cutting', () => {
983
988
  });
984
989
 
985
990
  it('returns 200 and writes to GCS when code passes verification', async () => {
991
+ // Use process: (ctx) => ... so extracted process source re-parses when wrapped in parens (method shorthand process(ctx){ } is invalid as a standalone expression).
986
992
  const validCode = `module.exports = {
987
993
  config: { name: 'SafeComp', skills: ['lib'], requires: {} },
988
- process(ctx) { return { ok: true, date: ctx.runDate }; }
994
+ process: (ctx) => ({ ok: true, date: ctx.date })
989
995
  };`;
990
996
  const res = await request(app)
991
997
  .post('/computations/upload')
@@ -169,6 +169,13 @@ function createMockFirestore() {
169
169
  commit: async () => { for (const op of ops) await op(); },
170
170
  };
171
171
  },
172
+ runTransaction(callback) {
173
+ const t = {
174
+ get: async (ref) => ref.get(),
175
+ set: async (ref, data, opts) => ref.set(data, opts),
176
+ };
177
+ return callback(t);
178
+ },
172
179
  // ─── Test helper ─────────────────────────────────────────
173
180
  // Seed a document at a Firestore-like path
174
181
  seed(path, data) {
@@ -116,6 +116,8 @@ async function runVerification(code, name, userId, options = {}) {
116
116
  addStage(STAGE_GCS_RULES, 'passed', 'GCS rules (no select-all) satisfied');
117
117
 
118
118
  // Stage 3: Dry run with mock BQ and short timeout
119
+ // In test env, use in-process run (no WASM) so integration tests don't require quickjs-emscripten
120
+ const isTestEnv = process.env.NODE_ENV === 'test';
119
121
  const sandboxExecutionConfig = {
120
122
  ...config.execution,
121
123
  timeoutMs: SANDBOX_TIMEOUT_MS,
@@ -124,8 +126,8 @@ async function runVerification(code, name, userId, options = {}) {
124
126
  memoryLimitBytes: config.execution?.memoryLimitBytes ?? 64 * 1024 * 1024,
125
127
  maxStackSizeBytes: config.execution?.maxStackSizeBytes ?? 327680,
126
128
  runtime: config.execution?.runtime ?? 'default',
127
- sandbox: true,
128
- useQuickJS: config.execution?.useQuickJS !== false
129
+ sandbox: !isTestEnv,
130
+ useQuickJS: isTestEnv ? false : (config.execution?.useQuickJS !== false)
129
131
  };
130
132
 
131
133
  const mockBq = new MockBQAdapter(options.fixtureData || {}, config);
@@ -154,6 +156,15 @@ async function runVerification(code, name, userId, options = {}) {
154
156
  usageCollector: null
155
157
  });
156
158
 
159
+ // In test, dynamic recipe only has processSource; provide a callable process so in-process run works without WASM
160
+ if (isTestEnv && entry.recipe.processSource && entry.recipe.process === null) {
161
+ const processSource = entry.recipe.processSource;
162
+ entry.recipe.process = function (context) {
163
+ const fn = new Function('ctx', `return (${processSource})(ctx);`);
164
+ return Promise.resolve(fn(context));
165
+ };
166
+ }
167
+
157
168
  try {
158
169
  const result = await runner.run(entry.recipe, ctx);
159
170
  if (result && result.status === 'completed') {
@@ -62,14 +62,15 @@ class IntelligentProxyManager {
62
62
  /**
63
63
  * Direct fetch to target URL (no proxy). Used when circuit is open or as fallback after proxy exhaustion.
64
64
  * @private
65
+ * @param {object} fetchMeta - { usedDirect: true, attemptCount: number } to attach to response.
65
66
  */
66
- async _directFetch(targetUrl, options = {}) {
67
+ async _directFetch(targetUrl, options = {}, fetchMeta = { usedDirect: true, attemptCount: 1 }) {
67
68
  const method = options.method || 'GET';
68
69
  const headers = options.headers || {};
69
70
  const fetchOptions = { method, headers };
70
71
  if (options.signal) fetchOptions.signal = options.signal;
71
72
  const res = await fetch(targetUrl, fetchOptions);
72
- return {
73
+ const out = {
73
74
  ok: res.ok,
74
75
  status: res.status,
75
76
  headers: res.headers,
@@ -78,6 +79,8 @@ class IntelligentProxyManager {
78
79
  isUrlFetchError: false,
79
80
  isRateLimitError: false
80
81
  };
82
+ out._fetchMeta = fetchMeta;
83
+ return out;
81
84
  }
82
85
 
83
86
  /**
@@ -177,11 +180,17 @@ class IntelligentProxyManager {
177
180
  } catch (_) { /* use in-memory */ }
178
181
  }
179
182
  if (circuitOpenUntil && Date.now() < circuitOpenUntil) {
180
- this.logger.log('INFO', '[ProxyManager] Circuit open using direct fetch (no proxy).', { targetUrl, cooldownRemainingMs: circuitOpenUntil - Date.now(), shared: !!redis });
181
- return this._directFetch(targetUrl, options);
183
+ this.logger.log('INFO', '[ProxyManager] Circuit breaker open: using direct fetch only (1 hour TTL). 3 consecutive request failures.', {
184
+ targetUrl,
185
+ cooldownRemainingMs: circuitOpenUntil - Date.now(),
186
+ ttlMinutes: Math.ceil(this.CIRCUIT_COOLDOWN_MS / 60000),
187
+ shared: !!redis
188
+ });
189
+ return this._directFetch(targetUrl, options, { usedDirect: true, attemptCount: 1 });
182
190
  }
183
191
 
184
192
  let lastResponse = null;
193
+ let proxyAttemptCount = 0;
185
194
 
186
195
  for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
187
196
  let proxy;
@@ -189,15 +198,20 @@ class IntelligentProxyManager {
189
198
  proxy = await this._selectProxy();
190
199
  } catch (error) {
191
200
  this.logger.log('ERROR', '[ProxyManager] fetch failed: All proxies are locked.', { url: targetUrl });
192
- return { ok: false, status: 503, error: { message: error.message }, headers: new Headers() };
201
+ const errResp = { ok: false, status: 503, error: { message: error.message }, headers: new Headers() };
202
+ errResp._fetchMeta = { usedDirect: false, attemptCount: 0 };
203
+ return errResp;
193
204
  }
194
205
 
206
+ proxyAttemptCount = attempt;
195
207
  const response = await this._fetchViaAppsScript(proxy.url, targetUrl, options);
196
208
  lastResponse = response;
197
209
 
198
210
  if (response.ok) {
199
211
  this.consecutiveFailures = 0;
200
212
  if (redis) { try { await redis.set(CIRCUIT_FAILURES_KEY, '0', { ex: CIRCUIT_FAILURES_TTL_SEC }); } catch (_) {} }
213
+ if (!response._fetchMeta) response._fetchMeta = { usedDirect: false, attemptCount: proxyAttemptCount };
214
+ else response._fetchMeta.attemptCount = proxyAttemptCount;
201
215
  return response;
202
216
  }
203
217
 
@@ -216,7 +230,8 @@ class IntelligentProxyManager {
216
230
 
217
231
  this.consecutiveFailures = 0;
218
232
  if (redis) { try { await redis.set(CIRCUIT_FAILURES_KEY, '0', { ex: CIRCUIT_FAILURES_TTL_SEC }); } catch (_) {} }
219
- return response;
233
+ if (!lastResponse._fetchMeta) lastResponse._fetchMeta = { usedDirect: false, attemptCount: proxyAttemptCount };
234
+ return lastResponse;
220
235
  }
221
236
 
222
237
  let newCount = this.consecutiveFailures + 1;
@@ -236,10 +251,15 @@ class IntelligentProxyManager {
236
251
  await redis.del(CIRCUIT_FAILURES_KEY);
237
252
  } catch (_) {}
238
253
  }
239
- this.logger.log('WARN', '[ProxyManager] Circuit breaker open: 3 consecutive proxy failures. Bypassing proxy for 60 minutes.', { cooldownMs: this.CIRCUIT_COOLDOWN_MS, shared: !!redis });
240
- return this._directFetch(targetUrl, options);
254
+ this.logger.log('WARN', '[ProxyManager] Circuit breaker open: 3 consecutive request failures overall. Using direct fetch only for 1 hour TTL.', {
255
+ cooldownMs: this.CIRCUIT_COOLDOWN_MS,
256
+ ttlMinutes: Math.ceil(this.CIRCUIT_COOLDOWN_MS / 60000),
257
+ shared: !!redis
258
+ });
259
+ return this._directFetch(targetUrl, options, { usedDirect: true, attemptCount: proxyAttemptCount + 1 });
241
260
  }
242
261
  this.logger.log('ERROR', `[ProxyManager] Request failed after ${this.MAX_RETRIES} proxy attempts.`, { url: targetUrl, lastStatus: lastResponse?.status });
262
+ if (lastResponse && !lastResponse._fetchMeta) lastResponse._fetchMeta = { usedDirect: false, attemptCount: proxyAttemptCount };
243
263
  return lastResponse;
244
264
  }
245
265
 
@@ -9,6 +9,10 @@ function parseIncomingPayload(message) {
9
9
  return (typeof rawData === 'string') ? JSON.parse(rawData) : rawData;
10
10
  }
11
11
 
12
+ function defaultFetchMeta() {
13
+ return { usedDirect: true, attemptCount: 1 };
14
+ }
15
+
12
16
  async function fetchJsonWithHeader({ url, dependencies }) {
13
17
  const { headerManager, proxyManager, adapters, logger } = dependencies;
14
18
  const selectedHeader = await headerManager.selectHeader();
@@ -20,8 +24,12 @@ async function fetchJsonWithHeader({ url, dependencies }) {
20
24
  logger
21
25
  });
22
26
 
27
+ const fetchMeta = response?._fetchMeta || defaultFetchMeta();
28
+
23
29
  if (!response?.ok) {
24
- throw new Error(`[TaskEngineV1] Fetch failed: ${response?.status || 'unknown'} for ${url}`);
30
+ const err = new Error(`[TaskEngineV1] Fetch failed: ${response?.status || 'unknown'} for ${url}`);
31
+ err.fetchMeta = fetchMeta;
32
+ throw err;
25
33
  }
26
34
 
27
35
  const contentType = (response.headers?.get?.('content-type') || '').toLowerCase();
@@ -29,20 +37,15 @@ async function fetchJsonWithHeader({ url, dependencies }) {
29
37
  const looksLikeHtml = contentType.includes('text/html') || /^\s*</.test(rawBody);
30
38
 
31
39
  if (looksLikeHtml) {
32
- const snippet = rawBody.length > 4000 ? rawBody.slice(0, 4000) + '\n... [truncated]' : rawBody;
33
- logger.log('WARN', '[TaskEngineV1] Received HTML instead of JSON', {
34
- url,
35
- contentType,
36
- bodyLength: rawBody.length,
37
- bodySnippet: snippet
38
- });
39
- throw new Error(`[TaskEngineV1] Expected JSON but received HTML (${rawBody.length} chars) for ${url}`);
40
+ const err = new Error(`[TaskEngineV1] Expected JSON but received HTML (${rawBody.length} chars) for ${url}`);
41
+ err.fetchMeta = fetchMeta;
42
+ throw err; // End-result failure will be logged by caller; no per-attempt log here.
40
43
  }
41
44
 
42
45
  const data = JSON.parse(rawBody);
43
46
  if (url.includes('/portfolios')) dependencies.contracts?.assertPortfolioResponse(data);
44
47
  if (url.includes('/history/public/credit/flat')) dependencies.contracts?.assertHistoryResponse(data);
45
- return data;
48
+ return { data, fetchMeta };
46
49
  }
47
50
 
48
51
  async function storeUserSnapshot({ dependencies, collection, cid, date, payload, type }) {
@@ -74,38 +77,55 @@ async function handleUserUpdate(taskData, configObj, dependencies, isPopularInve
74
77
  const historyUrl = `${historyBase}?StartTime=${encodeURIComponent(startTime)}&PageNumber=1&ItemsPerPage=30000&PublicHistoryPortfolioFilter=&CID=${cid}&client_request_id=${clientRequestId}`;
75
78
 
76
79
  const userType = isPopularInvestor ? 'popular_investor' : 'signed_in_user';
77
- // Fetch independently so one failure (e.g. bad history URL) does not discard portfolio or block social
78
- let portfolioData = null;
79
- let historyData = null;
80
- try {
81
- portfolioData = await fetchJsonWithHeader({ url: portfolioUrl, dependencies });
82
- if (portfolioData) {
83
- logger.log('INFO', '[TaskEngineV1] Fetched portfolio', { cid, username, userType, dataType: 'portfolio' });
84
- }
85
- } catch (err) {
86
- logger.log('WARN', '[TaskEngineV1] Portfolio fetch failed', { cid, error: err.message, url: portfolioUrl });
80
+ const collections = isPopularInvestor
81
+ ? { portfolio: config.FIRESTORE_COLLECTION_PI_PORTFOLIOS_OVERALL || 'pi_portfolios_overall', history: config.FIRESTORE_COLLECTION_PI_HISTORY || 'pi_trade_history' }
82
+ : { portfolio: config.FIRESTORE_COLLECTION_SIGNED_IN_USER_PORTFOLIOS || 'signed_in_users', history: config.FIRESTORE_COLLECTION_SIGNED_IN_HISTORY || 'signed_in_user_history' };
83
+
84
+ function formatFetchMeta(meta) {
85
+ const n = (meta && meta.attemptCount) != null ? meta.attemptCount : 1;
86
+ const mode = (meta && meta.usedDirect) ? 'direct' : 'proxy';
87
+ return { n, mode };
87
88
  }
89
+
90
+ // Portfolio: one end-result log per task type / user type / user ID
91
+ let portfolioData = null;
88
92
  try {
89
- historyData = await fetchJsonWithHeader({ url: historyUrl, dependencies });
90
- if (historyData) {
91
- logger.log('INFO', '[TaskEngineV1] Fetched trade history', { cid, username, userType, dataType: 'history' });
92
- }
93
+ const result = await fetchJsonWithHeader({ url: portfolioUrl, dependencies });
94
+ portfolioData = result.data;
95
+ const { n, mode } = formatFetchMeta(result.fetchMeta);
96
+ logger.log('INFO', `[TaskEngineV1] Success to fetch portfolio for ${userType} for ID ${cid} after ${n} attempts with ${mode}`, {
97
+ taskType: 'portfolio', userType, userId: cid, attemptCount: n, mode
98
+ });
93
99
  } catch (err) {
94
- logger.log('WARN', '[TaskEngineV1] History fetch failed', { cid, error: err.message, url: historyUrl });
100
+ const { n, mode } = formatFetchMeta(err.fetchMeta);
101
+ logger.log('WARN', `[TaskEngineV1] Failure to fetch portfolio for ${userType} for ID ${cid} after ${n} attempts with ${mode}`, {
102
+ taskType: 'portfolio', userType, userId: cid, attemptCount: n, mode, error: err.message
103
+ });
95
104
  }
96
-
97
- const collections = isPopularInvestor
98
- ? { portfolio: config.FIRESTORE_COLLECTION_PI_PORTFOLIOS_OVERALL || 'pi_portfolios_overall', history: config.FIRESTORE_COLLECTION_PI_HISTORY || 'pi_trade_history' }
99
- : { portfolio: config.FIRESTORE_COLLECTION_SIGNED_IN_USER_PORTFOLIOS || 'signed_in_users', history: config.FIRESTORE_COLLECTION_SIGNED_IN_HISTORY || 'signed_in_user_history' };
100
105
  if (portfolioData) {
101
106
  await storeUserSnapshot({ dependencies, collection: collections.portfolio, cid, date, payload: portfolioData, type: 'portfolio' });
102
- logger.log('INFO', '[TaskEngineV1] Stored portfolio', { cid, username, userType, dataType: 'portfolio', collection: collections.portfolio, date });
107
+ }
108
+
109
+ // History: one end-result log per task type / user type / user ID
110
+ let historyData = null;
111
+ try {
112
+ const result = await fetchJsonWithHeader({ url: historyUrl, dependencies });
113
+ historyData = result.data;
114
+ const { n, mode } = formatFetchMeta(result.fetchMeta);
115
+ logger.log('INFO', `[TaskEngineV1] Success to fetch history for ${userType} for ID ${cid} after ${n} attempts with ${mode}`, {
116
+ taskType: 'history', userType, userId: cid, attemptCount: n, mode
117
+ });
118
+ } catch (err) {
119
+ const { n, mode } = formatFetchMeta(err.fetchMeta);
120
+ logger.log('WARN', `[TaskEngineV1] Failure to fetch history for ${userType} for ID ${cid} after ${n} attempts with ${mode}`, {
121
+ taskType: 'history', userType, userId: cid, attemptCount: n, mode, error: err.message
122
+ });
103
123
  }
104
124
  if (historyData) {
105
125
  await storeUserSnapshot({ dependencies, collection: collections.history, cid, date, payload: historyData, type: 'history' });
106
- logger.log('INFO', '[TaskEngineV1] Stored trade history', { cid, username, userType, dataType: 'history', collection: collections.history, date });
107
126
  }
108
127
 
128
+ // Social: one end-result log per task type / user type / user ID (no fetchMeta from inner call; use defaults)
109
129
  if (taskData.data?.includeSocial !== false) {
110
130
  try {
111
131
  await handleSocialTaskPayload({
@@ -113,10 +133,13 @@ async function handleUserUpdate(taskData, configObj, dependencies, isPopularInve
113
133
  id: String(cid),
114
134
  username
115
135
  }, configObj.social || configObj.taskEngine?.social || {}, dependencies);
116
- logger.log('INFO', '[TaskEngineV1] Fetched social', { cid, username, userType, dataType: 'social' });
136
+ logger.log('INFO', `[TaskEngineV1] Success to fetch social for ${userType} for ID ${cid} after 1 attempts with proxy`, {
137
+ taskType: 'social', userType, userId: cid, attemptCount: 1, mode: 'proxy'
138
+ });
117
139
  } catch (socialErr) {
118
- // 404 = eToro has no user for this username (e.g. placeholder "ondemand-{cid}" or private). Portfolio + history already saved.
119
- logger.log('WARN', '[TaskEngineV1] Social fetch skipped', { cid, username, error: socialErr.message });
140
+ logger.log('WARN', `[TaskEngineV1] Failure to fetch social for ${userType} for ID ${cid} after 1 attempts with proxy`, {
141
+ taskType: 'social', userType, userId: cid, attemptCount: 1, mode: 'proxy', error: socialErr.message
142
+ });
120
143
  }
121
144
  }
122
145
  }
@@ -29,6 +29,7 @@ async function fetchWithProxyFallback({
29
29
  throw new Error('[AppscriptClient] No fetch implementation available (pass fetchImpl or use Node 18+)');
30
30
  }
31
31
 
32
+ let proxyMeta = null;
32
33
  if (proxyManager && typeof proxyManager.fetch === 'function') {
33
34
  const timeoutMs = options.timeout ?? DEFAULT_PROXY_TIMEOUT_MS;
34
35
  try {
@@ -39,23 +40,23 @@ async function fetchWithProxyFallback({
39
40
  if (response?.ok) {
40
41
  return response;
41
42
  }
42
- if (logger && typeof logger.log === 'function') {
43
- logger.log('INFO', '[AppscriptClient] Proxy returned non-ok, using direct fetch fallback', {
44
- status: response?.status,
45
- url
46
- });
47
- }
43
+ proxyMeta = response?._fetchMeta || null;
44
+ // No per-attempt log: caller logs end-result only (e.g. task engine).
48
45
  } catch (proxyErr) {
49
- if (logger && typeof logger.log === 'function') {
50
- logger.log('WARN', '[AppscriptClient] Proxy fetch failed, using direct fetch fallback', {
51
- error: proxyErr?.message,
52
- url
53
- });
54
- }
46
+ // No per-attempt log: caller logs end-result only (e.g. task engine).
55
47
  }
56
48
  }
57
49
 
58
- return impl(url, options);
50
+ const directResponse = await impl(url, options);
51
+ if (directResponse && typeof directResponse === 'object' && !directResponse._fetchMeta) {
52
+ // If we had proxy attempts then fell back to direct, proxyMeta may have attemptCount; add 1 for the direct attempt.
53
+ const usedDirect = true;
54
+ const attemptCount = proxyMeta
55
+ ? (proxyMeta.attemptCount || 0) + 1
56
+ : 1;
57
+ directResponse._fetchMeta = { usedDirect, attemptCount };
58
+ }
59
+ return directResponse;
59
60
  }
60
61
 
61
62
  async function fetchJsonWithProxyFallback(params) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.892",
3
+ "version": "1.0.894",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [