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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ dist/
build/
*.tsbuildinfo

# k6 test results (but track .gitkeep to preserve directory)
k6/results/*
!k6/results/.gitkeep

# Generated HTML reports (including k6 output)
*.html

# Editors
.vscode/
.idea/
Expand Down
98 changes: 96 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Express 5 REST API for EquipChain — a decentralized utility meter monitoring a
- [Architecture Overview](#architecture-overview)
- [Configuration Reference](#configuration-reference)
- [Development Guide](#development-guide)
- [Load Testing with k6](#load-testing-with-k6)
- [Deployment Guide](#deployment-guide)
- [Contributing](#contributing)
- [Related](#related)
Expand Down Expand Up @@ -251,7 +252,7 @@ Same parameters as daily-summary, returns monthly rollups.

Connect to `ws://localhost:3000/ws`.

### Example Response
### Example Responses

```json
GET /
Expand Down Expand Up @@ -353,7 +354,7 @@ EquipChain-backend/
Request
[Logger] → HTTP request logging
[Logger] → HTTP request logging (Pino + Correlation ID)
[Rate Limiter] → Rate limiting per IP/user
[CORS] → Cross-origin resource sharing
[Auth] → JWT verification for protected routes
Expand Down Expand Up @@ -452,6 +453,99 @@ docker run -p 3000:3000 --env-file .env equipchain-backend

---

## Load Testing with k6

Performance testing is implemented using [Grafana k6](https://k6.io), an open-source load testing tool to identify performance bottlenecks under stress.

### Installation

Install k6 by following the [official installation guide](https://k6.io/docs/get-started/installation/):

```bash
# macOS
brew install k6

# Ubuntu/Debian
sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update && sudo apt-get install k6

# Windows (winget)
winget install k6
```

Verify installation:

```bash
k6 version
```

### Test Scenarios

All test scripts are located in the `k6/` directory and are parameterizable via environment variables.

| Test | File | Description | Command |
|------|------|-------------|---------|
| **Smoke** | `k6/smoke.js` | 1 VU performing all API operations for 30s. Verifies basic functionality under no load. | `npm run k6:smoke` |
| **Load** | `k6/load.js` | Ramp up to 50 VUs over 1 min, sustain for 3 min, ramp down over 1 min. 80% reads, 20% writes. | `npm run k6:load` |
| **Stress** | `k6/stress.js` | Gradual increase from 10 → 50 → 100 → 200 → 500 VUs to identify the breaking point. | `npm run k6:stress` |
| **Spike** | `k6/spike.js` | Sudden jump from 0 to 200 VUs in 10s, sustain for 1 min, then cool down. | `npm run k6:spike` |
| **Soak** | `k6/soak.js` | 50 VUs sustained for 30+ minutes to detect memory leaks and performance degradation. | `npm run k6:soak` |
| **Quick** | (all except soak) | Runs smoke, load, stress, and spike tests sequentially. | `npm run k6:quick` |

### Configuration

Override the base URL and other parameters via environment variables:

```bash
# Point to a different environment
k6 run k6/smoke.js -e BASE_URL=https://staging.example.com

# Override soak test duration and concurrency
k6 run k6/soak.js -e DURATION=60m -e VUS=100
```

### Metrics Collected

Each test measures and reports:

| Metric | Description |
|--------|-------------|
| **Request Rate (RPS)** | Number of requests per second |
| **Response Time Percentiles** | p50, p75, p90, p95, p99 — median and tail latency |
| **Error Rate** | Percentage of failed/non-2xx requests |
| **Checks** | Application-level assertions (e.g., status is 200, body has required fields) |

### Generating HTML Reports

Generate visual HTML reports for detailed analysis:

```bash
k6 run --out html=k6/results/load-report.html k6/load.js
```

### CI Integration

The standard CI workflow (`.github/workflows/ci.yml`) runs unit tests only (`npm test`). For load testing in CI, add a separate workflow step that installs k6 and runs the smoke test as a quick health check:

```yaml
- name: Install k6
run: |
curl -fsSL https://github.com/grafana/k6/releases/download/v0.54.0/k6-v0.54.0-linux-amd64.tar.gz | tar -xz
sudo cp k6-v0.54.0-linux-amd64/k6 /usr/local/bin/

- name: Run k6 smoke test
run: k6 run k6/smoke.js
env:
BASE_URL: ${{ secrets.BASE_URL }}
```

### Results

Test results (HTML reports, JSON summaries) are stored in `k6/results/`. This directory is gitignored and will not be committed to the repository.

---

## Deployment Guide

### Docker (Recommended)
Expand Down
57 changes: 57 additions & 0 deletions k6/load.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Load Test
*
* Purpose: Determine how the API performs under expected normal traffic.
* Scenarios: Ramp up to 50 VUs over 1 min, stay at 50 for 3 min, ramp down to 0 over 1 min.
* Mix: 80 % read operations (GET), 20 % write operations (POST).
* Thresholds: p95 < 2000 ms, error rate < 1 %.
*
* Run: k6 run k6/load.js
* k6 run k6/load.js -e BASE_URL=https://staging.example.com
*/

