Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
169 changes: 169 additions & 0 deletions backend/src/testing/syntheticBot.js
Original file line number Diff line number Diff line change
@@ -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()));
});
}
110 changes: 110 additions & 0 deletions backend/src/testing/syntheticBot.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});