From aa0c79e9eab46af20219039d16d251ed1db08f0e Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Thu, 29 Jan 2026 05:09:14 -0600 Subject: [PATCH] test: add k6 load testing suite with 5 scenarios --- .../telemetry/events-2026-01-29.ndjson | 2 + load-tests/README.md | 144 +++++++++ load-tests/config.js | 33 +++ load-tests/k6/mixed-load.js | 113 +++++++ load-tests/k6/read-load.js | 74 +++++ load-tests/k6/smoke.js | 104 +++++++ load-tests/k6/write-load.js | 87 ++++++ load-tests/k6/ws-stress.js | 66 +++++ package.json | 2 + server/.veritas-kanban/activity.json | 22 ++ server/.veritas-kanban/agent-status.json | 6 +- server/.veritas-kanban/status-history.json | 8 + web/src/__tests__/api-helpers.test.ts | 102 +++++++ web/src/__tests__/test-utils.tsx | 278 ++++++++++++++++++ 14 files changed, 1038 insertions(+), 3 deletions(-) create mode 100644 load-tests/README.md create mode 100644 load-tests/config.js create mode 100644 load-tests/k6/mixed-load.js create mode 100644 load-tests/k6/read-load.js create mode 100644 load-tests/k6/smoke.js create mode 100644 load-tests/k6/write-load.js create mode 100644 load-tests/k6/ws-stress.js create mode 100644 web/src/__tests__/api-helpers.test.ts create mode 100644 web/src/__tests__/test-utils.tsx diff --git a/.veritas-kanban/telemetry/events-2026-01-29.ndjson b/.veritas-kanban/telemetry/events-2026-01-29.ndjson index 9c682e87..1921db8e 100644 --- a/.veritas-kanban/telemetry/events-2026-01-29.ndjson +++ b/.veritas-kanban/telemetry/events-2026-01-29.ndjson @@ -2185,3 +2185,5 @@ {"type":"task.created","taskId":"task_20260129_Av4XVI","status":"todo","id":"evt_m7XO70YXzlog","timestamp":"2026-01-29T11:04:58.821Z"} {"type":"task.status_changed","taskId":"task_20260129_yAgwCQ","status":"done","previousStatus":"in-progress","id":"evt_KdPRuaIJBbCu","timestamp":"2026-01-29T11:05:08.280Z"} {"type":"task.status_changed","taskId":"task_20260128_RcHzcp","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_jnx8dyiqxYpi","timestamp":"2026-01-29T11:06:16.056Z"} +{"type":"task.status_changed","taskId":"task_20260128_znWaEu","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_D-Jtdq_-UGvG","timestamp":"2026-01-29T11:07:03.894Z"} +{"type":"task.status_changed","taskId":"task_20260129_5A1uqL","status":"in-progress","previousStatus":"todo","id":"evt_-N1aaujR45Q1","timestamp":"2026-01-29T11:07:03.903Z"} diff --git a/load-tests/README.md b/load-tests/README.md new file mode 100644 index 00000000..ff85f85f --- /dev/null +++ b/load-tests/README.md @@ -0,0 +1,144 @@ +# Load Tests + +Performance and load tests for the Veritas Kanban API using [k6](https://k6.io/). + +## Prerequisites + +### Install k6 + +**macOS (Homebrew):** + +```bash +brew install k6 +``` + +**Other platforms:** +See [k6 installation docs](https://grafana.com/docs/k6/latest/set-up/install-k6/). + +### Verify installation + +```bash +k6 version +``` + +### Start the server + +```bash +# From repo root +pnpm dev +``` + +Server should be running at `http://localhost:3001`. + +## Test Scenarios + +| Script | VUs | Duration | Description | +| --------------- | ---- | ----------- | --------------------------------------------- | +| `smoke.js` | 1 | 1 iteration | CRUD lifecycle — create, read, update, delete | +| `read-load.js` | 50 | 30s | Read-heavy: list + detail endpoints | +| `write-load.js` | 20 | 30s | Write-heavy: create, update, delete | +| `mixed-load.js` | 0→30 | 60s | 70% reads / 30% writes with ramp-up | +| `ws-stress.js` | 25 | 30s | WebSocket connection stress | + +## Running Tests + +### Quick smoke test + +```bash +pnpm test:load:smoke +# or directly: +k6 run load-tests/k6/smoke.js +``` + +### Run all load tests + +```bash +pnpm test:load +``` + +### Run a specific scenario + +```bash +k6 run load-tests/k6/read-load.js +k6 run load-tests/k6/write-load.js +k6 run load-tests/k6/mixed-load.js +k6 run load-tests/k6/ws-stress.js +``` + +### Override configuration via environment variables + +```bash +# Custom server URL +k6 run -e BASE_URL=http://localhost:4000 load-tests/k6/smoke.js + +# Custom API key +k6 run -e API_KEY=my-secret-key load-tests/k6/read-load.js + +# Custom WebSocket URL +k6 run -e WS_URL=ws://localhost:4000/ws load-tests/k6/ws-stress.js +``` + +### Adjust VUs / duration on the fly + +```bash +# Override VU count and duration +k6 run --vus 100 --duration 60s load-tests/k6/read-load.js +``` + +## Interpreting Results + +After a run, k6 prints a summary like: + +``` + ✓ list → 200 + ✓ detail → 200 + + checks.....................: 100.00% ✓ 4820 ✗ 0 + http_req_duration..........: avg=12.3ms min=2.1ms med=10.5ms max=95.2ms p(90)=22.1ms p(95)=31.4ms + http_reqs..................: 4820 160.6/s + errors.....................: 0.00% ✓ 0 ✗ 4820 +``` + +### Key metrics + +| Metric | What it means | Target | +| ------------------------- | ------------------------------------ | ----------------------------- | +| `http_req_duration p(95)` | 95th percentile response time | <200ms reads, <500ms writes | +| `errors` | Percentage of failed checks | <1% | +| `http_reqs` | Total requests / requests per second | Higher = better throughput | +| `checks` | Assertion pass rate | 100% for smoke, >99% for load | +| `ws_errors` | WebSocket connection failure rate | <5% | +| `ws_messages_received` | Total WS messages received | >0 per connection | + +### Thresholds + +Tests define built-in thresholds. If a threshold is breached, k6 exits with code `99`: + +- **Read-heavy:** p95 < 200ms, errors < 1% +- **Write-heavy:** p95 < 500ms, errors < 1% +- **Mixed:** p95 < 500ms, errors < 1% +- **WebSocket:** connection errors < 5% + +### Export results (optional) + +```bash +# JSON output +k6 run --out json=results.json load-tests/k6/read-load.js + +# CSV output +k6 run --out csv=results.csv load-tests/k6/read-load.js +``` + +## File Structure + +``` +load-tests/ +├── README.md # This file +├── config.js # Shared config (base URL, headers, helpers) +└── k6/ + ├── smoke.js # Scenario 1: CRUD smoke test + ├── read-load.js # Scenario 2: Read-heavy load + ├── write-load.js # Scenario 3: Write-heavy load + ├── mixed-load.js # Scenario 4: Mixed workload + └── ws-stress.js # Scenario 5: WebSocket stress +``` diff --git a/load-tests/config.js b/load-tests/config.js new file mode 100644 index 00000000..1e264c75 --- /dev/null +++ b/load-tests/config.js @@ -0,0 +1,33 @@ +/** + * Shared configuration for k6 load tests. + * + * k6 does NOT use Node.js — `export` is ES module syntax + * understood by the k6 runtime. + */ + +export const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001'; +export const API_BASE = `${BASE_URL}/api/v1`; +export const WS_URL = __ENV.WS_URL || 'ws://localhost:3001/ws'; + +export const API_KEY = __ENV.API_KEY || 'test-load-key'; + +export const defaultHeaders = { + 'Content-Type': 'application/json', + 'X-API-Key': API_KEY, +}; + +/** + * Generate a unique task payload for creation. + * @param {string} prefix - Prefix for the task title + * @returns {object} Task creation payload + */ +export function makeTask(prefix = 'load-test') { + const ts = Date.now(); + const rand = Math.random().toString(36).substring(2, 8); + return { + title: `${prefix}-${ts}-${rand}`, + description: `Load test task created at ${new Date().toISOString()}`, + type: 'code', + priority: 'low', + }; +} diff --git a/load-tests/k6/mixed-load.js b/load-tests/k6/mixed-load.js new file mode 100644 index 00000000..34c91232 --- /dev/null +++ b/load-tests/k6/mixed-load.js @@ -0,0 +1,113 @@ +/** + * Scenario 4: Mixed Workload + * + * 70 % reads, 30 % writes — 30 virtual users. + * Ramp up from 0 → 30 over 10 s, hold for 50 s. + * Total duration: 60 seconds. + */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate } from 'k6/metrics'; +import { API_BASE, defaultHeaders, makeTask } from '../config.js'; + +const errorRate = new Rate('errors'); + +export const options = { + stages: [ + { duration: '10s', target: 30 }, // ramp up + { duration: '50s', target: 30 }, // hold + ], + thresholds: { + http_req_duration: ['p(95)<500'], + errors: ['rate<0.01'], + }, +}; + +// ── Read scenario (70 %) ───────────────────────────────────── +function readScenario() { + const listRes = http.get(`${API_BASE}/tasks`, { + headers: defaultHeaders, + tags: { name: 'GET /tasks' }, + }); + + const listOk = check(listRes, { 'list → 200': (r) => r.status === 200 }); + errorRate.add(!listOk); + + // Read a random task from the list + try { + const body = JSON.parse(listRes.body); + const tasks = Array.isArray(body) ? body : body.tasks || []; + if (tasks.length > 0) { + const id = tasks[Math.floor(Math.random() * tasks.length)].id; + const detailRes = http.get(`${API_BASE}/tasks/${id}`, { + headers: defaultHeaders, + tags: { name: 'GET /tasks/:id' }, + }); + const detailOk = check(detailRes, { 'detail → 200': (r) => r.status === 200 }); + errorRate.add(!detailOk); + } + } catch { + // ignore + } + + sleep(0.3); +} + +// ── Write scenario (30 %) ──────────────────────────────────── +function writeScenario() { + const payload = makeTask('mixed'); + const createRes = http.post( + `${API_BASE}/tasks`, + JSON.stringify(payload), + { + headers: defaultHeaders, + tags: { name: 'POST /tasks' }, + } + ); + + const createOk = check(createRes, { 'create → 201': (r) => r.status === 201 }); + errorRate.add(!createOk); + + if (!createOk) { + sleep(0.5); + return; + } + + const created = JSON.parse(createRes.body); + const taskId = created.id || created.task?.id; + + sleep(0.2); + + // Update + const updateRes = http.patch( + `${API_BASE}/tasks/${taskId}`, + JSON.stringify({ priority: 'high' }), + { + headers: defaultHeaders, + tags: { name: 'PATCH /tasks/:id' }, + } + ); + const updateOk = check(updateRes, { 'update → 200': (r) => r.status === 200 }); + errorRate.add(!updateOk); + + sleep(0.2); + + // Delete (cleanup) + const delRes = http.del(`${API_BASE}/tasks/${taskId}`, null, { + headers: defaultHeaders, + tags: { name: 'DELETE /tasks/:id' }, + }); + const delOk = check(delRes, { 'delete → 200/204': (r) => r.status === 200 || r.status === 204 }); + errorRate.add(!delOk); + + sleep(0.3); +} + +// ── Main ───────────────────────────────────────────────────── +export default function () { + if (Math.random() < 0.7) { + readScenario(); + } else { + writeScenario(); + } +} diff --git a/load-tests/k6/read-load.js b/load-tests/k6/read-load.js new file mode 100644 index 00000000..f84c3d45 --- /dev/null +++ b/load-tests/k6/read-load.js @@ -0,0 +1,74 @@ +/** + * Scenario 2: Read-Heavy Load + * + * 50 virtual users hammering GET /tasks and GET /tasks/:id + * for 30 seconds. + * + * Thresholds: + * p(95) response time < 200 ms + * error rate < 1 % + */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; +import { API_BASE, defaultHeaders } from '../config.js'; + +const errorRate = new Rate('errors'); +const listDuration = new Trend('list_duration', true); +const detailDuration = new Trend('detail_duration', true); + +export const options = { + vus: 50, + duration: '30s', + thresholds: { + http_req_duration: ['p(95)<200'], + errors: ['rate<0.01'], + }, +}; + +export default function () { + // ── List tasks ───────────────────────────────────────────── + const listRes = http.get(`${API_BASE}/tasks`, { + headers: defaultHeaders, + tags: { name: 'GET /tasks' }, + }); + + listDuration.add(listRes.timings.duration); + + const listOk = check(listRes, { + 'list → 200': (r) => r.status === 200, + }); + errorRate.add(!listOk); + + // Grab a task id from the list for detail reads + let taskId = null; + try { + const body = JSON.parse(listRes.body); + const tasks = Array.isArray(body) ? body : body.tasks || []; + if (tasks.length > 0) { + // Pick a random task + taskId = tasks[Math.floor(Math.random() * tasks.length)].id; + } + } catch { + // ignore parse errors + } + + sleep(0.1); + + // ── Read single task (if we found one) ───────────────────── + if (taskId) { + const detailRes = http.get(`${API_BASE}/tasks/${taskId}`, { + headers: defaultHeaders, + tags: { name: 'GET /tasks/:id' }, + }); + + detailDuration.add(detailRes.timings.duration); + + const detailOk = check(detailRes, { + 'detail → 200': (r) => r.status === 200, + }); + errorRate.add(!detailOk); + } + + sleep(0.2); +} diff --git a/load-tests/k6/smoke.js b/load-tests/k6/smoke.js new file mode 100644 index 00000000..12711836 --- /dev/null +++ b/load-tests/k6/smoke.js @@ -0,0 +1,104 @@ +/** + * Scenario 1: API CRUD Smoke Test + * + * Exercises the full task lifecycle: + * Create → Read → Update → Delete + * + * 1 virtual user, 1 iteration. + */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { API_BASE, defaultHeaders, makeTask } from '../config.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: { + checks: ['rate==1.0'], // every check must pass + }, +}; + +export default function () { + // ── CREATE ───────────────────────────────────────────────── + const payload = makeTask('smoke'); + const createRes = http.post( + `${API_BASE}/tasks`, + JSON.stringify(payload), + { headers: defaultHeaders } + ); + + const createOk = check(createRes, { + 'POST /tasks → 201': (r) => r.status === 201, + 'POST /tasks → has id': (r) => { + try { + const body = JSON.parse(r.body); + return !!(body.id || (body.task && body.task.id)); + } catch { + return false; + } + }, + }); + + if (!createOk) { + console.error(`CREATE failed: ${createRes.status} — ${createRes.body}`); + return; + } + + const created = JSON.parse(createRes.body); + const taskId = created.id || created.task?.id; + + sleep(0.3); + + // ── READ (single) ───────────────────────────────────────── + const getRes = http.get(`${API_BASE}/tasks/${taskId}`, { + headers: defaultHeaders, + }); + + check(getRes, { + 'GET /tasks/:id → 200': (r) => r.status === 200, + }); + + sleep(0.3); + + // ── UPDATE ───────────────────────────────────────────────── + const updateRes = http.patch( + `${API_BASE}/tasks/${taskId}`, + JSON.stringify({ title: `${payload.title}-updated`, priority: 'high' }), + { headers: defaultHeaders } + ); + + check(updateRes, { + 'PATCH /tasks/:id → 200': (r) => r.status === 200, + }); + + sleep(0.3); + + // ── READ LIST ────────────────────────────────────────────── + const listRes = http.get(`${API_BASE}/tasks`, { + headers: defaultHeaders, + }); + + check(listRes, { + 'GET /tasks → 200': (r) => r.status === 200, + 'GET /tasks → is array or has tasks': (r) => { + try { + const body = JSON.parse(r.body); + return Array.isArray(body) || Array.isArray(body.tasks); + } catch { + return false; + } + }, + }); + + sleep(0.3); + + // ── DELETE ───────────────────────────────────────────────── + const delRes = http.del(`${API_BASE}/tasks/${taskId}`, null, { + headers: defaultHeaders, + }); + + check(delRes, { + 'DELETE /tasks/:id → 200 or 204': (r) => + r.status === 200 || r.status === 204, + }); +} diff --git a/load-tests/k6/write-load.js b/load-tests/k6/write-load.js new file mode 100644 index 00000000..363c0074 --- /dev/null +++ b/load-tests/k6/write-load.js @@ -0,0 +1,87 @@ +/** + * Scenario 3: Write-Heavy Load + * + * 20 virtual users creating, updating, and deleting tasks + * for 30 seconds. + * + * Thresholds: + * p(95) response time < 500 ms + * error rate < 1 % + */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate } from 'k6/metrics'; +import { API_BASE, defaultHeaders, makeTask } from '../config.js'; + +const errorRate = new Rate('errors'); + +export const options = { + vus: 20, + duration: '30s', + thresholds: { + http_req_duration: ['p(95)<500'], + errors: ['rate<0.01'], + }, +}; + +export default function () { + // ── CREATE ───────────────────────────────────────────────── + const payload = makeTask('write-load'); + const createRes = http.post( + `${API_BASE}/tasks`, + JSON.stringify(payload), + { + headers: defaultHeaders, + tags: { name: 'POST /tasks' }, + } + ); + + const createOk = check(createRes, { + 'create → 201': (r) => r.status === 201, + }); + errorRate.add(!createOk); + + if (!createOk) { + sleep(0.5); + return; + } + + const created = JSON.parse(createRes.body); + const taskId = created.id || created.task?.id; + + sleep(0.2); + + // ── UPDATE ───────────────────────────────────────────────── + const updateRes = http.patch( + `${API_BASE}/tasks/${taskId}`, + JSON.stringify({ + title: `${payload.title}-updated`, + priority: 'high', + description: 'Updated during write-load test', + }), + { + headers: defaultHeaders, + tags: { name: 'PATCH /tasks/:id' }, + } + ); + + const updateOk = check(updateRes, { + 'update → 200': (r) => r.status === 200, + }); + errorRate.add(!updateOk); + + sleep(0.2); + + // ── DELETE ───────────────────────────────────────────────── + const delRes = http.del(`${API_BASE}/tasks/${taskId}`, null, { + headers: defaultHeaders, + tags: { name: 'DELETE /tasks/:id' }, + }); + + const delOk = check(delRes, { + 'delete → 200 or 204': (r) => r.status === 200 || r.status === 204, + }); + errorRate.add(!delOk); + + sleep(0.3); +} diff --git a/load-tests/k6/ws-stress.js b/load-tests/k6/ws-stress.js new file mode 100644 index 00000000..6e0a71e7 --- /dev/null +++ b/load-tests/k6/ws-stress.js @@ -0,0 +1,66 @@ +/** + * Scenario 5: WebSocket Stress + * + * 25 concurrent WebSocket connections. + * Each connection stays open for 30 seconds, verifying + * that the connection is established and messages can be received. + */ +import ws from 'k6/ws'; +import { check, sleep } from 'k6'; +import { Rate, Counter } from 'k6/metrics'; +import { WS_URL, API_KEY } from '../config.js'; + +const wsErrors = new Rate('ws_errors'); +const wsMessages = new Counter('ws_messages_received'); + +export const options = { + vus: 25, + duration: '30s', + thresholds: { + ws_errors: ['rate<0.05'], // <5% connection errors + }, +}; + +export default function () { + const url = `${WS_URL}?apiKey=${API_KEY}`; + + const res = ws.connect(url, {}, function (socket) { + socket.on('open', () => { + // Send a ping / subscribe message to trigger responses + socket.send(JSON.stringify({ type: 'ping' })); + }); + + socket.on('message', (data) => { + wsMessages.add(1); + + // Validate message is parseable JSON + try { + JSON.parse(data); + } catch { + // Binary or non-JSON frames are acceptable + } + }); + + socket.on('error', (e) => { + wsErrors.add(1); + console.error(`WS error: ${e}`); + }); + + // Keep connection alive — send periodic pings + socket.setInterval(() => { + socket.send(JSON.stringify({ type: 'ping' })); + }, 5000); + + // Hold the connection open for the test duration + socket.setTimeout(() => { + socket.close(); + }, 28000); // close slightly before 30s to avoid abrupt teardown + }); + + const connected = check(res, { + 'WS connected (101)': (r) => r && r.status === 101, + }); + wsErrors.add(!connected); + + sleep(1); +} diff --git a/package.json b/package.json index 01cfea02..fc838b5b 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:ui": "playwright test --ui", + "test:load:smoke": "k6 run load-tests/k6/smoke.js", + "test:load": "k6 run load-tests/k6/smoke.js && k6 run load-tests/k6/read-load.js && k6 run load-tests/k6/write-load.js && k6 run load-tests/k6/mixed-load.js && k6 run load-tests/k6/ws-stress.js", "clean": "pnpm -r clean && rm -rf node_modules", "audit": "pnpm audit --prod", "audit:all": "pnpm audit", diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json index ee90957f..34be861c 100644 --- a/server/.veritas-kanban/activity.json +++ b/server/.veritas-kanban/activity.json @@ -1,4 +1,26 @@ [ + { + "id": "activity_1769684823903_4j3z6nj0v", + "type": "status_changed", + "taskId": "task_20260129_5A1uqL", + "taskTitle": "[v1.1] TESTING: Create load testing suite with k6 or Artillery", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-29T11:07:03.903Z" + }, + { + "id": "activity_1769684823894_d7xlvr08f", + "type": "status_changed", + "taskId": "task_20260128_znWaEu", + "taskTitle": "QUALITY: Add frontend unit tests (1 test for 152 source files)", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-29T11:07:03.894Z" + }, { "id": "activity_1769684783257_lolvoukac", "type": "comment_added", diff --git a/server/.veritas-kanban/agent-status.json b/server/.veritas-kanban/agent-status.json index 47e9478f..b2fad940 100644 --- a/server/.veritas-kanban/agent-status.json +++ b/server/.veritas-kanban/agent-status.json @@ -1,5 +1,5 @@ { - "status": "idle", - "subAgentCount": 0, - "lastUpdated": "2026-01-29T11:04:59.927Z" + "status": "sub-agent", + "subAgentCount": 2, + "lastUpdated": "2026-01-29T11:07:03.912Z" } diff --git a/server/.veritas-kanban/status-history.json b/server/.veritas-kanban/status-history.json index 4866cae1..84b4fc6f 100644 --- a/server/.veritas-kanban/status-history.json +++ b/server/.veritas-kanban/status-history.json @@ -1,4 +1,12 @@ [ + { + "id": "status_1769684823912_uwsoqieu5", + "timestamp": "2026-01-29T11:07:03.912Z", + "previousStatus": "idle", + "newStatus": "sub-agent", + "subAgentCount": 2, + "durationMs": 319196 + }, { "id": "status_1769684504716_zgmnogkbo", "timestamp": "2026-01-29T11:01:44.716Z", diff --git a/web/src/__tests__/api-helpers.test.ts b/web/src/__tests__/api-helpers.test.ts new file mode 100644 index 00000000..b27669f6 --- /dev/null +++ b/web/src/__tests__/api-helpers.test.ts @@ -0,0 +1,102 @@ +/** + * Tests for lib/api/helpers.ts — handleResponse envelope unwrapping. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleResponse } from '@/lib/api/helpers'; + +// ── Helpers ────────────────────────────────────────────────── + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response; +} + +function brokenJsonResponse(status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => { + throw new SyntaxError('Unexpected token'); + }, + } as Response; +} + +// ── Tests ──────────────────────────────────────────────────── + +describe('handleResponse', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns undefined for 204 No Content', async () => { + const response = { ok: true, status: 204, json: vi.fn() } as unknown as Response; + const result = await handleResponse(response); + expect(result).toBeUndefined(); + // json() should NOT be called for 204 + expect(response.json).not.toHaveBeenCalled(); + }); + + it('unwraps a success envelope and returns data', async () => { + const body = { + success: true, + data: { id: '1', title: 'Hello' }, + meta: { timestamp: '2025-01-01T00:00:00Z' }, + }; + const result = await handleResponse<{ id: string; title: string }>(jsonResponse(body)); + expect(result).toEqual({ id: '1', title: 'Hello' }); + }); + + it('throws with server message for error envelope', async () => { + const body = { + success: false, + error: { code: 'NOT_FOUND', message: 'Task not found' }, + meta: { timestamp: '2025-01-01T00:00:00Z' }, + }; + await expect(handleResponse(jsonResponse(body, 404))).rejects.toThrow('Task not found'); + }); + + it('includes code and details on error envelope errors', async () => { + const body = { + success: false, + error: { code: 'VALIDATION', message: 'Bad input', details: { field: 'title' } }, + meta: { timestamp: '2025-01-01T00:00:00Z' }, + }; + try { + await handleResponse(jsonResponse(body, 400)); + expect.fail('Should have thrown'); + } catch (err: unknown) { + const e = err as Error & { code?: string; details?: unknown }; + expect(e.code).toBe('VALIDATION'); + expect(e.details).toEqual({ field: 'title' }); + } + }); + + it('falls through to non-envelope path for non-ok response', async () => { + const body = { error: 'Internal Server Error' }; + await expect(handleResponse(jsonResponse(body, 500))).rejects.toThrow('Internal Server Error'); + }); + + it('uses HTTP status for non-ok non-envelope response without error string', async () => { + const body = { some: 'data' }; + await expect(handleResponse(jsonResponse(body, 502))).rejects.toThrow('HTTP 502'); + }); + + it('returns raw body for non-envelope OK response', async () => { + const body = [1, 2, 3]; + const result = await handleResponse(jsonResponse(body)); + expect(result).toEqual([1, 2, 3]); + }); + + it('handles null body from broken json gracefully for non-ok', async () => { + // When json() throws, body is null; should throw "HTTP {status}" + await expect(handleResponse(brokenJsonResponse(500))).rejects.toThrow('HTTP 500'); + }); + + it('returns null body as-is for ok response with broken json', async () => { + const result = await handleResponse(brokenJsonResponse(200)); + expect(result).toBeNull(); + }); +}); diff --git a/web/src/__tests__/test-utils.tsx b/web/src/__tests__/test-utils.tsx new file mode 100644 index 00000000..037a4a14 --- /dev/null +++ b/web/src/__tests__/test-utils.tsx @@ -0,0 +1,278 @@ +/** + * Shared test utilities — custom render wrapper, mock factories, and helpers. + */ +import React, { type ReactNode } from 'react'; +import { render, type RenderOptions } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { WebSocketStatusProvider } from '@/contexts/WebSocketContext'; +import type { ConnectionState } from '@/hooks/useWebSocket'; +import type { + Task, + TaskStatus, + TaskPriority, + TaskTypeConfig, + ProjectConfig, + SprintConfig, +} from '@veritas-kanban/shared'; + +// ── Query Client Factory ───────────────────────────────────── + +/** Create a fresh QueryClient configured for testing (no retries, no GC). */ +export function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: Infinity, + }, + mutations: { + retry: false, + }, + }, + }); +} + +// ── WebSocket Status Defaults ──────────────────────────────── + +export interface TestWebSocketStatus { + isConnected: boolean; + connectionState: ConnectionState; + reconnectAttempt: number; +} + +const DEFAULT_WS_STATUS: TestWebSocketStatus = { + isConnected: true, + connectionState: 'connected', + reconnectAttempt: 0, +}; + +// ── All Providers Wrapper ──────────────────────────────────── + +interface AllProvidersProps { + children: ReactNode; + queryClient?: QueryClient; + wsStatus?: Partial; +} + +function AllProviders({ children, queryClient, wsStatus }: AllProvidersProps) { + const qc = queryClient ?? createTestQueryClient(); + const ws = { ...DEFAULT_WS_STATUS, ...wsStatus }; + return ( + + + {children} + + + ); +} + +// ── Custom Render ──────────────────────────────────────────── + +interface CustomRenderOptions extends Omit { + queryClient?: QueryClient; + wsStatus?: Partial; +} + +/** + * Render with all necessary providers (QueryClient, WebSocket context). + * Accepts optional overrides for each provider. + */ +export function renderWithProviders(ui: React.ReactElement, options: CustomRenderOptions = {}) { + const { queryClient, wsStatus, ...renderOptions } = options; + const qc = queryClient ?? createTestQueryClient(); + + return { + ...render(ui, { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + ...renderOptions, + }), + queryClient: qc, + }; +} + +// ── Mock Data Factories ────────────────────────────────────── + +let taskCounter = 0; + +/** Create a mock Task with sensible defaults. Override any field. */ +export function createMockTask(overrides: Partial = {}): Task { + taskCounter += 1; + const id = overrides.id ?? `task_${taskCounter}`; + return { + id, + title: `Test Task ${taskCounter}`, + description: `Description for task ${taskCounter}`, + type: 'feature', + status: 'todo' as TaskStatus, + priority: 'medium' as TaskPriority, + created: '2025-01-01T00:00:00Z', + updated: '2025-01-01T00:00:00Z', + ...overrides, + }; +} + +/** Create a batch of mock tasks with sequential defaults. */ +export function createMockTasks(count: number, overrides: Partial = {}): Task[] { + return Array.from({ length: count }, () => createMockTask(overrides)); +} + +/** Create a mock TaskTypeConfig. */ +export function createMockTaskType(overrides: Partial = {}): TaskTypeConfig { + return { + id: 'feature', + label: 'Feature', + icon: 'Code', + order: 0, + created: '2025-01-01T00:00:00Z', + updated: '2025-01-01T00:00:00Z', + ...overrides, + }; +} + +/** Create a mock ProjectConfig. */ +export function createMockProject(overrides: Partial = {}): ProjectConfig { + return { + id: 'proj-1', + label: 'Test Project', + order: 0, + created: '2025-01-01T00:00:00Z', + updated: '2025-01-01T00:00:00Z', + ...overrides, + }; +} + +/** Create a mock SprintConfig. */ +export function createMockSprint(overrides: Partial = {}): SprintConfig { + return { + id: 'sprint-1', + label: 'Sprint 1', + order: 0, + created: '2025-01-01T00:00:00Z', + updated: '2025-01-01T00:00:00Z', + ...overrides, + }; +} + +// ── Fetch Mock Helper ──────────────────────────────────────── + +/** + * Mock global fetch with an envelope response. + * Returns the mock so you can inspect calls and change return values. + */ +export function mockFetch(data: unknown, ok = true) { + const response = { + ok, + status: ok ? 200 : 500, + json: async () => ({ + success: ok, + data, + meta: { timestamp: new Date().toISOString() }, + ...(ok ? {} : { error: { code: 'TEST_ERROR', message: 'Test error' } }), + }), + } as Response; + + const fetchMock = globalThis.fetch as ReturnType | undefined; + if (fetchMock && typeof fetchMock.mockResolvedValue === 'function') { + fetchMock.mockResolvedValue(response); + return fetchMock; + } + + const mock = Object.assign(async () => response, { + mockResolvedValue: () => {}, + }) as unknown as typeof globalThis.fetch; + globalThis.fetch = mock; + return mock; +} + +/** + * Create a mock WebSocket class for testing. + * Does NOT actually open a connection; instead it exposes helpers + * so tests can manually trigger onopen / onmessage / onclose / onerror. + */ +export function createMockWebSocket() { + const instances: MockWebSocketInstance[] = []; + + class MockWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + readonly CONNECTING = 0; + readonly OPEN = 1; + readonly CLOSING = 2; + readonly CLOSED = 3; + + readyState = MockWebSocket.CONNECTING; + url: string; + protocol = ''; + extensions = ''; + bufferedAmount = 0; + binaryType: BinaryType = 'blob'; + + onopen: ((ev: Event) => void) | null = null; + onmessage: ((ev: MessageEvent) => void) | null = null; + onclose: ((ev: CloseEvent) => void) | null = null; + onerror: ((ev: Event) => void) | null = null; + + sent: string[] = []; + + constructor(url: string | URL, _protocols?: string | string[]) { + this.url = typeof url === 'string' ? url : url.toString(); + instances.push(this as unknown as MockWebSocketInstance); + } + + send(data: string) { + this.sent.push(data); + } + + close(_code?: number, _reason?: string) { + this.readyState = MockWebSocket.CLOSED; + this.onclose?.(new CloseEvent('close', { code: _code, reason: _reason })); + } + + addEventListener() {} + removeEventListener() {} + dispatchEvent() { + return false; + } + + // Test helpers + simulateOpen() { + this.readyState = MockWebSocket.OPEN; + this.onopen?.(new Event('open')); + } + + simulateMessage(data: Record) { + this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(data) })); + } + + simulateError() { + this.onerror?.(new Event('error')); + } + + simulateClose(code = 1000, reason = '') { + this.readyState = MockWebSocket.CLOSED; + this.onclose?.(new CloseEvent('close', { code, reason })); + } + } + + type MockWebSocketInstance = InstanceType; + + return { + MockWebSocket: MockWebSocket as unknown as typeof WebSocket, + instances, + /** Get the most recently created instance. */ + get latest() { + return instances[instances.length - 1]; + }, + }; +}