import { check, sleep } from 'k6';
import http from 'k6/http';
import { BASE_URL, DEFAULT_HEADERS, randomWallet } from './shared.js';

export const options = {
stages: [
{ target: 50, duration: '1m' }, // Ramp up to 50 VUs
{ target: 50, duration: '3m' }, // Stay at 50 VUs
{ target: 0, duration: '1m' }, // Ramp down to 0
],
thresholds: {
http_req_duration: ['p(95)<2000'],
http_req_failed: ['rate<0.01'],
checks: ['rate>0.99'],
},
summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(50)', 'p(75)', 'p(90)', 'p(95)', 'p(99)'],
};

export default function () {
// 80 % read operations
if (Math.random() < 0.8) {
// Read: GET /
const resp = http.get(`${BASE_URL}/`, {
headers: DEFAULT_HEADERS,
});
check(resp, {
'load-read: status is 200': (r) => r.status === 200,
'load-read: has project': (r) => r.json('project') !== undefined,
});
sleep(1);
} else {
// Write: POST /api/auth/challenge
const wallet = randomWallet();
const resp = http.post(
`${BASE_URL}/api/auth/challenge`,
JSON.stringify({ wallet }),
{ headers: DEFAULT_HEADERS },
);
check(resp, {
'load-write: status is 200': (r) => r.status === 200,
'load-write: token returned': (r) => r.json('token') !== undefined,
});
sleep(1);
}
}
8 changes: 8 additions & 0 deletions k6/results/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# k6 Test Results Directory
#
# This directory stores HTML reports and JSON summaries generated by k6 load tests.
# The directory is gitignored via .gitignore, but this .gitkeep ensures the
# directory exists when the repository is first cloned.
#
# Generate a report:
# k6 run --out html=k6/results/report.html k6/smoke.js
65 changes: 65 additions & 0 deletions k6/shared.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Shared configuration, thresholds, and helper functions for k6 load tests.
*
* All test scenarios import from this module to maintain consistent defaults.
* Environment variables override any value at runtime.
*
* Usage:
* import { BASE_URL, thresholds, randomWallet } from './shared.js';
*/

// ---------------------------------------------------------------------------
// Base URL & Default Headers
// ---------------------------------------------------------------------------
export const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';

export const DEFAULT_HEADERS = {
'Content-Type': 'application/json',
Accept: 'application/json',
};

// ---------------------------------------------------------------------------
// Thresholds (applied across all tests; individual tests may override)
// ---------------------------------------------------------------------------
export const THRESHOLDS = {
/** 95 % of requests should complete under 2 seconds */
http_req_duration: ['p(95)<2000'],
/** Less than 1 % of requests may return errors */
http_req_failed: ['rate<0.01'],
/** All checks must pass (100 % success rate) */
checks: ['rate===1'],
};

