mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
test: add k6 load testing suite with 5 scenarios
This commit is contained in:
parent
a005020bc5
commit
aa0c79e9ea
14 changed files with 1038 additions and 3 deletions
|
|
@ -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"}
|
||||
|
|
|
|||
144
load-tests/README.md
Normal file
144
load-tests/README.md
Normal file
|
|
@ -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
|
||||
```
|
||||
33
load-tests/config.js
Normal file
33
load-tests/config.js
Normal file
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
113
load-tests/k6/mixed-load.js
Normal file
113
load-tests/k6/mixed-load.js
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
74
load-tests/k6/read-load.js
Normal file
74
load-tests/k6/read-load.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
104
load-tests/k6/smoke.js
Normal file
104
load-tests/k6/smoke.js
Normal file
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
87
load-tests/k6/write-load.js
Normal file
87
load-tests/k6/write-load.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
66
load-tests/k6/ws-stress.js
Normal file
66
load-tests/k6/ws-stress.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
102
web/src/__tests__/api-helpers.test.ts
Normal file
102
web/src/__tests__/api-helpers.test.ts
Normal file
|
|
@ -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<void>(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<number[]>(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();
|
||||
});
|
||||
});
|
||||
278
web/src/__tests__/test-utils.tsx
Normal file
278
web/src/__tests__/test-utils.tsx
Normal file
|
|
@ -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<TestWebSocketStatus>;
|
||||
}
|
||||
|
||||
function AllProviders({ children, queryClient, wsStatus }: AllProvidersProps) {
|
||||
const qc = queryClient ?? createTestQueryClient();
|
||||
const ws = { ...DEFAULT_WS_STATUS, ...wsStatus };
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<WebSocketStatusProvider
|
||||
isConnected={ws.isConnected}
|
||||
connectionState={ws.connectionState}
|
||||
reconnectAttempt={ws.reconnectAttempt}
|
||||
>
|
||||
{children}
|
||||
</WebSocketStatusProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Custom Render ────────────────────────────────────────────
|
||||
|
||||
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||
queryClient?: QueryClient;
|
||||
wsStatus?: Partial<TestWebSocketStatus>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }) => (
|
||||
<AllProviders queryClient={qc} wsStatus={wsStatus}>
|
||||
{children}
|
||||
</AllProviders>
|
||||
),
|
||||
...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> = {}): 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> = {}): Task[] {
|
||||
return Array.from({ length: count }, () => createMockTask(overrides));
|
||||
}
|
||||
|
||||
/** Create a mock TaskTypeConfig. */
|
||||
export function createMockTaskType(overrides: Partial<TaskTypeConfig> = {}): 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> = {}): 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> = {}): 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<typeof import('vitest').vi.fn> | 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<string, unknown>) {
|
||||
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<typeof MockWebSocket>;
|
||||
|
||||
return {
|
||||
MockWebSocket: MockWebSocket as unknown as typeof WebSocket,
|
||||
instances,
|
||||
/** Get the most recently created instance. */
|
||||
get latest() {
|
||||
return instances[instances.length - 1];
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue