Skip to content

Commit c14dc6a

Browse files
Add API benchmark suite
1 parent 5426895 commit c14dc6a

11 files changed

Lines changed: 1878 additions & 1 deletion

File tree

.env.benchmark.example

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#
2+
# API benchmark configuration.
3+
# Copy this file to .env.benchmark before running against a deployed target.
4+
#
5+
6+
# Leave blank to start the local Express app in-process on a random port.
7+
# Set this for deployed environments, for example: https://api.example.com
8+
BENCHMARK_TARGET_URL=
9+
10+
# Optional dedicated benchmark JWTs for protected routes in deployed targets.
11+
# Local loopback runs generate short-lived benchmark tokens automatically.
12+
BENCHMARK_ADMIN_TOKEN=
13+
BENCHMARK_CLIENT_TOKEN=
14+
BENCHMARK_FREELANCER_TOKEN=
15+
16+
# Full benchmark defaults keep total local requests below the API rate limiter.
17+
BENCHMARK_REQUESTS_PER_ENDPOINT=8
18+
BENCHMARK_CONCURRENCY=2
19+
BENCHMARK_WARMUP_REQUESTS=1
20+
21+
# CI-friendly mode. `npm run benchmark:smoke` sets this automatically.
22+
BENCHMARK_SMOKE=false
23+
BENCHMARK_SMOKE_REQUESTS_PER_ENDPOINT=2
24+
BENCHMARK_SMOKE_CONCURRENCY=1
25+
BENCHMARK_SMOKE_WARMUP_REQUESTS=0
26+
27+
# Optional output directory override.
28+
BENCHMARK_RESULTS_DIR=benchmarks/results
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: API benchmark smoke
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- "apps/api/**"
7+
- "benchmarks/**"
8+
- "package.json"
9+
- "package-lock.json"
10+
- ".github/workflows/api-benchmark-smoke.yml"
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
smoke:
17+
runs-on: ubuntu-latest
18+
19+
steps:
20+
- name: Checkout
21+
uses: actions/checkout@v4
22+
23+
- name: Setup Node
24+
uses: actions/setup-node@v4
25+
with:
26+
node-version: 20
27+
cache: npm
28+
29+
- name: Install dependencies
30+
run: npm ci
31+
32+
- name: Run API benchmark smoke
33+
run: npm run benchmark:smoke

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ node_modules
33
dist
44
.env
55
.env.*
6+
!.env.benchmark.example
67
coverage
78
*.log

apps/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"scripts": {
66
"dev": "node src/server.js",
77
"start": "node src/server.js",
8-
"test": "node --test src/tests"
8+
"test": "node --test src/tests/*.test.js"
99
},
1010
"dependencies": {
1111
"cors": "^2.8.5",

benchmarks/README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# API Benchmarks
2+
3+
This directory contains the reproducible API benchmark for every route mounted under `/api/*`, plus the public `/health` probe.
4+
5+
## Commands
6+
7+
Run the full local benchmark:
8+
9+
```sh
10+
npm run benchmark
11+
```
12+
13+
Run the CI smoke benchmark:
14+
15+
```sh
16+
npm run benchmark:smoke
17+
```
18+
19+
Both commands write:
20+
21+
- `benchmarks/results/latest.json`
22+
- `benchmarks/results/latest.md`
23+
24+
## Configuration
25+
26+
Copy `.env.benchmark.example` to `.env.benchmark` when you need to override defaults.
27+
28+
By default the runner starts the local Express app in-process on a random port. Set `BENCHMARK_TARGET_URL` to benchmark a deployed API instead.
29+
30+
Protected routes need dedicated benchmark tokens when running against a deployed target:
31+
32+
```sh
33+
BENCHMARK_TARGET_URL=https://api.example.com
34+
BENCHMARK_ADMIN_TOKEN=<dedicated benchmark admin jwt>
35+
```
36+
37+
For local loopback runs, the benchmark generates short-lived JWTs with a `benchmark: true` claim using the app's configured `JWT_SECRET`.
38+
39+
## Coverage
40+
41+
The endpoint matrix lives in `benchmarks/endpoints.mjs` and currently covers:
42+
43+
- `/api/auth/register`
44+
- `/api/auth/login`
45+
- `/api/auth/oauth/:provider/callback`
46+
- `/api/auth/refresh`
47+
- `/api/users`
48+
- `/api/jobs`
49+
- `/api/proposals`
50+
- `/api/payments`
51+
- `/api/reviews`
52+
- `/api/messages`
53+
- `/api/notifications`
54+
- `/api/uploads`
55+
- `/api/search`
56+
- `/api/admin/metrics`
57+
58+
Each endpoint includes a realistic request scenario and payload where the route accepts a request body.
59+
60+
## Metrics
61+
62+
The runner records:
63+
64+
- p50, p95 and p99 full-response latency
65+
- p50, p95 and p99 time to first byte
66+
- sustained and peak requests per second
67+
- error rate
68+
- status-code distribution
69+
70+
Thresholds for CI live in `benchmarks/thresholds.json`.

benchmarks/endpoints.mjs

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
export const benchmarkEndpoints = [
2+
{
3+
name: "health-check",
4+
method: "GET",
5+
path: "/health",
6+
expectedStatus: 200,
7+
scenario: "Public health probe used by uptime checks and deployment smoke tests."
8+
},
9+
{
10+
name: "auth-register-client",
11+
method: "POST",
12+
path: "/api/auth/register",
13+
expectedStatus: 201,
14+
scenario: "Client creates a new account before posting freelance work.",
15+
json: ({ nextId }) => ({
16+
email: `benchmark.client.${nextId()}@example.com`,
17+
password: "benchmark-pass-123",
18+
role: "client"
19+
})
20+
},
21+
{
22+
name: "auth-login-client",
23+
method: "POST",
24+
path: "/api/auth/login",
25+
expectedStatus: 200,
26+
scenario: "Existing client signs in with email and password.",
27+
json: () => ({
28+
email: "benchmark.client@example.com",
29+
password: "benchmark-pass-123"
30+
})
31+
},
32+
{
33+
name: "auth-oauth-callback",
34+
method: "GET",
35+
path: "/api/auth/oauth/github/callback",
36+
expectedStatus: 200,
37+
scenario: "OAuth provider callback reaches the API callback endpoint."
38+
},
39+
{
40+
name: "auth-refresh-token",
41+
method: "POST",
42+
path: "/api/auth/refresh",
43+
expectedStatus: 200,
44+
scenario: "Authenticated session requests a refreshed access token."
45+
},
46+
{
47+
name: "users-list",
48+
method: "GET",
49+
path: "/api/users",
50+
expectedStatus: 200,
51+
scenario: "Directory view loads users for marketplace discovery."
52+
},
53+
{
54+
name: "users-create-freelancer",
55+
method: "POST",
56+
path: "/api/users",
57+
expectedStatus: 201,
58+
scenario: "Freelancer profile is created with portfolio metadata.",
59+
json: ({ nextId }) => ({
60+
email: `benchmark.freelancer.${nextId()}@example.com`,
61+
name: "Benchmark Freelancer",
62+
role: "freelancer",
63+
skills: ["node.js", "api performance", "sql"],
64+
hourlyRate: 85
65+
})
66+
},
67+
{
68+
name: "jobs-list",
69+
method: "GET",
70+
path: "/api/jobs",
71+
expectedStatus: 200,
72+
scenario: "Marketplace job list is loaded by a freelancer browsing work."
73+
},
74+
{
75+
name: "jobs-create",
76+
method: "POST",
77+
path: "/api/jobs",
78+
expectedStatus: 201,
79+
scenario: "Client posts a realistic API performance optimisation job.",
80+
json: ({ nextId }) => ({
81+
title: `Benchmark API optimisation ${nextId()}`,
82+
description: "Audit API latency, add dashboard metrics, and reduce p95 response time for high-traffic endpoints.",
83+
budgetMin: 750,
84+
budgetMax: 2500,
85+
categoryId: "cat_api_engineering",
86+
skills: ["node.js", "express", "benchmarking", "observability"]
87+
})
88+
},
89+
{
90+
name: "proposals-list",
91+
method: "GET",
92+
path: "/api/proposals",
93+
expectedStatus: 200,
94+
scenario: "Client reviews proposals submitted for open work."
95+
},
96+
{
97+
name: "proposals-create",
98+
method: "POST",
99+
path: "/api/proposals",
100+
expectedStatus: 201,
101+
scenario: "Freelancer submits a detailed proposal against a job.",
102+
json: ({ nextId }) => ({
103+
jobId: `job_benchmark_${nextId()}`,
104+
freelancerId: "usr_benchmark_freelancer",
105+
coverLetter: "I will profile the critical API routes, publish reproducible results, and tune the slowest handlers.",
106+
bidAmount: 1200,
107+
deliveryDays: 5
108+
})
109+
},
110+
{
111+
name: "payments-create",
112+
method: "POST",
113+
path: "/api/payments",
114+
expectedStatus: 201,
115+
scenario: "Client opens an escrow-style payment intent for accepted work.",
116+
json: ({ nextId }) => ({
117+
proposalId: `prp_benchmark_${nextId()}`,
118+
amount: 1200,
119+
currency: "usd",
120+
customerId: "cus_benchmark_client"
121+
})
122+
},
123+
{
124+
name: "reviews-list",
125+
method: "GET",
126+
path: "/api/reviews",
127+
expectedStatus: 200,
128+
scenario: "Public profile loads completed-work reviews."
129+
},
130+
{
131+
name: "reviews-create",
132+
method: "POST",
133+
path: "/api/reviews",
134+
expectedStatus: 201,
135+
scenario: "Client leaves a post-project review for a freelancer.",
136+
json: ({ nextId }) => ({
137+
contractId: `ctr_benchmark_${nextId()}`,
138+
reviewerId: "usr_benchmark_client",
139+
revieweeId: "usr_benchmark_freelancer",
140+
rating: 5,
141+
comment: "Delivered the benchmark report quickly with clear latency improvements."
142+
})
143+
},
144+
{
145+
name: "messages-list",
146+
method: "GET",
147+
path: "/api/messages",
148+
expectedStatus: 200,
149+
scenario: "Conversation pane fetches project messages."
150+
},
151+
{
152+
name: "messages-create",
153+
method: "POST",
154+
path: "/api/messages",
155+
expectedStatus: 201,
156+
scenario: "Client sends project clarification to a freelancer.",
157+
json: ({ nextId }) => ({
158+
threadId: `thr_benchmark_${nextId()}`,
159+
senderId: "usr_benchmark_client",
160+
recipientId: "usr_benchmark_freelancer",
161+
body: "Please include p50, p95, p99, sustained RPS, peak RPS, error rate, and TTFB in the report."
162+
})
163+
},
164+
{
165+
name: "notifications-list",
166+
method: "GET",
167+
path: "/api/notifications",
168+
expectedStatus: 200,
169+
scenario: "User notification drawer loads recent events."
170+
},
171+
{
172+
name: "notifications-create",
173+
method: "POST",
174+
path: "/api/notifications",
175+
expectedStatus: 201,
176+
scenario: "System creates a notification for a new proposal.",
177+
json: ({ nextId }) => ({
178+
userId: "usr_benchmark_client",
179+
type: "proposal_received",
180+
title: "New benchmark proposal received",
181+
body: `Proposal ${nextId()} includes latency and throughput reporting.`
182+
})
183+
},
184+
{
185+
name: "uploads-create",
186+
method: "POST",
187+
path: "/api/uploads",
188+
expectedStatus: 201,
189+
scenario: "Freelancer uploads a small project artefact.",
190+
multipart: ({ nextId }) => ({
191+
files: [
192+
{
193+
field: "file",
194+
filename: `benchmark-report-${nextId()}.txt`,
195+
type: "text/plain",
196+
content: "Synthetic benchmark upload payload for API coverage.\n"
197+
}
198+
]
199+
})
200+
},
201+
{
202+
name: "search-global",
203+
method: "GET",
204+
path: "/api/search?q=api%20benchmark",
205+
expectedStatus: 200,
206+
scenario: "Global search looks for API benchmark related marketplace data."
207+
},
208+
{
209+
name: "admin-metrics",
210+
method: "GET",
211+
path: "/api/admin/metrics",
212+
expectedStatus: 200,
213+
auth: "admin",
214+
scenario: "Admin user loads platform metrics with a dedicated benchmark token."
215+
}
216+
];

0 commit comments

Comments
 (0)