From 126d8b86e3da97e5e47916814359da3a0804a124 Mon Sep 17 00:00:00 2001 From: Timothy Egwuda <33420739+Timrossid@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:53:54 +0000 Subject: [PATCH 1/2] feat: add comprehensive k6 load testing suite --- .gitignore | 9 ++++ README.md | 128 ++++++++++++++++++++++++++++++++++++++++++++ index.js | 32 +++++++++++ k6/load.js | 57 ++++++++++++++++++++ k6/results/.gitkeep | 8 +++ k6/shared.js | 65 ++++++++++++++++++++++ k6/smoke.js | 87 ++++++++++++++++++++++++++++++ k6/soak.js | 88 ++++++++++++++++++++++++++++++ k6/spike.js | 65 ++++++++++++++++++++++ k6/stress.js | 80 +++++++++++++++++++++++++++ package.json | 8 ++- test/server.test.js | 85 ++++++++++++++++++++++++++++- 12 files changed, 710 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 k6/load.js create mode 100644 k6/results/.gitkeep create mode 100644 k6/shared.js create mode 100644 k6/smoke.js create mode 100644 k6/soak.js create mode 100644 k6/spike.js create mode 100644 k6/stress.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..291c40be --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +.env + +# k6 test results (but track .gitkeep to preserve directory) +k6/results/* +!k6/results/.gitkeep + +# Generated HTML reports +*.html diff --git a/README.md b/README.md index ac807d16..40763473 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,134 @@ Returns project metadata: } ``` +### `GET /api/health` + +Health check endpoint, returns server uptime and status: + +```json +{ + "status": "healthy", + "uptime": 123.45, + "timestamp": 1700000000000 +} +``` + +### `POST /api/auth/challenge` + +Simulates a wallet-based auth challenge and returns a mock JWT token. + +**Request body:** + +```json +{ + "wallet": "GANONEXISTENT123..." +} +``` + +**Response:** + +```json +{ + "token": "mock-jwt-GANONEXISTENT123...-1700000000000", + "expiresIn": 3600 +} +``` + +### `GET /api/protected` + +Protected endpoint requiring a valid `Authorization: Bearer ` header. Returns sensitive meter data. + +## Load Testing with k6 + +Performance testing is implemented using [Grafana k6](https://k6.io), an open-source load testing tool. + +### 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. + ## Related - [Equipchain Contracts](https://github.com/EquipChain/EquipChain-contracts) diff --git a/index.js b/index.js index 78bba20d..a160675f 100644 --- a/index.js +++ b/index.js @@ -1,8 +1,40 @@ const express = require('express'); const app = express(); +app.use(express.json()); + const contractId = process.env.CONTRACT_ID || 'CB7PSJZALNWNX7NLOAM6LOEL4OJZMFPQZJMIYO522ZSACYWXTZIDEDSS'; +// Health check endpoint +app.get('/api/health', (req, res) => { + res.json({ + status: 'healthy', + uptime: process.uptime(), + timestamp: Date.now(), + }); +}); + +// Auth challenge - returns a mock JWT token +app.post('/api/auth/challenge', (req, res) => { + const { wallet } = req.body || {}; + res.json({ + token: `mock-jwt-${wallet || 'anonymous'}-${Date.now()}`, + expiresIn: 3600, + }); +}); + +// Protected route - requires Authorization header +app.get('/api/protected', (req, res) => { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Unauthorized' }); + } + res.json({ + data: 'Sensitive meter data', + contract: contractId, + }); +}); + app.get('/', (req, res) => { res.json({ project: 'Equipchain', diff --git a/k6/load.js b/k6/load.js new file mode 100644 index 00000000..c7c3cd64 --- /dev/null +++ b/k6/load.js @@ -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); + } +} diff --git a/k6/results/.gitkeep b/k6/results/.gitkeep new file mode 100644 index 00000000..69337f50 --- /dev/null +++ b/k6/results/.gitkeep @@ -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 diff --git a/k6/shared.js b/k6/shared.js new file mode 100644 index 00000000..6fe2da7b --- /dev/null +++ b/k6/shared.js @@ -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; +} diff --git a/k6/smoke.js b/k6/smoke.js new file mode 100644 index 00000000..4a0099fc --- /dev/null +++ b/k6/smoke.js @@ -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, + }); +} diff --git a/k6/soak.js b/k6/soak.js new file mode 100644 index 00000000..a6a164b3 --- /dev/null +++ b/k6/soak.js @@ -0,0 +1,88 @@ +/** + * Soak / Endurance Test + * + * Purpose: Detect memory leaks and performance degradation over extended periods. + * Scenarios: 50 VUs sustained for 30+ minutes. + * Thresholds: p95 < 2000 ms, error rate < 1 %. + * + * Run: k6 run k6/soak.js + * k6 run k6/soak.js -e BASE_URL=https://staging.example.com + * k6 run k6/soak.js -e DURATION=60m -e VUS=100 + */ + +import { check, sleep } from 'k6'; +import http from 'k6/http'; +import { BASE_URL, DEFAULT_HEADERS, randomWallet, randomInt } from './shared.js'; + +// Allow duration and VUs to be overridden via environment variables +const DURATION = __ENV.DURATION || '30m'; +const VUS = parseInt(__ENV.VUS, 10) || 50; + +export const options = { + vus: VUS, + duration: DURATION, + 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 () { + // Realistic user behaviour: mix of operations with think time + const choice = Math.random(); + + if (choice < 0.35) { + // GET / + const resp = http.get(`${BASE_URL}/`, { headers: DEFAULT_HEADERS }); + check(resp, { + 'soak-home: status is 200': (r) => r.status === 200, + }); + } else if (choice < 0.55) { + // GET /api/health + const resp = http.get(`${BASE_URL}/api/health`, { headers: DEFAULT_HEADERS }); + check(resp, { + 'soak-health: status is 200': (r) => r.status === 200, + }); + } else if (choice < 0.75) { + // POST /api/auth/challenge + const wallet = randomWallet(); + const resp = http.post( + `${BASE_URL}/api/auth/challenge`, + JSON.stringify({ wallet }), + { headers: DEFAULT_HEADERS }, + ); + check(resp, { + 'soak-auth: status is 200': (r) => r.status === 200, + }); + } else if (choice < 0.9) { + // GET /api/protected (with token) + const wallet = randomWallet(); + const authResp = http.post( + `${BASE_URL}/api/auth/challenge`, + JSON.stringify({ wallet }), + { headers: DEFAULT_HEADERS }, + ); + const token = authResp.json('token'); + if (token) { + const resp = http.get(`${BASE_URL}/api/protected`, { + headers: { ...DEFAULT_HEADERS, Authorization: `Bearer ${token}` }, + }); + check(resp, { + 'soak-protected: status is 200': (r) => r.status === 200, + }); + } + } else { + // Unauthorized access (401 expected) + const resp = http.get(`${BASE_URL}/api/protected`, { + headers: DEFAULT_HEADERS, + }); + check(resp, { + 'soak-unauthorized: status is 401': (r) => r.status === 401, + }); + } + + // Think time: simulate real user pause between actions + sleep(randomInt(1, 3)); +} diff --git a/k6/spike.js b/k6/spike.js new file mode 100644 index 00000000..140d480c --- /dev/null +++ b/k6/spike.js @@ -0,0 +1,65 @@ +/** + * Spike Test + * + * Purpose: Verify the API can handle sudden traffic surges. + * Scenarios: Sudden jump from 0 → 200 VUs, sustain for 1 min, then immediate drop. + * Thresholds: p99 < 3000 ms, error rate < 2 %. + * + * Run: k6 run k6/spike.js + * k6 run k6/spike.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: 200, duration: '10s' }, // Instant spike to 200 VUs + { target: 200, duration: '1m' }, // Sustain spike + { target: 0, duration: '10s' }, // Cool down + ], + thresholds: { + http_req_duration: ['p(99)<3000'], + http_req_failed: ['rate<0.02'], + checks: ['rate>0.98'], + }, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(50)', 'p(75)', 'p(90)', 'p(95)', 'p(99)'], +}; + +export default function () { + // Primarily read operations (GET) during spike + const resp = http.get(`${BASE_URL}/`, { + headers: DEFAULT_HEADERS, + tags: { operation: 'spike-read' }, + }); + check(resp, { + 'spike: status is 200': (r) => r.status === 200, + 'spike: has project': (r) => r.json('project') !== undefined, + }); + + // Every 5th request also hits the health endpoint + if (__ITER % 5 === 0) { + const healthResp = http.get(`${BASE_URL}/api/health`, { + headers: DEFAULT_HEADERS, + }); + check(healthResp, { + 'spike-health: status is 200': (r) => r.status === 200, + }); + } + + // Every 10th request also hits the auth endpoint + if (__ITER % 10 === 0) { + const wallet = randomWallet(); + const authResp = http.post( + `${BASE_URL}/api/auth/challenge`, + JSON.stringify({ wallet }), + { headers: DEFAULT_HEADERS }, + ); + check(authResp, { + 'spike-auth: status is 200': (r) => r.status === 200, + }); + } + + sleep(0.3); +} diff --git a/k6/stress.js b/k6/stress.js new file mode 100644 index 00000000..286584ff --- /dev/null +++ b/k6/stress.js @@ -0,0 +1,80 @@ +/** + * Stress Test + * + * Purpose: Identify the breaking point of the API by gradually increasing load. + * Scenarios: Ramp up from 10 → 50 → 100 → 200 → 500 VUs in stages. + * Thresholds: p95 < 5000 ms, error rate < 5 % (relaxed for high load). + * + * Run: k6 run k6/stress.js + * k6 run k6/stress.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: 10, duration: '30s' }, // Warm-up + { target: 50, duration: '1m' }, // Moderate load + { target: 100, duration: '1m' }, // High load + { target: 200, duration: '1m' }, // Very high load + { target: 500, duration: '2m' }, // Stress peak + { target: 0, duration: '30s' }, // Cool down + ], + thresholds: { + http_req_duration: ['p(95)<5000'], + http_req_failed: ['rate<0.05'], + checks: ['rate>0.95'], + }, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(50)', 'p(75)', 'p(90)', 'p(95)', 'p(99)'], +}; + +export default function () { + // Mix of endpoints to simulate realistic traffic + const choice = Math.random(); + + if (choice < 0.4) { + // GET / + const resp = http.get(`${BASE_URL}/`, { headers: DEFAULT_HEADERS }); + check(resp, { + 'stress-home: status is 200': (r) => r.status === 200, + }); + } else if (choice < 0.7) { + // GET /api/health + const resp = http.get(`${BASE_URL}/api/health`, { headers: DEFAULT_HEADERS }); + check(resp, { + 'stress-health: status is 200': (r) => r.status === 200, + }); + } else if (choice < 0.9) { + // POST /api/auth/challenge + const wallet = randomWallet(); + const resp = http.post( + `${BASE_URL}/api/auth/challenge`, + JSON.stringify({ wallet }), + { headers: DEFAULT_HEADERS }, + ); + check(resp, { + 'stress-auth: status is 200': (r) => r.status === 200, + }); + } else { + // GET /api/protected (with token) + const wallet = randomWallet(); + const authResp = http.post( + `${BASE_URL}/api/auth/challenge`, + JSON.stringify({ wallet }), + { headers: DEFAULT_HEADERS }, + ); + const token = authResp.json('token'); + if (token) { + const resp = http.get(`${BASE_URL}/api/protected`, { + headers: { ...DEFAULT_HEADERS, Authorization: `Bearer ${token}` }, + }); + check(resp, { + 'stress-protected: status is 200': (r) => r.status === 200, + }); + } + } + + sleep(0.5); +} diff --git a/package.json b/package.json index 6bffb3d7..4d3b1157 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,13 @@ "main": "index.js", "scripts": { "test": "node --test", - "start": "node index.js" + "start": "node index.js", + "k6:smoke": "k6 run k6/smoke.js", + "k6:load": "k6 run k6/load.js", + "k6:stress": "k6 run k6/stress.js", + "k6:spike": "k6 run k6/spike.js", + "k6:soak": "k6 run k6/soak.js", + "k6:quick": "k6 run k6/smoke.js && k6 run k6/load.js && k6 run k6/stress.js && k6 run k6/spike.js" }, "dependencies": { "cors": "^2.8.6", diff --git a/test/server.test.js b/test/server.test.js index 6712fb29..61ba433f 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -6,7 +6,7 @@ const server = app.listen(0); after(() => server.close()); -it('responds with project info', async () => { +it('GET / responds with project info', async () => { const res = await fetch(`http://localhost:${server.address().port}/`); assert.strictEqual(res.status, 200); @@ -15,3 +15,86 @@ it('responds with project info', async () => { assert.strictEqual(data.status, 'Monitoring Meters'); assert.ok(data.contract); }); + +it('GET /api/health returns healthy status', async () => { + const res = await fetch(`http://localhost:${server.address().port}/api/health`); + assert.strictEqual(res.status, 200); + + const data = await res.json(); + assert.strictEqual(data.status, 'healthy'); + assert.ok(typeof data.uptime === 'number'); + assert.ok(typeof data.timestamp === 'number'); +}); + +it('POST /api/auth/challenge returns a token', async () => { + const res = await fetch( + `http://localhost:${server.address().port}/api/auth/challenge`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ wallet: 'GANONEXISTENT123' }), + }, + ); + assert.strictEqual(res.status, 200); + + const data = await res.json(); + assert.ok(data.token); + assert.strictEqual(data.expiresIn, 3600); +}); + +it('POST /api/auth/challenge works without wallet', async () => { + const res = await fetch( + `http://localhost:${server.address().port}/api/auth/challenge`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }, + ); + assert.strictEqual(res.status, 200); + + const data = await res.json(); + assert.ok(data.token); +}); + +it('GET /api/protected returns data with valid token', async () => { + // First get a token + const authRes = await fetch( + `http://localhost:${server.address().port}/api/auth/challenge`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ wallet: 'GTESTWALLET123' }), + }, + ); + const { token } = await authRes.json(); + + // Use token to access protected route + const res = await fetch( + `http://localhost:${server.address().port}/api/protected`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + assert.strictEqual(res.status, 200); + + const data = await res.json(); + assert.strictEqual(data.data, 'Sensitive meter data'); + assert.ok(data.contract); +}); + +it('GET /api/protected returns 401 without Authorization header', async () => { + const res = await fetch( + `http://localhost:${server.address().port}/api/protected`, + ); + assert.strictEqual(res.status, 401); + + const data = await res.json(); + assert.strictEqual(data.error, 'Unauthorized'); +}); + +it('GET /api/protected returns 401 without Bearer prefix', async () => { + const res = await fetch( + `http://localhost:${server.address().port}/api/protected`, + { headers: { Authorization: 'Token some-value' } }, + ); + assert.strictEqual(res.status, 401); +}); From 0377d769c86d1b8ffbfcd84d07df538551808053 Mon Sep 17 00:00:00 2001 From: Timothy Egwuda <33420739+Timrossid@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:57:05 +0000 Subject: [PATCH 2/2] chore: add upstream source files --- src/config/logger.js | 25 ++ src/config/tracing.js | 25 ++ src/schemas/common.schema.js | 66 ++++ src/utils/errors.js | 28 ++ src/utils/pagination.js | 478 ++++++++++++++++++++++++++ test/common.schema.test.js | 147 ++++++++ test/logger.test.js | 58 ++++ test/pagination.test.js | 635 +++++++++++++++++++++++++++++++++++ 8 files changed, 1462 insertions(+) create mode 100644 src/config/logger.js create mode 100644 src/config/tracing.js create mode 100644 src/schemas/common.schema.js create mode 100644 src/utils/errors.js create mode 100644 src/utils/pagination.js create mode 100644 test/common.schema.test.js create mode 100644 test/logger.test.js create mode 100644 test/pagination.test.js diff --git a/src/config/logger.js b/src/config/logger.js new file mode 100644 index 00000000..26c83a60 --- /dev/null +++ b/src/config/logger.js @@ -0,0 +1,25 @@ +const pino = require('pino'); + +const isProduction = process.env.NODE_ENV === 'production'; +const isTest = process.env.NODE_ENV === 'test'; + +const logger = pino({ + level: process.env.LOG_LEVEL || 'info', + transport: + isProduction || isTest + ? undefined + : { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + }, + }, +}); + +function childLogger(moduleName) { + return logger.child({ module: moduleName }); +} + +module.exports = { logger, childLogger }; diff --git a/src/config/tracing.js b/src/config/tracing.js new file mode 100644 index 00000000..9767bd8d --- /dev/null +++ b/src/config/tracing.js @@ -0,0 +1,25 @@ +const { NodeSDK } = require('@opentelemetry/sdk-node'); +const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node'); +const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http'); + +const isTest = process.env.NODE_ENV === 'test'; + +let sdk = null; + +if (!isTest) { + sdk = new NodeSDK({ + serviceName: process.env.OTEL_SERVICE_NAME || 'equipchain-api', + traceExporter: new OTLPTraceExporter(), + instrumentations: [getNodeAutoInstrumentations()], + }); + + sdk.start(); + + process.on('SIGTERM', () => { + sdk + .shutdown() + .finally(() => process.exit(0)); + }); +} + +module.exports = { sdk }; diff --git a/src/schemas/common.schema.js b/src/schemas/common.schema.js new file mode 100644 index 00000000..1a28ea46 --- /dev/null +++ b/src/schemas/common.schema.js @@ -0,0 +1,66 @@ +const { z } = require('zod'); + +const { + DEFAULT_PAGE, + DEFAULT_LIMIT, + MAX_LIMIT, + isIsoDateString, +} = require('../utils/pagination'); + +/** + * Date bounds are validated with the utilities' own predicate rather than Zod's + * .datetime(), which rejects a plain `2024-01-01`. Sharing the predicate keeps the + * schema and filterByDateRange from drifting apart: anything one accepts, the other + * can parse. + */ +const isoDateString = z + .string() + .refine(isIsoDateString, { message: 'must be an ISO 8601 date or date-time' }); + +/** + * Query parameters shared by every list endpoint. Values arrive as strings, so page and + * limit are coerced. Unknown keys are stripped by Zod, which is the whitelist behaviour + * list endpoints want — declare domain filters via makeListQuerySchema to accept them. + * + * `sortBy` is an unconstrained string here because the base schema cannot know an + * endpoint's columns. Prefer makeListQuerySchema({ sortableFields }) so an unsupported + * sort field is rejected at validation time instead of throwing inside applySorting. + */ +const paginationQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(DEFAULT_PAGE), + limit: z.coerce.number().int().min(1).max(MAX_LIMIT).default(DEFAULT_LIMIT), + sortBy: z.string().optional(), + sortOrder: z.enum(['asc', 'desc']).default('asc'), + q: z.string().max(200).optional(), + createdAfter: isoDateString.optional(), + createdBefore: isoDateString.optional(), +}); + +/** + * Builds an endpoint-specific query schema on top of paginationQuerySchema. + * + * @param {{ sortableFields?: string[], filters?: string[] }} [config] + * sortableFields narrows sortBy to that set; filters are added as optional strings so + * they survive Zod's stripping of unknown keys. + * @returns {import('zod').ZodObject} + */ +function makeListQuerySchema(config = {}) { + const { sortableFields = [], filters = [] } = config || {}; + const shape = {}; + + if (sortableFields.length > 0) { + shape.sortBy = z.enum(sortableFields).optional(); + } + + for (const filter of filters) { + shape[filter] = z.string().min(1).optional(); + } + + return paginationQuerySchema.extend(shape); +} + +module.exports = { + paginationQuerySchema, + makeListQuerySchema, + isoDateString, +}; diff --git a/src/utils/errors.js b/src/utils/errors.js new file mode 100644 index 00000000..394f573d --- /dev/null +++ b/src/utils/errors.js @@ -0,0 +1,28 @@ +/** + * Raised when caller-supplied input (typically query parameters) is invalid. + * + * Carries statusCode 400 and a machine-readable `details` array so a future error + * middleware can serialize it without knowing where it came from. Malformed *programmer* + * input — a non-array data set, a bad options object — throws TypeError instead, because + * that is a bug rather than something a client can fix by changing its request. + */ +class ValidationError extends Error { + /** + * @param {Array<{ field: string, message: string }>|{ field: string, message: string }} details + */ + constructor(details) { + const list = Array.isArray(details) ? details : [details]; + super(list.map((detail) => detail.message).join('; ')); + + this.name = 'ValidationError'; + this.code = 'VALIDATION_ERROR'; + this.statusCode = 400; + this.details = list; + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ValidationError); + } + } +} + +module.exports = { ValidationError }; diff --git a/src/utils/pagination.js b/src/utils/pagination.js new file mode 100644 index 00000000..1f189055 --- /dev/null +++ b/src/utils/pagination.js @@ -0,0 +1,478 @@ +const { ValidationError } = require('./errors'); + +const DEFAULT_PAGE = 1; +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; + +// Query params consumed by the utilities themselves, so they are never mistaken for filters. +const RESERVED_PARAMS = ['page', 'limit', 'sortBy', 'sortOrder', 'q', 'createdAfter', 'createdBefore']; + +const SORT_ORDERS = ['asc', 'desc']; + +const ISO_DATE_PREFIX = /^\d{4}-\d{2}-\d{2}/; + +function assertArray(value, name) { + if (!Array.isArray(value)) { + throw new TypeError(`${name} must be an array`); + } +} + +/** + * Parses a value into a timestamp, but only for Date instances and ISO-8601-looking + * strings. Date.parse is lenient enough to turn '2' into a real date, which would make + * ordinary strings sort as dates, so the format is checked first. + * Returns null when the value is not a usable date. + */ +function toTimestamp(value) { + if (value instanceof Date) { + const time = value.getTime(); + return Number.isNaN(time) ? null : time; + } + + if (typeof value !== 'string' || !ISO_DATE_PREFIX.test(value)) { + return null; + } + + const time = Date.parse(value); + return Number.isNaN(time) ? null : time; +} + +/** + * True when a value is a date this module can actually filter on — an ISO 8601 date + * (`2024-01-01`) or datetime (`2024-01-01T00:00:00Z`). + * + * paginationQuerySchema validates createdAfter/createdBefore with this exact predicate, + * so the schema can never accept a bound the utilities reject, or vice versa. + * + * @param {unknown} value + * @returns {boolean} + */ +function isIsoDateString(value) { + return typeof value === 'string' && toTimestamp(value) !== null; +} + +/** + * Accepts a positive integer as either a number or the numeric string that arrives on + * req.query. Returns null when the value cannot be one. + */ +function toPositiveInteger(value) { + if (typeof value === 'number') { + return Number.isInteger(value) && value >= 1 ? value : null; + } + + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 1 ? parsed : null; + } + + return null; +} + +function resolveMaxLimit(maxLimit) { + if (maxLimit === undefined || maxLimit === null) { + return MAX_LIMIT; + } + + const resolved = toPositiveInteger(maxLimit); + if (resolved === null) { + throw new TypeError('options.maxLimit must be a positive integer'); + } + + return resolved; +} + +/** + * Validates page and limit together so a request with both wrong reports both problems + * at once, rather than making the caller fix them one round trip at a time. + */ +function parsePageParams(page, limit, maxLimit) { + const details = []; + + let resolvedPage = DEFAULT_PAGE; + if (page !== undefined && page !== null) { + resolvedPage = toPositiveInteger(page); + if (resolvedPage === null) { + details.push({ field: 'page', message: 'page must be an integer greater than or equal to 1' }); + } + } + + let resolvedLimit = DEFAULT_LIMIT; + if (limit !== undefined && limit !== null) { + resolvedLimit = toPositiveInteger(limit); + if (resolvedLimit === null) { + details.push({ field: 'limit', message: 'limit must be an integer greater than or equal to 1' }); + } else if (resolvedLimit > maxLimit) { + details.push({ field: 'limit', message: `limit must not be greater than ${maxLimit}` }); + } + } + + if (details.length > 0) { + throw new ValidationError(details); + } + + return { page: resolvedPage, limit: resolvedLimit }; +} + +function compareValues(left, right) { + if (typeof left === 'number' && typeof right === 'number') { + return left - right; + } + + const leftTime = toTimestamp(left); + const rightTime = toTimestamp(right); + if (leftTime !== null && rightTime !== null) { + return leftTime - rightTime; + } + + const leftText = String(left); + const rightText = String(right); + if (leftText === rightText) return 0; + return leftText < rightText ? -1 : 1; +} + +/** + * Validates raw page/limit query values and resolves them into the numbers a data source + * needs, including a zero-based `offset` for SQL LIMIT/OFFSET. + * + * This is the database-backed half of the utility: a repository that cannot afford to + * load every row calls this for the query bounds, then buildPaginationMeta with the + * COUNT(*) total. The in-memory paginate() is those two composed over an array, so both + * paths validate identically and emit the same envelope. + * + * @param {{ page?: number|string, limit?: number|string, maxLimit?: number }} [params] + * @returns {{ page: number, limit: number, offset: number }} + * @throws {ValidationError} when page or limit is out of range or not an integer + */ +function getPaginationParams(params = {}) { + const { page, limit, maxLimit } = params || {}; + const resolvedMax = resolveMaxLimit(maxLimit); + const { page: currentPage, limit: perPage } = parsePageParams(page, limit, resolvedMax); + + return { + page: currentPage, + limit: perPage, + offset: (currentPage - 1) * perPage, + }; +} + +/** + * Builds the pagination metadata block from a page, a limit and a known total. Pair with + * getPaginationParams when the rows come from a database and `total` is a COUNT(*). + * + * @param {{ page?: number|string, limit?: number|string, total: number, maxLimit?: number }} params + * @returns {{ page: number, limit: number, total: number, totalPages: number, + * hasNext: boolean, hasPrev: boolean }} + * @throws {ValidationError} when page or limit is invalid + * @throws {TypeError} when total is not a non-negative integer + */ +function buildPaginationMeta(params = {}) { + const { page, limit, total, maxLimit } = params || {}; + + if (!Number.isInteger(total) || total < 0) { + throw new TypeError('total must be a non-negative integer'); + } + + const resolvedMax = resolveMaxLimit(maxLimit); + const { page: currentPage, limit: perPage } = parsePageParams(page, limit, resolvedMax); + const totalPages = Math.ceil(total / perPage); + + return { + page: currentPage, + limit: perPage, + total, + totalPages, + hasNext: currentPage < totalPages, + hasPrev: currentPage > 1, + }; +} + +/** + * Slices an array into a page and returns it alongside pagination metadata. + * + * Invalid page/limit values throw a ValidationError (statusCode 400) listing every + * offending field, so a bad request surfaces instead of being silently reinterpreted. + * Omitting either value is not an error — page falls back to 1 and limit to 20. + * + * A page past the end of the data is a valid request with no results: the requested page + * is echoed back with an empty array, and total/totalPages still describe the full data + * set. The page is deliberately not clamped to the last one, because returning page 5's + * rows under `page: 999` would hand a paging client duplicate records with no way to + * detect it. + * + * @param {Array} data + * @param {{ page?: number|string, limit?: number|string, maxLimit?: number }} [params] + * @returns {{ data: Array, pagination: { page: number, limit: number, total: number, + * totalPages: number, hasNext: boolean, hasPrev: boolean } }} + * @throws {ValidationError} when page or limit is out of range or not an integer + * @throws {TypeError} when data is not an array + */ +function paginate(data, params = {}) { + assertArray(data, 'data'); + + const { limit, offset } = getPaginationParams(params); + const { page, maxLimit } = params || {}; + + return { + data: data.slice(offset, offset + limit), + pagination: buildPaginationMeta({ page, limit, total: data.length, maxLimit }), + }; +} + +/** + * Applies exact-match filters. Only keys present in allowedFields are honoured — this + * whitelist is what stops callers from filtering on fields an endpoint does not expose. + * + * Unknown keys are ignored rather than rejected: real query strings pick up unrelated + * parameters (tracking tags, client state) and failing the request over them would be + * hostile. Values arrive from the query string, so both sides are compared as strings. + * + * @param {Array} data + * @param {Object} filters + * @param {string[]} allowedFields + * @returns {Array} + * @throws {TypeError} when data or allowedFields is not an array + */ +function filterData(data, filters, allowedFields) { + assertArray(data, 'data'); + assertArray(allowedFields, 'allowedFields'); + + if (!filters || allowedFields.length === 0) { + return data; + } + + const active = Object.entries(filters).filter( + ([field, value]) => + allowedFields.includes(field) && value !== undefined && value !== null && value !== '' + ); + + if (active.length === 0) { + return data; + } + + return data.filter((item) => + active.every(([field, value]) => String(item?.[field]) === String(value)) + ); +} + +/** + * Returns a new array sorted by sortBy. Omitting sortBy is a no-op, but asking for a + * field outside allowedFields throws a ValidationError — silently returning unsorted + * rows would look like the sort had been applied. Null/undefined values sort last in + * both directions. + * + * @param {Array} data + * @param {string} [sortBy] + * @param {'asc'|'desc'} [sortOrder] + * @param {string[]} allowedFields + * @returns {Array} + * @throws {ValidationError} when sortBy is not whitelisted or sortOrder is not asc/desc + * @throws {TypeError} when data or allowedFields is not an array + */ +function applySorting(data, sortBy, sortOrder, allowedFields) { + assertArray(data, 'data'); + assertArray(allowedFields, 'allowedFields'); + + if (sortOrder !== undefined && sortOrder !== null && !SORT_ORDERS.includes(sortOrder)) { + throw new ValidationError({ + field: 'sortOrder', + message: `sortOrder must be one of: ${SORT_ORDERS.join(', ')}`, + }); + } + + if (sortBy === undefined || sortBy === null || sortBy === '') { + return data; + } + + if (!allowedFields.includes(sortBy)) { + throw new ValidationError({ + field: 'sortBy', + message: + allowedFields.length > 0 + ? `sortBy must be one of: ${allowedFields.join(', ')}` + : 'sortBy is not supported by this endpoint', + }); + } + + const direction = sortOrder === 'desc' ? -1 : 1; + + return [...data].sort((leftItem, rightItem) => { + const left = leftItem?.[sortBy]; + const right = rightItem?.[sortBy]; + + const leftMissing = left === null || left === undefined; + const rightMissing = right === null || right === undefined; + if (leftMissing && rightMissing) return 0; + if (leftMissing) return 1; + if (rightMissing) return -1; + + return direction * compareValues(left, right); + }); +} + +/** + * Case-insensitive substring search across searchableFields. Matching uses + * String.includes rather than a regex, so special characters in the term are literal. + * An empty or whitespace-only query returns the data untouched. + * + * @param {Array} data + * @param {string} [query] + * @param {string[]} searchableFields + * @returns {Array} + * @throws {ValidationError} when query is present but not a string + * @throws {TypeError} when data or searchableFields is not an array + */ +function searchData(data, query, searchableFields) { + assertArray(data, 'data'); + assertArray(searchableFields, 'searchableFields'); + + if (query === undefined || query === null) { + return data; + } + + if (typeof query !== 'string') { + throw new ValidationError({ field: 'q', message: 'q must be a string' }); + } + + const term = query.trim().toLowerCase(); + if (!term || searchableFields.length === 0) { + return data; + } + + return data.filter((item) => + searchableFields.some((field) => { + const value = item?.[field]; + if (value === null || value === undefined) return false; + return String(value).toLowerCase().includes(term); + }) + ); +} + +/** + * Filters by an inclusive date range on dateField. A bound that is present but not a + * parseable date throws, since silently ignoring it would return rows the caller + * explicitly asked to exclude. Rows whose own date is unparseable fall outside any + * active range. + * + * @throws {ValidationError} when a supplied bound is not a valid date + */ +function filterByDateRange(data, after, before, dateField) { + assertArray(data, 'data'); + + const details = []; + + let afterTime = null; + if (after !== undefined && after !== null && after !== '') { + afterTime = toTimestamp(after); + if (afterTime === null) { + details.push({ field: 'createdAfter', message: 'createdAfter must be a valid ISO 8601 date' }); + } + } + + let beforeTime = null; + if (before !== undefined && before !== null && before !== '') { + beforeTime = toTimestamp(before); + if (beforeTime === null) { + details.push({ + field: 'createdBefore', + message: 'createdBefore must be a valid ISO 8601 date', + }); + } + } + + if (details.length > 0) { + throw new ValidationError(details); + } + + if (afterTime === null && beforeTime === null) { + return data; + } + + return data.filter((item) => { + const time = toTimestamp(item?.[dateField]); + if (time === null) return false; + if (afterTime !== null && time < afterTime) return false; + if (beforeTime !== null && time > beforeTime) return false; + return true; + }); +} + +/** + * Chains search -> filter -> date range -> sort -> paginate in one call, returning the + * standard { data, pagination } envelope. `total` reflects the count after filtering, + * not the size of the input. + * + * Any query param that is not reserved (page, limit, sortBy, sortOrder, q, createdAfter, + * createdBefore) is treated as a candidate exact-match filter and still has to pass the + * options.allowedFilters whitelist. + * + * Note: options.defaultSort.order only applies when the caller did not supply sortOrder. + * paginationQuerySchema defaults sortOrder to 'asc', so endpoints that want a descending + * default should either pass sortOrder explicitly or relax that schema default. + * + * Validate the query with makeListQuerySchema({ sortableFields }) rather than the bare + * paginationQuerySchema: the base schema leaves sortBy an unconstrained string, so an + * unsupported field would reach applySorting and throw there instead of being reported + * as a clean validation failure at the edge. + * + * @param {Array} data + * @param {Object} queryParams + * @param {{ allowedFilters?: string[], searchableFields?: string[], sortableFields?: string[], + * defaultSort?: { field?: string, order?: 'asc'|'desc' }, maxLimit?: number, + * dateField?: string }} [options] + * @throws {ValidationError} for any invalid query parameter + * @throws {TypeError} when data or an options whitelist is not an array + */ +function paginateAndFilter(data, queryParams = {}, options = {}) { + assertArray(data, 'data'); + + const { + allowedFilters = [], + searchableFields = [], + defaultSort = {}, + maxLimit, + dateField = 'createdAt', + } = options || {}; + + const query = queryParams || {}; + + // Sorting defaults to the filterable fields plus the default sort field unless the + // endpoint declares its own sortable whitelist. + const sortableFields = + options?.sortableFields || [...allowedFilters, defaultSort.field].filter(Boolean); + + const filters = {}; + for (const [key, value] of Object.entries(query)) { + if (!RESERVED_PARAMS.includes(key)) { + filters[key] = value; + } + } + + let result = searchData(data, query.q, searchableFields); + result = filterData(result, filters, allowedFilters); + result = filterByDateRange(result, query.createdAfter, query.createdBefore, dateField); + + const sortBy = query.sortBy || defaultSort.field; + const sortOrder = query.sortOrder || defaultSort.order; + result = applySorting(result, sortBy, sortOrder, sortableFields); + + return paginate(result, { page: query.page, limit: query.limit, maxLimit }); +} + +module.exports = { + DEFAULT_PAGE, + DEFAULT_LIMIT, + MAX_LIMIT, + RESERVED_PARAMS, + ValidationError, + isIsoDateString, + getPaginationParams, + buildPaginationMeta, + paginate, + filterData, + applySorting, + searchData, + paginateAndFilter, + // Issue #17 names the combined entry point applyPagination in its description and + // paginateAndFilter in its implementation steps; both resolve to the same function. + applyPagination: paginateAndFilter, +}; diff --git a/test/common.schema.test.js b/test/common.schema.test.js new file mode 100644 index 00000000..ebe3e1df --- /dev/null +++ b/test/common.schema.test.js @@ -0,0 +1,147 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); + +const { paginationQuerySchema, makeListQuerySchema } = require('../src/schemas/common.schema'); +const { isIsoDateString } = require('../src/utils/pagination'); + +function issuePaths(result) { + return result.error.issues.map((issue) => issue.path.join('.')); +} + +describe('paginationQuerySchema', () => { + it('applies defaults for an empty query', () => { + const result = paginationQuerySchema.safeParse({}); + + assert.strictEqual(result.success, true); + assert.deepStrictEqual(result.data, { page: 1, limit: 20, sortOrder: 'asc' }); + }); + + it('coerces numeric strings from the query string', () => { + const result = paginationQuerySchema.safeParse({ page: '2', limit: '5' }); + + assert.strictEqual(result.success, true); + assert.strictEqual(result.data.page, 2); + assert.strictEqual(result.data.limit, 5); + }); + + it('rejects a limit above the maximum', () => { + const result = paginationQuerySchema.safeParse({ limit: '101' }); + + assert.strictEqual(result.success, false); + assert.deepStrictEqual(issuePaths(result), ['limit']); + }); + + it('rejects a page below one', () => { + for (const page of ['0', '-1']) { + const result = paginationQuerySchema.safeParse({ page }); + + assert.strictEqual(result.success, false, `page=${page}`); + assert.deepStrictEqual(issuePaths(result), ['page']); + } + }); + + it('rejects non-integer and non-numeric pagination values', () => { + assert.strictEqual(paginationQuerySchema.safeParse({ page: '1.5' }).success, false); + assert.strictEqual(paginationQuerySchema.safeParse({ limit: 'abc' }).success, false); + }); + + it('rejects an unknown sort order', () => { + const result = paginationQuerySchema.safeParse({ sortOrder: 'sideways' }); + + assert.strictEqual(result.success, false); + assert.deepStrictEqual(issuePaths(result), ['sortOrder']); + }); + + it('accepts ISO date-times and plain ISO dates', () => { + for (const createdAfter of ['2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00Z', '2024-01-01']) { + const result = paginationQuerySchema.safeParse({ createdAfter }); + assert.strictEqual(result.success, true, `createdAfter=${createdAfter}`); + } + }); + + it('rejects malformed date bounds', () => { + for (const createdAfter of ['01/01/2024', 'yesterday', '2024', '']) { + const result = paginationQuerySchema.safeParse({ createdAfter }); + assert.strictEqual(result.success, false, `createdAfter=${createdAfter}`); + } + }); + + it('agrees with the utilities on which date bounds are valid', () => { + // Regression guard: the schema and filterByDateRange share one predicate, so a bound + // accepted at the edge can never be rejected downstream. + for (const value of ['2024-01-01', '2024-01-01T00:00:00.000Z', '01/01/2024', 'nope']) { + assert.strictEqual( + paginationQuerySchema.safeParse({ createdAfter: value }).success, + isIsoDateString(value), + `value=${value}` + ); + } + }); + + it('rejects an overlong search term', () => { + const result = paginationQuerySchema.safeParse({ q: 'x'.repeat(201) }); + + assert.strictEqual(result.success, false); + assert.deepStrictEqual(issuePaths(result), ['q']); + }); + + it('strips undeclared query parameters', () => { + const result = paginationQuerySchema.safeParse({ role: 'admin', 'drop table': '1' }); + + assert.strictEqual(result.success, true); + assert.strictEqual('role' in result.data, false); + assert.deepStrictEqual(result.data, { page: 1, limit: 20, sortOrder: 'asc' }); + }); +}); + +describe('makeListQuerySchema', () => { + const schema = makeListQuerySchema({ + sortableFields: ['id', 'createdAt'], + filters: ['status', 'role'], + }); + + it('keeps the shared pagination defaults', () => { + const result = schema.safeParse({}); + + assert.strictEqual(result.success, true); + assert.strictEqual(result.data.page, 1); + assert.strictEqual(result.data.limit, 20); + }); + + it('accepts declared filters', () => { + const result = schema.safeParse({ status: 'active', role: 'admin' }); + + assert.strictEqual(result.success, true); + assert.strictEqual(result.data.status, 'active'); + assert.strictEqual(result.data.role, 'admin'); + }); + + it('still strips filters it does not declare', () => { + const result = schema.safeParse({ secret: 'value' }); + + assert.strictEqual(result.success, true); + assert.strictEqual('secret' in result.data, false); + }); + + it('accepts a whitelisted sort field', () => { + const result = schema.safeParse({ sortBy: 'createdAt', sortOrder: 'desc' }); + + assert.strictEqual(result.success, true); + assert.strictEqual(result.data.sortBy, 'createdAt'); + assert.strictEqual(result.data.sortOrder, 'desc'); + }); + + it('rejects a sort field outside the whitelist', () => { + const result = schema.safeParse({ sortBy: 'password' }); + + assert.strictEqual(result.success, false); + assert.deepStrictEqual(issuePaths(result), ['sortBy']); + }); + + it('leaves sortBy unconstrained when no sortable fields are declared', () => { + const result = makeListQuerySchema().safeParse({ sortBy: 'anything' }); + + assert.strictEqual(result.success, true); + assert.strictEqual(result.data.sortBy, 'anything'); + }); +}); diff --git a/test/logger.test.js b/test/logger.test.js new file mode 100644 index 00000000..b39b6cf4 --- /dev/null +++ b/test/logger.test.js @@ -0,0 +1,58 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); + +const loggerPath = path.join(__dirname, '..', 'src', 'config', 'logger.js'); + +function runLoggerScript(script, env) { + const output = execFileSync(process.execPath, ['-e', script], { + env: { ...process.env, ...env }, + encoding: 'utf8', + }); + + return output + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +describe('logger', () => { + it('outputs structured JSON logs', () => { + const script = ` + const { logger } = require(${JSON.stringify(loggerPath)}); + logger.info('hello world'); + `; + const [entry] = runLoggerScript(script, { NODE_ENV: 'production', LOG_LEVEL: 'info' }); + + assert.strictEqual(entry.msg, 'hello world'); + assert.strictEqual(entry.level, 30); + assert.ok(entry.time); + }); + + it('respects LOG_LEVEL and suppresses lower-priority logs', () => { + const script = ` + const { logger } = require(${JSON.stringify(loggerPath)}); + logger.info('should not appear'); + logger.error('should appear'); + `; + const entries = runLoggerScript(script, { NODE_ENV: 'production', LOG_LEVEL: 'error' }); + + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0].msg, 'should appear'); + assert.strictEqual(entries[0].level, 50); + }); + + it('child loggers inherit and extend parent context', () => { + const script = ` + const { childLogger } = require(${JSON.stringify(loggerPath)}); + const log = childLogger('test-module'); + log.info('from child'); + `; + const [entry] = runLoggerScript(script, { NODE_ENV: 'production', LOG_LEVEL: 'info' }); + + assert.strictEqual(entry.module, 'test-module'); + assert.strictEqual(entry.msg, 'from child'); + }); +}); diff --git a/test/pagination.test.js b/test/pagination.test.js new file mode 100644 index 00000000..f0f51ef8 --- /dev/null +++ b/test/pagination.test.js @@ -0,0 +1,635 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); + +const { + DEFAULT_LIMIT, + MAX_LIMIT, + ValidationError, + isIsoDateString, + getPaginationParams, + buildPaginationMeta, + paginate, + filterData, + applySorting, + searchData, + paginateAndFilter, + applyPagination, +} = require('../src/utils/pagination'); + +const STATUSES = ['active', 'idle', 'faulty']; +const ROLES = ['admin', 'operator']; + +// 25 meters: ids 1..25, statuses cycle every 3, roles alternate, createdAt 2024-01-01..25. +function makeMeters(count = 25) { + return Array.from({ length: count }, (_, index) => { + const id = index + 1; + return { + id, + name: `Meter ${id}`, + status: STATUSES[index % STATUSES.length], + role: ROLES[index % ROLES.length], + createdAt: new Date(Date.UTC(2024, 0, id)).toISOString(), + }; + }); +} + +// Asserts the call throws a ValidationError whose details name exactly `fields`. +function assertValidationError(fn, fields, message) { + assert.throws( + fn, + (error) => { + assert.ok(error instanceof ValidationError, `expected ValidationError, got ${error.name}`); + assert.strictEqual(error.statusCode, 400); + assert.strictEqual(error.code, 'VALIDATION_ERROR'); + assert.deepStrictEqual( + error.details.map((detail) => detail.field), + fields + ); + return true; + }, + message + ); +} + +describe('ValidationError', () => { + it('carries a 400 status and a joined message', () => { + const error = new ValidationError([ + { field: 'page', message: 'page is bad' }, + { field: 'limit', message: 'limit is bad' }, + ]); + + assert.ok(error instanceof Error); + assert.strictEqual(error.name, 'ValidationError'); + assert.strictEqual(error.statusCode, 400); + assert.strictEqual(error.message, 'page is bad; limit is bad'); + assert.strictEqual(error.details.length, 2); + }); +}); + +describe('isIsoDateString', () => { + it('accepts ISO dates and date-times', () => { + assert.strictEqual(isIsoDateString('2024-01-01'), true); + assert.strictEqual(isIsoDateString('2024-01-01T00:00:00.000Z'), true); + }); + + it('rejects other formats and non-strings', () => { + for (const value of ['01/01/2024', 'not-a-date', '2024-13-45', '', 42, null, new Date()]) { + assert.strictEqual(isIsoDateString(value), false, `value=${String(value)}`); + } + }); +}); + +describe('getPaginationParams', () => { + it('resolves a zero-based offset for database queries', () => { + assert.deepStrictEqual(getPaginationParams({ page: 1, limit: 20 }), { + page: 1, + limit: 20, + offset: 0, + }); + assert.deepStrictEqual(getPaginationParams({ page: 3, limit: 25 }), { + page: 3, + limit: 25, + offset: 50, + }); + }); + + it('applies the same defaults as paginate', () => { + assert.deepStrictEqual(getPaginationParams(), { page: 1, limit: DEFAULT_LIMIT, offset: 0 }); + }); + + it('coerces query strings and enforces the same validation', () => { + assert.deepStrictEqual(getPaginationParams({ page: '2', limit: '10' }), { + page: 2, + limit: 10, + offset: 10, + }); + assertValidationError(() => getPaginationParams({ limit: '101' }), ['limit']); + assertValidationError(() => getPaginationParams({ page: '0' }), ['page']); + }); +}); + +describe('buildPaginationMeta', () => { + it('builds metadata from a COUNT-style total without the rows', () => { + assert.deepStrictEqual(buildPaginationMeta({ page: 2, limit: 10, total: 95 }), { + page: 2, + limit: 10, + total: 95, + totalPages: 10, + hasNext: true, + hasPrev: true, + }); + }); + + it('matches what paginate produces for the same page of an array', () => { + const meters = makeMeters(); + const fromArray = paginate(meters, { page: 2, limit: 5 }).pagination; + const fromTotal = buildPaginationMeta({ page: 2, limit: 5, total: meters.length }); + + assert.deepStrictEqual(fromTotal, fromArray); + }); + + it('handles a zero total', () => { + assert.deepStrictEqual(buildPaginationMeta({ total: 0 }), { + page: 1, + limit: DEFAULT_LIMIT, + total: 0, + totalPages: 0, + hasNext: false, + hasPrev: false, + }); + }); + + it('throws a TypeError for a missing or invalid total', () => { + assert.throws(() => buildPaginationMeta({ page: 1, limit: 10 }), TypeError); + assert.throws(() => buildPaginationMeta({ total: -1 }), TypeError); + assert.throws(() => buildPaginationMeta({ total: 1.5 }), TypeError); + assert.throws(() => buildPaginationMeta({ total: '10' }), TypeError); + }); + + it('validates page and limit like the rest of the module', () => { + assertValidationError(() => buildPaginationMeta({ page: 0, total: 10 }), ['page']); + }); +}); + +describe('paginate', () => { + it('returns empty metadata for empty data', () => { + const result = paginate([]); + + assert.deepStrictEqual(result.data, []); + assert.deepStrictEqual(result.pagination, { + page: 1, + limit: DEFAULT_LIMIT, + total: 0, + totalPages: 0, + hasNext: false, + hasPrev: false, + }); + }); + + it('applies default page and limit when none are given', () => { + const result = paginate(makeMeters()); + + assert.strictEqual(result.data.length, DEFAULT_LIMIT); + assert.strictEqual(result.data[0].id, 1); + assert.strictEqual(result.pagination.page, 1); + assert.strictEqual(result.pagination.limit, 20); + assert.strictEqual(result.pagination.total, 25); + assert.strictEqual(result.pagination.totalPages, 2); + }); + + it('reports a single page when everything fits', () => { + const result = paginate(makeMeters(4), { page: 1, limit: 10 }); + + assert.strictEqual(result.data.length, 4); + assert.strictEqual(result.pagination.totalPages, 1); + assert.strictEqual(result.pagination.hasNext, false); + assert.strictEqual(result.pagination.hasPrev, false); + }); + + it('tracks hasNext and hasPrev across multiple pages', () => { + const meters = makeMeters(); + + const first = paginate(meters, { page: 1, limit: 5 }); + assert.strictEqual(first.data[0].id, 1); + assert.strictEqual(first.pagination.hasNext, true); + assert.strictEqual(first.pagination.hasPrev, false); + + const middle = paginate(meters, { page: 3, limit: 5 }); + assert.strictEqual(middle.data[0].id, 11); + assert.strictEqual(middle.data.length, 5); + assert.strictEqual(middle.pagination.hasNext, true); + assert.strictEqual(middle.pagination.hasPrev, true); + + const last = paginate(meters, { page: 5, limit: 5 }); + assert.strictEqual(last.data[4].id, 25); + assert.strictEqual(last.pagination.hasNext, false); + assert.strictEqual(last.pagination.hasPrev, true); + }); + + it('returns the remainder on a partial last page', () => { + const result = paginate(makeMeters(23), { page: 5, limit: 5 }); + + assert.strictEqual(result.data.length, 3); + assert.deepStrictEqual( + result.data.map((meter) => meter.id), + [21, 22, 23] + ); + assert.strictEqual(result.pagination.total, 23); + assert.strictEqual(result.pagination.totalPages, 5); + assert.strictEqual(result.pagination.hasNext, false); + }); + + it('returns an empty page beyond the last one rather than clamping', () => { + const result = paginate(makeMeters(), { page: 999, limit: 5 }); + + assert.deepStrictEqual(result.data, []); + assert.deepStrictEqual(result.pagination, { + page: 999, + limit: 5, + total: 25, + totalPages: 5, + hasNext: false, + hasPrev: true, + }); + }); + + it('throws a ValidationError for a page below one or not an integer', () => { + const meters = makeMeters(); + + for (const page of [0, -1, 1.5, 'abc', '', '2.5']) { + assertValidationError(() => paginate(meters, { page, limit: 5 }), ['page'], `page=${page}`); + } + }); + + it('throws a ValidationError for a limit outside the allowed range', () => { + const meters = makeMeters(); + + for (const limit of [0, -5, 'abc', 101]) { + assertValidationError(() => paginate(meters, { limit }), ['limit'], `limit=${limit}`); + } + }); + + it('reports every invalid pagination field in one error', () => { + assertValidationError(() => paginate(makeMeters(), { page: 0, limit: 101 }), ['page', 'limit']); + }); + + it('names the effective maximum when a custom maxLimit is exceeded', () => { + assert.throws( + () => paginate(makeMeters(), { limit: 50, maxLimit: 10 }), + (error) => { + assert.strictEqual(error.details[0].message, 'limit must not be greater than 10'); + return true; + } + ); + + assert.strictEqual(paginate(makeMeters(), { limit: 10, maxLimit: 10 }).pagination.limit, 10); + }); + + it('accepts the maximum limit itself', () => { + assert.strictEqual(paginate(makeMeters(), { limit: MAX_LIMIT }).pagination.limit, MAX_LIMIT); + }); + + it('treats omitted page and limit as defaults, not as errors', () => { + const result = paginate(makeMeters(), { page: undefined, limit: null }); + + assert.strictEqual(result.pagination.page, 1); + assert.strictEqual(result.pagination.limit, DEFAULT_LIMIT); + }); + + it('coerces numeric strings from the query string', () => { + const result = paginate(makeMeters(), { page: '2', limit: '5' }); + + assert.strictEqual(result.pagination.page, 2); + assert.strictEqual(result.pagination.limit, 5); + assert.strictEqual(result.data[0].id, 6); + }); + + it('throws a TypeError when data is not an array', () => { + assert.throws(() => paginate(undefined), TypeError); + assert.throws(() => paginate({ length: 3 }), TypeError); + }); + + it('does not mutate the source array', () => { + const meters = makeMeters(5); + const snapshot = meters.map((meter) => meter.id); + + paginate(meters, { page: 2, limit: 2 }); + + assert.deepStrictEqual( + meters.map((meter) => meter.id), + snapshot + ); + }); +}); + +describe('filterData', () => { + const meters = makeMeters(); + + it('filters by a single field', () => { + const result = filterData(meters, { status: 'active' }, ['status']); + + assert.strictEqual(result.length, 9); + assert.ok(result.every((meter) => meter.status === 'active')); + }); + + it('combines multiple filters', () => { + const result = filterData(meters, { status: 'active', role: 'admin' }, ['status', 'role']); + + assert.strictEqual(result.length, 5); + assert.ok(result.every((meter) => meter.status === 'active' && meter.role === 'admin')); + }); + + it('ignores fields that are not whitelisted', () => { + const result = filterData(meters, { role: 'admin' }, ['status']); + + assert.strictEqual(result.length, meters.length); + }); + + it('returns an empty array when nothing matches', () => { + const result = filterData(meters, { status: 'decommissioned' }, ['status']); + + assert.deepStrictEqual(result, []); + }); + + it('matches numeric fields against string query values', () => { + const result = filterData(meters, { id: '7' }, ['id']); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].id, 7); + }); + + it('skips empty filter values', () => { + const result = filterData(meters, { status: '', role: undefined }, ['status', 'role']); + + assert.strictEqual(result.length, meters.length); + }); + + it('throws a TypeError when allowedFields is not an array', () => { + assert.throws(() => filterData(meters, { status: 'active' }, 'status'), TypeError); + }); +}); + +describe('applySorting', () => { + const meters = makeMeters(5); + + it('sorts ascending', () => { + const result = applySorting(meters, 'name', 'asc', ['name']); + + assert.deepStrictEqual( + result.map((meter) => meter.name), + ['Meter 1', 'Meter 2', 'Meter 3', 'Meter 4', 'Meter 5'] + ); + }); + + it('sorts descending', () => { + const result = applySorting(meters, 'id', 'desc', ['id']); + + assert.deepStrictEqual( + result.map((meter) => meter.id), + [5, 4, 3, 2, 1] + ); + }); + + it('defaults to ascending when sortOrder is omitted', () => { + const result = applySorting(makeMeters(5).reverse(), 'id', undefined, ['id']); + + assert.deepStrictEqual( + result.map((meter) => meter.id), + [1, 2, 3, 4, 5] + ); + }); + + it('sorts ISO date strings chronologically', () => { + const result = applySorting(meters, 'createdAt', 'desc', ['createdAt']); + + assert.strictEqual(result[0].createdAt, '2024-01-05T00:00:00.000Z'); + assert.strictEqual(result[4].createdAt, '2024-01-01T00:00:00.000Z'); + }); + + it('is a no-op when no sort field is requested', () => { + assert.deepStrictEqual(applySorting(meters, undefined, 'desc', ['id']), meters); + assert.deepStrictEqual(applySorting(meters, '', 'desc', ['id']), meters); + }); + + it('throws a ValidationError for a sort field outside the whitelist', () => { + assertValidationError(() => applySorting(meters, 'secret', 'asc', ['id']), ['sortBy']); + + assert.throws( + () => applySorting(meters, 'secret', 'asc', ['id', 'name']), + (error) => { + assert.strictEqual(error.details[0].message, 'sortBy must be one of: id, name'); + return true; + } + ); + }); + + it('throws a ValidationError for an unknown sort order', () => { + assertValidationError(() => applySorting(meters, 'id', 'sideways', ['id']), ['sortOrder']); + }); + + it('sorts missing values last in both directions', () => { + const partial = [{ id: 1, tier: 'b' }, { id: 2 }, { id: 3, tier: 'a' }]; + + assert.deepStrictEqual( + applySorting(partial, 'tier', 'asc', ['tier']).map((item) => item.id), + [3, 1, 2] + ); + assert.deepStrictEqual( + applySorting(partial, 'tier', 'desc', ['tier']).map((item) => item.id), + [1, 3, 2] + ); + }); + + it('does not mutate the source array', () => { + const source = makeMeters(5); + const snapshot = source.map((meter) => meter.id); + + applySorting(source, 'id', 'desc', ['id']); + + assert.deepStrictEqual( + source.map((meter) => meter.id), + snapshot + ); + }); +}); + +describe('searchData', () => { + const meters = makeMeters(); + + it('matches case-insensitively', () => { + const result = searchData(meters, 'METER 25', ['name']); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].id, 25); + }); + + it('matches substrings across the whole set', () => { + const result = searchData(meters, 'meter 1', ['name']); + + // Meter 1 plus Meter 10..19 + assert.strictEqual(result.length, 11); + }); + + it('returns an empty array when nothing matches', () => { + assert.deepStrictEqual(searchData(meters, 'turbine', ['name']), []); + }); + + it('searches across multiple fields', () => { + const result = searchData(meters, 'faulty', ['name', 'status']); + + assert.strictEqual(result.length, 8); + assert.ok(result.every((meter) => meter.status === 'faulty')); + }); + + it('treats a multi-word query as one substring', () => { + const records = [{ label: 'north wing meter' }, { label: 'north meter wing' }]; + const result = searchData(records, 'north wing', ['label']); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].label, 'north wing meter'); + }); + + it('treats regex special characters literally', () => { + const records = [{ name: 'meter.*' }, { name: 'meter (spare)' }, { name: 'meter one' }]; + + assert.deepStrictEqual(searchData(records, '.*', ['name']), [{ name: 'meter.*' }]); + assert.deepStrictEqual(searchData(records, '(spare)', ['name']), [{ name: 'meter (spare)' }]); + }); + + it('returns everything for an empty, whitespace, or absent query', () => { + assert.strictEqual(searchData(meters, '', ['name']).length, meters.length); + assert.strictEqual(searchData(meters, ' ', ['name']).length, meters.length); + assert.strictEqual(searchData(meters, undefined, ['name']).length, meters.length); + }); + + it('throws a ValidationError when the query is not a string', () => { + assertValidationError(() => searchData(meters, 42, ['name']), ['q']); + }); + + it('skips records missing the searchable field', () => { + const records = [{ name: 'meter one' }, { id: 2 }]; + + assert.strictEqual(searchData(records, 'meter', ['name']).length, 1); + }); +}); + +describe('paginateAndFilter', () => { + const options = { + allowedFilters: ['status', 'role'], + searchableFields: ['name', 'status'], + sortableFields: ['id', 'name', 'createdAt'], + defaultSort: { field: 'id', order: 'asc' }, + }; + + it('returns defaults with the standard envelope when no params are given', () => { + const result = paginateAndFilter(makeMeters(), {}, options); + + assert.strictEqual(result.data.length, 20); + assert.strictEqual(result.pagination.page, 1); + assert.strictEqual(result.pagination.limit, 20); + assert.strictEqual(result.pagination.total, 25); + assert.strictEqual(result.pagination.totalPages, 2); + assert.strictEqual(result.pagination.hasNext, true); + }); + + it('chains search, filter, sort and pagination', () => { + const result = paginateAndFilter( + makeMeters(), + { q: 'meter 1', status: 'active', sortBy: 'id', sortOrder: 'desc', limit: '2' }, + options + ); + + // 'meter 1' matches ids 1 and 10..19; of those, active ones are 1, 10, 13, 16, 19. + assert.strictEqual(result.pagination.total, 5); + assert.strictEqual(result.pagination.totalPages, 3); + assert.deepStrictEqual( + result.data.map((meter) => meter.id), + [19, 16] + ); + }); + + it('reports total after filtering, not the input size', () => { + const result = paginateAndFilter(makeMeters(), { status: 'active' }, options); + + assert.strictEqual(result.pagination.total, 9); + }); + + it('applies defaultSort when sortBy is absent', () => { + const meters = makeMeters(5).reverse(); + const result = paginateAndFilter(meters, {}, options); + + assert.deepStrictEqual( + result.data.map((meter) => meter.id), + [1, 2, 3, 4, 5] + ); + }); + + it('honours a descending defaultSort order', () => { + const result = paginateAndFilter( + makeMeters(5), + {}, + { ...options, defaultSort: { field: 'id', order: 'desc' } } + ); + + assert.deepStrictEqual( + result.data.map((meter) => meter.id), + [5, 4, 3, 2, 1] + ); + }); + + it('ignores filters that are not whitelisted', () => { + const result = paginateAndFilter(makeMeters(), { name: 'Meter 3' }, options); + + assert.strictEqual(result.pagination.total, 25); + }); + + it('filters by an inclusive createdAt range', () => { + const result = paginateAndFilter( + makeMeters(), + { createdAfter: '2024-01-05T00:00:00.000Z', createdBefore: '2024-01-09T00:00:00.000Z' }, + options + ); + + assert.strictEqual(result.pagination.total, 5); + assert.deepStrictEqual( + result.data.map((meter) => meter.id), + [5, 6, 7, 8, 9] + ); + }); + + it('throws a ValidationError for an unparseable date bound', () => { + assertValidationError( + () => paginateAndFilter(makeMeters(), { createdAfter: 'not-a-date' }, options), + ['createdAfter'] + ); + + assertValidationError( + () => + paginateAndFilter( + makeMeters(), + { createdAfter: 'nope', createdBefore: 'also-nope' }, + options + ), + ['createdAfter', 'createdBefore'] + ); + }); + + it('propagates validation errors from every stage', () => { + assertValidationError(() => paginateAndFilter(makeMeters(), { page: '0' }, options), ['page']); + assertValidationError(() => paginateAndFilter(makeMeters(), { limit: '101' }, options), [ + 'limit', + ]); + assertValidationError(() => paginateAndFilter(makeMeters(), { sortBy: 'role' }, options), [ + 'sortBy', + ]); + assertValidationError(() => paginateAndFilter(makeMeters(), { sortOrder: 'up' }, options), [ + 'sortOrder', + ]); + }); + + it('accepts date-only bounds as well as full timestamps', () => { + const result = paginateAndFilter( + makeMeters(), + { createdAfter: '2024-01-05', createdBefore: '2024-01-09' }, + options + ); + + assert.strictEqual(result.pagination.total, 5); + }); + + it('handles empty data', () => { + const result = paginateAndFilter([], { q: 'meter', page: '3' }, options); + + assert.deepStrictEqual(result.data, []); + assert.strictEqual(result.pagination.total, 0); + assert.strictEqual(result.pagination.totalPages, 0); + }); + + it('is also exported as applyPagination, the name used in the issue description', () => { + assert.strictEqual(applyPagination, paginateAndFilter); + + const viaAlias = applyPagination(makeMeters(), { limit: '5' }, options); + assert.strictEqual(viaAlias.data.length, 5); + assert.strictEqual(viaAlias.pagination.total, 25); + }); +});