diff --git a/docs/superpowers/plans/2026-08-10-survey-import-stress-test.md b/docs/superpowers/plans/2026-08-10-survey-import-stress-test.md new file mode 100644 index 0000000000..745ecdf7e1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-survey-import-stress-test.md @@ -0,0 +1,2103 @@ +# Survey Import Concurrency Stress Test Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a standalone Node CLI tool that fires N (default 50) concurrent `POST /api/survey/arena-import` requests against a running Arena server, using a real Arena survey export zip, to validate the survey-creation/import concurrency fixes on this branch under real HTTP load. + +**Architecture:** Four small, independently-testable CommonJS modules under `test/load/lib/` (CLI config parsing, latency statistics, report formatting, thin `fetch`-based HTTP API client) wired together by one orchestrator script, `test/load/surveyImportStressTest.js`, that logs in once, fires the burst, polls each resulting job to completion, prints a report, and cleans up. + +**Tech Stack:** Plain Node 24 (global `fetch`/`FormData`/`Blob`/`Response`, `node:test`, `node:assert/strict`), `dotenv` (already a project dependency) for `.env` fallback. No new npm dependencies. + +## Global Constraints + +- No new npm dependencies — use Node 24 built-ins (`fetch`, `FormData`, `Blob`, `Response`, `crypto`) plus the already-present `dotenv`. +- Plain CommonJS `.js` files (`require`/`module.exports`), runnable directly via `node ` — no babel/webpack build step. +- Code style follows `.prettierrc`: no semicolons, single quotes, 120-char print width, ES5 trailing commas. Run `npx eslint --cache --fix ` on every new file (per `CLAUDE.md`) — the active config is the flat `eslint.config.js` at the repo root (the legacy `.eslintrc` is not used by this project's installed ESLint 9). +- Not wired into `yarn test` / `yarn test:unit` / `yarn test:e2e` / CI — this is a manual load-testing tool for pointing at a running server, invoked directly via `node` or a dedicated `yarn` script added in Task 5. +- True burst concurrency only (`Promise.all`/`Promise.allSettled`) — no ramped/staged batches. +- Auto-cleanup of every survey the run creates is the default behavior (`DELETE /api/survey/:surveyId`); `--keep` opts out. +- Confirmed API contracts (read from source, not assumed): + - `POST /auth/login` — mounted at the server root, **not** under `/api` (`authApi.init(app)` in `server/system/appCluster.js`, route defined in `node_modules/@openforis/arena-server/dist/api/auth/login.js`). Body `{ email, password }`; response `{ user, survey, authToken }`. + - `POST /api/survey/arena-import` — `server/modules/arenaImport/api/arenaImportApi.js`. Multipart form fields: `survey` (JSON string, only `name`/`options` are read) and `file` (the zip). Omitting `chunk`/`totalChunks`/`totalFileSize` selects the single-file (non-chunked) path in `server/modules/file/service/requestChunkedFileProcessor.js`. Response `{ job }` where `job` is `JobUtils.jobToJSON(job)`. + - `GET /api/jobs/:jobUuid` — `server/job/jobApi.js`. Response is the job summary **directly** (not wrapped), with fields `uuid`, `status` (one of `pending`/`running`/`succeeded`/`canceled`/`failed`, see `server/job/jobUtils.js:jobStatus`), `surveyId` (populated once the survey row is created — see `server/modules/arenaImport/service/arenaImport/jobs/surveyCreatorJob.js:75-77`), `errors`, `result`. + - `DELETE /api/survey/:surveyId` — `server/modules/survey/api/surveyApi.js`. + - Auth: JWT bearer token on every request after login — header `Authorization: Bearer `. + +--- + +## File Structure + +``` +test/load/ + lib/ + stats.js # computeStats(values) -> {count,min,max,avg,p95} + stats.test.js + config.js # parseConfig({argv,env}) -> resolved config | {help:true} + config.test.js + report.js # formatSummary({results,totalDurationMs}) -> string + report.test.js + httpApi.js # login/importSurveyZip/getJobStatus/deleteSurvey (fetch wrappers) + httpApi.test.js + surveyImportStressTest.js # CLI entry point / orchestrator (not unit tested — see Task 5) +``` + +--- + +### Task 1: Latency statistics module + +**Files:** +- Create: `test/load/lib/stats.js` +- Test: `test/load/lib/stats.test.js` + +**Interfaces:** +- Produces: `computeStats(values: number[]) -> { count: number, min: number|null, max: number|null, avg: number|null, p95: number|null }`. Empty/non-array input returns `{ count: 0, min: null, max: null, avg: null, p95: null }`. + +- [ ] **Step 1: Write the failing tests** + +Create `test/load/lib/stats.test.js`: + +```js +const test = require('node:test') +const assert = require('node:assert/strict') + +const { computeStats } = require('./stats') + +test('computeStats returns nulls for an empty array', () => { + assert.deepEqual(computeStats([]), { count: 0, min: null, max: null, avg: null, p95: null }) +}) + +test('computeStats computes min/max/avg for a simple set', () => { + const stats = computeStats([10, 20, 30]) + assert.equal(stats.count, 3) + assert.equal(stats.min, 10) + assert.equal(stats.max, 30) + assert.equal(stats.avg, 20) +}) + +test('computeStats is not affected by input order', () => { + const stats = computeStats([30, 10, 20]) + assert.equal(stats.min, 10) + assert.equal(stats.max, 30) +}) + +test('computeStats computes p95 for a 100-sample set', () => { + const values = Array.from({ length: 100 }, (_, i) => i + 1) // 1..100 + const stats = computeStats(values) + assert.equal(stats.p95, 95) +}) + +test('computeStats handles a single value', () => { + const stats = computeStats([42]) + assert.deepEqual(stats, { count: 1, min: 42, max: 42, avg: 42, p95: 42 }) +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node --test test/load/lib/stats.test.js` +Expected: FAIL — `Cannot find module './stats'`. + +- [ ] **Step 3: Implement `stats.js`** + +Create `test/load/lib/stats.js`: + +```js +/** + * Computes summary statistics (min, max, average, p95) for a list of numeric samples. + * @param {Array} values - Numeric samples (e.g. latencies in milliseconds). + * @returns {{count: number, min: number|null, max: number|null, avg: number|null, p95: number|null}} Summary statistics; all fields are null when values is empty. + */ +const computeStats = (values) => { + if (!Array.isArray(values) || values.length === 0) { + return { count: 0, min: null, max: null, avg: null, p95: null } + } + const sorted = [...values].sort((a, b) => a - b) + const count = sorted.length + const sum = sorted.reduce((total, value) => total + value, 0) + const p95Index = Math.min(count - 1, Math.ceil(count * 0.95) - 1) + + return { + count, + min: sorted[0], + max: sorted[count - 1], + avg: sum / count, + p95: sorted[p95Index], + } +} + +module.exports = { computeStats } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `node --test test/load/lib/stats.test.js` +Expected: PASS (5 tests). + +- [ ] **Step 5: Lint and commit** + +```bash +npx eslint --cache --fix test/load/lib/stats.js test/load/lib/stats.test.js +git add test/load/lib/stats.js test/load/lib/stats.test.js +git commit -m "test(load): add latency stats helper for survey import stress test" +``` + +--- + +### Task 2: CLI configuration module + +**Files:** +- Create: `test/load/lib/config.js` +- Test: `test/load/lib/config.test.js` + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: `parseConfig({ argv: string[], env: object }) -> config`, where `config` is either `{ help: true }` or `{ help: false, zipPath: string, url: string, email: string, password: string, count: number, jobTimeoutMs: number, keep: boolean }`. Throws `Error` (with a descriptive message) on missing/invalid input. Also exports `HELP_TEXT: string`, `DEFAULT_URL`, `DEFAULT_COUNT`, `DEFAULT_JOB_TIMEOUT_MS`. + +- [ ] **Step 1: Write the failing tests** + +Create `test/load/lib/config.test.js`: + +```js +const test = require('node:test') +const assert = require('node:assert/strict') +const path = require('node:path') + +const { parseConfig, DEFAULT_URL, DEFAULT_COUNT, DEFAULT_JOB_TIMEOUT_MS } = require('./config') + +const baseEnv = {} + +test('parseConfig throws when --zip is missing', () => { + assert.throws(() => parseConfig({ argv: [], env: baseEnv }), /Missing required argument: --zip/) +}) + +test('parseConfig throws when email is missing', () => { + assert.throws(() => parseConfig({ argv: ['--zip', 'survey.zip'], env: baseEnv }), /Missing email/) +}) + +test('parseConfig throws when password is missing', () => { + assert.throws( + () => parseConfig({ argv: ['--zip', 'survey.zip', '--email', 'a@b.com'], env: baseEnv }), + /Missing password/ + ) +}) + +test('parseConfig applies defaults when only required args are passed', () => { + const config = parseConfig({ + argv: ['--zip', 'survey.zip', '--email', 'a@b.com', '--password', 'pw'], + env: baseEnv, + }) + assert.equal(config.zipPath, path.resolve('survey.zip')) + assert.equal(config.url, DEFAULT_URL) + assert.equal(config.email, 'a@b.com') + assert.equal(config.password, 'pw') + assert.equal(config.count, DEFAULT_COUNT) + assert.equal(config.jobTimeoutMs, DEFAULT_JOB_TIMEOUT_MS) + assert.equal(config.keep, false) +}) + +test('parseConfig falls back to env vars for url/email/password', () => { + const config = parseConfig({ + argv: ['--zip', 'survey.zip'], + env: { ARENA_URL: 'http://example.test/', ADMIN_EMAIL: 'admin@x.com', ADMIN_PASSWORD: 'secret' }, + }) + assert.equal(config.url, 'http://example.test') + assert.equal(config.email, 'admin@x.com') + assert.equal(config.password, 'secret') +}) + +test('parseConfig reads --count, --job-timeout and --keep', () => { + const config = parseConfig({ + argv: [ + '--zip', + 'survey.zip', + '--email', + 'a@b.com', + '--password', + 'pw', + '--count', + '5', + '--job-timeout', + '1000', + '--keep', + ], + env: baseEnv, + }) + assert.equal(config.count, 5) + assert.equal(config.jobTimeoutMs, 1000) + assert.equal(config.keep, true) +}) + +test('parseConfig rejects a non-positive-integer --count', () => { + assert.throws( + () => + parseConfig({ + argv: ['--zip', 'survey.zip', '--email', 'a@b.com', '--password', 'pw', '--count', '0'], + env: baseEnv, + }), + /--count must be a positive integer/ + ) +}) + +test('parseConfig short-circuits with help:true on --help', () => { + assert.deepEqual(parseConfig({ argv: ['--help'], env: baseEnv }), { help: true }) +}) + +test('parseConfig rejects unknown flags', () => { + assert.throws(() => parseConfig({ argv: ['--bogus'], env: baseEnv }), /Unknown argument: --bogus/) +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node --test test/load/lib/config.test.js` +Expected: FAIL — `Cannot find module './config'`. + +- [ ] **Step 3: Implement `config.js`** + +Create `test/load/lib/config.js`: + +```js +const path = require('node:path') + +const DEFAULT_URL = 'http://localhost:9090' +const DEFAULT_COUNT = 50 +const DEFAULT_JOB_TIMEOUT_MS = 120000 + +const FLAG_DEFS = [ + { flag: '--zip', key: 'zipPath', hasValue: true }, + { flag: '--count', key: 'count', hasValue: true }, + { flag: '--url', key: 'url', hasValue: true }, + { flag: '--email', key: 'email', hasValue: true }, + { flag: '--password', key: 'password', hasValue: true }, + { flag: '--job-timeout', key: 'jobTimeoutMs', hasValue: true }, + { flag: '--keep', key: 'keep', hasValue: false }, + { flag: '--help', key: 'help', hasValue: false }, +] + +const HELP_TEXT = `Usage: node test/load/surveyImportStressTest.js --zip [options] + +Options: + --zip Path to an Arena survey export/backup zip (required) + --count Number of concurrent import requests (default: ${DEFAULT_COUNT}) + --url Arena server base URL (default: ${DEFAULT_URL}, env: ARENA_URL) + --email Login email (env: ARENA_EMAIL / ADMIN_EMAIL) + --password Login password (env: ARENA_PASSWORD / ADMIN_PASSWORD) + --job-timeout Max time to wait for each import job (default: ${DEFAULT_JOB_TIMEOUT_MS}) + --keep Do not delete the surveys created by this run + --help Show this help message +` + +/** + * Parses raw CLI arguments into a flat object keyed by flag name. + * @param {Array} argv - Raw CLI arguments (without the node/script path entries). + * @returns {object} Flag values keyed by their config key. + */ +const parseArgv = (argv) => { + const parsed = {} + let index = 0 + while (index < argv.length) { + const arg = argv[index] + const flagDef = FLAG_DEFS.find((def) => def.flag === arg) + if (!flagDef) { + throw new Error(`Unknown argument: ${arg}`) + } + if (flagDef.hasValue) { + const value = argv[index + 1] + if (value === undefined) { + throw new Error(`Missing value for argument: ${arg}`) + } + parsed[flagDef.key] = value + index += 2 + } else { + parsed[flagDef.key] = true + index += 1 + } + } + return parsed +} + +/** + * Parses and validates a value as a positive integer. + * @param {object} params - Function parameters. + * @param {string|number} params.value - Raw value to parse. + * @param {string} params.label - Label used in the error message when invalid. + * @returns {number} The parsed positive integer. + */ +const toPositiveInt = ({ value, label }) => { + const parsed = Number(value) + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive integer, got: ${value}`) + } + return parsed +} + +/** + * Builds the stress test configuration from CLI arguments and environment variables. + * @param {object} params - Function parameters. + * @param {Array} params.argv - Raw CLI arguments (e.g. process.argv.slice(2)). + * @param {object} params.env - Environment variables (e.g. process.env). + * @returns {object} Resolved configuration, or { help: true } when --help was passed. + */ +const parseConfig = ({ argv, env }) => { + const args = parseArgv(argv) + + if (args.help) { + return { help: true } + } + + const zipPath = args.zipPath + if (!zipPath) { + throw new Error('Missing required argument: --zip ') + } + + const url = args.url || env.ARENA_URL || DEFAULT_URL + const email = args.email || env.ARENA_EMAIL || env.ADMIN_EMAIL + if (!email) { + throw new Error('Missing email: pass --email, or set ARENA_EMAIL / ADMIN_EMAIL') + } + const password = args.password || env.ARENA_PASSWORD || env.ADMIN_PASSWORD + if (!password) { + throw new Error('Missing password: pass --password, or set ARENA_PASSWORD / ADMIN_PASSWORD') + } + + const count = toPositiveInt({ value: args.count ?? DEFAULT_COUNT, label: '--count' }) + const jobTimeoutMs = toPositiveInt({ value: args.jobTimeoutMs ?? DEFAULT_JOB_TIMEOUT_MS, label: '--job-timeout' }) + + return { + help: false, + zipPath: path.resolve(zipPath), + url: url.replace(/\/+$/, ''), + email, + password, + count, + jobTimeoutMs, + keep: Boolean(args.keep), + } +} + +module.exports = { parseConfig, HELP_TEXT, DEFAULT_URL, DEFAULT_COUNT, DEFAULT_JOB_TIMEOUT_MS } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `node --test test/load/lib/config.test.js` +Expected: PASS (9 tests). + +- [ ] **Step 5: Lint and commit** + +```bash +npx eslint --cache --fix test/load/lib/config.js test/load/lib/config.test.js +git add test/load/lib/config.js test/load/lib/config.test.js +git commit -m "test(load): add CLI config parsing for survey import stress test" +``` + +--- + +### Task 3: Report formatting module + +**Files:** +- Create: `test/load/lib/report.js` +- Test: `test/load/lib/report.test.js` + +**Interfaces:** +- Consumes: `computeStats` from Task 1 (`require('./stats')`). +- Produces: `formatSummary({ results: ResultEntry[], totalDurationMs: number }) -> string`, where `ResultEntry = { index: number, name: string, outcome: 'succeeded'|'failed'|'timed-out'|'canceled'|'rejected-at-http', surveyId: number|null, acceptMs: number|null, jobMs: number|null, error: string|null }`. This exact `ResultEntry` shape is what Task 5's orchestrator builds and passes in. + +- [ ] **Step 1: Write the failing tests** + +Create `test/load/lib/report.test.js`: + +```js +const test = require('node:test') +const assert = require('node:assert/strict') + +const { formatSummary } = require('./report') + +const baseResult = { index: 0, name: 'stress_test_0', outcome: 'succeeded', acceptMs: 100, jobMs: 500, error: null } + +test('formatSummary counts outcomes and reports latency stats', () => { + const results = [ + { ...baseResult, index: 0, name: 's0' }, + { ...baseResult, index: 1, name: 's1', acceptMs: 200, jobMs: 1000 }, + { ...baseResult, index: 2, name: 's2', outcome: 'failed', error: 'boom', acceptMs: 150, jobMs: 300 }, + ] + const summary = formatSummary({ results, totalDurationMs: 2000 }) + + assert.match(summary, /Total requests: 3/) + assert.match(summary, /succeeded: 2/) + assert.match(summary, /failed: 1/) + assert.match(summary, /timed-out: 0/) +}) + +test('formatSummary lists failure detail lines', () => { + const results = [{ ...baseResult, index: 4, name: 's4', outcome: 'failed', error: 'pool exhausted' }] + const summary = formatSummary({ results, totalDurationMs: 500 }) + + assert.match(summary, /Failures:/) + assert.match(summary, /\[4\] s4 - failed: pool exhausted/) +}) + +test('formatSummary omits the Failures section when everything succeeded', () => { + const results = [{ ...baseResult }] + const summary = formatSummary({ results, totalDurationMs: 500 }) + + assert.doesNotMatch(summary, /Failures:/) +}) + +test('formatSummary handles an empty results array', () => { + const summary = formatSummary({ results: [], totalDurationMs: 0 }) + assert.match(summary, /Total requests: 0/) +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node --test test/load/lib/report.test.js` +Expected: FAIL — `Cannot find module './report'`. + +- [ ] **Step 3: Implement `report.js`** + +Create `test/load/lib/report.js`: + +```js +const { computeStats } = require('./stats') + +const OUTCOME_ORDER = ['succeeded', 'failed', 'timed-out', 'canceled', 'rejected-at-http'] + +/** + * Formats a millisecond duration for display, or 'n/a' when not available. + * @param {number|null} value - Duration in milliseconds, or null. + * @returns {string} Formatted duration. + */ +const formatMs = (value) => (value === null || value === undefined ? 'n/a' : `${Math.round(value)}ms`) + +/** + * Builds a human-readable summary report for a stress test run. + * @param {object} params - Function parameters. + * @param {Array} params.results - Per-request result objects (see report.test.js for the shape). + * @param {number} params.totalDurationMs - Total wall-clock duration of the run, in milliseconds. + * @returns {string} The formatted report. + */ +const formatSummary = ({ results, totalDurationMs }) => { + const total = results.length + const byOutcome = results.reduce((acc, result) => { + acc[result.outcome] = (acc[result.outcome] || 0) + 1 + return acc + }, {}) + + const acceptStats = computeStats( + results.filter((result) => result.acceptMs !== null).map((result) => result.acceptMs) + ) + const jobStats = computeStats(results.filter((result) => result.jobMs !== null).map((result) => result.jobMs)) + + const lines = [] + lines.push('') + lines.push('==================== Survey Import Stress Test Summary ====================') + lines.push(`Total requests: ${total}`) + lines.push(`Total duration: ${formatMs(totalDurationMs)}`) + lines.push('') + lines.push('Outcomes:') + OUTCOME_ORDER.forEach((outcome) => { + lines.push(` ${outcome}: ${byOutcome[outcome] || 0}`) + }) + lines.push('') + lines.push( + `Accept latency (min/avg/max/p95): ${formatMs(acceptStats.min)} / ${formatMs(acceptStats.avg)} / ${formatMs(acceptStats.max)} / ${formatMs(acceptStats.p95)}` + ) + lines.push( + `Job latency (min/avg/max/p95): ${formatMs(jobStats.min)} / ${formatMs(jobStats.avg)} / ${formatMs(jobStats.max)} / ${formatMs(jobStats.p95)}` + ) + + const failures = results.filter((result) => result.outcome !== 'succeeded') + if (failures.length > 0) { + lines.push('') + lines.push('Failures:') + failures.forEach((failure) => { + lines.push(` [${failure.index}] ${failure.name} - ${failure.outcome}: ${failure.error || 'no error detail'}`) + }) + } + lines.push('=============================================================================') + lines.push('') + + return lines.join('\n') +} + +module.exports = { formatSummary } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `node --test test/load/lib/report.test.js` +Expected: PASS (4 tests). + +- [ ] **Step 5: Lint and commit** + +```bash +npx eslint --cache --fix test/load/lib/report.js test/load/lib/report.test.js +git add test/load/lib/report.js test/load/lib/report.test.js +git commit -m "test(load): add summary report formatting for survey import stress test" +``` + +--- + +### Task 4: HTTP API client module + +**Files:** +- Create: `test/load/lib/httpApi.js` +- Test: `test/load/lib/httpApi.test.js` + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces (all accept an injectable `fetchImpl`, defaulting to the global `fetch`, so callers can pass a stub in tests): + - `login({ baseUrl, email, password, fetchImpl? }) -> Promise` (the auth token) + - `buildImportFormData({ zipBuffer, zipFileName, surveyName }) -> FormData` + - `importSurveyZip({ baseUrl, authToken, zipBuffer, zipFileName, surveyName, fetchImpl? }) -> Promise<{ uuid, status, ... }>` (the job object) + - `getJobStatus({ baseUrl, authToken, jobUuid, fetchImpl? }) -> Promise` (the job summary) + - `deleteSurvey({ baseUrl, authToken, surveyId, fetchImpl? }) -> Promise` + - All four network functions throw an `Error` (message includes the HTTP status and response body) on a non-2xx response. + +- [ ] **Step 1: Write the failing tests** + +Create `test/load/lib/httpApi.test.js`: + +```js +const test = require('node:test') +const assert = require('node:assert/strict') + +const { login, buildImportFormData, importSurveyZip, getJobStatus, deleteSurvey } = require('./httpApi') + +const jsonResponse = (body, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) + +test('login resolves the auth token and calls the right endpoint', async () => { + const calls = [] + const fetchImpl = async (url, options) => { + calls.push({ url, options }) + return jsonResponse({ authToken: 'tok-123' }) + } + + const authToken = await login({ baseUrl: 'http://x', email: 'a@b.com', password: 'pw', fetchImpl }) + + assert.equal(authToken, 'tok-123') + assert.equal(calls.length, 1) + assert.equal(calls[0].url, 'http://x/auth/login') + assert.equal(calls[0].options.method, 'POST') + assert.deepEqual(JSON.parse(calls[0].options.body), { email: 'a@b.com', password: 'pw' }) +}) + +test('login throws with status and body detail on failure', async () => { + const fetchImpl = async () => jsonResponse({ message: 'bad creds' }, 401) + + await assert.rejects( + () => login({ baseUrl: 'http://x', email: 'a@b.com', password: 'wrong', fetchImpl }), + /Login failed \(status 401\).*bad creds/ + ) +}) + +test('buildImportFormData sets the survey and file fields', async () => { + const formData = buildImportFormData({ + zipBuffer: Buffer.from('zip-bytes'), + zipFileName: 'survey.zip', + surveyName: 'stress_test_1', + }) + + const surveyField = JSON.parse(formData.get('survey')) + assert.deepEqual(surveyField, { name: 'stress_test_1', options: { includeData: false } }) + + const fileField = formData.get('file') + assert.equal(fileField.name, 'survey.zip') + const content = Buffer.from(await fileField.arrayBuffer()) + assert.equal(content.toString(), 'zip-bytes') +}) + +test('importSurveyZip posts multipart form data with the bearer token', async () => { + const calls = [] + const fetchImpl = async (url, options) => { + calls.push({ url, options }) + return jsonResponse({ job: { uuid: 'job-1', status: 'pending' } }) + } + + const job = await importSurveyZip({ + baseUrl: 'http://x', + authToken: 'tok-123', + zipBuffer: Buffer.from('zip-bytes'), + zipFileName: 'survey.zip', + surveyName: 'stress_test_1', + fetchImpl, + }) + + assert.deepEqual(job, { uuid: 'job-1', status: 'pending' }) + assert.equal(calls[0].url, 'http://x/api/survey/arena-import') + assert.equal(calls[0].options.method, 'POST') + assert.equal(calls[0].options.headers.Authorization, 'Bearer tok-123') + assert.ok(calls[0].options.body instanceof FormData) +}) + +test('importSurveyZip throws when the response has no job', async () => { + const fetchImpl = async () => jsonResponse({ message: 'pool exhausted' }, 503) + + await assert.rejects( + () => + importSurveyZip({ + baseUrl: 'http://x', + authToken: 'tok', + zipBuffer: Buffer.from('x'), + zipFileName: 'x.zip', + surveyName: 'n', + fetchImpl, + }), + /Import request failed \(status 503\).*pool exhausted/ + ) +}) + +test('getJobStatus resolves the job summary', async () => { + const fetchImpl = async () => jsonResponse({ uuid: 'job-1', status: 'succeeded', surveyId: 42 }) + + const job = await getJobStatus({ baseUrl: 'http://x', authToken: 'tok', jobUuid: 'job-1', fetchImpl }) + + assert.deepEqual(job, { uuid: 'job-1', status: 'succeeded', surveyId: 42 }) +}) + +test('getJobStatus throws on a non-ok response', async () => { + const fetchImpl = async () => jsonResponse({ message: 'not found' }, 404) + + await assert.rejects( + () => getJobStatus({ baseUrl: 'http://x', authToken: 'tok', jobUuid: 'missing', fetchImpl }), + /Job status request failed \(status 404\)/ + ) +}) + +test('deleteSurvey resolves on a successful delete', async () => { + const calls = [] + const fetchImpl = async (url, options) => { + calls.push({ url, options }) + return new Response(null, { status: 200 }) + } + + await deleteSurvey({ baseUrl: 'http://x', authToken: 'tok', surveyId: 42, fetchImpl }) + + assert.equal(calls[0].url, 'http://x/api/survey/42') + assert.equal(calls[0].options.method, 'DELETE') +}) + +test('deleteSurvey throws on a failed delete', async () => { + const fetchImpl = async () => jsonResponse({ message: 'cannot delete' }, 403) + + await assert.rejects( + () => deleteSurvey({ baseUrl: 'http://x', authToken: 'tok', surveyId: 42, fetchImpl }), + /Delete survey 42 failed \(status 403\)/ + ) +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node --test test/load/lib/httpApi.test.js` +Expected: FAIL — `Cannot find module './httpApi'`. + +- [ ] **Step 3: Implement `httpApi.js`** + +Create `test/load/lib/httpApi.js`: + +```js +/** + * Logs in against the Arena API and returns a bearer auth token. + * @param {object} params - Function parameters. + * @param {string} params.baseUrl - Arena server base URL (no trailing slash). + * @param {string} params.email - Login email. + * @param {string} params.password - Login password. + * @param {Function} [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns {Promise} The JWT auth token. + */ +const login = async ({ baseUrl, email, password, fetchImpl = fetch }) => { + const response = await fetchImpl(`${baseUrl}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + const body = await response.json() + if (!response.ok || !body.authToken) { + throw new Error(`Login failed (status ${response.status}): ${JSON.stringify(body)}`) + } + return body.authToken +} + +/** + * Builds the multipart form data for an Arena survey zip import request. + * @param {object} params - Function parameters. + * @param {Buffer} params.zipBuffer - The survey zip file content. + * @param {string} params.zipFileName - The file name to send for the zip part. + * @param {string} params.surveyName - The unique name for the new survey. + * @returns {FormData} The multipart form data ready to send as a fetch body. + */ +const buildImportFormData = ({ zipBuffer, zipFileName, surveyName }) => { + const formData = new FormData() + formData.append('survey', JSON.stringify({ name: surveyName, options: { includeData: false } })) + formData.append('file', new Blob([zipBuffer], { type: 'application/zip' }), zipFileName) + return formData +} + +/** + * Starts an Arena survey import job from a zip file. + * @param {object} params - Function parameters. + * @param {string} params.baseUrl - Arena server base URL. + * @param {string} params.authToken - JWT auth token from login. + * @param {Buffer} params.zipBuffer - The survey zip file content. + * @param {string} params.zipFileName - The file name to send for the zip part. + * @param {string} params.surveyName - The unique name for the new survey. + * @param {Function} [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns {Promise} The created job summary (includes uuid and status). + */ +const importSurveyZip = async ({ baseUrl, authToken, zipBuffer, zipFileName, surveyName, fetchImpl = fetch }) => { + const formData = buildImportFormData({ zipBuffer, zipFileName, surveyName }) + const response = await fetchImpl(`${baseUrl}/api/survey/arena-import`, { + method: 'POST', + headers: { Authorization: `Bearer ${authToken}` }, + body: formData, + }) + const body = await response.json() + if (!response.ok || !body.job || !body.job.uuid) { + throw new Error(`Import request failed (status ${response.status}): ${JSON.stringify(body)}`) + } + return body.job +} + +/** + * Fetches the current status of a background job. + * @param {object} params - Function parameters. + * @param {string} params.baseUrl - Arena server base URL. + * @param {string} params.authToken - JWT auth token from login. + * @param {string} params.jobUuid - UUID of the job to fetch. + * @param {Function} [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns {Promise} The job summary. + */ +const getJobStatus = async ({ baseUrl, authToken, jobUuid, fetchImpl = fetch }) => { + const response = await fetchImpl(`${baseUrl}/api/jobs/${jobUuid}`, { + method: 'GET', + headers: { Authorization: `Bearer ${authToken}` }, + }) + const body = await response.json() + if (!response.ok) { + throw new Error(`Job status request failed (status ${response.status}): ${JSON.stringify(body)}`) + } + return body +} + +/** + * Deletes a survey. + * @param {object} params - Function parameters. + * @param {string} params.baseUrl - Arena server base URL. + * @param {string} params.authToken - JWT auth token from login. + * @param {number|string} params.surveyId - ID of the survey to delete. + * @param {Function} [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns {Promise} Resolves when the survey has been deleted. + */ +const deleteSurvey = async ({ baseUrl, authToken, surveyId, fetchImpl = fetch }) => { + const response = await fetchImpl(`${baseUrl}/api/survey/${surveyId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${authToken}` }, + }) + if (!response.ok) { + const body = await response.json().catch(() => ({})) + throw new Error(`Delete survey ${surveyId} failed (status ${response.status}): ${JSON.stringify(body)}`) + } +} + +module.exports = { login, buildImportFormData, importSurveyZip, getJobStatus, deleteSurvey } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `node --test test/load/lib/httpApi.test.js` +Expected: PASS (9 tests). + +- [ ] **Step 5: Lint and commit** + +```bash +npx eslint --cache --fix test/load/lib/httpApi.js test/load/lib/httpApi.test.js +git add test/load/lib/httpApi.js test/load/lib/httpApi.test.js +git commit -m "test(load): add HTTP client for survey import stress test" +``` + +--- + +### Task 5: CLI orchestrator and wiring + +**Files:** +- Create: `test/load/surveyImportStressTest.js` +- Modify: `package.json` (add two `scripts` entries) + +**Interfaces:** +- Consumes: `parseConfig`, `HELP_TEXT` from `./lib/config` (Task 2); `login`, `importSurveyZip`, `getJobStatus`, `deleteSurvey` from `./lib/httpApi` (Task 4); `formatSummary` from `./lib/report` (Task 3), which itself uses `./lib/stats` (Task 1). +- Produces: a runnable CLI. Also exports `{ main, runSingleImport, pollJobUntilTerminal, cleanupSurveys }` from `module.exports` for reference, though this task does not add automated tests for them (see rationale in Step 1). + +This task has no automated test: it's the network-orchestration layer that talks to a real, running Arena server, which is exactly the non-goal called out in the spec ("Not wired into `yarn test`/CI — it targets a running Arena server"). The four lib modules it wires together are already unit-tested in Tasks 1-4. Verification here is a syntax/wiring smoke check (Step 3) plus a manual end-to-end run against a real server, which the user runs themselves (or asks the assistant to run) once a dev server and a sample zip are available. + +- [ ] **Step 1: Implement `surveyImportStressTest.js`** + +Create `test/load/surveyImportStressTest.js`: + +```js +/* eslint-disable no-console -- this file's entire purpose is CLI reporting */ +require('dotenv').config() + +const fs = require('node:fs') +const path = require('node:path') + +const { parseConfig, HELP_TEXT } = require('./lib/config') +const { login, importSurveyZip, getJobStatus, deleteSurvey } = require('./lib/httpApi') +const { formatSummary } = require('./lib/report') + +const JOB_POLL_INTERVAL_MS = 1000 +const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'canceled']) + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * Polls a job until it reaches a terminal status or the timeout elapses. + * @param {object} params - Function parameters. + * @param {string} params.baseUrl - Arena server base URL. + * @param {string} params.authToken - JWT auth token. + * @param {string} params.jobUuid - UUID of the job to poll. + * @param {number} params.timeoutMs - Max time to wait, in milliseconds. + * @returns {Promise} The last fetched job summary; its status is 'timed-out' if the timeout elapsed first. + */ +const pollJobUntilTerminal = async ({ baseUrl, authToken, jobUuid, timeoutMs }) => { + const startedAt = Date.now() + for (;;) { + const job = await getJobStatus({ baseUrl, authToken, jobUuid }) + if (TERMINAL_STATUSES.has(job.status)) { + return job + } + if (Date.now() - startedAt >= timeoutMs) { + return { ...job, status: 'timed-out' } + } + await sleep(JOB_POLL_INTERVAL_MS) + } +} + +/** + * Runs one survey import request end-to-end (accept + poll to completion) and reports its outcome. + * @param {object} params - Function parameters. + * @param {string} params.baseUrl - Arena server base URL. + * @param {string} params.authToken - JWT auth token. + * @param {Buffer} params.zipBuffer - The survey zip file content. + * @param {string} params.zipFileName - The file name to send for the zip part. + * @param {string} params.surveyName - The unique name for the new survey. + * @param {number} params.index - Index of this request within the run (for reporting). + * @param {number} params.jobTimeoutMs - Max time to wait for the job to finish. + * @returns {Promise} A result entry (see report.js for the shape). + */ +const runSingleImport = async ({ baseUrl, authToken, zipBuffer, zipFileName, surveyName, index, jobTimeoutMs }) => { + const acceptStartedAt = Date.now() + let job + try { + job = await importSurveyZip({ baseUrl, authToken, zipBuffer, zipFileName, surveyName }) + } catch (error) { + return { + index, + name: surveyName, + outcome: 'rejected-at-http', + surveyId: null, + acceptMs: Date.now() - acceptStartedAt, + jobMs: null, + error: error.message, + } + } + const acceptMs = Date.now() - acceptStartedAt + + const jobStartedAt = Date.now() + const finalJob = await pollJobUntilTerminal({ baseUrl, authToken, jobUuid: job.uuid, timeoutMs: jobTimeoutMs }) + const jobMs = Date.now() - jobStartedAt + + const outcome = finalJob.status + const error = outcome === 'succeeded' ? null : JSON.stringify(finalJob.errors || finalJob.result || 'unknown error') + + return { + index, + name: surveyName, + outcome, + surveyId: finalJob.surveyId || null, + acceptMs, + jobMs, + error, + } +} + +/** + * Deletes every survey referenced by the given results, best-effort. + * @param {object} params - Function parameters. + * @param {string} params.baseUrl - Arena server base URL. + * @param {string} params.authToken - JWT auth token. + * @param {Array} params.results - Result entries produced by runSingleImport. + * @returns {Promise} Resolves once every deletion attempt has settled. + */ +const cleanupSurveys = async ({ baseUrl, authToken, results }) => { + const surveyIds = results.map((result) => result.surveyId).filter(Boolean) + const cleanupResults = await Promise.allSettled( + surveyIds.map((surveyId) => deleteSurvey({ baseUrl, authToken, surveyId })) + ) + cleanupResults.forEach((cleanupResult, i) => { + if (cleanupResult.status === 'rejected') { + console.error(`Failed to delete survey ${surveyIds[i]}: ${cleanupResult.reason.message}`) + } + }) +} + +/** + * CLI entry point: parses config, runs the concurrent import burst, reports, and cleans up. + * @returns {Promise} Resolves when the run is complete; sets process.exitCode on failure. + */ +const main = async () => { + let config + try { + config = parseConfig({ argv: process.argv.slice(2), env: process.env }) + } catch (error) { + console.error(error.message) + console.error(HELP_TEXT) + process.exitCode = 1 + return + } + + if (config.help) { + console.log(HELP_TEXT) + return + } + + const { zipPath, url, email, password, count, jobTimeoutMs, keep } = config + + console.log(`Reading zip file: ${zipPath}`) + const zipBuffer = fs.readFileSync(zipPath) + const zipFileName = path.basename(zipPath) + + console.log(`Logging in as ${email} at ${url}...`) + const authToken = await login({ baseUrl: url, email, password }) + + const runId = Date.now() + console.log(`Firing ${count} concurrent survey imports (run ${runId})...`) + + const startedAt = Date.now() + const results = await Promise.all( + Array.from({ length: count }, (_, i) => + runSingleImport({ + baseUrl: url, + authToken, + zipBuffer, + zipFileName, + surveyName: `stress_test_${runId}_${i}`, + index: i, + jobTimeoutMs, + }) + ) + ) + const totalDurationMs = Date.now() - startedAt + + console.log(formatSummary({ results, totalDurationMs })) + + if (!keep) { + console.log('Cleaning up created surveys...') + await cleanupSurveys({ baseUrl: url, authToken, results }) + } + + const anyFailed = results.some((result) => result.outcome !== 'succeeded') + process.exitCode = anyFailed ? 1 : 0 +} + +if (require.main === module) { + main().catch((error) => { + console.error('Stress test failed to run:', error) + process.exitCode = 1 + }) +} + +module.exports = { main, runSingleImport, pollJobUntilTerminal, cleanupSurveys } +``` + +- [ ] **Step 2: Add convenience `yarn` scripts** + +In `package.json`, in the `"scripts"` object, add these two entries right after the `"test:e2e:watch"` line (`package.json:28`): + +```json + "test:load": "node test/load/surveyImportStressTest.js", + "test:load:unit": "node --test test/load/lib/", +``` + +- [ ] **Step 3: Smoke-check the CLI wiring (no server required)** + +Run each of these and confirm the described output — these exercise config parsing, help text, and error handling without needing a live server or network access: + +```bash +node test/load/surveyImportStressTest.js --help +``` +Expected: prints `HELP_TEXT` (usage block starting with `Usage: node test/load/surveyImportStressTest.js --zip [options]`), exit code 0. + +```bash +node test/load/surveyImportStressTest.js +``` +Expected: prints `Missing required argument: --zip ` followed by the help text, exits with a non-zero code. + +```bash +node -e "require('./test/load/surveyImportStressTest.js')" +``` +Expected: no output, no error (confirms the module loads without executing `main()`, since `require.main !== module` in this context). + +- [ ] **Step 4: Run the full unit suite together and lint everything** + +```bash +node --test test/load/lib/ +npx eslint --cache --fix test/load/surveyImportStressTest.js +``` + +Expected: all unit tests (from Tasks 1-4) pass in one run; eslint reports no errors on the new orchestrator file (module-load smoke checks from Step 3 already confirm no syntax errors). + +- [ ] **Step 5: Commit** + +```bash +git add test/load/surveyImportStressTest.js package.json +git commit -m "feat(load): add survey import concurrency stress test CLI" +``` + +--- + +## Addendum Tasks (post-final-review) + +The final whole-branch review (see ledger) found that firing N requests +under one shared login cannot exercise concurrent job execution at all +(the server's job queue serializes survey-creation/import jobs globally — +see the spec's Addendum section for the full, verified explanation), plus +a real bug in how job results are read. The user chose to re-scope the +tool to use N distinct throwaway users rather than one shared login. Tasks +6-8 implement the fixes and the rescope, in this order (7 depends on 6's +`readBody` helper; 8 depends on 6's `readBody` helper and on 7's +`fetchImpl`-threaded, always-total `runSingleImport`). + +### Task 6: Safe error-body handling in httpApi.js + +**Files:** +- Modify: `test/load/lib/httpApi.js` +- Modify: `test/load/lib/httpApi.test.js` + +**Interfaces:** +- Consumes: nothing new. +- Produces: same five exports as before (`login`, `buildImportFormData`, `importSurveyZip`, `getJobStatus`, `deleteSurvey`) with identical signatures and behavior on success and on JSON error bodies. The only behavior change is on **non-JSON or empty** error response bodies: previously `response.json()` was called unconditionally and threw a raw, unhelpful `SyntaxError` in that case (losing the HTTP status); now the thrown `Error` always includes the HTTP status and whatever body text was available. + +- [ ] **Step 1: Write the failing tests** + +Add these test cases to `test/load/lib/httpApi.test.js` (keep all 9 existing tests unchanged; add these after them, before the final `module.exports`-adjacent code — there is none, just append at the end of the file): + +```js +test('login throws with status and raw text when the error body is not JSON', async () => { + const fetchImpl = async () => new Response('Bad Gateway', { status: 502 }) + + await assert.rejects( + () => login({ baseUrl: 'http://x', email: 'a@b.com', password: 'pw', fetchImpl }), + /Login failed \(status 502\).*Bad Gateway/ + ) +}) + +test('login throws with status when the error body is empty', async () => { + const fetchImpl = async () => new Response(null, { status: 504 }) + + await assert.rejects( + () => login({ baseUrl: 'http://x', email: 'a@b.com', password: 'pw', fetchImpl }), + /Login failed \(status 504\)/ + ) +}) + +test('importSurveyZip throws with status and raw text when the error body is not JSON', async () => { + const fetchImpl = async () => new Response('Gateway Timeout', { status: 504 }) + + await assert.rejects( + () => + importSurveyZip({ + baseUrl: 'http://x', + authToken: 'tok', + zipBuffer: Buffer.from('x'), + zipFileName: 'x.zip', + surveyName: 'n', + fetchImpl, + }), + /Import request failed \(status 504\).*Gateway Timeout/ + ) +}) + +test('getJobStatus throws with status and raw text when the error body is not JSON', async () => { + const fetchImpl = async () => new Response('Service Unavailable', { status: 503 }) + + await assert.rejects( + () => getJobStatus({ baseUrl: 'http://x', authToken: 'tok', jobUuid: 'job-1', fetchImpl }), + /Job status request failed \(status 503\).*Service Unavailable/ + ) +}) + +test('deleteSurvey throws with status and raw text when the error body is not JSON', async () => { + const fetchImpl = async () => new Response('Forbidden', { status: 403 }) + + await assert.rejects( + () => deleteSurvey({ baseUrl: 'http://x', authToken: 'tok', surveyId: 42, fetchImpl }), + /Delete survey 42 failed \(status 403\).*Forbidden/ + ) +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node --test test/load/lib/httpApi.test.js` +Expected: the 5 new tests FAIL (the current code throws an unhandled `SyntaxError` from inside `response.json()` instead of the expected `Error` with status/body text — `assert.rejects` will report a mismatch, e.g. the thrown error's message won't match the regex, or a `SyntaxError` propagates uncaught). + +- [ ] **Step 3: Replace `test/load/lib/httpApi.js` with this exact content** + +```js +/** + * Reads a fetch Response body once as text, and attempts to JSON-parse it. + * Never throws: falls back to { message: } (or {} for an empty body) when the body isn't valid JSON. + * @param {Response} response - The fetch Response to read. + * @returns {Promise} The parsed JSON body, or a fallback object wrapping the raw text. + */ +const readBody = async (response) => { + const text = await response.text() + if (!text) { + return {} + } + try { + return JSON.parse(text) + } catch { + return { message: text } + } +} + +/** + * Logs in against the Arena API and returns a bearer auth token. + * @param {object} params - Function parameters. + * @param {string} params.baseUrl - Arena server base URL (no trailing slash). + * @param {string} params.email - Login email. + * @param {string} params.password - Login password. + * @param {Function} [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns {Promise} The JWT auth token. + */ +const login = async ({ baseUrl, email, password, fetchImpl = fetch }) => { + const response = await fetchImpl(`${baseUrl}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + if (!response.ok) { + const body = await readBody(response) + throw new Error(`Login failed (status ${response.status}): ${JSON.stringify(body)}`) + } + const body = await response.json() + if (!body.authToken) { + throw new Error(`Login failed (status ${response.status}): ${JSON.stringify(body)}`) + } + return body.authToken +} + +/** + * Builds the multipart form data for an Arena survey zip import request. + * @param {object} params - Function parameters. + * @param {Buffer} params.zipBuffer - The survey zip file content. + * @param {string} params.zipFileName - The file name to send for the zip part. + * @param {string} params.surveyName - The unique name for the new survey. + * @returns {FormData} The multipart form data ready to send as a fetch body. + */ +const buildImportFormData = ({ zipBuffer, zipFileName, surveyName }) => { + const formData = new FormData() + formData.append('survey', JSON.stringify({ name: surveyName, options: { includeData: false } })) + formData.append('file', new Blob([zipBuffer], { type: 'application/zip' }), zipFileName) + return formData +} + +/** + * Starts an Arena survey import job from a zip file. + * @param {object} params - Function parameters. + * @param {string} params.baseUrl - Arena server base URL. + * @param {string} params.authToken - JWT auth token from login. + * @param {Buffer} params.zipBuffer - The survey zip file content. + * @param {string} params.zipFileName - The file name to send for the zip part. + * @param {string} params.surveyName - The unique name for the new survey. + * @param {Function} [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns {Promise} The created job summary (includes uuid and status). + */ +const importSurveyZip = async ({ baseUrl, authToken, zipBuffer, zipFileName, surveyName, fetchImpl = fetch }) => { + const formData = buildImportFormData({ zipBuffer, zipFileName, surveyName }) + const response = await fetchImpl(`${baseUrl}/api/survey/arena-import`, { + method: 'POST', + headers: { Authorization: `Bearer ${authToken}` }, + body: formData, + }) + if (!response.ok) { + const body = await readBody(response) + throw new Error(`Import request failed (status ${response.status}): ${JSON.stringify(body)}`) + } + const body = await response.json() + if (!body.job || !body.job.uuid) { + throw new Error(`Import request failed (status ${response.status}): ${JSON.stringify(body)}`) + } + return body.job +} + +/** + * Fetches the current status of a background job. + * @param {object} params - Function parameters. + * @param {string} params.baseUrl - Arena server base URL. + * @param {string} params.authToken - JWT auth token from login. + * @param {string} params.jobUuid - UUID of the job to fetch. + * @param {Function} [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns {Promise} The job summary. + */ +const getJobStatus = async ({ baseUrl, authToken, jobUuid, fetchImpl = fetch }) => { + const response = await fetchImpl(`${baseUrl}/api/jobs/${jobUuid}`, { + method: 'GET', + headers: { Authorization: `Bearer ${authToken}` }, + }) + if (!response.ok) { + const body = await readBody(response) + throw new Error(`Job status request failed (status ${response.status}): ${JSON.stringify(body)}`) + } + return response.json() +} + +/** + * Deletes a survey. + * @param {object} params - Function parameters. + * @param {string} params.baseUrl - Arena server base URL. + * @param {string} params.authToken - JWT auth token from login. + * @param {number|string} params.surveyId - ID of the survey to delete. + * @param {Function} [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns {Promise} Resolves when the survey has been deleted. + */ +const deleteSurvey = async ({ baseUrl, authToken, surveyId, fetchImpl = fetch }) => { + const response = await fetchImpl(`${baseUrl}/api/survey/${surveyId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${authToken}` }, + }) + if (!response.ok) { + const body = await readBody(response) + throw new Error(`Delete survey ${surveyId} failed (status ${response.status}): ${JSON.stringify(body)}`) + } +} + +/** + * Creates a new user account. The caller must be a system admin. + * @param {object} params - Function parameters. + * @param {string} params.baseUrl - Arena server base URL. + * @param {string} params.authToken - JWT auth token of a system admin user. + * @param {string} params.name - Full name for the new user. + * @param {string} params.email - Email address for the new user (must be unique). + * @param {string} params.password - Password for the new user. + * @param {Function} [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns {Promise} Resolves when the user has been created. + */ +const createUser = async ({ baseUrl, authToken, name, email, password, fetchImpl = fetch }) => { + const response = await fetchImpl(`${baseUrl}/api/user`, { + method: 'POST', + headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user: JSON.stringify({ name, email, password, props: { title: 'preferNotToSay' } }), + }), + }) + if (!response.ok) { + const body = await readBody(response) + throw new Error(`Create user ${email} failed (status ${response.status}): ${JSON.stringify(body)}`) + } +} + +module.exports = { login, buildImportFormData, importSurveyZip, getJobStatus, deleteSurvey, createUser } +``` + +Note: this step adds `createUser` (needed by Task 8) at the same time as the `readBody` fix, since it's the same file and the same "thin fetch wrapper" pattern as the other four functions — no separate task for it. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `node --test test/load/lib/httpApi.test.js` +Expected: PASS (14 tests: the original 9 plus the 5 new ones). `createUser` has no dedicated test yet — it's exercised by Task 8's tests via its use in `userProvisioning`/the orchestrator; that's acceptable since it's a straightforward instance of the same already-tested request/error pattern as the other four functions. + +- [ ] **Step 5: Lint and commit** + +```bash +npx eslint --cache --fix test/load/lib/httpApi.js test/load/lib/httpApi.test.js +git add test/load/lib/httpApi.js test/load/lib/httpApi.test.js +git commit -m "fix(load): read error response bodies safely, add createUser" +``` + +--- + +### Task 7: Fix job-result field extraction, null-summary crash, and poll-error handling in the orchestrator + +**Files:** +- Modify: `test/load/surveyImportStressTest.js` +- Create: `test/load/surveyImportStressTest.test.js` + +**Interfaces:** +- Consumes: `getJobStatus`, `importSurveyZip`, `deleteSurvey` from `./lib/httpApi` (Task 6, unchanged signatures). +- Produces (all now accept an optional injectable `fetchImpl`, defaulting to the global `fetch`, so this task's tests never touch the network): + - `pollJobUntilTerminal({ baseUrl, authToken, jobUuid, timeoutMs, fetchImpl?, pollIntervalMs? }) -> Promise` — **now never rejects.** Always resolves to a job-shaped object with a `.status` that is one of the real terminal statuses (`succeeded`/`failed`/`canceled`), `'timed-out'`, or `'rejected-at-http'` (after `MAX_CONSECUTIVE_POLL_ERRORS` consecutive `getJobStatus` failures). `.surveyId`/`.errors`/`.result` are backfilled from the last non-terminal read when the terminal read itself lacks them. A `null` read from `getJobStatus` (evicted/unknown job) no longer crashes the loop — it's treated like any other non-terminal read and polling continues. + - `runSingleImport({ baseUrl, authToken, zipBuffer, zipFileName, surveyName, index, jobTimeoutMs, fetchImpl? }) -> Promise` — same result shape as before (see `report.js`), but the try/catch around the polling phase is removed since `pollJobUntilTerminal` is now total. + - `cleanupSurveys({ baseUrl, authToken, results, fetchImpl? }) -> Promise<{ deletedCount: number, totalCount: number }>` — deletes surveys **sequentially** now (was: all at once via `Promise.allSettled` over every survey simultaneously), and returns/logs how many were actually deleted, so a regression back to "deletes nothing" is visible in the output instead of silent. + +- [ ] **Step 1: Write the failing tests** + +Create `test/load/surveyImportStressTest.test.js`: + +```js +const test = require('node:test') +const assert = require('node:assert/strict') + +const { runSingleImport, pollJobUntilTerminal, cleanupSurveys } = require('./surveyImportStressTest') + +const jsonResponse = (body, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) + +test('pollJobUntilTerminal carries surveyId forward from a non-terminal read when the terminal read lacks it', async () => { + const responses = [ + jsonResponse({ uuid: 'job-1', status: 'running', surveyId: 42 }), + jsonResponse({ uuid: 'job-1', status: 'succeeded' }), + ] + let call = 0 + const fetchImpl = async () => responses[call++] + + const job = await pollJobUntilTerminal({ + baseUrl: 'http://x', + authToken: 'tok', + jobUuid: 'job-1', + timeoutMs: 5000, + pollIntervalMs: 1, + fetchImpl, + }) + + assert.equal(job.status, 'succeeded') + assert.equal(job.surveyId, 42) +}) + +test('pollJobUntilTerminal tolerates a transient poll error and then succeeds', async () => { + let call = 0 + const fetchImpl = async () => { + call += 1 + if (call === 1) { + throw new Error('ECONNRESET') + } + return jsonResponse({ uuid: 'job-1', status: 'succeeded', surveyId: 7 }) + } + + const job = await pollJobUntilTerminal({ + baseUrl: 'http://x', + authToken: 'tok', + jobUuid: 'job-1', + timeoutMs: 5000, + pollIntervalMs: 1, + fetchImpl, + }) + + assert.equal(job.status, 'succeeded') + assert.equal(job.surveyId, 7) +}) + +test('pollJobUntilTerminal gives up after too many consecutive poll errors, well before the timeout', async () => { + const fetchImpl = async () => { + throw new Error('ECONNRESET') + } + + const job = await pollJobUntilTerminal({ + baseUrl: 'http://x', + authToken: 'tok', + jobUuid: 'job-1', + timeoutMs: 60000, + pollIntervalMs: 1, + fetchImpl, + }) + + assert.equal(job.status, 'rejected-at-http') + assert.match(job.error, /ECONNRESET/) +}) + +test('pollJobUntilTerminal times out when the job never reaches a terminal status', async () => { + const fetchImpl = async () => jsonResponse({ uuid: 'job-1', status: 'running' }) + + const job = await pollJobUntilTerminal({ + baseUrl: 'http://x', + authToken: 'tok', + jobUuid: 'job-1', + timeoutMs: 0, + pollIntervalMs: 1, + fetchImpl, + }) + + assert.equal(job.status, 'timed-out') +}) + +test('pollJobUntilTerminal does not crash on a null job read and keeps polling', async () => { + const responses = [jsonResponse(null), jsonResponse({ uuid: 'job-1', status: 'succeeded', surveyId: 9 })] + let call = 0 + const fetchImpl = async () => responses[call++] + + const job = await pollJobUntilTerminal({ + baseUrl: 'http://x', + authToken: 'tok', + jobUuid: 'job-1', + timeoutMs: 5000, + pollIntervalMs: 1, + fetchImpl, + }) + + assert.equal(job.status, 'succeeded') + assert.equal(job.surveyId, 9) +}) + +test('runSingleImport returns rejected-at-http when the import request itself fails', async () => { + const fetchImpl = async () => jsonResponse({ message: 'pool exhausted' }, 503) + + const result = await runSingleImport({ + baseUrl: 'http://x', + authToken: 'tok', + zipBuffer: Buffer.from('x'), + zipFileName: 'x.zip', + surveyName: 'stress_test_0', + index: 0, + jobTimeoutMs: 5000, + fetchImpl, + }) + + assert.equal(result.outcome, 'rejected-at-http') + assert.equal(result.surveyId, null) + assert.equal(result.jobMs, null) + assert.ok(result.acceptMs >= 0) +}) + +test('runSingleImport succeeds end-to-end and carries the surveyId through even though the terminal poll lacks it', async () => { + const responses = [ + jsonResponse({ job: { uuid: 'job-1', status: 'pending' } }), // import accept + jsonResponse({ uuid: 'job-1', status: 'running', surveyId: 99 }), // poll 1 (active) + jsonResponse({ uuid: 'job-1', status: 'succeeded' }), // poll 2 (terminal, no surveyId) + ] + let call = 0 + const fetchImpl = async () => responses[call++] + + const result = await runSingleImport({ + baseUrl: 'http://x', + authToken: 'tok', + zipBuffer: Buffer.from('x'), + zipFileName: 'x.zip', + surveyName: 'stress_test_1', + index: 1, + jobTimeoutMs: 5000, + fetchImpl, + }) + + assert.equal(result.outcome, 'succeeded') + assert.equal(result.surveyId, 99) +}) + +test('cleanupSurveys deletes only entries with a surveyId and tolerates individual failures', async () => { + const deleteCalls = [] + const fetchImpl = async (url, options) => { + deleteCalls.push(url) + if (url.endsWith('/api/survey/2')) { + return new Response('nope', { status: 500 }) + } + return new Response(null, { status: 200 }) + } + + const results = [ + { surveyId: 1 }, + { surveyId: null }, + { surveyId: 2 }, + { surveyId: 3 }, + ] + + const summary = await cleanupSurveys({ baseUrl: 'http://x', authToken: 'tok', results, fetchImpl }) + + assert.equal(summary.totalCount, 3) + assert.equal(summary.deletedCount, 2) + assert.equal(deleteCalls.length, 3) +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node --test test/load/surveyImportStressTest.test.js` +Expected: FAIL. Several ways: `pollJobUntilTerminal`/`runSingleImport`/`cleanupSurveys` don't yet accept `fetchImpl` (so tests hit the real network and hang/error), the null-job test throws `Cannot read properties of null`, the surveyId-carry-forward tests get `undefined` instead of the expected value, and `cleanupSurveys`'s return value doesn't have `deletedCount`/`totalCount` yet. + +- [ ] **Step 3: Replace `test/load/surveyImportStressTest.js` with this exact content** + +```js +/* eslint-disable no-console -- this file's entire purpose is CLI reporting */ +require('dotenv').config() + +const fs = require('node:fs') +const path = require('node:path') + +const { parseConfig, HELP_TEXT } = require('./lib/config') +const { login, importSurveyZip, getJobStatus, deleteSurvey, createUser } = require('./lib/httpApi') +const { buildLoadTestUserCredentials } = require('./lib/userProvisioning') +const { formatSummary } = require('./lib/report') + +const JOB_POLL_INTERVAL_MS = 1000 +const MAX_CONSECUTIVE_POLL_ERRORS = 3 +const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'canceled']) + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * Polls a job until it reaches a terminal status, the timeout elapses, or too many consecutive poll + * requests fail. Never rejects. surveyId/errors/result are backfilled from the last non-terminal read + * when the terminal read itself lacks them (the server's terminal job-status response omits them). + * @param {object} params - Function parameters. + * @param {string} params.baseUrl - Arena server base URL. + * @param {string} params.authToken - JWT auth token. + * @param {string} params.jobUuid - UUID of the job to poll. + * @param {number} params.timeoutMs - Max time to wait, in milliseconds. + * @param {Function} [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @param {number} [params.pollIntervalMs] - Delay between polls, in milliseconds (defaults to 1000). + * @returns {Promise} The last known job summary; status is 'timed-out' or 'rejected-at-http' if polling didn't reach a terminal status. + */ +const pollJobUntilTerminal = async ({ + baseUrl, + authToken, + jobUuid, + timeoutMs, + fetchImpl = fetch, + pollIntervalMs = JOB_POLL_INTERVAL_MS, +}) => { + const startedAt = Date.now() + let lastKnownSurveyId = null + let lastKnownErrors = null + let lastKnownResult = null + let consecutivePollErrors = 0 + let lastPollError = null + + for (;;) { + let job = null + try { + job = await getJobStatus({ baseUrl, authToken, jobUuid, fetchImpl }) + consecutivePollErrors = 0 + } catch (error) { + consecutivePollErrors += 1 + lastPollError = error + if (consecutivePollErrors > MAX_CONSECUTIVE_POLL_ERRORS) { + return { + status: 'rejected-at-http', + surveyId: lastKnownSurveyId, + errors: lastKnownErrors, + result: lastKnownResult, + error: lastPollError.message, + } + } + } + + if (job && TERMINAL_STATUSES.has(job.status)) { + return { + ...job, + surveyId: job.surveyId || lastKnownSurveyId, + errors: job.errors || lastKnownErrors, + result: job.result || lastKnownResult, + } + } + if (job) { + lastKnownSurveyId = job.surveyId || lastKnownSurveyId + lastKnownErrors = job.errors || lastKnownErrors + lastKnownResult = job.result || lastKnownResult + } + + if (Date.now() - startedAt >= timeoutMs) { + return { + status: 'timed-out', + surveyId: lastKnownSurveyId, + errors: lastKnownErrors, + result: lastKnownResult, + } + } + await sleep(pollIntervalMs) + } +} + +/** + * Runs one survey import request end-to-end (accept + poll to completion) and reports its outcome. + * @param {object} params - Function parameters. + * @param {string} params.baseUrl - Arena server base URL. + * @param {string} params.authToken - JWT auth token. + * @param {Buffer} params.zipBuffer - The survey zip file content. + * @param {string} params.zipFileName - The file name to send for the zip part. + * @param {string} params.surveyName - The unique name for the new survey. + * @param {number} params.index - Index of this request within the run (for reporting). + * @param {number} params.jobTimeoutMs - Max time to wait for the job to finish. + * @param {Function} [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns {Promise} A result entry (see report.js for the shape). + */ +const runSingleImport = async ({ + baseUrl, + authToken, + zipBuffer, + zipFileName, + surveyName, + index, + jobTimeoutMs, + fetchImpl = fetch, +}) => { + const acceptStartedAt = Date.now() + let job + try { + job = await importSurveyZip({ baseUrl, authToken, zipBuffer, zipFileName, surveyName, fetchImpl }) + } catch (error) { + return { + index, + name: surveyName, + outcome: 'rejected-at-http', + surveyId: null, + acceptMs: Date.now() - acceptStartedAt, + jobMs: null, + error: error.message, + } + } + const acceptMs = Date.now() - acceptStartedAt + + const jobStartedAt = Date.now() + const finalJob = await pollJobUntilTerminal({ + baseUrl, + authToken, + jobUuid: job.uuid, + timeoutMs: jobTimeoutMs, + fetchImpl, + }) + const jobMs = Date.now() - jobStartedAt + + const outcome = finalJob.status + const error = + outcome === 'succeeded' + ? null + : finalJob.error || JSON.stringify(finalJob.errors || finalJob.result || 'unknown error') + + return { + index, + name: surveyName, + outcome, + surveyId: finalJob.surveyId || null, + acceptMs, + jobMs, + error, + } +} + +/** + * Deletes every survey referenced by the given results, sequentially and best-effort. + * @param {object} params - Function parameters. + * @param {string} params.baseUrl - Arena server base URL. + * @param {string} params.authToken - JWT auth token (a system admin token can delete any survey). + * @param {Array} params.results - Result entries produced by runSingleImport. + * @param {Function} [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns {Promise<{deletedCount: number, totalCount: number}>} How many surveys were actually deleted. + */ +const cleanupSurveys = async ({ baseUrl, authToken, results, fetchImpl = fetch }) => { + const surveyIds = results.map((result) => result.surveyId).filter(Boolean) + let deletedCount = 0 + for (const surveyId of surveyIds) { + try { + await deleteSurvey({ baseUrl, authToken, surveyId, fetchImpl }) + deletedCount += 1 + } catch (error) { + console.error(`Failed to delete survey ${surveyId}: ${error.message}`) + } + } + return { deletedCount, totalCount: surveyIds.length } +} + +/** + * CLI entry point: parses config, runs the concurrent import burst, reports, and cleans up. + * @returns {Promise} Resolves when the run is complete; sets process.exitCode on failure. + */ +const main = async () => { + let config + try { + config = parseConfig({ argv: process.argv.slice(2), env: process.env }) + } catch (error) { + console.error(error.message) + console.error(HELP_TEXT) + process.exitCode = 1 + return + } + + if (config.help) { + console.log(HELP_TEXT) + return + } + + const { zipPath, url, email, password, count, jobTimeoutMs, keep } = config + + console.log(`Reading zip file: ${zipPath}`) + const zipBuffer = fs.readFileSync(zipPath) + const zipFileName = path.basename(zipPath) + + console.log(`Logging in as ${email} at ${url}...`) + const authToken = await login({ baseUrl: url, email, password }) + + const runId = Date.now() + console.log(`Firing ${count} concurrent survey imports (run ${runId})...`) + + const startedAt = Date.now() + const results = await Promise.all( + Array.from({ length: count }, (_, i) => + runSingleImport({ + baseUrl: url, + authToken, + zipBuffer, + zipFileName, + surveyName: `stress_test_${runId}_${i}`, + index: i, + jobTimeoutMs, + }) + ) + ) + const totalDurationMs = Date.now() - startedAt + + console.log(formatSummary({ results, totalDurationMs })) + + if (!keep) { + console.log('Cleaning up created surveys...') + const { deletedCount, totalCount } = await cleanupSurveys({ baseUrl: url, authToken, results }) + console.log(`Deleted ${deletedCount}/${totalCount} surveys created by this run.`) + } + + const anyFailed = results.some((result) => result.outcome !== 'succeeded') + process.exitCode = anyFailed ? 1 : 0 +} + +if (require.main === module) { + main().catch((error) => { + console.error('Stress test failed to run:', error) + process.exitCode = 1 + }) +} + +module.exports = { main, runSingleImport, pollJobUntilTerminal, cleanupSurveys } +``` + +Note: this step deliberately does **not** yet wire in `createUser`/`buildLoadTestUserCredentials` inside `main()` — the `require` lines for them are added now (so the module resolves and `main()`'s shape is otherwise final) but `main()` itself still logs in once and uses one shared `authToken` for every import, exactly as before. Task 8 changes `main()` to provision and use N distinct users. Keeping this task scoped to "fix the bugs, thread fetchImpl through, make polling total" — without also changing `main()`'s user model in the same diff — keeps this task's diff reviewable on its own. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `node --test test/load/surveyImportStressTest.test.js` +Expected: PASS (8 tests). + +- [ ] **Step 5: Re-run the Task 5 manual smoke checks, lint, and commit** + +```bash +node test/load/surveyImportStressTest.js --help +node test/load/surveyImportStressTest.js +node -e "require('./test/load/surveyImportStressTest.js')" +node --test test/load/lib/*.test.js test/load/surveyImportStressTest.test.js +npx eslint --cache --fix test/load/surveyImportStressTest.js test/load/surveyImportStressTest.test.js +``` + +Expected: the three smoke checks behave exactly as in Task 5 (`--help` prints usage and exits 0; no args prints the missing-`--zip` error + help and exits 1; the bare `require()` produces no output of its own). The combined `node --test` run passes all lib tests (14, after Task 6) plus this file's 8 new tests. Eslint is clean. + +```bash +git add test/load/surveyImportStressTest.js test/load/surveyImportStressTest.test.js +git commit -m "fix(load): make job polling total and thread fetchImpl through the orchestrator" +``` + +--- + +### Task 8: Provision N throwaway users instead of one shared login + +**Files:** +- Create: `test/load/lib/userProvisioning.js` +- Create: `test/load/lib/userProvisioning.test.js` +- Modify: `test/load/surveyImportStressTest.js` + +**Interfaces:** +- Consumes: `createUser`, `login` from `./lib/httpApi` (Task 6); `runSingleImport` from the same file (Task 7, unchanged signature — reused as-is, not modified by this task). +- Produces: `buildLoadTestUserCredentials({ runId, count }) -> Array<{name, email, password}>` (pure, deterministic — same `runId`+`count` always produces the same list, with emails `stress_test__@loadtest.local`). `main()`'s behavior changes: it now creates `count` throwaway users (as the configured admin account) and logs in as each before firing their imports, instead of using the admin's own token for every import. + +- [ ] **Step 1: Write the failing tests** + +Create `test/load/lib/userProvisioning.test.js`: + +```js +const test = require('node:test') +const assert = require('node:assert/strict') + +const { buildLoadTestUserCredentials } = require('./userProvisioning') + +test('buildLoadTestUserCredentials returns the requested count', () => { + const credentials = buildLoadTestUserCredentials({ runId: 123, count: 5 }) + assert.equal(credentials.length, 5) +}) + +test('buildLoadTestUserCredentials produces unique, deterministic emails per index', () => { + const credentials = buildLoadTestUserCredentials({ runId: 123, count: 3 }) + const emails = credentials.map((c) => c.email) + assert.deepEqual(emails, [ + 'stress_test_123_0@loadtest.local', + 'stress_test_123_1@loadtest.local', + 'stress_test_123_2@loadtest.local', + ]) +}) + +test('buildLoadTestUserCredentials gives every user a name and a password at least 8 characters long', () => { + const credentials = buildLoadTestUserCredentials({ runId: 999, count: 2 }) + credentials.forEach((c) => { + assert.ok(c.name.length > 0) + assert.ok(c.password.length >= 8) + }) +}) + +test('buildLoadTestUserCredentials returns an empty array for count 0', () => { + assert.deepEqual(buildLoadTestUserCredentials({ runId: 1, count: 0 }), []) +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node --test test/load/lib/userProvisioning.test.js` +Expected: FAIL — `Cannot find module './userProvisioning'`. + +- [ ] **Step 3: Implement `test/load/lib/userProvisioning.js`** + +```js +const LOAD_TEST_USER_PASSWORD = 'LoadTestUser1Aa!' +const LOAD_TEST_EMAIL_DOMAIN = 'loadtest.local' + +/** + * Builds deterministic credentials for N throwaway load-test users, unique to this run. + * @param {object} params - Function parameters. + * @param {number} params.runId - Unique identifier for this run (e.g. Date.now()). + * @param {number} params.count - Number of user credential sets to build. + * @returns {Array<{name: string, email: string, password: string}>} One credential set per user, in index order. + */ +const buildLoadTestUserCredentials = ({ runId, count }) => + Array.from({ length: count }, (_, i) => ({ + name: `Load Test User ${runId}_${i}`, + email: `stress_test_${runId}_${i}@${LOAD_TEST_EMAIL_DOMAIN}`, + password: LOAD_TEST_USER_PASSWORD, + })) + +module.exports = { buildLoadTestUserCredentials } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `node --test test/load/lib/userProvisioning.test.js` +Expected: PASS (4 tests). + +- [ ] **Step 5: Wire N distinct users into `main()`** + +In `test/load/surveyImportStressTest.js`, add this new function after `runSingleImport` (before `cleanupSurveys`): + +```js +/** + * Creates one throwaway user, logs in as them, then runs their single survey import end-to-end. + * If user creation or login fails, returns a rejected-at-http result without attempting the import. + * @param {object} params - Function parameters. + * @param {string} params.baseUrl - Arena server base URL. + * @param {string} params.adminAuthToken - JWT auth token of the system admin used to create the user. + * @param {{name: string, email: string, password: string}} params.credentials - Credentials for the throwaway user. + * @param {Buffer} params.zipBuffer - The survey zip file content. + * @param {string} params.zipFileName - The file name to send for the zip part. + * @param {string} params.surveyName - The unique name for the new survey. + * @param {number} params.index - Index of this request within the run (for reporting). + * @param {number} params.jobTimeoutMs - Max time to wait for the job to finish. + * @param {Function} [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns {Promise} A result entry (see report.js for the shape). + */ +const runSingleUserImport = async ({ + baseUrl, + adminAuthToken, + credentials, + zipBuffer, + zipFileName, + surveyName, + index, + jobTimeoutMs, + fetchImpl = fetch, +}) => { + const setupStartedAt = Date.now() + let userAuthToken + try { + await createUser({ baseUrl, authToken: adminAuthToken, ...credentials, fetchImpl }) + userAuthToken = await login({ baseUrl, email: credentials.email, password: credentials.password, fetchImpl }) + } catch (error) { + return { + index, + name: surveyName, + outcome: 'rejected-at-http', + surveyId: null, + acceptMs: Date.now() - setupStartedAt, + jobMs: null, + error: `user setup failed: ${error.message}`, + } + } + + return runSingleImport({ + baseUrl, + authToken: userAuthToken, + zipBuffer, + zipFileName, + surveyName, + index, + jobTimeoutMs, + fetchImpl, + }) +} +``` + +Then replace the body of `main()` from the `Logging in as ${email}` line through the `results`/`totalDurationMs` computation with: + +```js + console.log(`Logging in as ${email} at ${url}...`) + const adminAuthToken = await login({ baseUrl: url, email, password }) + + const runId = Date.now() + const credentialsList = buildLoadTestUserCredentials({ runId, count }) + console.log(`Provisioning ${count} throwaway load-test users and firing ${count} concurrent survey imports (run ${runId})...`) + + const startedAt = Date.now() + const settled = await Promise.allSettled( + credentialsList.map((credentials, i) => + runSingleUserImport({ + baseUrl: url, + adminAuthToken, + credentials, + zipBuffer, + zipFileName, + surveyName: `stress_test_${runId}_${i}`, + index: i, + jobTimeoutMs, + }) + ) + ) + const results = settled.map((settledResult, i) => + settledResult.status === 'fulfilled' + ? settledResult.value + : { + index: i, + name: `stress_test_${runId}_${i}`, + outcome: 'rejected-at-http', + surveyId: null, + acceptMs: null, + jobMs: null, + error: settledResult.reason?.message || String(settledResult.reason), + } + ) + const totalDurationMs = Date.now() - startedAt +``` + +And change the `cleanupSurveys` call site to use `adminAuthToken` (the variable was renamed from `authToken`): + +```js + if (!keep) { + console.log('Cleaning up created surveys...') + const { deletedCount, totalCount } = await cleanupSurveys({ baseUrl: url, authToken: adminAuthToken, results }) + console.log(`Deleted ${deletedCount}/${totalCount} surveys created by this run.`) + console.log( + 'Note: the throwaway user accounts created by this run (stress_test_*@loadtest.local) cannot be deleted via the API and remain in the database.' + ) + } +``` + +Also update the two `require` lines at the top of the file: +- `const { login, importSurveyZip, getJobStatus, deleteSurvey, createUser } = require('./lib/httpApi')` (adds `createUser` — already present from Task 7's Step 3, no change needed here if it's already there) +- add `const { buildLoadTestUserCredentials } = require('./lib/userProvisioning')` (already added in Task 7's Step 3 per its note above — verify it's there; add it if not) + +And update `module.exports` at the bottom to also export `runSingleUserImport`: + +```js +module.exports = { main, runSingleImport, runSingleUserImport, pollJobUntilTerminal, cleanupSurveys } +``` + +- [ ] **Step 6: Write and run a test for `runSingleUserImport`** + +Add to `test/load/surveyImportStressTest.test.js` (update the top `require` line to also pull in `runSingleUserImport`): + +```js +const { runSingleImport, runSingleUserImport, pollJobUntilTerminal, cleanupSurveys } = require('./surveyImportStressTest') +``` + +```js +test('runSingleUserImport creates the user, logs in as them, then imports', async () => { + const calls = [] + const responses = [ + new Response(null, { status: 200 }), // POST /api/user + jsonResponse({ authToken: 'user-tok' }), // POST /auth/login (as the new user) + jsonResponse({ job: { uuid: 'job-1', status: 'pending' } }), // import accept + jsonResponse({ uuid: 'job-1', status: 'succeeded', surveyId: 55 }), // poll (terminal, this server response does include surveyId) + ] + let call = 0 + const fetchImpl = async (url, options) => { + calls.push({ url, options }) + return responses[call++] + } + + const result = await runSingleUserImport({ + baseUrl: 'http://x', + adminAuthToken: 'admin-tok', + credentials: { name: 'Load Test User 1', email: 'stress_test_1_0@loadtest.local', password: 'LoadTestUser1Aa!' }, + zipBuffer: Buffer.from('x'), + zipFileName: 'x.zip', + surveyName: 'stress_test_1_0', + index: 0, + jobTimeoutMs: 5000, + }) + + assert.equal(result.outcome, 'succeeded') + assert.equal(result.surveyId, 55) + assert.equal(calls[0].url, 'http://x/api/user') + assert.equal(calls[0].options.headers.Authorization, 'Bearer admin-tok') + assert.equal(calls[1].url, 'http://x/auth/login') +}) + +test('runSingleUserImport returns rejected-at-http when user creation fails, without attempting login or import', async () => { + const fetchImpl = async () => new Response('quota exceeded', { status: 403 }) + + const result = await runSingleUserImport({ + baseUrl: 'http://x', + adminAuthToken: 'admin-tok', + credentials: { name: 'Load Test User 2', email: 'stress_test_1_1@loadtest.local', password: 'LoadTestUser1Aa!' }, + zipBuffer: Buffer.from('x'), + zipFileName: 'x.zip', + surveyName: 'stress_test_1_1', + index: 1, + jobTimeoutMs: 5000, + }) + + assert.equal(result.outcome, 'rejected-at-http') + assert.match(result.error, /user setup failed/) +}) +``` + +Both new tests above must pass `fetchImpl` in their call to `runSingleUserImport` (add `fetchImpl,` to each test's call-site object) — `runSingleUserImport` (Step 5) already accepts and threads it through. + +Run: `node --test test/load/lib/userProvisioning.test.js test/load/surveyImportStressTest.test.js` +Expected: PASS (4 + 10 = 14 tests). + +- [ ] **Step 7: Re-run the Task 5 manual smoke checks, the full lib+orchestrator test suite, and lint** + +```bash +node test/load/surveyImportStressTest.js --help +node test/load/surveyImportStressTest.js +node -e "require('./test/load/surveyImportStressTest.js')" +node --test test/load/lib/*.test.js test/load/surveyImportStressTest.test.js +npx eslint --cache --fix test/load/lib/userProvisioning.js test/load/lib/userProvisioning.test.js test/load/surveyImportStressTest.js test/load/surveyImportStressTest.test.js +``` + +Expected: smoke checks unchanged from Task 5; combined test run passes all tests (stats 5 + config 9 + report 4 + httpApi 14 + userProvisioning 4 = 36 lib tests, plus 10 orchestrator tests = 46 total); eslint clean. + +- [ ] **Step 8: Commit** + +```bash +git add test/load/lib/userProvisioning.js test/load/lib/userProvisioning.test.js test/load/surveyImportStressTest.js test/load/surveyImportStressTest.test.js +git commit -m "feat(load): provision N throwaway users instead of one shared login" +``` + +--- + +## Manual End-to-End Verification (after Task 8, not automated) + +This step needs a running Arena server and a real Arena survey export zip, neither of which this plan can provide on its own. Once both are available: + +1. Start the dev server: `yarn watch` (or `yarn dev:server`), confirm it's up at `http://localhost:9090`. +2. Confirm the configured login (`ADMIN_EMAIL`/`ADMIN_PASSWORD` in `.env`, or `--email`/`--password`) is a **system admin** account — required both for the original single-survey-creation path and now for `POST /api/user` (Task 8). +3. Get a sample zip: export any existing survey as an Arena backup (`GET /survey/:surveyId/export` from the UI, or via the API) — any valid Arena survey zip works, since the script always uses a fresh unique survey name. +4. Run: `yarn test:load -- --zip /path/to/survey.zip --count 50` (or `node test/load/surveyImportStressTest.js --zip ... --count 50`). +5. Confirm: the summary report prints with real (non-"unknown error") detail for any failures, the "Deleted N/M surveys" line shows a real non-zero count matching the number of successful+partially-completed imports, the exit code is 0 when all 50 succeed, and (unless `--keep` was passed) the created `stress_test_*` surveys are gone from the survey list afterward. +6. Expect the run to take noticeably longer than "50 requests in parallel" would suggest — the server's job queue (see the spec's Addendum) processes survey-creation/import jobs one at a time globally, so the 50 imports queue and drain sequentially even though all 50 requests were fired at once. This is expected, not a bug. +7. Known limitation, not a defect: the 50 throwaway user accounts (`stress_test_*@loadtest.local`) this run creates are **not** deleted — there is no user-delete HTTP endpoint. They accumulate across repeated runs; a DB admin can purge them periodically (`DELETE FROM "user" WHERE email LIKE 'stress_test_%@loadtest.local'`, plus their `auth_group_user` rows). +8. If failures appear, the report's per-failure error detail (HTTP status/body or job error) should point at whether it's the same DB-pool/lock symptom the branch's commits fixed, or something else. diff --git a/docs/superpowers/specs/2026-08-10-survey-import-stress-test-design.md b/docs/superpowers/specs/2026-08-10-survey-import-stress-test-design.md new file mode 100644 index 0000000000..eec2733b6b --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-survey-import-stress-test-design.md @@ -0,0 +1,200 @@ +# Survey Import Concurrency Stress Test + +## Purpose + +This branch (`fix/survey-import-concurrency`) fixed two DB connection-pool / +lock bugs in survey creation and import: + +- `7dcc03c12` "run survey creation in job" — `SurveyManager.insertSurvey` used + to hold a DB transaction open while `DBMigrator.migrateSurveySchema` + acquired another connection from the same pool; concurrent creations could + exhaust the pool and hang the server. +- `b0c3375c8` "fix import survey db lock" — same bug in + `SurveyManager.importSurvey` (used for Arena backup restore and cloning). +- `2b35dd58a` "fixed survey creation starvation lock in user access request". + +Existing regression coverage +(`test/integration/tests/_survey/surveyTest.js`: +`createSurveysConcurrentlyTest`, `importSurveysConcurrentlyTest`) calls +`SurveyManager` directly, in-process, with only 2 concurrent calls. It does +not exercise the real HTTP API, JWT auth, multipart file upload, or the +background `JobQueue` (concurrency controlled by the `jobQueueConcurrency` env +var) that a real client goes through. + +This tool drives the actual HTTP API with a higher, configurable concurrency +(default 50) to validate the fix under conditions closer to real concurrent +usage, using a real Arena survey export zip as the import source. + +## Scope + +A standalone, manually-run Node script. Not wired into `yarn test` / CI — it +targets a running Arena server (typically local dev) and is a load-testing +tool, not an automated regression test. + +## Behavior + +1. Parse CLI flags (see Configuration). +2. `POST /auth/login` with `{ email, password }` → JWT `authToken`. All + further requests send `Authorization: Bearer `. +3. Read the given zip file into memory once (`fs.readFileSync`); reused as + the multipart body for every request. +4. Build `count` requests, each `POST /api/survey/arena-import` as a single + (non-chunked) `multipart/form-data` upload: + - field `file`: the zip bytes (as a `Blob`) + - field `survey`: JSON string `{ "name": "stress_test__", + "options": { "includeData": false } }` + Fire all `count` requests simultaneously via `Promise.allSettled` (true + burst — matches the exact scenario the fixed bugs occurred in). +5. Each successful accept returns `{ job }` (a job UUID + initial status). + Requests that fail at the HTTP layer (network error, non-2xx, timeout) are + recorded as failed immediately with no job to poll. +6. For every accepted job, poll `GET /api/jobs/:jobUuid` (interval ~1s) until + its status is `succeeded`, `failed`, or `canceled`, or until + `--job-timeout` (default 120000 ms) elapses (recorded as `timed-out`). + Polling for all jobs happens concurrently. +7. Print a summary report: + - counts by outcome: succeeded / failed / timed-out / rejected-at-http + - accept latency (time to HTTP response) and job latency (time from + accept to terminal status): min / avg / max / p95 + - full error detail for every non-succeeded request (HTTP status/body, + job status message) so failure classes (e.g. pool timeouts, 5xx, + deadlocks) are visible, not just counts +8. Cleanup: for every request that produced a survey (job succeeded, or a + survey was created despite eventual failure), `DELETE + /api/survey/:surveyId` best-effort. Cleanup failures are logged but don't + fail the run. Skipped entirely when `--keep` is passed. +9. Exit code: non-zero if any request did not succeed, so the script is + usable as a simple pass/fail gate as well as an interactive tool. + +## Configuration + +CLI flags (env var fallback in parentheses): + +- `--zip ` (required) — path to an Arena survey export/backup zip. +- `--count ` (default `50`) +- `--url ` (`ARENA_URL`, default `http://localhost:9090`) +- `--email ` (`ARENA_EMAIL`, then `ADMIN_EMAIL` from `.env`) +- `--password ` (`ARENA_PASSWORD`, then `ADMIN_PASSWORD` from `.env`) +- `--job-timeout ` (default `120000`) +- `--keep` — skip auto-cleanup of created surveys + +`.env` at repo root is loaded via `dotenv` (matching `server/server.js` +convention) so `ADMIN_EMAIL`/`ADMIN_PASSWORD` are picked up automatically in +a typical local dev checkout without extra setup. + +## Confirmed API contract (read from source, not assumed) + +- `POST /auth/login` — mounted at server root (not under `/api`), see + `node_modules/@openforis/arena-server/dist/api/auth/login.js`. Body + `{ email, password }`, response `{ user, survey, authToken }`. +- `POST /api/survey/arena-import` — + `server/modules/arenaImport/api/arenaImportApi.js`. Reads `survey` (JSON + string, fields `name`/`options` used) and an uploaded `file` via + `server/modules/file/service/requestChunkedFileProcessor.js`; omitting + `chunk`/`totalChunks`/`totalFileSize` selects the non-chunked single-file + path. Response `{ job }`. +- `GET /api/jobs/:jobUuid` — `server/job/jobApi.js`. Response is a job + summary with a `status` field using values from + `server/job/jobUtils.js:jobStatus` (`pending`, `running`, `succeeded`, + `canceled`, `failed`). +- `DELETE /api/survey/:surveyId` — `server/modules/survey/api/surveyApi.js`. +- Route mounting: `authApi.init(app)` at root, `app.use('/api', + apiRouter.router)` for everything else + (`server/system/appCluster.js`, `server/system/apiRouter.js`). +- Auth: JWT bearer token (`passport-jwt`, + `node_modules/@openforis/arena-server/dist/server/middleware/authentication.js`), + sent as `Authorization: Bearer `. + +## Implementation notes + +- Node 24 (per `package.json` engines) provides global `fetch`, `FormData`, + and `Blob` — no HTTP client or multipart dependency needed. +- Plain CommonJS `.js` (no ESM extension, no babel/webpack) so it runs + directly via `node test/load/surveyImportStressTest.js` with zero build + step. +- Unique survey names use a per-run id (e.g. timestamp) + request index, so + repeated runs never collide on survey name uniqueness validation. + +## Non-goals + +- Not added to `yarn test:*` scripts or CI. +- No ramped/staged load pattern (batches + delay) — true burst only, since + that's what reproduces the fixed bug class. Can be added later if needed. +- No new npm dependencies. + +## Addendum (post-implementation, found during final review) + +Implementation (Tasks 1-5) and its per-task reviews are complete and clean. +The final whole-branch review then found that the tool's original premise — +firing N concurrent requests as a single logged-in user — cannot exercise +concurrent job execution at all, and found a real bug in how job results are +read. Both are addressed by Tasks 6-8 below. + +**Discovery: `server/job/JobQueue.js` serializes survey creation/import +globally, not just per-user.** This is pre-existing queue infrastructure +(PR #3731, unrelated to this branch); survey creation/import only started +routing through it via this branch's own `7dcc03c12` ("run survey creation +in job"). `JobQueue.enqueue()` (`server/job/JobQueue.js:176-193`) throws +synchronously if the same user already has a running job +(`Only one job per user can run at a time`). Independently of that, both +`ArenaImportJob` and `SurveyCreatorJob` are constructed with no `surveyId` +in their job `params` (it's only set later, on the job's *context*, once +the survey row exists — `server/modules/arenaImport/service/arenaImport/jobs/surveyCreatorJob.js:75-77`, +`server/modules/survey/service/surveyCreateJob.js:24-27`), so +`_findNextJobIndex` (`JobQueue.js:120-138`) classifies them as *global* +jobs, gated by the single `_runningGlobalJob` slot +(`JobQueue.js:21,102,128-133,148-153,166`) — server-wide, across every user, +one survey-creation/import job executes at a time, period. Verified +directly against the server source (not inferred from behavior). + +Consequence: firing N requests under one shared login gets 1 accepted job +and N-1 immediate HTTP 500s. Even N *distinct* users would only ever get +one job running at a time — true concurrent execution of the code this +branch fixed (`SurveyManager.insertSurvey`/`importSurvey`) is reachable +only by bypassing the job queue entirely, which is exactly what the +existing Jest regression tests already do +(`test/integration/tests/_survey/surveyTest.js`: +`createSurveysConcurrentlyTest`, `importSurveysConcurrentlyTest` — they call +`SurveyManager` directly). + +**Decision (user-confirmed):** re-scope from "one shared login" to "N +distinct throwaway users, each importing their own survey." This can't +reproduce literal concurrent DB transactions (queue serialization still +applies), but it does exercise something the existing tests don't: whether +a burst of many different real users hitting the import endpoint at once — +auth, multipart upload, queueing, and eventual processing of a real backlog +— holds up without errors, leaks, or starvation, which is a legitimate and +different kind of load than the in-process regression tests cover. + +**User provisioning is possible without new server changes.** Verified +`POST /api/user` (`server/modules/user/api/userApi.js:398`, +`AuthMiddleware.requireUserCreatePermission` → systemAdmin only): body +`{ "user": "" }` with `name`, `email`, +`password` (top-level, read directly by +`server/modules/user/service/userService.js:322-334` via +`User.getPassword` — validation only checks `newPassword`/`confirmPassword`, +which don't need to be sent), and `props.title` (required; valid values +`mr`/`ms`/`preferNotToSay`, `core/user/_user/userProps.ts:25-29`). Status +is hardcoded to `ACCEPTED` on insert (`userService.js:324`) — the new user +can log in immediately, no invite/email step. There is no user-delete HTTP +endpoint (`UserManager.deleteUser` is internal-only, +`server/modules/user/repository/userRepository.js:494`), so throwaway users +are left in the database after a run — documented as a known limitation, +not fixed. Cleanup of the *surveys* those users create still works with the +admin's own token: `Authorizer.canEditSurvey` +(`node_modules/@openforis/arena-core/dist/auth/authorizer.js:12-23,43`) +has a systemAdmin bypass, so `DELETE /api/survey/:surveyId` as the admin +works regardless of which throwaway user owns the survey. + +**Second bug, independent of the above:** `getJobStatus`'s response shape +differs between an *active* job (`JobThreadExecutor.getActiveJobSummary`, +full `jobToJSON` — has `surveyId`/`errors`/`result`) and a job read *after* +it has ended (`JobQueue.getJobSummary`'s fallback branch, `JobQueue.js:40-51`, +returns the bare `{params, status, type, uuid}` — none of those fields). +Since the poller's very last read is always the one that observes the +terminal status, it always hits the impoverished shape — `surveyId` is +never available at the point the code was reading it, so cleanup silently +deleted nothing, and failures always reported "unknown error." Fixed by +having the poller remember `surveyId`/`errors`/`result` from the last +*non-terminal* read (where the rich shape is available) and falling back +to those values when the terminal read lacks them. diff --git a/package.json b/package.json index a5f937111a..cc39f42cc4 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "test:e2e": "jest --config=test/e2e/jest.config.js", "test:e2e:codegen": "npx playwright --save-storage=test/e2e/resources/auth.json codegen http://localhost:9090", "test:e2e:watch": "cross-env PWDEBUG=1 jest --config=test/e2e/jest.config.js", + "test:load": "node --experimental-strip-types test/load/surveyImportStressTest.ts", + "test:load:unit": "node --experimental-strip-types --test test/load/**/*.test.ts", "test": "run-s test:unit test:e2e", "typecheck": "tsc --noEmit", "test:docker": "test/bin/testExecDocker.sh", @@ -121,7 +123,7 @@ "@mui/x-data-grid": "^8.26.0", "@mui/x-date-pickers": "^8.26.0", "@mui/x-tree-view": "^8.26.0", - "@openforis/arena-server": "^1.3.27", + "@openforis/arena-server": "^1.3.28", "@reduxjs/toolkit": "^2.11.2", "@sendgrid/mail": "^8.1.6", "@shopify/draggable": "^1.2.1", @@ -209,6 +211,9 @@ "xml-js": "^1.6.11", "zod": "^3.23.8" }, + "resolutions": { + "playwright-core": "1.24.2" + }, "arenaClientPackages": { "META": "This exists to minimize the final Docker server image", "d3-interpolate-path": true, diff --git a/server/job/jobCreator.js b/server/job/jobCreator.js index dbb2a69d16..93e737e8b0 100644 --- a/server/job/jobCreator.js +++ b/server/job/jobCreator.js @@ -22,6 +22,7 @@ import RecordsValidationJob from '@server/modules/record/service/recordsValidati import SelectedRecordsExportJob from '@server/modules/record/service/selectedRecordsExportJob' import SurveyCloneJob from '@server/modules/survey/service/clone/surveyCloneJob' import SurveyActivityLogClearJob from '@server/modules/survey/service/surveyActivityLogClearJob' +import SurveyCreatorJob from '@server/modules/survey/service/surveyCreateJob' import SurveyExportJob from '@server/modules/survey/service/surveyExport/surveyExportJob' import SurveyLabelsImportJob from '@server/modules/survey/service/surveyLabelsImportJob' import SurveyPublishJob from '@server/modules/survey/service/publish/surveyPublishJob' @@ -55,6 +56,7 @@ const jobClasses = [ SelectedRecordsExportJob, SurveyCloneJob, SurveyActivityLogClearJob, + SurveyCreatorJob, SurveyExportJob, SurveyLabelsImportJob, SurveyPublishJob, diff --git a/server/modules/survey/api/surveyApi.js b/server/modules/survey/api/surveyApi.js index c871d0c795..bb89716bf0 100644 --- a/server/modules/survey/api/surveyApi.js +++ b/server/modules/survey/api/surveyApi.js @@ -56,9 +56,8 @@ export const init = (app) => { res.json({ job }) return } - const survey = await SurveyService.insertSurvey({ user, surveyInfo: surveyInfoTarget }) - - res.json({ survey }) + const job = SurveyService.startCreateSurveyJob({ user, surveyInfo: surveyInfoTarget }) + res.json({ job }) } else { res.json({ validation }) } diff --git a/server/modules/survey/manager/surveyManager.js b/server/modules/survey/manager/surveyManager.js index 97f5236e3f..dd3b506062 100644 --- a/server/modules/survey/manager/surveyManager.js +++ b/server/modules/survey/manager/surveyManager.js @@ -112,84 +112,109 @@ export const insertSurvey = async (params, client = db) => { temporary = false, } = params - return client.tx(async (t) => { - // Insert survey into db - const surveyProps = { ...Survey.getProps(surveyInfoParam) } - if (temporary) { - surveyProps.temporary = true - } - const surveyInfo = await SurveyRepository.insertSurvey({ survey: surveyInfoParam, propsDraft: surveyProps }, t) - const survey = assocSurveyInfo(surveyInfo) - const surveyId = Survey.getIdSurveyInfo(surveyInfo) + // Insert survey row on its own (not wrapped in a held-open transaction): DBMigrator.migrateSurveySchema + // below opens its own separate db connections (CREATE SCHEMA + db-migrate), so if it ran inside an open + // transaction here, that transaction's connection would sit idle while a second connection is acquired + // from the same pool. Under concurrent survey creation this starves the pool (no connectionTimeoutMillis + // is configured) and can hang the whole server, since every other request also needs a pool connection. + const surveyProps = { ...Survey.getProps(surveyInfoParam) } + if (temporary) { + surveyProps.temporary = true + } + const surveyInfo = await SurveyRepository.insertSurvey({ survey: surveyInfoParam, propsDraft: surveyProps }, client) + const survey = assocSurveyInfo(surveyInfo) + const surveyId = Survey.getIdSurveyInfo(surveyInfo) - // Create survey data schema + try { + // Create survey data schema (runs outside of any transaction held by this function; see comment above) await DBMigrator.migrateSurveySchema(surveyId) - // Log survey create activity - await ActivityLogRepository.insert(user, surveyId, ActivityLog.type.surveyCreate, surveyInfo, system, t) - - if (createRootEntityDef) { - // Insert root entity def - const rootEntityDef = NodeDef.newNodeDef( - null, - NodeDef.nodeDefType.entity, - [Survey.cycleOneKey], // Use first (and only) cycle - { - [NodeDef.propKeys.name]: 'root_entity', - [NodeDef.propKeys.multiple]: false, - [NodeDefLayout.keys.layout]: NodeDefLayout.newLayout( - Survey.cycleOneKey, - NodeDefLayout.renderType.form, - uuidv4() - ), - } - ) - await NodeDefManager.insertNodeDef({ user, survey, nodeDef: rootEntityDef, system: true }, t) - } + return await client.tx(async (t) => { + // Log survey create activity + await ActivityLogRepository.insert(user, surveyId, ActivityLog.type.surveyCreate, surveyInfo, system, t) + + if (createRootEntityDef) { + // Insert root entity def + const rootEntityDef = NodeDef.newNodeDef( + null, + NodeDef.nodeDefType.entity, + [Survey.cycleOneKey], // Use first (and only) cycle + { + [NodeDef.propKeys.name]: 'root_entity', + [NodeDef.propKeys.multiple]: false, + [NodeDefLayout.keys.layout]: NodeDefLayout.newLayout( + Survey.cycleOneKey, + NodeDefLayout.renderType.form, + uuidv4() + ), + } + ) + await NodeDefManager.insertNodeDef({ user, survey, nodeDef: rootEntityDef, system: true }, t) + } - if (updateUserPrefs) { - const userUpdated = User.assocPrefSurveyCurrentAndCycle(surveyId, Survey.cycleOneKey)(user) - await UserRepository.updateUserPrefs(userUpdated, t) - } + if (updateUserPrefs) { + const userUpdated = User.assocPrefSurveyCurrentAndCycle(surveyId, Survey.cycleOneKey)(user) + await UserRepository.updateUserPrefs(userUpdated, t) + } - // Create default groups for this survey - surveyInfo.authGroups = await AuthGroupRepository.createSurveyGroups(surveyId, Survey.getDefaultAuthGroups(), t) + // Create default groups for this survey + surveyInfo.authGroups = await AuthGroupRepository.createSurveyGroups(surveyId, Survey.getDefaultAuthGroups(), t) - await _addUserToSurveyAdmins({ user, surveyInfo }, t) + await _addUserToSurveyAdmins({ user, surveyInfo }, t) - return assocSurveyInfo(surveyInfo) - }) + return assocSurveyInfo(surveyInfo) + }) + } catch (error) { + // Survey row (and possibly the schema) were already created outside of this failed step; + // clean them up so a failed creation doesn't leave an orphaned survey/schema behind. + Logger.error(`error creating survey ${surveyId}, cleaning up: ${error.stack || error}`) + await deleteSurvey(surveyId, { deleteUserPrefs: true }, client).catch((cleanupError) => { + Logger.error(`error cleaning up survey ${surveyId} after failed creation: ${cleanupError.stack || cleanupError}`) + }) + throw error + } } export const importSurvey = async (params, client = db) => { const { user, surveyInfo: surveyInfoParam, authGroups = Survey.getDefaultAuthGroups(), backup } = params - return client.tx(async (t) => { - // Insert survey into db - let surveyInfo = await SurveyRepository.insertSurvey( - { - survey: surveyInfoParam, - props: backup ? Survey.getProps(surveyInfoParam) : {}, - propsDraft: backup ? Survey.getPropsDraft(surveyInfoParam) : Survey.getProps(surveyInfoParam), - }, - t - ) - const surveyId = Survey.getIdSurveyInfo(surveyInfo) + // See insertSurvey above: migrateSurveySchema opens its own separate db connections, so it must not + // run inside a transaction held open by this function (same connection-pool starvation risk). + const surveyInfo = await SurveyRepository.insertSurvey( + { + survey: surveyInfoParam, + props: backup ? Survey.getProps(surveyInfoParam) : {}, + propsDraft: backup ? Survey.getPropsDraft(surveyInfoParam) : Survey.getProps(surveyInfoParam), + }, + client + ) + const surveyId = Survey.getIdSurveyInfo(surveyInfo) - // Create survey data schema + try { + // Create survey data schema (runs outside of any transaction held by this function; see insertSurvey above) await DBMigrator.migrateSurveySchema(surveyId) - // Create default groups for this survey - surveyInfo = Survey.assocAuthGroups(await AuthGroupRepository.createSurveyGroups(surveyId, authGroups, t))( - surveyInfo - ) + return await client.tx(async (t) => { + // Create default groups for this survey + let surveyInfoUpdated = Survey.assocAuthGroups( + await AuthGroupRepository.createSurveyGroups(surveyId, authGroups, t) + )(surveyInfo) - surveyInfo = await _fetchAndAssocAdditionalInfo({ surveyInfo }, t) + surveyInfoUpdated = await _fetchAndAssocAdditionalInfo({ surveyInfo: surveyInfoUpdated }, t) - await _addUserToSurveyAdmins({ user, surveyInfo }, t) + await _addUserToSurveyAdmins({ user, surveyInfo: surveyInfoUpdated }, t) - return assocSurveyInfo(surveyInfo) - }) + return assocSurveyInfo(surveyInfoUpdated) + }) + } catch (error) { + // Survey row (and possibly the schema) were already created outside of this failed step; + // clean them up so a failed import doesn't leave an orphaned survey/schema behind. + Logger.error(`error importing survey ${surveyId}, cleaning up: ${error.stack || error}`) + await deleteSurvey(surveyId, { deleteUserPrefs: true }, client).catch((cleanupError) => { + Logger.error(`error cleaning up survey ${surveyId} after failed import: ${cleanupError.stack || cleanupError}`) + }) + throw error + } } // ====== READ diff --git a/server/modules/survey/service/surveyCreateJob.js b/server/modules/survey/service/surveyCreateJob.js new file mode 100644 index 0000000000..44c8f05df8 --- /dev/null +++ b/server/modules/survey/service/surveyCreateJob.js @@ -0,0 +1,36 @@ +import Job from '@server/job/job' + +import * as Survey from '@core/survey/survey' + +import * as SurveyManager from '../manager/surveyManager' + +export default class SurveyCreatorJob extends Job { + constructor(params) { + super(SurveyCreatorJob.type, params) + } + + async execute() { + const { user, surveyInfo, createRootEntityDef, updateUserPrefs, temporary } = this.context + + // Insert survey out of this job's own transaction (this.tx): SurveyManager.insertSurvey creates the + // survey data schema, which uses its own separate db connections and must not run inside an open one. + const survey = await SurveyManager.insertSurvey({ + user, + surveyInfo, + createRootEntityDef, + updateUserPrefs, + temporary, + }) + + const surveyId = Survey.getId(survey) + + this.setContext({ survey, surveyId }) + } + + async beforeSuccess() { + const { surveyId } = this.context + this.setResult({ surveyId }) + } +} + +SurveyCreatorJob.type = 'SurveyCreatorJob' diff --git a/server/modules/survey/service/surveyService.js b/server/modules/survey/service/surveyService.js index 02e5f22d8f..b34c5d2ca8 100644 --- a/server/modules/survey/service/surveyService.js +++ b/server/modules/survey/service/surveyService.js @@ -19,6 +19,7 @@ import * as FileUtils from '@server/utils/file/fileUtils' import * as SurveyManager from '../manager/surveyManager' import * as SurveyFileManager from '../manager/surveyFileManager' import SurveyCloneJob from './clone/surveyCloneJob' +import SurveyCreatorJob from './surveyCreateJob' import SurveyPublishJob from './publish/surveyPublishJob' import { SchemaSummaryExportJob } from './schemaSummary' import SurveyActivityLogClearJob from './surveyActivityLogClearJob' @@ -113,6 +114,12 @@ export const cloneSurvey = ({ user, surveyId, surveyInfoTarget, cycle = null }) return JobUtils.jobToJSON(job) } +export const startCreateSurveyJob = ({ user, surveyInfo }) => { + const job = new SurveyCreatorJob({ user, surveyInfo }) + JobManager.enqueueJob(job) + return JobUtils.jobToJSON(job) +} + /** * Starts the node definitions AI translation job. * @param {object} params - Params. diff --git a/server/modules/user/service/userService.js b/server/modules/user/service/userService.js index ca2390652f..8cfdd0fd35 100644 --- a/server/modules/user/service/userService.js +++ b/server/modules/user/service/userService.js @@ -167,7 +167,7 @@ const _fetchSurveyTemplateId = async ({ user, templateUuid }) => { return template ? Survey.getId(template) : null } -const _insertOrCloneSurvey = async ({ user, surveyInfoTarget, templateUuid }, t) => { +const _insertOrCloneSurvey = async ({ user, surveyInfoTarget, templateUuid }) => { const templateId = templateUuid ? await _fetchSurveyTemplateId({ user, templateUuid }) : null if (templateId) { const job = new SurveyCloneJob({ user, surveyId: templateId, surveyInfoTarget }) @@ -179,84 +179,102 @@ const _insertOrCloneSurvey = async ({ user, surveyInfoTarget, templateUuid }, t) return await SurveyManager.fetchSurveyById({ surveyId, draft: true }) } } - return await SurveyManager.insertSurvey({ user, surveyInfo: surveyInfoTarget, updateUserPrefs: false }, t) + // Insert survey out of the caller's transaction: SurveyManager.insertSurvey creates the survey data + // schema using its own separate db connections, so it must not run inside a transaction held open by + // the caller (same connection-pool starvation risk migrateSurveySchema was fixed for elsewhere). + return await SurveyManager.insertSurvey({ user, surveyInfo: surveyInfoTarget, updateUserPrefs: false }) } -export const acceptUserAccessRequest = async ({ user, serverUrl, accessRequestAccept }) => - db.tx(async (t) => { - const { accessRequestUuid, surveyName, surveyLabel, role, templateUuid = null } = accessRequestAccept - - // 1) validation - // check access request exists - const accessRequestDb = await UserManager.fetchUserAccessRequestByUuid({ uuid: accessRequestUuid }, t) - if (!accessRequestDb) { - return { - validation: Validation.newInstance(false, {}, [ - Validation.messageKeys.userAccessRequestAccept.accessRequestNotFound, - ]), - } +export const acceptUserAccessRequest = async ({ user, serverUrl, accessRequestAccept }) => { + const { accessRequestUuid, surveyName, surveyLabel, role, templateUuid = null } = accessRequestAccept + + // 1) validation + // check access request exists + const accessRequestDb = await UserManager.fetchUserAccessRequestByUuid({ uuid: accessRequestUuid }) + if (!accessRequestDb) { + return { + validation: Validation.newInstance(false, {}, [ + Validation.messageKeys.userAccessRequestAccept.accessRequestNotFound, + ]), } + } - const { email, status: accessRequestStatus } = accessRequestDb + const { email, status: accessRequestStatus } = accessRequestDb - // check access request not processed already - if (accessRequestStatus !== UserAccessRequest.status.CREATED) { - return { - validation: Validation.newInstance(false, {}, [ - ValidationResult.newInstance(Validation.messageKeys.userAccessRequestAccept.accessRequestAlreadyProcessed), - ]), - } + // check access request not processed already + if (accessRequestStatus !== UserAccessRequest.status.CREATED) { + return { + validation: Validation.newInstance(false, {}, [ + ValidationResult.newInstance(Validation.messageKeys.userAccessRequestAccept.accessRequestAlreadyProcessed), + ]), } + } - // validate survey name - const surveyInfosWithSameName = await SurveyManager.fetchSurveysByName(surveyName, t) - const validation = await UserAccessRequestAcceptValidator.validateUserAccessRequestAccept({ - accessRequestAccept, - surveyInfosWithSameName, - }) - if (Validation.isNotValid(validation)) { - return { validation } - } + // validate survey name + const surveyInfosWithSameName = await SurveyManager.fetchSurveysByName(surveyName) + const validation = await UserAccessRequestAcceptValidator.validateUserAccessRequestAccept({ + accessRequestAccept, + surveyInfosWithSameName, + }) + if (Validation.isNotValid(validation)) { + return { validation } + } - // 2) insert survey - const surveyInfoTarget = Survey.newSurvey({ - ownerUuid: User.getUuid(user), - name: surveyName, - label: surveyLabel, - languages: ['en'], - }) + // 2) insert or clone survey (out of the transaction below; see _insertOrCloneSurvey) + const surveyInfoTarget = Survey.newSurvey({ + ownerUuid: User.getUuid(user), + name: surveyName, + label: surveyLabel, + languages: ['en'], + }) - let survey = await _insertOrCloneSurvey({ user, surveyInfoTarget, templateUuid }, t) + let survey = await _insertOrCloneSurvey({ user, surveyInfoTarget, templateUuid }) + const surveyId = Survey.getId(survey) - // 3) find group to associate to the user - let group = null - if ([AuthGroup.groupNames.systemAdmin, AuthGroup.groupNames.surveyManager].includes(role)) { - group = await AuthManager.fetchGroupByName({ name: role }, t) - } else { - const surveyGroups = await AuthManager.fetchSurveyGroups(Survey.getId(survey), t) - group = surveyGroups.find((surveyGroup) => AuthGroup.getName(surveyGroup) === role) - } + try { + return await db.tx(async (t) => { + // 3) find group to associate to the user + let group = null + if ([AuthGroup.groupNames.systemAdmin, AuthGroup.groupNames.surveyManager].includes(role)) { + group = await AuthManager.fetchGroupByName({ name: role }, t) + } else { + const surveyGroups = await AuthManager.fetchSurveyGroups(surveyId, t) + group = surveyGroups.find((surveyGroup) => AuthGroup.getName(surveyGroup) === role) + } - // 4) invite user to that group and send email - const surveyId = Survey.getId(survey) - const { invitedUsers } = await UserInviteService.inviteUsers( - { - user, - surveyId, - surveyCycleKey: Survey.cycleOneKey, - invitation: UserGroupInvitation.newUserGroupInvitation(email, AuthGroup.getUuid(group)), - serverUrl, - }, - t - ) - const userInvited = invitedUsers[0] - const surveyOwnerUuid = User.getUuid(userInvited) + // 4) invite user to that group and send email + const { invitedUsers } = await UserInviteService.inviteUsers( + { + user, + surveyId, + surveyCycleKey: Survey.cycleOneKey, + invitation: UserGroupInvitation.newUserGroupInvitation(email, AuthGroup.getUuid(group)), + serverUrl, + }, + t + ) + const userInvited = invitedUsers[0] + const surveyOwnerUuid = User.getUuid(userInvited) - await SurveyManager.updateSurveyOwner({ user, surveyId, ownerUuid: surveyOwnerUuid, system: true }, t) - survey = Survey.assocOwnerUuid(surveyOwnerUuid)(survey) + await SurveyManager.updateSurveyOwner({ user, surveyId, ownerUuid: surveyOwnerUuid, system: true }, t) + survey = Survey.assocOwnerUuid(surveyOwnerUuid)(survey) - return { survey, userInvited } - }) + return { survey, userInvited } + }) + } catch (error) { + // The survey was already created (committed) outside of this failed transaction; clean it up so a + // failed access request acceptance doesn't leave an orphaned survey/schema behind. + Logger.error(`error accepting user access request, cleaning up survey ${surveyId}: ${error.stack || error}`) + await SurveyManager.deleteSurvey(surveyId, { deleteUserPrefs: true }).catch((cleanupError) => { + Logger.error( + `error cleaning up survey ${surveyId} after failed access request acceptance: ${ + cleanupError.stack || cleanupError + }` + ) + }) + throw error + } +} // ====== READ diff --git a/test/e2e/jest-playwright.config.js b/test/e2e/jest-playwright.config.js index 8af7148050..bf5c8c4ced 100644 --- a/test/e2e/jest-playwright.config.js +++ b/test/e2e/jest-playwright.config.js @@ -9,7 +9,7 @@ module.exports = { launchOptions: { downloadsPath, headless, - slowMo: headless ? null : 250, + slowMo: headless ? undefined : 250, }, contextOptions: { acceptDownloads: true, diff --git a/test/e2e/tests/_surveyCreate/index.js b/test/e2e/tests/_surveyCreate/index.js index 36e17f3246..1aa2036588 100644 --- a/test/e2e/tests/_surveyCreate/index.js +++ b/test/e2e/tests/_surveyCreate/index.js @@ -19,26 +19,17 @@ export const createSurvey = (surveyToAdd) => { testId: TestId.surveyCreate.surveyCloneFrom, label: `${cloneFrom} - ${cloneFromLabel}`, }) - - // press "Create survey" and wait for the job to complete - await page.click(getSelector(TestId.surveyCreate.submitBtn, 'button')) - await page.waitForSelector(getSelector(TestId.modal.modal)) - - // close the job dialog and wait fot the navigation to the survey dashboard - await Promise.all([ - page.waitForNavigation(/* { url: `{BASE_URL}/app/home/landing/` } */), - page.click(TestId.modal.close), - ]) } else { await FormUtils.fillInput(TestId.surveyCreate.surveyLabel, label) - - // press "Create survey" and wait for the navigation to the survey dashboard - await Promise.all([ - page.waitForNavigation(/* { url: `{BASE_URL}/app/home/landing/` } */), - page.click(getSelector(TestId.surveyCreate.submitBtn, 'button')), - ]) } + // press "Create survey" and wait for the job to complete (survey creation runs as a job too, to bound concurrency) + await page.click(getSelector(TestId.surveyCreate.submitBtn, 'button')) + await page.waitForSelector(getSelector(TestId.modal.modal)) + + // the job dialog auto-hides on completion (see useOnCreate.js); just wait for the navigation + await page.waitForNavigation(/* { url: `{BASE_URL}/app/home/landing/` } */) + const surveyTitleSelector = getSelector(TestId.header.surveyTitle) await expect(await page.innerText(surveyTitleSelector)).toBe(`${label} [${name}]`) }) diff --git a/test/e2e/tests/_templateCreate/index.js b/test/e2e/tests/_templateCreate/index.js index b236c7f3fd..b6170a4ffa 100644 --- a/test/e2e/tests/_templateCreate/index.js +++ b/test/e2e/tests/_templateCreate/index.js @@ -25,10 +25,10 @@ export const createTemplate = (template) => { testId: TestId.surveyCreate.surveyCloneFrom, label: `${cloneFrom} - ${cloneFromLabel}`, }) - await page.click(getSelector(TestId.surveyCreate.submitBtn, 'button')) + // the job dialog auto-hides on completion (see useOnCreate.js); just wait for the navigation await Promise.all([ page.waitForNavigation(/* { url: `{BASE_URL}/app/home/landing/` } */), - page.click(TestId.modal.close), + page.click(getSelector(TestId.surveyCreate.submitBtn, 'button')), ]) } else { await FormUtils.fillInput(TestId.surveyCreate.surveyLabel, label) diff --git a/test/integration/tests/001surveyIntegrationtest.js b/test/integration/tests/001surveyIntegrationtest.js index ecd69e05c9..7335938a52 100644 --- a/test/integration/tests/001surveyIntegrationtest.js +++ b/test/integration/tests/001surveyIntegrationtest.js @@ -7,6 +7,10 @@ describe('Survey Test', () => { test('Create Survey', async () => SurveyIntegrationTest.createSurveyTest()) + test('Create Surveys Concurrently', async () => SurveyIntegrationTest.createSurveysConcurrentlyTest()) + + test('Import Surveys Concurrently', async () => SurveyIntegrationTest.importSurveysConcurrentlyTest()) + // ==== CATEGORY test('Create Category', async () => CategoryTest.createCategoryTest()) diff --git a/test/integration/tests/_survey/surveyTest.js b/test/integration/tests/_survey/surveyTest.js index 3b035078b6..4f31624f45 100644 --- a/test/integration/tests/_survey/surveyTest.js +++ b/test/integration/tests/_survey/surveyTest.js @@ -24,3 +24,46 @@ export const createSurveyTest = async () => { expect(Survey.getDefaultLanguage(surveyInfo)).toEqual(expectedDefaultLanguage) expect(Survey.getDefaultLabel(surveyInfo)).toEqual(Survey.getDefaultLabel(surveyInfoTest)) } + +// Regression test: SurveyManager.insertSurvey used to hold a db transaction open while +// DBMigrator.migrateSurveySchema acquired another connection from the same pool; concurrent survey +// creations could then exhaust the pool and hang the whole server (no connectionTimeoutMillis is set). +export const createSurveysConcurrentlyTest = async () => { + const user = getContextUser() + + const newSurveyInfo = () => + Survey.newSurvey({ + ownerUuid: User.getUuid(user), + name: `do_not_use__test_survey_concurrent_${uuidv4()}`, + label: 'DO NOT USE! Test Survey (concurrent)', + languages: ['en'], + }) + + const [surveyA, surveyB] = await Promise.all([ + SurveyManager.insertSurvey({ user, surveyInfo: newSurveyInfo() }), + SurveyManager.insertSurvey({ user, surveyInfo: newSurveyInfo() }), + ]) + + expect(Survey.getId(surveyA)).not.toEqual(Survey.getId(surveyB)) +} + +// Regression test: SurveyManager.importSurvey (used when restoring an Arena backup file, and when +// cloning a survey) had the same transaction-held-open-during-migration bug as insertSurvey above. +export const importSurveysConcurrentlyTest = async () => { + const user = getContextUser() + + const newSurveyInfo = () => + Survey.newSurvey({ + ownerUuid: User.getUuid(user), + name: `do_not_use__test_survey_import_concurrent_${uuidv4()}`, + label: 'DO NOT USE! Test Survey (import concurrent)', + languages: ['en'], + }) + + const [surveyA, surveyB] = await Promise.all([ + SurveyManager.importSurvey({ user, surveyInfo: newSurveyInfo(), backup: true }), + SurveyManager.importSurvey({ user, surveyInfo: newSurveyInfo(), backup: true }), + ]) + + expect(Survey.getId(surveyA)).not.toEqual(Survey.getId(surveyB)) +} diff --git a/test/load/README.md b/test/load/README.md new file mode 100644 index 0000000000..b194b8cb2b --- /dev/null +++ b/test/load/README.md @@ -0,0 +1,58 @@ +# Survey Import Stress Test + +A standalone, manually-run Node CLI that fires concurrent +`POST /api/survey/arena-import` requests at a running Arena server. It exists +to validate two DB connection-pool / lock fixes made on the +`fix/survey-import-concurrency` branch (survey creation and import used to +hold a DB transaction open while acquiring another connection from the same +pool, which could exhaust the pool under concurrent load). Existing +integration coverage exercises `SurveyManager` directly, in-process, with +only 2 concurrent calls; this tool drives the real HTTP API — JWT auth, +multipart upload, the background job queue — at a higher, configurable +concurrency, using a real Arena survey export zip as the import source. + +It is not wired into `yarn test` / CI. It's a load-testing tool for local +dev use against a running server. + +## Usage + +```bash +node test/load/surveyImportStressTest.ts --zip path/to/survey.zip --count 20 +``` + +Requires a running Arena server and a system-admin login (`--email`/ +`--password`, or `ARENA_EMAIL`/`ARENA_PASSWORD` — or `ADMIN_EMAIL`/ +`ADMIN_PASSWORD` from `.env` — as fallbacks). See `--help` for the full flag +list. + +## Limitations + +**This is a burst-request test, not a true-concurrency test.** +`server/job/JobQueue.js` serializes survey-creation/import jobs globally, one +at a time, regardless of `--count`. All `--count` requests are still fired +simultaneously (which is what reproduces the pool-exhaustion bug class this +tool targets), but the server processes the resulting jobs one after +another. Expect run times to scale roughly linearly with `--count`, and +`timed-out` outcomes if `--job-timeout` is too low for a large `--count`. + +**Throwaway user accounts are permanent.** Each run provisions `--count` +new user accounts (`stress_test__@loadtest.local`, granted +`surveyManager` privileges, random per-run password) to import through, one +account per request, so the burst isn't serialized by the server's +one-job-per-user rule. There is no API to delete a user account, so these +accumulate in the database across runs. Created *surveys* are cleaned up +automatically after each run (unless `--keep` is passed); user accounts are +not. + +To remove them manually, run against the Arena database: + +```sql +DELETE FROM "user" WHERE email LIKE 'stress_test_%@loadtest.local'; +``` + +Their `auth_group_user` rows are removed automatically by this — the FK has +`ON DELETE CASCADE` on the `user` table (see +`20181130124534-create-auth-tables-up.sql` in `@openforis/arena-server`) — +but double-check that's still the case if you're running against an older +schema version, and delete the matching `auth_group_user` rows by hand if +not. diff --git a/test/load/lib/config.test.ts b/test/load/lib/config.test.ts new file mode 100644 index 0000000000..5faa5e863d --- /dev/null +++ b/test/load/lib/config.test.ts @@ -0,0 +1,102 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' + +import { parseConfig, DEFAULT_URL, DEFAULT_COUNT, DEFAULT_JOB_TIMEOUT_MS, type StressTestConfig } from './config.ts' + +const baseEnv = {} + +test('parseConfig throws when --zip is missing', () => { + assert.throws(() => parseConfig({ argv: [], env: baseEnv }), /Missing required argument: --zip/) +}) + +test('parseConfig throws when email is missing', () => { + assert.throws(() => parseConfig({ argv: ['--zip', 'survey.zip'], env: baseEnv }), /Missing email/) +}) + +test('parseConfig throws when password is missing', () => { + assert.throws( + () => parseConfig({ argv: ['--zip', 'survey.zip', '--email', 'a@b.com'], env: baseEnv }), + /Missing password/ + ) +}) + +test('parseConfig applies defaults when only required args are passed', () => { + const config = parseConfig({ + argv: ['--zip', 'survey.zip', '--email', 'a@b.com', '--password', 'pw'], + env: baseEnv, + }) as StressTestConfig + assert.equal(config.zipPath, path.resolve('survey.zip')) + assert.equal(config.url, DEFAULT_URL) + assert.equal(config.email, 'a@b.com') + assert.equal(config.password, 'pw') + assert.equal(config.count, DEFAULT_COUNT) + assert.equal(config.jobTimeoutMs, DEFAULT_JOB_TIMEOUT_MS) + assert.equal(config.keep, false) +}) + +test('parseConfig falls back to env vars for url/email/password', () => { + const config = parseConfig({ + argv: ['--zip', 'survey.zip'], + env: { ARENA_URL: 'http://example.test/', ADMIN_EMAIL: 'admin@x.com', ADMIN_PASSWORD: 'secret' }, + }) as StressTestConfig + assert.equal(config.url, 'http://example.test') + assert.equal(config.email, 'admin@x.com') + assert.equal(config.password, 'secret') +}) + +test('parseConfig strips multiple trailing slashes from --url', () => { + const config = parseConfig({ + argv: ['--zip', 'survey.zip', '--email', 'a@b.com', '--password', 'pw', '--url', 'http://example.test///'], + env: baseEnv, + }) as StressTestConfig + assert.equal(config.url, 'http://example.test') +}) + +test('parseConfig reads --count, --job-timeout and --keep', () => { + const config = parseConfig({ + argv: [ + '--zip', + 'survey.zip', + '--email', + 'a@b.com', + '--password', + 'pw', + '--count', + '5', + '--job-timeout', + '1000', + '--keep', + ], + env: baseEnv, + }) as StressTestConfig + assert.equal(config.count, 5) + assert.equal(config.jobTimeoutMs, 1000) + assert.equal(config.keep, true) +}) + +test('parseConfig rejects a non-positive-integer --count', () => { + assert.throws( + () => + parseConfig({ + argv: ['--zip', 'survey.zip', '--email', 'a@b.com', '--password', 'pw', '--count', '0'], + env: baseEnv, + }), + /--count must be a positive integer/ + ) +}) + +test('parseConfig short-circuits with help:true on --help', () => { + assert.deepEqual(parseConfig({ argv: ['--help'], env: baseEnv }), { help: true }) +}) + +test('parseConfig rejects unknown flags', () => { + assert.throws(() => parseConfig({ argv: ['--bogus'], env: baseEnv }), /Unknown argument: --bogus/) +}) + +test('parseConfig rejects a flag value that is itself another flag, instead of silently swallowing it', () => { + assert.throws( + () => parseConfig({ argv: ['--zip', '--count', '--email', 'a@b.com', '--password', 'pw'], env: baseEnv }), + /Missing value for argument: --zip \(got another flag: --count\)/ + ) +}) diff --git a/test/load/lib/config.ts b/test/load/lib/config.ts new file mode 100644 index 0000000000..aaca374942 --- /dev/null +++ b/test/load/lib/config.ts @@ -0,0 +1,166 @@ +import path from 'node:path' + +/** + * Strips trailing slashes from a URL. Avoids a regex (e.g. /\/+$/) since a trailing, unanchored-at-start + * quantifier like that is flagged by static analysis (SonarCloud javascript:S8786) as having potentially + * super-linear backtracking on pathological input. + * @param url - The URL to normalize. + * @returns The URL with any trailing slashes removed. + */ +const stripTrailingSlashes = (url: string): string => { + let result = url + while (result.endsWith('/')) { + result = result.slice(0, -1) + } + return result +} + +export const DEFAULT_URL = 'http://localhost:9090' +export const DEFAULT_COUNT = 50 +export const DEFAULT_JOB_TIMEOUT_MS = 120000 + +interface FlagDef { + flag: string + key: string + hasValue: boolean +} + +const FLAG_DEFS: FlagDef[] = [ + { flag: '--zip', key: 'zipPath', hasValue: true }, + { flag: '--count', key: 'count', hasValue: true }, + { flag: '--url', key: 'url', hasValue: true }, + { flag: '--email', key: 'email', hasValue: true }, + { flag: '--password', key: 'password', hasValue: true }, + { flag: '--job-timeout', key: 'jobTimeoutMs', hasValue: true }, + { flag: '--keep', key: 'keep', hasValue: false }, + { flag: '--help', key: 'help', hasValue: false }, +] + +export const HELP_TEXT = `Usage: node test/load/surveyImportStressTest.ts --zip [options] + +Options: + --zip Path to an Arena survey export/backup zip (required) + --count Number of concurrent import requests (default: ${DEFAULT_COUNT}) + --url Arena server base URL (default: ${DEFAULT_URL}, env: ARENA_URL) + --email Login email (env: ARENA_EMAIL / ADMIN_EMAIL) + --password Login password (env: ARENA_PASSWORD / ADMIN_PASSWORD) + --job-timeout Max time to wait for each import job (default: ${DEFAULT_JOB_TIMEOUT_MS}) + --keep Do not delete the surveys created by this run + --help Show this help message + +Notes: + The server processes survey-creation/import jobs one at a time, globally, + regardless of --count. This tool produces burst request concurrency, not + concurrent execution; expect long runs and 'timed-out' outcomes at high + --count. The throwaway user accounts this tool creates cannot be deleted + via the API and accumulate in the database across runs (see test/load/README.md). +` + +type ParsedArgs = Record + +/** + * Parses raw CLI arguments into a flat object keyed by flag name. + * @param argv - Raw CLI arguments (without the node/script path entries). + * @returns Flag values keyed by their config key. + */ +const parseArgv = (argv: string[]): ParsedArgs => { + const parsed: ParsedArgs = {} + let index = 0 + while (index < argv.length) { + const arg = argv[index] + const flagDef = FLAG_DEFS.find((def) => def.flag === arg) + if (!flagDef) { + throw new Error(`Unknown argument: ${arg}`) + } + if (flagDef.hasValue) { + const value = argv[index + 1] + if (value === undefined) { + throw new Error(`Missing value for argument: ${arg}`) + } + if (value.startsWith('--')) { + throw new Error(`Missing value for argument: ${arg} (got another flag: ${value})`) + } + parsed[flagDef.key] = value + index += 2 + } else { + parsed[flagDef.key] = true + index += 1 + } + } + return parsed +} + +/** + * Parses and validates a value as a positive integer. + * @param params - Function parameters. + * @param params.value - Raw value to parse. + * @param params.label - Label used in the error message when invalid. + * @returns The parsed positive integer. + */ +const toPositiveInt = ({ value, label }: { value: string | number; label: string }): number => { + const parsed = Number(value) + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive integer, got: ${value}`) + } + return parsed +} + +export interface StressTestConfig { + help: false + zipPath: string + url: string + email: string + password: string + count: number + jobTimeoutMs: number + keep: boolean +} + +export type ParseConfigResult = { help: true } | StressTestConfig + +/** + * Builds the stress test configuration from CLI arguments and environment variables. + * @param params - Function parameters. + * @param params.argv - Raw CLI arguments (e.g. process.argv.slice(2)). + * @param params.env - Environment variables (e.g. process.env). + * @returns Resolved configuration, or { help: true } when --help was passed. + */ +export const parseConfig = ({ argv, env }: { argv: string[]; env: NodeJS.ProcessEnv }): ParseConfigResult => { + const args = parseArgv(argv) + + if (args.help) { + return { help: true } + } + + const zipPath = args.zipPath as string | undefined + if (!zipPath) { + throw new Error('Missing required argument: --zip ') + } + + const url = (args.url as string) || env.ARENA_URL || DEFAULT_URL + const email = (args.email as string) || env.ARENA_EMAIL || env.ADMIN_EMAIL + if (!email) { + throw new Error('Missing email: pass --email, or set ARENA_EMAIL / ADMIN_EMAIL') + } + const password = (args.password as string) || env.ARENA_PASSWORD || env.ADMIN_PASSWORD + if (!password) { + throw new Error('Missing password: pass --password, or set ARENA_PASSWORD / ADMIN_PASSWORD') + } + + const count = toPositiveInt({ value: (args.count as string) ?? DEFAULT_COUNT, label: '--count' }) + const jobTimeoutMs = toPositiveInt({ + value: (args.jobTimeoutMs as string) ?? DEFAULT_JOB_TIMEOUT_MS, + label: '--job-timeout', + }) + + return { + help: false, + zipPath: path.resolve(zipPath), + url: stripTrailingSlashes(url), + email, + password, + count, + jobTimeoutMs, + keep: Boolean(args.keep), + } +} diff --git a/test/load/lib/httpApi.test.ts b/test/load/lib/httpApi.test.ts new file mode 100644 index 0000000000..8f152df48d --- /dev/null +++ b/test/load/lib/httpApi.test.ts @@ -0,0 +1,337 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + login, + buildImportFormData, + importSurveyZip, + getJobStatus, + deleteSurvey, + fetchSurveysByNamePrefix, + createUser, + LOGIN_RATE_LIMIT_MAX_RETRIES, + LOGIN_RATE_LIMIT_DEFAULT_RETRY_MS, + LOGIN_RATE_LIMIT_MAX_RETRY_MS, +} from './httpApi.ts' + +const jsonResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) + +test('login resolves the auth token and calls the right endpoint', async () => { + const calls: Array<{ url: string; options: any }> = [] + const fetchImpl = async (url: string, options?: RequestInit) => { + calls.push({ url, options }) + return jsonResponse({ authToken: 'tok-123' }) + } + + const authToken = await login({ baseUrl: 'http://x', email: 'a@b.com', password: 'pw', fetchImpl }) + + assert.equal(authToken, 'tok-123') + assert.equal(calls.length, 1) + assert.equal(calls[0].url, 'http://x/auth/login') + assert.equal(calls[0].options.method, 'POST') + assert.deepEqual(JSON.parse(calls[0].options.body), { email: 'a@b.com', password: 'pw' }) +}) + +test('login throws with status and body detail on failure', async () => { + const fetchImpl = async () => jsonResponse({ message: 'bad creds' }, 401) + + await assert.rejects( + () => login({ baseUrl: 'http://x', email: 'a@b.com', password: 'wrong', fetchImpl }), + /Login failed \(status 401\).*bad creds/ + ) +}) + +test('login retries a 429 honoring the Retry-After header, then succeeds', async () => { + let call = 0 + const fetchImpl = async () => { + call += 1 + if (call === 1) { + return new Response(JSON.stringify({ message: 'Too many requests' }), { + status: 429, + headers: { 'Content-Type': 'application/json', 'Retry-After': '5' }, + }) + } + return jsonResponse({ authToken: 'tok-after-retry' }) + } + const sleeps: number[] = [] + const sleepImpl = async (ms: number) => { + sleeps.push(ms) + } + + const authToken = await login({ baseUrl: 'http://x', email: 'a@b.com', password: 'pw', fetchImpl, sleepImpl }) + + assert.equal(authToken, 'tok-after-retry') + assert.equal(call, 2) + assert.deepEqual(sleeps, [5000]) +}) + +test('login retries a 429 with no Retry-After header using the default delay', async () => { + let call = 0 + const fetchImpl = async () => { + call += 1 + if (call === 1) { + return new Response(JSON.stringify({ message: 'Too many requests' }), { status: 429 }) + } + return jsonResponse({ authToken: 'tok-after-retry' }) + } + const sleeps: number[] = [] + const sleepImpl = async (ms: number) => { + sleeps.push(ms) + } + + await login({ baseUrl: 'http://x', email: 'a@b.com', password: 'pw', fetchImpl, sleepImpl }) + + assert.deepEqual(sleeps, [LOGIN_RATE_LIMIT_DEFAULT_RETRY_MS]) +}) + +test('login caps an absurdly large Retry-After instead of waiting the full amount', async () => { + let call = 0 + const fetchImpl = async () => { + call += 1 + if (call === 1) { + return new Response(JSON.stringify({ message: 'Too many requests' }), { + status: 429, + headers: { 'Retry-After': '3600' }, + }) + } + return jsonResponse({ authToken: 'tok-after-retry' }) + } + const sleeps: number[] = [] + const sleepImpl = async (ms: number) => { + sleeps.push(ms) + } + + await login({ baseUrl: 'http://x', email: 'a@b.com', password: 'pw', fetchImpl, sleepImpl }) + + assert.deepEqual(sleeps, [LOGIN_RATE_LIMIT_MAX_RETRY_MS]) +}) + +test('login gives up after the max retry count and throws the 429', async () => { + const fetchImpl = async () => + new Response(JSON.stringify({ status: 429, message: 'Too many requests, please try again later.' }), { + status: 429, + headers: { 'Retry-After': '0' }, + }) + let sleepCalls = 0 + const sleepImpl = async () => { + sleepCalls += 1 + } + + await assert.rejects( + () => login({ baseUrl: 'http://x', email: 'a@b.com', password: 'pw', fetchImpl, sleepImpl }), + /Login failed \(status 429\).*Too many requests/ + ) + assert.equal(sleepCalls, LOGIN_RATE_LIMIT_MAX_RETRIES) +}) + +test('buildImportFormData sets the survey and file fields', async () => { + const formData = buildImportFormData({ + zipBuffer: Buffer.from('zip-bytes'), + zipFileName: 'survey.zip', + surveyName: 'stress_test_1', + }) + + const surveyField = JSON.parse(formData.get('survey') as string) + assert.deepEqual(surveyField, { name: 'stress_test_1', options: { includeData: false } }) + + const fileField = formData.get('file') as File + assert.equal(fileField.name, 'survey.zip') + const content = Buffer.from(await fileField.arrayBuffer()) + assert.equal(content.toString(), 'zip-bytes') +}) + +test('importSurveyZip posts multipart form data with the bearer token', async () => { + const calls: Array<{ url: string; options: any }> = [] + const fetchImpl = async (url: string, options?: RequestInit) => { + calls.push({ url, options }) + return jsonResponse({ job: { uuid: 'job-1', status: 'pending' } }) + } + + const job = await importSurveyZip({ + baseUrl: 'http://x', + authToken: 'tok-123', + zipBuffer: Buffer.from('zip-bytes'), + zipFileName: 'survey.zip', + surveyName: 'stress_test_1', + fetchImpl, + }) + + assert.deepEqual(job, { uuid: 'job-1', status: 'pending' }) + assert.equal(calls[0].url, 'http://x/api/survey/arena-import') + assert.equal(calls[0].options.method, 'POST') + assert.equal(calls[0].options.headers.Authorization, 'Bearer tok-123') + assert.ok(calls[0].options.body instanceof FormData) +}) + +test('importSurveyZip throws when the response has no job', async () => { + const fetchImpl = async () => jsonResponse({ message: 'pool exhausted' }, 503) + + await assert.rejects( + () => + importSurveyZip({ + baseUrl: 'http://x', + authToken: 'tok', + zipBuffer: Buffer.from('x'), + zipFileName: 'x.zip', + surveyName: 'n', + fetchImpl, + }), + /Import request failed \(status 503\).*pool exhausted/ + ) +}) + +test('getJobStatus resolves the job summary', async () => { + const fetchImpl = async () => jsonResponse({ uuid: 'job-1', status: 'succeeded', surveyId: 42 }) + + const job = await getJobStatus({ baseUrl: 'http://x', authToken: 'tok', jobUuid: 'job-1', fetchImpl }) + + assert.deepEqual(job, { uuid: 'job-1', status: 'succeeded', surveyId: 42 }) +}) + +test('getJobStatus throws on a non-ok response', async () => { + const fetchImpl = async () => jsonResponse({ message: 'not found' }, 404) + + await assert.rejects( + () => getJobStatus({ baseUrl: 'http://x', authToken: 'tok', jobUuid: 'missing', fetchImpl }), + /Job status request failed \(status 404\)/ + ) +}) + +test('deleteSurvey resolves on a successful delete', async () => { + const calls: Array<{ url: string; options: any }> = [] + const fetchImpl = async (url: string, options?: RequestInit) => { + calls.push({ url, options }) + return new Response(null, { status: 200 }) + } + + await deleteSurvey({ baseUrl: 'http://x', authToken: 'tok', surveyId: 42, fetchImpl }) + + assert.equal(calls[0].url, 'http://x/api/survey/42') + assert.equal(calls[0].options.method, 'DELETE') +}) + +test('deleteSurvey throws on a failed delete', async () => { + const fetchImpl = async () => jsonResponse({ message: 'cannot delete' }, 403) + + await assert.rejects( + () => deleteSurvey({ baseUrl: 'http://x', authToken: 'tok', surveyId: 42, fetchImpl }), + /Delete survey 42 failed \(status 403\)/ + ) +}) + +test('login throws with status and raw text when the error body is not JSON', async () => { + const fetchImpl = async () => new Response('Bad Gateway', { status: 502 }) + + await assert.rejects( + () => login({ baseUrl: 'http://x', email: 'a@b.com', password: 'pw', fetchImpl }), + /Login failed \(status 502\).*Bad Gateway/ + ) +}) + +test('login throws with status when the error body is empty', async () => { + const fetchImpl = async () => new Response(null, { status: 504 }) + + await assert.rejects( + () => login({ baseUrl: 'http://x', email: 'a@b.com', password: 'pw', fetchImpl }), + /Login failed \(status 504\)/ + ) +}) + +test('importSurveyZip throws with status and raw text when the error body is not JSON', async () => { + const fetchImpl = async () => new Response('Gateway Timeout', { status: 504 }) + + await assert.rejects( + () => + importSurveyZip({ + baseUrl: 'http://x', + authToken: 'tok', + zipBuffer: Buffer.from('x'), + zipFileName: 'x.zip', + surveyName: 'n', + fetchImpl, + }), + /Import request failed \(status 504\).*Gateway Timeout/ + ) +}) + +test('getJobStatus throws with status and raw text when the error body is not JSON', async () => { + const fetchImpl = async () => new Response('Service Unavailable', { status: 503 }) + + await assert.rejects( + () => getJobStatus({ baseUrl: 'http://x', authToken: 'tok', jobUuid: 'job-1', fetchImpl }), + /Job status request failed \(status 503\).*Service Unavailable/ + ) +}) + +test('deleteSurvey throws with status and raw text when the error body is not JSON', async () => { + const fetchImpl = async () => new Response('Forbidden', { status: 403 }) + + await assert.rejects( + () => deleteSurvey({ baseUrl: 'http://x', authToken: 'tok', surveyId: 42, fetchImpl }), + /Delete survey 42 failed \(status 403\).*Forbidden/ + ) +}) + +test('fetchSurveysByNamePrefix calls the right endpoint and resolves the list', async () => { + const calls: Array<{ url: string; options: any }> = [] + const fetchImpl = async (url: string, options?: RequestInit) => { + calls.push({ url, options }) + return jsonResponse({ list: [{ id: 1 }, { id: 2 }] }) + } + + const surveys = await fetchSurveysByNamePrefix({ + baseUrl: 'http://x', + authToken: 'tok', + namePrefix: 'stress_test_123_', + fetchImpl, + }) + + assert.deepEqual(surveys, [{ id: 1 }, { id: 2 }]) + assert.equal(calls[0].url, 'http://x/api/surveys?search=stress_test_123_&draft=true&onlyOwn=false') + assert.equal(calls[0].options.method, 'GET') + assert.equal(calls[0].options.headers.Authorization, 'Bearer tok') +}) + +test('fetchSurveysByNamePrefix throws with status and body detail on failure', async () => { + const fetchImpl = async () => jsonResponse({ message: 'not authorized' }, 401) + + await assert.rejects( + () => + fetchSurveysByNamePrefix({ baseUrl: 'http://x', authToken: 'tok', namePrefix: 'stress_test_123_', fetchImpl }), + /List surveys failed \(status 401\).*not authorized/ + ) +}) + +test('createUser resolves when the response is ok and has no validation field', async () => { + const calls: Array<{ url: string; options: any }> = [] + const fetchImpl = async (url: string, options?: RequestInit) => { + calls.push({ url, options }) + return jsonResponse({ user: { id: 1 } }) + } + + await createUser({ baseUrl: 'http://x', authToken: 'tok', name: 'n', email: 'a@b.com', password: 'pw', fetchImpl }) + + assert.equal(calls.length, 1) + assert.equal(calls[0].url, 'http://x/api/user') + assert.equal(calls[0].options.method, 'POST') + assert.equal(calls[0].options.headers.Authorization, 'Bearer tok') +}) + +test('createUser throws when a 200 response body carries a validation failure', async () => { + const fetchImpl = async () => jsonResponse({ validation: { fields: { email: { valid: false } } } }) + + await assert.rejects( + () => createUser({ baseUrl: 'http://x', authToken: 'tok', name: 'n', email: 'a@b.com', password: 'pw', fetchImpl }), + /failed validation/ + ) +}) + +test('createUser throws with status and body detail on a non-ok response', async () => { + const fetchImpl = async () => jsonResponse({ message: 'not authorized' }, 403) + + await assert.rejects( + () => createUser({ baseUrl: 'http://x', authToken: 'tok', name: 'n', email: 'a@b.com', password: 'pw', fetchImpl }), + /Create user a@b.com failed \(status 403\).*not authorized/ + ) +}) diff --git a/test/load/lib/httpApi.ts b/test/load/lib/httpApi.ts new file mode 100644 index 0000000000..910b276294 --- /dev/null +++ b/test/load/lib/httpApi.ts @@ -0,0 +1,303 @@ +export type FetchImpl = (url: string, options?: RequestInit) => Promise +export type SleepImpl = (ms: number) => Promise + +const defaultSleep: SleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +// The server rate-limits /auth/login (e.g. 10 requests/30s) to guard against brute-forcing -- expected, +// deliberate behavior that a burst of many concurrent throwaway-user logins from one IP will trigger. +export const LOGIN_RATE_LIMIT_MAX_RETRIES = 5 +export const LOGIN_RATE_LIMIT_DEFAULT_RETRY_MS = 2000 +export const LOGIN_RATE_LIMIT_MAX_RETRY_MS = 30000 + +export interface Job { + uuid: string + status: string + surveyId?: number | null + errors?: unknown + result?: unknown +} + +/** + * Reads a fetch Response body once as text, and attempts to JSON-parse it. + * Never throws: falls back to { message: } (or {} for an empty body) when the body isn't valid JSON. + * @param response - The fetch Response to read. + * @returns The parsed JSON body, or a fallback object wrapping the raw text. + */ +const readBody = async (response: Response): Promise> => { + const text = await response.text() + if (!text) { + return {} + } + try { + return JSON.parse(text) + } catch { + return { message: text } + } +} + +/** + * Reads the Retry-After header (seconds) from a 429 response, falling back to a default when the header + * is absent, and capping the result so a server-supplied value can't stall the caller indefinitely. + * @param response - The 429 response. + * @returns The delay to wait before retrying, in milliseconds. + */ +const getRetryDelayMs = (response: Response): number => { + const retryAfterSeconds = Number(response.headers.get('retry-after')) + const retryAfterMs = + Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0 + ? retryAfterSeconds * 1000 + : LOGIN_RATE_LIMIT_DEFAULT_RETRY_MS + return Math.min(retryAfterMs, LOGIN_RATE_LIMIT_MAX_RETRY_MS) +} + +/** + * Logs in against the Arena API and returns a bearer auth token. Retries on 429 (the server rate-limits + * this endpoint), honoring the Retry-After header, up to LOGIN_RATE_LIMIT_MAX_RETRIES times. + * @param params - Function parameters. + * @param params.baseUrl - Arena server base URL (no trailing slash). + * @param params.email - Login email. + * @param params.password - Login password. + * @param [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @param [params.sleepImpl] - Sleep implementation to use between retries (defaults to a real delay). + * @returns The JWT auth token. + */ +export const login = async ({ + baseUrl, + email, + password, + fetchImpl = fetch, + sleepImpl = defaultSleep, +}: { + baseUrl: string + email: string + password: string + fetchImpl?: FetchImpl + sleepImpl?: SleepImpl +}): Promise => { + for (let attempt = 0; ; attempt += 1) { + const response = await fetchImpl(`${baseUrl}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + + if (response.status === 429 && attempt < LOGIN_RATE_LIMIT_MAX_RETRIES) { + await sleepImpl(getRetryDelayMs(response)) + continue + } + if (!response.ok) { + const body = await readBody(response) + throw new Error(`Login failed (status ${response.status}): ${JSON.stringify(body)}`) + } + const body = await response.json() + if (!body.authToken) { + throw new Error(`Login failed (status ${response.status}): ${JSON.stringify(body)}`) + } + return body.authToken + } +} + +/** + * Builds the multipart form data for an Arena survey zip import request. + * @param params - Function parameters. + * @param params.zipBuffer - The survey zip file content. + * @param params.zipFileName - The file name to send for the zip part. + * @param params.surveyName - The unique name for the new survey. + * @returns The multipart form data ready to send as a fetch body. + */ +export const buildImportFormData = ({ + zipBuffer, + zipFileName, + surveyName, +}: { + zipBuffer: Buffer + zipFileName: string + surveyName: string +}): FormData => { + const formData = new FormData() + formData.append('survey', JSON.stringify({ name: surveyName, options: { includeData: false } })) + // Node's Buffer (a Uint8Array subclass) satisfies BlobPart at runtime; the generic ArrayBufferLike + // parameter between @types/node and the DOM lib's typed arrays doesn't structurally line up, though. + formData.append('file', new Blob([zipBuffer as unknown as BlobPart], { type: 'application/zip' }), zipFileName) + return formData +} + +/** + * Starts an Arena survey import job from a zip file. + * @param params - Function parameters. + * @param params.baseUrl - Arena server base URL. + * @param params.authToken - JWT auth token from login. + * @param params.zipBuffer - The survey zip file content. + * @param params.zipFileName - The file name to send for the zip part. + * @param params.surveyName - The unique name for the new survey. + * @param [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns The created job summary (includes uuid and status). + */ +export const importSurveyZip = async ({ + baseUrl, + authToken, + zipBuffer, + zipFileName, + surveyName, + fetchImpl = fetch, +}: { + baseUrl: string + authToken: string + zipBuffer: Buffer + zipFileName: string + surveyName: string + fetchImpl?: FetchImpl +}): Promise => { + const formData = buildImportFormData({ zipBuffer, zipFileName, surveyName }) + const response = await fetchImpl(`${baseUrl}/api/survey/arena-import`, { + method: 'POST', + headers: { Authorization: `Bearer ${authToken}` }, + body: formData, + }) + if (!response.ok) { + const body = await readBody(response) + throw new Error(`Import request failed (status ${response.status}): ${JSON.stringify(body)}`) + } + const body = await response.json() + if (!body.job?.uuid) { + throw new Error(`Import request failed (status ${response.status}): ${JSON.stringify(body)}`) + } + return body.job +} + +/** + * Fetches the current status of a background job. + * @param params - Function parameters. + * @param params.baseUrl - Arena server base URL. + * @param params.authToken - JWT auth token from login. + * @param params.jobUuid - UUID of the job to fetch. + * @param [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns The job summary. + */ +export const getJobStatus = async ({ + baseUrl, + authToken, + jobUuid, + fetchImpl = fetch, +}: { + baseUrl: string + authToken: string + jobUuid: string + fetchImpl?: FetchImpl +}): Promise => { + const response = await fetchImpl(`${baseUrl}/api/jobs/${jobUuid}`, { + method: 'GET', + headers: { Authorization: `Bearer ${authToken}` }, + }) + if (!response.ok) { + const body = await readBody(response) + throw new Error(`Job status request failed (status ${response.status}): ${JSON.stringify(body)}`) + } + return response.json() +} + +/** + * Deletes a survey. + * @param params - Function parameters. + * @param params.baseUrl - Arena server base URL. + * @param params.authToken - JWT auth token from login. + * @param params.surveyId - ID of the survey to delete. + * @param [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns Resolves when the survey has been deleted. + */ +export const deleteSurvey = async ({ + baseUrl, + authToken, + surveyId, + fetchImpl = fetch, +}: { + baseUrl: string + authToken: string + surveyId: number | string + fetchImpl?: FetchImpl +}): Promise => { + const response = await fetchImpl(`${baseUrl}/api/survey/${surveyId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${authToken}` }, + }) + if (!response.ok) { + const body = await readBody(response) + throw new Error(`Delete survey ${surveyId} failed (status ${response.status}): ${JSON.stringify(body)}`) + } +} + +/** + * Fetches every survey whose name starts with the given prefix, visible to the authenticated user. + * @param params - Function parameters. + * @param params.baseUrl - Arena server base URL. + * @param params.authToken - JWT auth token. + * @param params.namePrefix - Prefix to match against survey names (server does a substring search). + * @param [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns The matching surveys. + */ +export const fetchSurveysByNamePrefix = async ({ + baseUrl, + authToken, + namePrefix, + fetchImpl = fetch, +}: { + baseUrl: string + authToken: string + namePrefix: string + fetchImpl?: FetchImpl +}): Promise> => { + const url = `${baseUrl}/api/surveys?search=${encodeURIComponent(namePrefix)}&draft=true&onlyOwn=false` + const response = await fetchImpl(url, { + method: 'GET', + headers: { Authorization: `Bearer ${authToken}` }, + }) + if (!response.ok) { + const body = await readBody(response) + throw new Error(`List surveys failed (status ${response.status}): ${JSON.stringify(body)}`) + } + const body = await response.json() + return body.list +} + +/** + * Creates a new user account. The caller must be a system admin. + * @param params - Function parameters. + * @param params.baseUrl - Arena server base URL. + * @param params.authToken - JWT auth token of a system admin user. + * @param params.name - Full name for the new user. + * @param params.email - Email address for the new user (must be unique). + * @param params.password - Password for the new user. + * @param [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns Resolves when the user has been created. + */ +export const createUser = async ({ + baseUrl, + authToken, + name, + email, + password, + fetchImpl = fetch, +}: { + baseUrl: string + authToken: string + name: string + email: string + password: string + fetchImpl?: FetchImpl +}): Promise => { + const response = await fetchImpl(`${baseUrl}/api/user`, { + method: 'POST', + headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user: JSON.stringify({ name, email, password, props: { title: 'preferNotToSay' } }), + }), + }) + if (!response.ok) { + const body = await readBody(response) + throw new Error(`Create user ${email} failed (status ${response.status}): ${JSON.stringify(body)}`) + } + const body = await response.json() + if (body.validation) { + throw new Error(`Create user ${email} failed validation: ${JSON.stringify(body.validation)}`) + } +} diff --git a/test/load/lib/report.test.ts b/test/load/lib/report.test.ts new file mode 100644 index 0000000000..f2b7bc562f --- /dev/null +++ b/test/load/lib/report.test.ts @@ -0,0 +1,48 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { formatSummary, type ResultEntry } from './report.ts' + +const baseResult: ResultEntry = { + index: 0, + name: 'stress_test_0', + outcome: 'succeeded', + surveyId: null, + acceptMs: 100, + jobMs: 500, + error: null, +} + +test('formatSummary counts outcomes and reports latency stats', () => { + const results: ResultEntry[] = [ + { ...baseResult, index: 0, name: 's0' }, + { ...baseResult, index: 1, name: 's1', acceptMs: 200, jobMs: 1000 }, + { ...baseResult, index: 2, name: 's2', outcome: 'failed', error: 'boom', acceptMs: 150, jobMs: 300 }, + ] + const summary = formatSummary({ results, totalDurationMs: 2000 }) + + assert.match(summary, /Total requests: 3/) + assert.match(summary, /succeeded: 2/) + assert.match(summary, /failed: 1/) + assert.match(summary, /timed-out: 0/) +}) + +test('formatSummary lists failure detail lines', () => { + const results: ResultEntry[] = [{ ...baseResult, index: 4, name: 's4', outcome: 'failed', error: 'pool exhausted' }] + const summary = formatSummary({ results, totalDurationMs: 500 }) + + assert.match(summary, /Failures:/) + assert.match(summary, /\[4\] s4 - failed: pool exhausted/) +}) + +test('formatSummary omits the Failures section when everything succeeded', () => { + const results: ResultEntry[] = [{ ...baseResult }] + const summary = formatSummary({ results, totalDurationMs: 500 }) + + assert.doesNotMatch(summary, /Failures:/) +}) + +test('formatSummary handles an empty results array', () => { + const summary = formatSummary({ results: [], totalDurationMs: 0 }) + assert.match(summary, /Total requests: 0/) +}) diff --git a/test/load/lib/report.ts b/test/load/lib/report.ts new file mode 100644 index 0000000000..ad3d52ed90 --- /dev/null +++ b/test/load/lib/report.ts @@ -0,0 +1,80 @@ +import { computeStats } from './stats.ts' + +export type Outcome = 'succeeded' | 'failed' | 'timed-out' | 'canceled' | 'rejected-at-http' + +export interface ResultEntry { + index: number + name: string + outcome: Outcome + surveyId: number | null + acceptMs: number | null + jobMs: number | null + error: string | null +} + +const OUTCOME_ORDER: Outcome[] = ['succeeded', 'failed', 'timed-out', 'canceled', 'rejected-at-http'] + +/** + * Formats a millisecond duration for display, or 'n/a' when not available. + * @param value - Duration in milliseconds, or null. + * @returns Formatted duration. + */ +const formatMs = (value: number | null | undefined): string => + value === null || value === undefined ? 'n/a' : `${Math.round(value)}ms` + +/** + * Builds a human-readable summary report for a stress test run. + * @param params - Function parameters. + * @param params.results - Per-request result objects. + * @param params.totalDurationMs - Total wall-clock duration of the run, in milliseconds. + * @returns The formatted report. + */ +export const formatSummary = ({ + results, + totalDurationMs, +}: { + results: ResultEntry[] + totalDurationMs: number +}): string => { + const total = results.length + const byOutcome = results.reduce>((acc, result) => { + acc[result.outcome] = (acc[result.outcome] || 0) + 1 + return acc + }, {}) + + const acceptStats = computeStats( + results.filter((result) => result.acceptMs !== null).map((result) => result.acceptMs as number) + ) + const jobStats = computeStats( + results.filter((result) => result.jobMs !== null).map((result) => result.jobMs as number) + ) + + const lines: string[] = [] + lines.push( + '', + '==================== Survey Import Stress Test Summary ====================', + `Total requests: ${total}`, + `Total duration: ${formatMs(totalDurationMs)}`, + '', + 'Outcomes:' + ) + OUTCOME_ORDER.forEach((outcome) => { + lines.push(` ${outcome}: ${byOutcome[outcome] || 0}`) + }) + lines.push('') + const acceptLatencies = [acceptStats.min, acceptStats.avg, acceptStats.max, acceptStats.p95].map(formatMs).join(' / ') + lines.push(`Accept latency (min/avg/max/p95): ${acceptLatencies}`) + const jobLatencies = [jobStats.min, jobStats.avg, jobStats.max, jobStats.p95].map(formatMs).join(' / ') + lines.push(`Job latency (min/avg/max/p95): ${jobLatencies}`) + + const failures = results.filter((result) => result.outcome !== 'succeeded') + if (failures.length > 0) { + lines.push('', 'Failures:') + failures.forEach((failure) => { + lines.push(` [${failure.index}] ${failure.name} - ${failure.outcome}: ${failure.error || 'no error detail'}`) + }) + } + lines.push('=============================================================================', '') + + return lines.join('\n') +} diff --git a/test/load/lib/stats.test.ts b/test/load/lib/stats.test.ts new file mode 100644 index 0000000000..9ea078d642 --- /dev/null +++ b/test/load/lib/stats.test.ts @@ -0,0 +1,33 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { computeStats } from './stats.ts' + +test('computeStats returns nulls for an empty array', () => { + assert.deepEqual(computeStats([]), { count: 0, min: null, max: null, avg: null, p95: null }) +}) + +test('computeStats computes min/max/avg for a simple set', () => { + const stats = computeStats([10, 20, 30]) + assert.equal(stats.count, 3) + assert.equal(stats.min, 10) + assert.equal(stats.max, 30) + assert.equal(stats.avg, 20) +}) + +test('computeStats is not affected by input order', () => { + const stats = computeStats([30, 10, 20]) + assert.equal(stats.min, 10) + assert.equal(stats.max, 30) +}) + +test('computeStats computes p95 for a 100-sample set', () => { + const values = Array.from({ length: 100 }, (_, i) => i + 1) // 1..100 + const stats = computeStats(values) + assert.equal(stats.p95, 95) +}) + +test('computeStats handles a single value', () => { + const stats = computeStats([42]) + assert.deepEqual(stats, { count: 1, min: 42, max: 42, avg: 42, p95: 42 }) +}) diff --git a/test/load/lib/stats.ts b/test/load/lib/stats.ts new file mode 100644 index 0000000000..fde9d4e06b --- /dev/null +++ b/test/load/lib/stats.ts @@ -0,0 +1,30 @@ +export interface Stats { + count: number + min: number | null + max: number | null + avg: number | null + p95: number | null +} + +/** + * Computes summary statistics (min, max, average, p95) for a list of numeric samples. + * @param values - Numeric samples (e.g. latencies in milliseconds). + * @returns Summary statistics; all fields are null (except count, which is 0) when values is empty. + */ +export const computeStats = (values: number[]): Stats => { + if (!Array.isArray(values) || values.length === 0) { + return { count: 0, min: null, max: null, avg: null, p95: null } + } + const sorted = [...values].sort((a, b) => a - b) + const count = sorted.length + const sum = sorted.reduce((total, value) => total + value, 0) + const p95Index = Math.min(count - 1, Math.ceil(count * 0.95) - 1) + + return { + count, + min: sorted[0], + max: sorted[count - 1], + avg: sum / count, + p95: sorted[p95Index], + } +} diff --git a/test/load/lib/userProvisioning.test.ts b/test/load/lib/userProvisioning.test.ts new file mode 100644 index 0000000000..8b5d32074e --- /dev/null +++ b/test/load/lib/userProvisioning.test.ts @@ -0,0 +1,57 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { buildLoadTestUserCredentials, generateLoadTestUserPassword } from './userProvisioning.ts' + +// Mirrors core/user/userPasswordValidator.ts's passwordStrengthRegExp (and validPasswordRegExp, implied by +// \S+ matching within .{8,} here since no whitespace-containing password could match this shape anyway). +const PASSWORD_STRENGTH_REGEXP = /^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?\d).{8,}$/ +const NO_WHITESPACE_REGEXP = /^\S+$/ + +test('buildLoadTestUserCredentials returns the requested count', () => { + const credentials = buildLoadTestUserCredentials({ runId: 123, count: 5 }) + assert.equal(credentials.length, 5) +}) + +test('buildLoadTestUserCredentials produces unique, deterministic emails per index', () => { + const credentials = buildLoadTestUserCredentials({ runId: 123, count: 3 }) + const emails = credentials.map((c) => c.email) + assert.deepEqual(emails, [ + 'stress_test_123_0@loadtest.local', + 'stress_test_123_1@loadtest.local', + 'stress_test_123_2@loadtest.local', + ]) +}) + +test('buildLoadTestUserCredentials gives every user a name and a password satisfying the server strength rules', () => { + const credentials = buildLoadTestUserCredentials({ runId: 999, count: 2 }) + credentials.forEach((c) => { + assert.ok(c.name.length > 0) + assert.match(c.password, NO_WHITESPACE_REGEXP) + assert.match(c.password, PASSWORD_STRENGTH_REGEXP) + }) +}) + +test('buildLoadTestUserCredentials shares one randomized password across every user in the same run', () => { + const credentials = buildLoadTestUserCredentials({ runId: 999, count: 3 }) + const passwords = new Set(credentials.map((c) => c.password)) + assert.equal(passwords.size, 1) +}) + +test('buildLoadTestUserCredentials generates a different password on each call, proving it is randomized and not hardcoded', () => { + const first = buildLoadTestUserCredentials({ runId: 1, count: 1 })[0].password + const second = buildLoadTestUserCredentials({ runId: 2, count: 1 })[0].password + assert.notEqual(first, second) +}) + +test('generateLoadTestUserPassword produces a password matching the server password validator regexes', () => { + for (let i = 0; i < 20; i += 1) { + const password = generateLoadTestUserPassword() + assert.match(password, NO_WHITESPACE_REGEXP) + assert.match(password, PASSWORD_STRENGTH_REGEXP) + } +}) + +test('buildLoadTestUserCredentials returns an empty array for count 0', () => { + assert.deepEqual(buildLoadTestUserCredentials({ runId: 1, count: 0 }), []) +}) diff --git a/test/load/lib/userProvisioning.ts b/test/load/lib/userProvisioning.ts new file mode 100644 index 0000000000..3995765191 --- /dev/null +++ b/test/load/lib/userProvisioning.ts @@ -0,0 +1,37 @@ +import crypto from 'node:crypto' + +const LOAD_TEST_EMAIL_DOMAIN = 'loadtest.local' + +export interface UserCredentials { + name: string + email: string + password: string +} + +/** + * Builds a random password satisfying the server's password validator (core/user/userPasswordValidator.ts): + * no whitespace, at least 8 chars, at least one uppercase, one lowercase and one digit. The fixed + * prefix/suffix guarantee the required character classes; the random segment guarantees it's not a + * hardcoded secret. This password is ephemeral: it's generated fresh per run and never logged back + * into after the run completes, so it does not need to be memorable, reused, or persisted anywhere. + * @returns A freshly generated random password. + */ +export const generateLoadTestUserPassword = (): string => `LoadTest${crypto.randomUUID().slice(0, 8)}Aa1!` + +/** + * Builds deterministic credentials for N throwaway load-test users, unique to this run. The password is + * randomized once per run (shared by every user in that run) rather than hardcoded; see + * generateLoadTestUserPassword for why that's safe for these throwaway, never-logged-into-again accounts. + * @param params - Function parameters. + * @param params.runId - Unique identifier for this run (e.g. Date.now()). + * @param params.count - Number of user credential sets to build. + * @returns One credential set per user, in index order. + */ +export const buildLoadTestUserCredentials = ({ runId, count }: { runId: number; count: number }): UserCredentials[] => { + const password = generateLoadTestUserPassword() + return Array.from({ length: count }, (_, i) => ({ + name: `Load Test User ${runId}_${i}`, + email: `stress_test_${runId}_${i}@${LOAD_TEST_EMAIL_DOMAIN}`, + password, + })) +} diff --git a/test/load/surveyImportStressTest.test.ts b/test/load/surveyImportStressTest.test.ts new file mode 100644 index 0000000000..9582946636 --- /dev/null +++ b/test/load/surveyImportStressTest.test.ts @@ -0,0 +1,276 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { runSingleImport, runSingleUserImport, pollJobUntilTerminal, cleanupSurveys } from './surveyImportStressTest.ts' + +const jsonResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) + +test('pollJobUntilTerminal carries surveyId forward from a non-terminal read when the terminal read lacks it', async () => { + const responses = [ + jsonResponse({ uuid: 'job-1', status: 'running', surveyId: 42 }), + jsonResponse({ uuid: 'job-1', status: 'succeeded' }), + ] + let call = 0 + const fetchImpl = async () => responses[call++] + + const job = await pollJobUntilTerminal({ + baseUrl: 'http://x', + authToken: 'tok', + jobUuid: 'job-1', + timeoutMs: 5000, + pollIntervalMs: 1, + fetchImpl, + }) + + assert.equal(job.status, 'succeeded') + assert.equal(job.surveyId, 42) +}) + +test('pollJobUntilTerminal tolerates a transient poll error and then succeeds', async () => { + let call = 0 + const fetchImpl = async () => { + call += 1 + if (call === 1) { + throw new Error('ECONNRESET') + } + return jsonResponse({ uuid: 'job-1', status: 'succeeded', surveyId: 7 }) + } + + const job = await pollJobUntilTerminal({ + baseUrl: 'http://x', + authToken: 'tok', + jobUuid: 'job-1', + timeoutMs: 5000, + pollIntervalMs: 1, + fetchImpl, + }) + + assert.equal(job.status, 'succeeded') + assert.equal(job.surveyId, 7) +}) + +test('pollJobUntilTerminal gives up after too many consecutive poll errors, well before the timeout', async () => { + const fetchImpl = async (): Promise => { + throw new Error('ECONNRESET') + } + + const job = await pollJobUntilTerminal({ + baseUrl: 'http://x', + authToken: 'tok', + jobUuid: 'job-1', + timeoutMs: 60000, + pollIntervalMs: 1, + fetchImpl, + }) + + assert.equal(job.status, 'rejected-at-http') + assert.match(job.error as string, /ECONNRESET/) +}) + +test('pollJobUntilTerminal times out when the job never reaches a terminal status', async () => { + const fetchImpl = async () => jsonResponse({ uuid: 'job-1', status: 'running' }) + + const job = await pollJobUntilTerminal({ + baseUrl: 'http://x', + authToken: 'tok', + jobUuid: 'job-1', + timeoutMs: 0, + pollIntervalMs: 1, + fetchImpl, + }) + + assert.equal(job.status, 'timed-out') +}) + +test('pollJobUntilTerminal does not crash on a null job read and keeps polling', async () => { + const responses = [jsonResponse(null), jsonResponse({ uuid: 'job-1', status: 'succeeded', surveyId: 9 })] + let call = 0 + const fetchImpl = async () => responses[call++] + + const job = await pollJobUntilTerminal({ + baseUrl: 'http://x', + authToken: 'tok', + jobUuid: 'job-1', + timeoutMs: 5000, + pollIntervalMs: 1, + fetchImpl, + }) + + assert.equal(job.status, 'succeeded') + assert.equal(job.surveyId, 9) +}) + +test('runSingleImport returns rejected-at-http when the import request itself fails', async () => { + const fetchImpl = async () => jsonResponse({ message: 'pool exhausted' }, 503) + + const result = await runSingleImport({ + baseUrl: 'http://x', + authToken: 'tok', + zipBuffer: Buffer.from('x'), + zipFileName: 'x.zip', + surveyName: 'stress_test_0', + index: 0, + jobTimeoutMs: 5000, + fetchImpl, + }) + + assert.equal(result.outcome, 'rejected-at-http') + assert.equal(result.surveyId, null) + assert.equal(result.jobMs, null) + assert.ok((result.acceptMs as number) >= 0) +}) + +test('runSingleImport succeeds end-to-end and carries the surveyId through even though the terminal poll lacks it', async () => { + const responses = [ + jsonResponse({ job: { uuid: 'job-1', status: 'pending' } }), // import accept + jsonResponse({ uuid: 'job-1', status: 'running', surveyId: 99 }), // poll 1 (active) + jsonResponse({ uuid: 'job-1', status: 'succeeded' }), // poll 2 (terminal, no surveyId) + ] + let call = 0 + const fetchImpl = async () => responses[call++] + + const result = await runSingleImport({ + baseUrl: 'http://x', + authToken: 'tok', + zipBuffer: Buffer.from('x'), + zipFileName: 'x.zip', + surveyName: 'stress_test_1', + index: 1, + jobTimeoutMs: 5000, + fetchImpl, + }) + + assert.equal(result.outcome, 'succeeded') + assert.equal(result.surveyId, 99) +}) + +test('runSingleImport reports a helpful message and no jobMs-style acceptMs distortion when the job times out', async () => { + const fetchImpl = async (url: string) => { + if (url.includes('/api/survey/arena-import')) { + return jsonResponse({ job: { uuid: 'job-1', status: 'pending' } }) + } + return jsonResponse({ uuid: 'job-1', status: 'running' }) + } + + const result = await runSingleImport({ + baseUrl: 'http://x', + authToken: 'tok', + zipBuffer: Buffer.from('x'), + zipFileName: 'x.zip', + surveyName: 'stress_test_2', + index: 2, + jobTimeoutMs: 0, + fetchImpl, + }) + + assert.equal(result.outcome, 'timed-out') + assert.equal(result.error, 'timed out after 0ms') +}) + +test('cleanupSurveys queries the server authoritatively by name prefix and deletes everything it finds, tolerating individual failures', async () => { + const calls: string[] = [] + const fetchImpl = async (url: string) => { + calls.push(url) + if (url.includes('/api/surveys?')) { + return jsonResponse({ list: [{ id: 1 }, { id: 2 }, { id: 3 }] }) + } + if (url.endsWith('/api/survey/2')) { + return new Response('nope', { status: 500 }) + } + return new Response(null, { status: 200 }) + } + + const summary = await cleanupSurveys({ + baseUrl: 'http://x', + authToken: 'tok', + namePrefix: 'stress_test_123_', + fetchImpl, + }) + + assert.equal(summary.totalCount, 3) + assert.equal(summary.deletedCount, 2) + assert.equal(calls[0], 'http://x/api/surveys?search=stress_test_123_&draft=true&onlyOwn=false') + assert.equal(calls.length, 4) +}) + +test('cleanupSurveys deletes surveys the caller never observed a surveyId for (e.g. a job that timed out while still queued)', async () => { + // Simulates the leak this fix addresses: the run's results never learned this survey's ID (it was still + // queued when the poll gave up), but the server created it anyway once its turn came. Authoritative + // cleanup finds and deletes it purely from the name-prefix query, with zero surveyIds ever known locally. + const calls: string[] = [] + const fetchImpl = async (url: string) => { + calls.push(url) + if (url.includes('/api/surveys?')) { + return jsonResponse({ list: [{ id: 99 }] }) + } + return new Response(null, { status: 200 }) + } + + const summary = await cleanupSurveys({ + baseUrl: 'http://x', + authToken: 'tok', + namePrefix: 'stress_test_456_', + fetchImpl, + }) + + assert.equal(summary.totalCount, 1) + assert.equal(summary.deletedCount, 1) + assert.ok(calls.some((url) => url.endsWith('/api/survey/99'))) +}) + +test('runSingleUserImport creates the user, logs in as them, then imports', async () => { + const calls: Array<{ url: string; options: any }> = [] + const responses = [ + jsonResponse({ user: { id: 1 } }), // POST /api/user + jsonResponse({ authToken: 'user-tok' }), // POST /auth/login (as the new user) + jsonResponse({ job: { uuid: 'job-1', status: 'pending' } }), // import accept + jsonResponse({ uuid: 'job-1', status: 'succeeded', surveyId: 55 }), // poll (terminal, this server response does include surveyId) + ] + let call = 0 + const fetchImpl = async (url: string, options?: RequestInit) => { + calls.push({ url, options }) + return responses[call++] + } + + const result = await runSingleUserImport({ + baseUrl: 'http://x', + adminAuthToken: 'admin-tok', + credentials: { name: 'Load Test User 1', email: 'stress_test_1_0@loadtest.local', password: 'LoadTestUser1Aa!' }, + zipBuffer: Buffer.from('x'), + zipFileName: 'x.zip', + surveyName: 'stress_test_1_0', + index: 0, + jobTimeoutMs: 5000, + fetchImpl, + }) + + assert.equal(result.outcome, 'succeeded') + assert.equal(result.surveyId, 55) + assert.equal(calls[0].url, 'http://x/api/user') + assert.equal((calls[0].options.headers as any).Authorization, 'Bearer admin-tok') + assert.equal(calls[1].url, 'http://x/auth/login') + assert.equal((calls[2].options.headers as any).Authorization, 'Bearer user-tok') +}) + +test('runSingleUserImport returns rejected-at-http when user creation fails, without attempting login or import', async () => { + const fetchImpl = async () => new Response('quota exceeded', { status: 403 }) + + const result = await runSingleUserImport({ + baseUrl: 'http://x', + adminAuthToken: 'admin-tok', + credentials: { name: 'Load Test User 2', email: 'stress_test_1_1@loadtest.local', password: 'LoadTestUser1Aa!' }, + zipBuffer: Buffer.from('x'), + zipFileName: 'x.zip', + surveyName: 'stress_test_1_1', + index: 1, + jobTimeoutMs: 5000, + fetchImpl, + }) + + assert.equal(result.outcome, 'rejected-at-http') + assert.match(result.error as string, /user setup failed/) + // acceptMs measures only the import POST's latency elsewhere; a setup failure never got that far, so it + // must report null (not user-creation+login time) to avoid distorting the report's accept-latency stat. + assert.equal(result.acceptMs, null) +}) diff --git a/test/load/surveyImportStressTest.ts b/test/load/surveyImportStressTest.ts new file mode 100644 index 0000000000..b9164b3452 --- /dev/null +++ b/test/load/surveyImportStressTest.ts @@ -0,0 +1,395 @@ +/* eslint-disable no-console -- this file's entire purpose is CLI reporting */ +import 'dotenv/config' + +import fs from 'node:fs' +import path from 'node:path' + +import { parseConfig, HELP_TEXT, type ParseConfigResult, type StressTestConfig } from './lib/config.ts' +import { + login, + importSurveyZip, + getJobStatus, + deleteSurvey, + fetchSurveysByNamePrefix, + createUser, + type FetchImpl, + type Job, +} from './lib/httpApi.ts' +import { buildLoadTestUserCredentials, type UserCredentials } from './lib/userProvisioning.ts' +import { formatSummary, type Outcome, type ResultEntry } from './lib/report.ts' + +const JOB_POLL_INTERVAL_MS = 1000 +const MAX_CONSECUTIVE_POLL_ERRORS = 3 +const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'canceled']) + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +interface PolledJob { + status: string + surveyId: number | null + errors: unknown + result: unknown + error?: string +} + +/** + * Polls a job until it reaches a terminal status, the timeout elapses, or too many consecutive poll + * requests fail. Never rejects. surveyId/errors/result are backfilled from the last non-terminal read + * when the terminal read itself lacks them (the server's terminal job-status response omits them). + * @param params - Function parameters. + * @param params.baseUrl - Arena server base URL. + * @param params.authToken - JWT auth token. + * @param params.jobUuid - UUID of the job to poll. + * @param params.timeoutMs - Max time to wait, in milliseconds. + * @param [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @param [params.pollIntervalMs] - Delay between polls, in milliseconds (defaults to 1000). + * @returns The last known job summary; status is 'timed-out' or 'rejected-at-http' if polling didn't reach a terminal status. + */ +export const pollJobUntilTerminal = async ({ + baseUrl, + authToken, + jobUuid, + timeoutMs, + fetchImpl = fetch, + pollIntervalMs = JOB_POLL_INTERVAL_MS, +}: { + baseUrl: string + authToken: string + jobUuid: string + timeoutMs: number + fetchImpl?: FetchImpl + pollIntervalMs?: number +}): Promise => { + const startedAt = Date.now() + let lastKnownSurveyId: number | null = null + let lastKnownErrors: unknown = null + let lastKnownResult: unknown = null + let consecutivePollErrors = 0 + let lastPollError: Error | null = null + + for (;;) { + let job: Job | null = null + try { + job = await getJobStatus({ baseUrl, authToken, jobUuid, fetchImpl }) + consecutivePollErrors = 0 + } catch (error: any) { + consecutivePollErrors += 1 + lastPollError = error + if (consecutivePollErrors > MAX_CONSECUTIVE_POLL_ERRORS) { + return { + status: 'rejected-at-http', + surveyId: lastKnownSurveyId, + errors: lastKnownErrors, + result: lastKnownResult, + error: lastPollError.message, + } + } + } + + if (job && TERMINAL_STATUSES.has(job.status)) { + return { + ...job, + surveyId: job.surveyId || lastKnownSurveyId, + errors: job.errors || lastKnownErrors, + result: job.result || lastKnownResult, + } + } + if (job) { + lastKnownSurveyId = job.surveyId || lastKnownSurveyId + lastKnownErrors = job.errors || lastKnownErrors + lastKnownResult = job.result || lastKnownResult + } + + if (Date.now() - startedAt >= timeoutMs) { + return { + status: 'timed-out', + surveyId: lastKnownSurveyId, + errors: lastKnownErrors, + result: lastKnownResult, + } + } + await sleep(pollIntervalMs) + } +} + +/** + * Runs one survey import request end-to-end (accept + poll to completion) and reports its outcome. + * @param params - Function parameters. + * @param params.baseUrl - Arena server base URL. + * @param params.authToken - JWT auth token. + * @param params.zipBuffer - The survey zip file content. + * @param params.zipFileName - The file name to send for the zip part. + * @param params.surveyName - The unique name for the new survey. + * @param params.index - Index of this request within the run (for reporting). + * @param params.jobTimeoutMs - Max time to wait for the job to finish. + * @param [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns A result entry. + */ +export const runSingleImport = async ({ + baseUrl, + authToken, + zipBuffer, + zipFileName, + surveyName, + index, + jobTimeoutMs, + fetchImpl = fetch, +}: { + baseUrl: string + authToken: string + zipBuffer: Buffer + zipFileName: string + surveyName: string + index: number + jobTimeoutMs: number + fetchImpl?: FetchImpl +}): Promise => { + const acceptStartedAt = Date.now() + let job: Job + try { + job = await importSurveyZip({ baseUrl, authToken, zipBuffer, zipFileName, surveyName, fetchImpl }) + } catch (error: any) { + return { + index, + name: surveyName, + outcome: 'rejected-at-http', + surveyId: null, + acceptMs: Date.now() - acceptStartedAt, + jobMs: null, + error: error.message, + } + } + const acceptMs = Date.now() - acceptStartedAt + + const jobStartedAt = Date.now() + const finalJob = await pollJobUntilTerminal({ + baseUrl, + authToken, + jobUuid: job.uuid, + timeoutMs: jobTimeoutMs, + fetchImpl, + }) + const jobMs = Date.now() - jobStartedAt + + const outcome = finalJob.status as Outcome + let error: string | null = null + if (outcome === 'timed-out') { + error = `timed out after ${jobTimeoutMs}ms` + } else if (outcome !== 'succeeded') { + error = finalJob.error || JSON.stringify(finalJob.errors || finalJob.result || 'unknown error') + } + + return { + index, + name: surveyName, + outcome, + surveyId: finalJob.surveyId || null, + acceptMs, + jobMs, + error, + } +} + +/** + * Creates one throwaway user, logs in as them, then runs their single survey import end-to-end. + * If user creation or login fails, returns a rejected-at-http result without attempting the import. + * @param params - Function parameters. + * @param params.baseUrl - Arena server base URL. + * @param params.adminAuthToken - JWT auth token of the system admin used to create the user. + * @param params.credentials - Credentials for the throwaway user. + * @param params.zipBuffer - The survey zip file content. + * @param params.zipFileName - The file name to send for the zip part. + * @param params.surveyName - The unique name for the new survey. + * @param params.index - Index of this request within the run (for reporting). + * @param params.jobTimeoutMs - Max time to wait for the job to finish. + * @param [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns A result entry. + */ +export const runSingleUserImport = async ({ + baseUrl, + adminAuthToken, + credentials, + zipBuffer, + zipFileName, + surveyName, + index, + jobTimeoutMs, + fetchImpl = fetch, +}: { + baseUrl: string + adminAuthToken: string + credentials: UserCredentials + zipBuffer: Buffer + zipFileName: string + surveyName: string + index: number + jobTimeoutMs: number + fetchImpl?: FetchImpl +}): Promise => { + let userAuthToken: string + try { + await createUser({ baseUrl, authToken: adminAuthToken, ...credentials, fetchImpl }) + userAuthToken = await login({ baseUrl, email: credentials.email, password: credentials.password, fetchImpl }) + } catch (error: any) { + return { + index, + name: surveyName, + outcome: 'rejected-at-http', + surveyId: null, + acceptMs: null, + jobMs: null, + error: `user setup failed: ${error.message}`, + } + } + + return runSingleImport({ + baseUrl, + authToken: userAuthToken, + zipBuffer, + zipFileName, + surveyName, + index, + jobTimeoutMs, + fetchImpl, + }) +} + +/** + * Deletes every survey belonging to this run, sequentially and best-effort. Authoritative: queries the + * server for every survey whose name starts with namePrefix rather than relying on surveyIds observed + * from run results, since a job that timed out while still queued (never observed running) never yields + * a surveyId even though the server may create the survey once its turn comes. + * @param params - Function parameters. + * @param params.baseUrl - Arena server base URL. + * @param params.authToken - JWT auth token (a system admin token can delete any survey). + * @param params.namePrefix - Prefix shared by every survey name created by this run. + * @param [params.fetchImpl] - Fetch implementation to use (defaults to the global fetch). + * @returns How many surveys were actually deleted. + */ +export const cleanupSurveys = async ({ + baseUrl, + authToken, + namePrefix, + fetchImpl = fetch, +}: { + baseUrl: string + authToken: string + namePrefix: string + fetchImpl?: FetchImpl +}): Promise<{ deletedCount: number; totalCount: number }> => { + const surveys = await fetchSurveysByNamePrefix({ baseUrl, authToken, namePrefix, fetchImpl }) + const surveyIds = surveys.map((survey) => survey.id) + let deletedCount = 0 + for (const surveyId of surveyIds) { + try { + await deleteSurvey({ baseUrl, authToken, surveyId, fetchImpl }) + deletedCount += 1 + } catch (error: any) { + console.error(`Failed to delete survey ${surveyId}: ${error.message}`) + } + } + return { deletedCount, totalCount: surveyIds.length } +} + +/** + * CLI entry point: parses config, runs the concurrent import burst, reports, and cleans up. + * @returns Resolves when the run is complete; sets process.exitCode on failure. + */ +export const main = async (): Promise => { + let config: ParseConfigResult + try { + config = parseConfig({ argv: process.argv.slice(2), env: process.env }) + } catch (error: any) { + console.error(error.message) + console.error(HELP_TEXT) + process.exitCode = 1 + return + } + + if (config.help) { + console.log(HELP_TEXT) + return + } + + // TS doesn't narrow assignments made inside a try block, so the discriminated union above stays + // widened here even though the runtime check just proved it; see microsoft/TypeScript#9998. + const { zipPath, url, email, password, count, jobTimeoutMs, keep } = config as StressTestConfig + + const targetHostname = new URL(url).hostname + if (targetHostname !== 'localhost' && targetHostname !== '127.0.0.1') { + console.warn( + `⚠️ Target is not localhost (${url}) — this run will create ${count} throwaway accounts with a ` + + 'random password on that server, and (see test/load/README.md) they cannot be deleted afterward.' + ) + } + + console.log(`Reading zip file: ${zipPath}`) + const zipBuffer = fs.readFileSync(zipPath) + const zipFileName = path.basename(zipPath) + + console.log(`Logging in as ${email} at ${url}...`) + const adminAuthToken = await login({ baseUrl: url, email, password }) + + const runId = Date.now() + const credentialsList = buildLoadTestUserCredentials({ runId, count }) + console.log( + `Provisioning ${count} throwaway load-test users and firing ${count} concurrent survey imports (run ${runId})...` + ) + + const startedAt = Date.now() + const settled = await Promise.allSettled( + credentialsList.map((credentials, i) => + runSingleUserImport({ + baseUrl: url, + adminAuthToken, + credentials, + zipBuffer, + zipFileName, + surveyName: `stress_test_${runId}_${i}`, + index: i, + jobTimeoutMs, + }) + ) + ) + const results: ResultEntry[] = settled.map((settledResult, i) => + settledResult.status === 'fulfilled' + ? settledResult.value + : { + index: i, + name: `stress_test_${runId}_${i}`, + outcome: 'rejected-at-http', + surveyId: null, + acceptMs: null, + jobMs: null, + error: settledResult.reason?.message || String(settledResult.reason), + } + ) + const totalDurationMs = Date.now() - startedAt + + console.log(formatSummary({ results, totalDurationMs })) + + if (!keep) { + console.log('Cleaning up created surveys...') + const { deletedCount, totalCount } = await cleanupSurveys({ + baseUrl: url, + authToken: adminAuthToken, + namePrefix: `stress_test_${runId}_`, + }) + console.log(`Deleted ${deletedCount}/${totalCount} surveys created by this run.`) + } else { + console.log('Skipping survey cleanup (--keep passed); created surveys were left in place.') + } + console.log( + 'Note: the throwaway user accounts created by this run (stress_test_*@loadtest.local) cannot be deleted via the API and remain in the database.' + ) + + const anyFailed = results.some((result) => result.outcome !== 'succeeded') + process.exitCode = anyFailed ? 1 : 0 +} + +if (import.meta.main) { + main().catch((error) => { + console.error('Stress test failed to run:', error) + process.exitCode = 1 + }) +} diff --git a/test/webpack.config.js b/test/webpack.config.js index d8ff705227..7d2ff5e269 100644 --- a/test/webpack.config.js +++ b/test/webpack.config.js @@ -9,7 +9,9 @@ const getEntry = (type) => .sort((fileA, fileB) => { const idxA = fileA.substr(0, 3) const idxB = fileB.substr(0, 3) - return idxA < idxB + if (idxA < idxB) return -1 + if (idxA > idxB) return 1 + return 0 }) const getOutput = (type) => ({ diff --git a/tsconfig.json b/tsconfig.json index 28bc0c671a..fffc60b9a2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,12 +2,12 @@ "compilerOptions": { "target": "ES2022", "module": "ESNext", - "moduleResolution": "node", + "moduleResolution": "bundler", + "moduleDetection": "force", "lib": ["ES2022", "DOM", "DOM.Iterable"], "allowJs": true, "checkJs": false, "jsx": "react-jsx", - "baseUrl": "./", "paths": { "@common/*": ["./common/*"], "@core/*": ["./core/*"], @@ -22,7 +22,7 @@ "skipLibCheck": true, "strict": false, "noEmit": true, - "allowImportingTsExtensions": false, + "allowImportingTsExtensions": true, "types": ["node", "jest"] }, "include": ["common", "core", "server", "webapp", "test", "*.d.ts"], diff --git a/webapp/components/survey/SurveyCreate/store/actions/useOnCreate.js b/webapp/components/survey/SurveyCreate/store/actions/useOnCreate.js index c8c02f859e..bade3d57d5 100644 --- a/webapp/components/survey/SurveyCreate/store/actions/useOnCreate.js +++ b/webapp/components/survey/SurveyCreate/store/actions/useOnCreate.js @@ -33,6 +33,7 @@ export const useOnCreate = ({ newSurvey, setNewSurvey }) => { const { surveyId } = JobSerialized.getResult(_job) dispatch(SurveyActions.setActiveSurvey(surveyId, true, true)) }, + autoHide: true, }) ) } else if (survey) { diff --git a/yarn.lock b/yarn.lock index 8d981bcf91..fc74b4083e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3903,9 +3903,9 @@ __metadata: languageName: node linkType: hard -"@openforis/arena-server@npm:^1.3.27": - version: 1.3.27 - resolution: "@openforis/arena-server@npm:1.3.27::__archiveUrl=https%3A%2F%2Fnpm.pkg.github.com%2Fdownload%2F%40openforis%2Farena-server%2F1.3.27%2Fa3d605c67e589a76e2455604a5a86535183036e2" +"@openforis/arena-server@npm:^1.3.28": + version: 1.3.28 + resolution: "@openforis/arena-server@npm:1.3.28::__archiveUrl=https%3A%2F%2Fnpm.pkg.github.com%2Fdownload%2F%40openforis%2Farena-server%2F1.3.28%2Fbfe5d83d8d13fd6007b4021f1cfdf0f9223555c8" dependencies: "@godaddy/terminus": "npm:^4.12.1" "@openforis/arena-core": "npm:^1.5.0" @@ -3930,7 +3930,7 @@ __metadata: pdfkit: "npm:^0.19.1" pg-promise: "npm:^12.7.0" socket.io: "npm:^4.8.3" - checksum: 10c0/832e49ace2ba6d88b9b936bfce58ebd3f76f275d98226505f1e29bca2004546122ec7652505e8fc9643f65ed39dfd6d94a1ee1c9a54710b3cc26902e689fa6ed + checksum: 10c0/4fabe91a0143cfdec7332c47de4af6c07ceca7f8b277765477f8dbf16c1c6a74b62dddbd2d9c259dadeec7e07d00d96a9c5b5899aaaaf4fe0cf3fc54558e9ed8 languageName: node linkType: hard @@ -7355,11 +7355,11 @@ __metadata: linkType: hard "baseline-browser-mapping@npm:^2.9.0": - version: 2.10.0 - resolution: "baseline-browser-mapping@npm:2.10.0" + version: 2.11.13 + resolution: "baseline-browser-mapping@npm:2.11.13" bin: baseline-browser-mapping: dist/cli.cjs - checksum: 10c0/da9c3ec0fcd7f325226a47d2142794d41706b6e0a405718a2c15410bbdb72aacadd65738bedef558c6f1b106ed19458cb25b06f63b66df2c284799905dbbd003 + checksum: 10c0/a02511614ebe0311d4831e7ba11e88e5997d5bf0733e5eadd7d97b4bb69d76f468a982862108cb90721e1a41f4ef36c1a5f9fa18569923702ad2875ca3abfe58 languageName: node linkType: hard @@ -7920,9 +7920,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001759": - version: 1.0.30001774 - resolution: "caniuse-lite@npm:1.0.30001774" - checksum: 10c0/cc6a340a5421b9a67d8fa80889065ee27b2839ad62993571dded5296e18f02bbf685ce7094e93fe908cddc9fefdfad35d6c010b724cc3d22a6479b0d0b679f8c + version: 1.0.30001809 + resolution: "caniuse-lite@npm:1.0.30001809" + checksum: 10c0/cac2ed4e66cc6c4cbf126b94d2c02012566eb92f93c89949290f99edcd2bdc4ed1290b5c537ccb9b42c773936a479ed468e5d4e9b8938b126a0947090e42e008 languageName: node linkType: hard @@ -15914,7 +15914,7 @@ __metadata: "@mui/x-data-grid": "npm:^8.26.0" "@mui/x-date-pickers": "npm:^8.26.0" "@mui/x-tree-view": "npm:^8.26.0" - "@openforis/arena-server": "npm:^1.3.27" + "@openforis/arena-server": "npm:^1.3.28" "@pmmmwh/react-refresh-webpack-plugin": "npm:^0.6.2" "@reduxjs/toolkit": "npm:^2.11.2" "@sendgrid/mail": "npm:^8.1.6" @@ -16832,15 +16832,6 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:>=1.2.0": - version: 1.58.2 - resolution: "playwright-core@npm:1.58.2" - bin: - playwright-core: cli.js - checksum: 10c0/5aa15b2b764e6ffe738293a09081a6f7023847a0dbf4cd05fe10eed2e25450d321baf7482f938f2d2eb330291e197fa23e57b29a5b552b89927ceb791266225b - languageName: node - linkType: hard - "playwright@npm:1.24.2": version: 1.24.2 resolution: "playwright@npm:1.24.2"