From 268418fd22a9cb33bd391d8bc64d184d24c6d54d Mon Sep 17 00:00:00 2001 From: Framma Williansyah Akbar Date: Wed, 30 Sep 2026 11:41:24 +0700 Subject: [PATCH] feat(testing): Add synthetic user transaction bot simulating 24/7 campaign activity on Testnet (#1364) --- backend/README.md | 4 + backend/package.json | 1 + backend/src/testing/syntheticBot.js | 169 +++++++++++++++++++++++ backend/src/testing/syntheticBot.test.js | 110 +++++++++++++++ 4 files changed, 284 insertions(+) create mode 100644 backend/src/testing/syntheticBot.js create mode 100644 backend/src/testing/syntheticBot.test.js diff --git a/backend/README.md b/backend/README.md index c677e320..30ccc451 100644 --- a/backend/README.md +++ b/backend/README.md @@ -713,3 +713,7 @@ Accept: application/vnd.trivela.v2+json ``` Endpoints that honour this header document it in their section below. All others ignore it. + +## Synthetic user bot + +`npm run bot:synthetic` runs a long-lived bot that repeatedly exercises the health, campaign list, campaign detail and stats endpoints of a running backend (e.g. Testnet) and tracks per-step success/failure. Configure with `BOT_BASE_URL`, `BOT_API_KEY`, `BOT_INTERVAL_MS`, `BOT_TIMEOUT_MS` and `BOT_FAILURE_THRESHOLD`. See `src/testing/syntheticBot.js`. diff --git a/backend/package.json b/backend/package.json index b44d1840..5e98828b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "test:snapshots:update": "UPDATE_SNAPSHOTS=1 node --test src/tests/api-snapshot.test.js", "lint": "eslint src/", "typecheck": "tsc --noEmit --project jsconfig.json", + "bot:synthetic": "node src/testing/syntheticBot.js", "db:migrate": "node src/db/migrate.js", "openapi:validate": "node scripts/validateOpenApi.js", "docs:build": "node scripts/build-redoc.js", diff --git a/backend/src/testing/syntheticBot.js b/backend/src/testing/syntheticBot.js new file mode 100644 index 00000000..27db2069 --- /dev/null +++ b/backend/src/testing/syntheticBot.js @@ -0,0 +1,169 @@ +/** + * Synthetic user transaction bot (#1364). + * + * Continuously simulates campaign activity against a running Trivela backend + * (typically Testnet) and records per-step results so end-to-end flow + * regressions surface without real users. + * + * Usage: node src/testing/syntheticBot.js + * BOT_BASE_URL backend URL (default http://localhost:3001) + * BOT_API_KEY optional API key sent as x-api-key + * BOT_INTERVAL_MS delay between cycles (default 60000, min 1000) + * BOT_TIMEOUT_MS per-request timeout (default 10000) + * BOT_FAILURE_THRESHOLD consecutive failed cycles before "unhealthy" (default 3) + */ +import { pathToFileURL } from 'node:url'; + +const DEFAULTS = { + baseUrl: 'http://localhost:3001', + intervalMs: 60_000, + timeoutMs: 10_000, + failureThreshold: 3, +}; + +function positiveInt(value, fallback, min = 1) { + const n = Number(value); + return Number.isFinite(n) && n >= min ? Math.floor(n) : fallback; +} + +export function resolveConfig(env = process.env) { + const baseUrl = (env.BOT_BASE_URL || DEFAULTS.baseUrl).replace(/\/+$/, ''); + try { + new URL(baseUrl); + } catch { + throw new Error(`Invalid BOT_BASE_URL: ${baseUrl}`); + } + return { + baseUrl, + apiKey: env.BOT_API_KEY || undefined, + intervalMs: positiveInt(env.BOT_INTERVAL_MS, DEFAULTS.intervalMs, 1000), + timeoutMs: positiveInt(env.BOT_TIMEOUT_MS, DEFAULTS.timeoutMs), + failureThreshold: positiveInt(env.BOT_FAILURE_THRESHOLD, DEFAULTS.failureThreshold), + }; +} + +export function createSyntheticBot({ + baseUrl = DEFAULTS.baseUrl, + apiKey, + timeoutMs = DEFAULTS.timeoutMs, + failureThreshold = DEFAULTS.failureThreshold, + fetchImpl = globalThis.fetch, + logger = console, + now = () => Date.now(), +} = {}) { + if (typeof fetchImpl !== 'function') throw new Error('fetch implementation required'); + + const stats = { + cycles: 0, + failedCycles: 0, + consecutiveFailures: 0, + steps: {}, + lastCycleAt: null, + lastError: null, + }; + + async function request(path) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetchImpl(`${baseUrl}${path}`, { + headers: apiKey ? { 'x-api-key': apiKey } : {}, + signal: controller.signal, + }); + if (!res.ok) throw new Error(`${path} responded ${res.status}`); + return await res.json(); + } finally { + clearTimeout(timer); + } + } + + async function step(name, fn) { + const started = now(); + const bucket = (stats.steps[name] ||= { ok: 0, failed: 0, lastMs: 0 }); + try { + const value = await fn(); + bucket.ok += 1; + return { name, ok: true, ms: now() - started, value }; + } catch (err) { + bucket.failed += 1; + return { name, ok: false, ms: now() - started, error: err?.message || String(err) }; + } finally { + bucket.lastMs = now() - started; + } + } + + /** Runs one simulated user journey: health -> browse -> open campaign -> stats. */ + async function runCycle() { + const results = []; + results.push(await step('health', () => request('/health'))); + + const list = await step('list-campaigns', async () => { + const body = await request('/api/v1/campaigns'); + const items = Array.isArray(body) ? body : body?.data ?? body?.campaigns; + if (!Array.isArray(items)) throw new Error('campaign list has unexpected shape'); + return items; + }); + results.push(list); + + const campaigns = list.ok ? list.value : []; + if (list.ok && campaigns.length > 0) { + const pick = campaigns[Math.floor(Math.random() * campaigns.length)]; + const id = encodeURIComponent(String(pick?.id ?? '')); + if (!pick?.id) { + results.push({ name: 'get-campaign', ok: false, ms: 0, error: 'campaign missing id' }); + } else { + results.push(await step('get-campaign', () => request(`/api/v1/campaigns/${id}`))); + results.push(await step('campaign-stats', () => request(`/api/v1/campaigns/${id}/stats`))); + } + } + + const ok = results.every((r) => r.ok); + stats.cycles += 1; + stats.lastCycleAt = new Date(now()).toISOString(); + if (ok) { + stats.consecutiveFailures = 0; + } else { + stats.failedCycles += 1; + stats.consecutiveFailures += 1; + stats.lastError = results.find((r) => !r.ok)?.error ?? null; + logger.warn?.(`[synthetic-bot] cycle failed: ${stats.lastError}`); + } + return { ok, results }; + } + + function getStatus() { + return { + ...stats, + steps: structuredClone(stats.steps), + healthy: stats.consecutiveFailures < failureThreshold, + }; + } + + /** Loops until the signal aborts. Cycle errors never stop the loop. */ + async function run({ intervalMs = DEFAULTS.intervalMs, signal } = {}) { + while (!signal?.aborted) { + try { + await runCycle(); + } catch (err) { + logger.error?.(`[synthetic-bot] unexpected error: ${err?.message || err}`); + } + await new Promise((resolve) => { + const t = setTimeout(resolve, intervalMs); + signal?.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true }); + }); + } + } + + return { runCycle, run, getStatus }; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + const config = resolveConfig(); + const bot = createSyntheticBot(config); + const ac = new AbortController(); + for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => ac.abort()); + console.log(`[synthetic-bot] targeting ${config.baseUrl} every ${config.intervalMs}ms`); + bot.run({ intervalMs: config.intervalMs, signal: ac.signal }).then(() => { + console.log('[synthetic-bot] stopped', JSON.stringify(bot.getStatus())); + }); +} diff --git a/backend/src/testing/syntheticBot.test.js b/backend/src/testing/syntheticBot.test.js new file mode 100644 index 00000000..4d4dd6dc --- /dev/null +++ b/backend/src/testing/syntheticBot.test.js @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createSyntheticBot, resolveConfig } from './syntheticBot.js'; + +const silent = { warn() {}, error() {} }; +const json = (body, status = 200) => ({ ok: status < 400, status, json: async () => body }); + +function makeFetch(routes) { + const calls = []; + const fn = async (url, opts) => { + const path = url.replace('http://x', ''); + calls.push({ path, opts }); + const handler = routes[path]; + if (!handler) return json({}, 404); + return typeof handler === 'function' ? handler() : handler; + }; + fn.calls = calls; + return fn; +} + +const happy = { + '/health': json({ status: 'ok' }), + '/api/v1/campaigns': json([{ id: 'c1' }]), + '/api/v1/campaigns/c1': json({ id: 'c1' }), + '/api/v1/campaigns/c1/stats': json({ participants: 1 }), +}; + +test('successful cycle walks the full journey', async () => { + const fetchImpl = makeFetch(happy); + const bot = createSyntheticBot({ baseUrl: 'http://x', apiKey: 'k', fetchImpl, logger: silent }); + const r = await bot.runCycle(); + assert.equal(r.ok, true); + assert.deepEqual(r.results.map((s) => s.name), ['health', 'list-campaigns', 'get-campaign', 'campaign-stats']); + assert.equal(fetchImpl.calls[0].opts.headers['x-api-key'], 'k'); + assert.equal(bot.getStatus().healthy, true); +}); + +test('accepts { data: [] } list shape and handles empty list', async () => { + const fetchImpl = makeFetch({ ...happy, '/api/v1/campaigns': json({ data: [] }) }); + const bot = createSyntheticBot({ baseUrl: 'http://x', fetchImpl, logger: silent }); + const r = await bot.runCycle(); + assert.equal(r.ok, true); + assert.equal(r.results.length, 2); +}); + +test('failures are recorded and flip health after threshold', async () => { + const fetchImpl = makeFetch({ ...happy, '/health': json({}, 503) }); + const bot = createSyntheticBot({ baseUrl: 'http://x', fetchImpl, failureThreshold: 2, logger: silent }); + await bot.runCycle(); + assert.equal(bot.getStatus().healthy, true); + await bot.runCycle(); + const s = bot.getStatus(); + assert.equal(s.healthy, false); + assert.equal(s.failedCycles, 2); + assert.match(s.lastError, /503/); + assert.equal(s.steps.health.failed, 2); +}); + +test('recovers after a successful cycle', async () => { + let fail = true; + const fetchImpl = makeFetch({ ...happy, '/health': () => (fail ? json({}, 500) : json({})) }); + const bot = createSyntheticBot({ baseUrl: 'http://x', fetchImpl, failureThreshold: 1, logger: silent }); + await bot.runCycle(); + assert.equal(bot.getStatus().healthy, false); + fail = false; + await bot.runCycle(); + assert.equal(bot.getStatus().healthy, true); +}); + +test('network errors and bad shapes do not throw', async () => { + const bot = createSyntheticBot({ + baseUrl: 'http://x', + fetchImpl: async () => { throw new Error('ECONNREFUSED'); }, + logger: silent, + }); + const r = await bot.runCycle(); + assert.equal(r.ok, false); + const bad = createSyntheticBot({ + baseUrl: 'http://x', + fetchImpl: makeFetch({ ...happy, '/api/v1/campaigns': json({ nope: 1 }) }), + logger: silent, + }); + assert.equal((await bad.runCycle()).ok, false); +}); + +test('request timeout aborts hung requests', async () => { + const fetchImpl = (_u, { signal }) => + new Promise((_, reject) => signal.addEventListener('abort', () => reject(new Error('aborted')))); + const bot = createSyntheticBot({ baseUrl: 'http://x', fetchImpl, timeoutMs: 10, logger: silent }); + const r = await bot.runCycle(); + assert.equal(r.ok, false); + assert.match(r.results[0].error, /aborted/); +}); + +test('run loops until aborted', async () => { + const bot = createSyntheticBot({ baseUrl: 'http://x', fetchImpl: makeFetch(happy), logger: silent }); + const ac = new AbortController(); + const p = bot.run({ intervalMs: 5, signal: ac.signal }); + await new Promise((r) => setTimeout(r, 40)); + ac.abort(); + await p; + assert.ok(bot.getStatus().cycles >= 2); +}); + +test('resolveConfig applies defaults, clamps and validates', () => { + const c = resolveConfig({ BOT_INTERVAL_MS: '5', BOT_BASE_URL: 'http://h:1/' }); + assert.equal(c.baseUrl, 'http://h:1'); + assert.equal(c.intervalMs, 60000); + assert.throws(() => resolveConfig({ BOT_BASE_URL: 'not a url' }), /Invalid BOT_BASE_URL/); +});