// ---------------------------------------------------------------------------
// Helper Functions
// ---------------------------------------------------------------------------

/**
* Generate a random wallet address for auth challenge tests.
* @returns {string} Random Stellar-like wallet address
*/
export function randomWallet() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let wallet = 'G';
for (let i = 0; i < 55; i++) {
wallet += chars.charAt(Math.floor(Math.random() * chars.length));
}
return wallet;
}

/**
* Return a random integer between min and max (inclusive).
*/
export function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}

/**
* Simulated think / sleep time to mimic real user behaviour.
* @param {number} t - base time in seconds
* @param {number} [jitter=0.5] - random jitter fraction (0..1)
*/
export function thinkTime(t, jitter = 0.5) {
const sleep = t * (1 + Math.random() * jitter);
return sleep;
}
87 changes: 87 additions & 0 deletions k6/smoke.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Smoke Test
*
* Purpose: Verify the API responds correctly under minimal load.
* Scenarios: 1 virtual user performing all primary operations.
* Duration: 30 seconds.
* Thresholds: All requests succeed, p95 < 1000 ms.
*
* Run: k6 run k6/smoke.js
* k6 run k6/smoke.js -e BASE_URL=https://staging.example.com
*/

import { check } from 'k6';
import http from 'k6/http';
import { BASE_URL, DEFAULT_HEADERS, randomWallet } from './shared.js';

// Allow both success (2xx) and expected auth failures (401) — 401 is intentional behaviour
http.setResponseCallback(http.expectedStatuses(200, 401));

export const options = {
vus: 1,
duration: '30s',
thresholds: {
http_req_duration: ['p(95)<1000'],
http_req_failed: ['rate<0.01'],
checks: ['rate===1'],
},
summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(50)', 'p(75)', 'p(90)', 'p(95)', 'p(99)'],
};

export default function () {
// 1. GET / – Project metadata
const homeResp = http.get(`${BASE_URL}/`, {
headers: DEFAULT_HEADERS,
});
check(homeResp, {
'home: status is 200': (r) => r.status === 200,
'home: body has project field': (r) => r.json('project') !== undefined,
'home: body has contract field': (r) => r.json('contract') !== undefined,
});

// 2. GET /api/health – Health check
const healthResp = http.get(`${BASE_URL}/api/health`, {
headers: DEFAULT_HEADERS,
});
check(healthResp, {
'health: status is 200': (r) => r.status === 200,
'health: body is healthy': (r) => r.json('status') === 'healthy',
'health: has uptime field': (r) => r.json('uptime') !== undefined,
});

// 3. POST /api/auth/challenge – Auth challenge
const wallet = randomWallet();
const authResp = http.post(
`${BASE_URL}/api/auth/challenge`,
JSON.stringify({ wallet }),
{ headers: DEFAULT_HEADERS },
);
const token = authResp.json('token');
check(authResp, {
'auth: status is 200': (r) => r.status === 200,
'auth: token is returned': (r) => token !== undefined,
'auth: token is non-empty': (r) => typeof token === 'string' && token.length > 0,
});

// 4. GET /api/protected – Protected route (using token from auth challenge)
const protectedHeaders = {
...DEFAULT_HEADERS,
Authorization: `Bearer ${token}`,
};
const protectedResp = http.get(`${BASE_URL}/api/protected`, {
headers: protectedHeaders,
});
check(protectedResp, {
'protected: status is 200': (r) => r.status === 200,
'protected: returns data': (r) => r.json('data') === 'Sensitive meter data',
});

// 5. GET /api/protected – Without token (expected 401)
const unauthorizedResp = http.get(`${BASE_URL}/api/protected`, {
headers: DEFAULT_HEADERS,
});
check(unauthorizedResp, {
'unauthorized: status is 401': (r) => r.status === 401,
'unauthorized: returns error': (r) => r.json('error') !== undefined,
});
}
Loading
Loading