Skip to content

Commit 1ae379f

Browse files
committed
Add API benchmark suite
1 parent b1386e5 commit 1ae379f

15 files changed

Lines changed: 4024 additions & 2 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
api-benchmark-smoke:
17+
runs-on: ubuntu-latest
18+
19+
steps:
20+
- name: Checkout
21+
uses: actions/checkout@v4
22+
23+
- name: Set up Node.js
24+
uses: actions/setup-node@v4
25+
with:
26+
node-version: 24
27+
cache: npm
28+
29+
- name: Install dependencies
30+
run: npm ci
31+
32+
- name: Verify benchmark route coverage
33+
run: npm run benchmark:verify
34+
35+
- name: Run smoke benchmark
36+
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/*.js\""
99
},
1010
"dependencies": {
1111
"cors": "^2.8.5",

benchmarks/.env.benchmark.example

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Optional: benchmark an already-running API instead of the local in-process app.
2+
# BENCHMARK_TARGET_URL=http://127.0.0.1:4000
3+
4+
# Required only when BENCHMARK_TARGET_URL points at an environment that does not
5+
# share the local development JWT secret.
6+
# BENCHMARK_AUTH_TOKEN=replace-with-dedicated-benchmark-token
7+
8+
# Defaults keep the full suite under the API rate limiter.
9+
# BENCHMARK_CONCURRENCY=2
10+
# BENCHMARK_REQUESTS_PER_ENDPOINT=8
11+
# BENCHMARK_WARMUP_REQUESTS=1
12+
13+
# CI can tune these separately for the smoke gate.
14+
# BENCHMARK_SMOKE_CONCURRENCY=1
15+
# BENCHMARK_SMOKE_REQUESTS_PER_ENDPOINT=2
16+
17+
# BENCHMARK_OUTPUT_DIR=benchmarks/results
18+
# BENCHMARK_THRESHOLDS=benchmarks/thresholds.json
19+
# BENCHMARK_FAIL_ON_THRESHOLD=true

benchmarks/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# API Benchmarks
2+
3+
This directory contains a reproducible benchmark suite for the FreelanceFlow API. It covers every mounted `/api/*` endpoint plus `/health`, records latency and throughput metrics, and writes both machine-readable and human-readable reports.
4+
5+
## Commands
6+
7+
```bash
8+
npm run benchmark
9+
npm run benchmark:smoke
10+
npm run benchmark:verify
11+
```
12+
13+
`npm run benchmark` starts the local Express app in-process unless `BENCHMARK_TARGET_URL` is set. The smoke command uses lower request counts and is intended for CI.
14+
15+
## Configuration
16+
17+
Copy `benchmarks/.env.benchmark.example` to your shell or CI environment and set only the values you need:
18+
19+
- `BENCHMARK_TARGET_URL`: optional external API base URL.
20+
- `BENCHMARK_AUTH_TOKEN`: dedicated benchmark JWT for protected routes when targeting staging.
21+
- `BENCHMARK_CONCURRENCY` / `BENCHMARK_REQUESTS_PER_ENDPOINT`: full-suite load settings.
22+
- `BENCHMARK_SMOKE_CONCURRENCY` / `BENCHMARK_SMOKE_REQUESTS_PER_ENDPOINT`: CI smoke settings.
23+
- `BENCHMARK_THRESHOLDS`: path to reviewable threshold JSON.
24+
25+
## Output
26+
27+
Each run writes JSON and Markdown to `benchmarks/results/`. The Markdown report is suitable for pasting into a PR description, while the JSON output preserves raw per-request samples for later regression analysis.

benchmarks/endpoints.mjs

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
function uniqueEmail(prefix, index) {
2+
return `${prefix}.${process.pid}.${Date.now()}.${index}@benchmark.local`;
3+
}
4+
5+
function jsonBody(factory) {
6+
return ({ index }) => ({
7+
headers: { "content-type": "application/json" },
8+
body: JSON.stringify(factory(index))
9+
});
10+
}
11+
12+
function uploadBody({ index }) {
13+
const form = new FormData();
14+
const content = [
15+
"FreelanceFlow benchmark upload",
16+
`sample=${index}`,
17+
"scope=api-smoke"
18+
].join("\n");
19+
20+
form.set(
21+
"file",
22+
new Blob([content], { type: "text/plain" }),
23+
`benchmark-${index}.txt`
24+
);
25+
26+
return { body: form };
27+
}
28+
29+
export const benchmarkEndpoints = [
30+
{
31+
id: "health",
32+
name: "Health check",
33+
method: "GET",
34+
path: "/health",
35+
routePath: "/health",
36+
expectedStatuses: [200],
37+
payloadProfile: "small health response"
38+
},
39+
{
40+
id: "auth-register",
41+
name: "Register user",
42+
method: "POST",
43+
path: "/api/auth/register",
44+
expectedStatuses: [201],
45+
payloadProfile: "client signup payload",
46+
buildRequest: jsonBody((index) => ({
47+
email: uniqueEmail("client", index),
48+
password: "benchmark-pass-123",
49+
role: "client"
50+
}))
51+
},
52+
{
53+
id: "auth-login",
54+
name: "Login user",
55+
method: "POST",
56+
path: "/api/auth/login",
57+
expectedStatuses: [200],
58+
payloadProfile: "existing user credential payload",
59+
buildRequest: jsonBody(() => ({
60+
email: "existing.client@benchmark.local",
61+
password: "benchmark-pass-123"
62+
}))
63+
},
64+
{
65+
id: "auth-oauth-callback",
66+
name: "OAuth callback",
67+
method: "GET",
68+
path: "/api/auth/oauth/github/callback?code=benchmark-code&state=benchmark-state",
69+
routePath: "/api/auth/oauth/:provider/callback",
70+
expectedStatuses: [200],
71+
payloadProfile: "provider callback query"
72+
},
73+
{
74+
id: "auth-refresh",
75+
name: "Refresh token",
76+
method: "POST",
77+
path: "/api/auth/refresh",
78+
expectedStatuses: [200],
79+
payloadProfile: "empty refresh request"
80+
},
81+
{
82+
id: "users-list",
83+
name: "List users",
84+
method: "GET",
85+
path: "/api/users",
86+
routePath: "/api/users/",
87+
expectedStatuses: [200],
88+
payloadProfile: "collection read"
89+
},
90+
{
91+
id: "users-create",
92+
name: "Create user",
93+
method: "POST",
94+
path: "/api/users",
95+
routePath: "/api/users/",
96+
expectedStatuses: [201],
97+
payloadProfile: "freelancer profile payload",
98+
buildRequest: jsonBody((index) => ({
99+
email: uniqueEmail("freelancer", index),
100+
displayName: `Benchmark Freelancer ${index}`,
101+
role: "freelancer",
102+
skills: ["node", "react", "api-testing"]
103+
}))
104+
},
105+
{
106+
id: "jobs-list",
107+
name: "List jobs",
108+
method: "GET",
109+
path: "/api/jobs",
110+
routePath: "/api/jobs/",
111+
expectedStatuses: [200],
112+
payloadProfile: "collection read"
113+
},
114+
{
115+
id: "jobs-create",
116+
name: "Create job",
117+
method: "POST",
118+
path: "/api/jobs",
119+
routePath: "/api/jobs/",
120+
expectedStatuses: [201],
121+
payloadProfile: "production-sized job brief",
122+
buildRequest: jsonBody((index) => ({
123+
title: `Build benchmark dashboard ${index}`,
124+
description: "Create a dashboard that tracks marketplace API latency, proposal volume, and conversion signals for client teams.",
125+
budgetMin: 1500,
126+
budgetMax: 3500,
127+
categoryId: "analytics",
128+
skills: ["node", "typescript", "postgres", "observability"]
129+
}))
130+
},
131+
{
132+
id: "proposals-list",
133+
name: "List proposals",
134+
method: "GET",
135+
path: "/api/proposals",
136+
routePath: "/api/proposals/",
137+
expectedStatuses: [200],
138+
payloadProfile: "collection read"
139+
},
140+
{
141+
id: "proposals-create",
142+
name: "Create proposal",
143+
method: "POST",
144+
path: "/api/proposals",
145+
routePath: "/api/proposals/",
146+
expectedStatuses: [201],
147+
payloadProfile: "proposal submission payload",
148+
buildRequest: jsonBody((index) => ({
149+
jobId: `job_benchmark_${index}`,
150+
freelancerId: `usr_freelancer_${index}`,
151+
bidAmount: 2400,
152+
estimatedDays: 14,
153+
coverLetter: "I can deliver the API benchmark dashboard with documented thresholds, CI smoke coverage, and release notes."
154+
}))
155+
},
156+
{
157+
id: "payments-create",
158+
name: "Create payment intent",
159+
method: "POST",
160+
path: "/api/payments",
161+
routePath: "/api/payments/",
162+
expectedStatuses: [201],
163+
payloadProfile: "Stripe-style payment intent payload",
164+
buildRequest: jsonBody((index) => ({
165+
proposalId: `prp_benchmark_${index}`,
166+
amount: 240000,
167+
currency: "usd",
168+
metadata: {
169+
clientId: "usr_client_benchmark",
170+
jobId: `job_benchmark_${index}`
171+
}
172+
}))
173+
},
174+
{
175+
id: "reviews-list",
176+
name: "List reviews",
177+
method: "GET",
178+
path: "/api/reviews",
179+
routePath: "/api/reviews/",
180+
expectedStatuses: [200],
181+
payloadProfile: "collection read"
182+
},
183+
{
184+
id: "reviews-create",
185+
name: "Create review",
186+
method: "POST",
187+
path: "/api/reviews",
188+
routePath: "/api/reviews/",
189+
expectedStatuses: [201],
190+
payloadProfile: "completed contract review",
191+
buildRequest: jsonBody((index) => ({
192+
contractId: `contract_benchmark_${index}`,
193+
reviewerId: "usr_client_benchmark",
194+
revieweeId: "usr_freelancer_benchmark",
195+
rating: 5,
196+
comment: "Delivered the API benchmark suite with clear regression gates and reusable documentation."
197+
}))
198+
},
199+
{
200+
id: "messages-list",
201+
name: "List messages",
202+
method: "GET",
203+
path: "/api/messages",
204+
routePath: "/api/messages/",
205+
expectedStatuses: [200],
206+
payloadProfile: "collection read"
207+
},
208+
{
209+
id: "messages-create",
210+
name: "Send message",
211+
method: "POST",
212+
path: "/api/messages",
213+
routePath: "/api/messages/",
214+
expectedStatuses: [201],
215+
payloadProfile: "client/freelancer message",
216+
buildRequest: jsonBody((index) => ({
217+
threadId: `thread_benchmark_${index}`,
218+
senderId: "usr_client_benchmark",
219+
recipientId: "usr_freelancer_benchmark",
220+
body: "Can you share the benchmark report and the p99 threshold settings before handoff?"
221+
}))
222+
},
223+
{
224+
id: "notifications-list",
225+
name: "List notifications",
226+
method: "GET",
227+
path: "/api/notifications",
228+
routePath: "/api/notifications/",
229+
expectedStatuses: [200],
230+
payloadProfile: "collection read"
231+
},
232+
{
233+
id: "notifications-create",
234+
name: "Create notification",
235+
method: "POST",
236+
path: "/api/notifications",
237+
routePath: "/api/notifications/",
238+
expectedStatuses: [201],
239+
payloadProfile: "notification payload",
240+
buildRequest: jsonBody((index) => ({
241+
userId: "usr_client_benchmark",
242+
type: "proposal_submitted",
243+
message: `Benchmark proposal ${index} is ready for review.`
244+
}))
245+
},
246+
{
247+
id: "uploads-create",
248+
name: "Upload file",
249+
method: "POST",
250+
path: "/api/uploads",
251+
routePath: "/api/uploads/",
252+
expectedStatuses: [201],
253+
payloadProfile: "multipart text attachment",
254+
buildRequest: uploadBody
255+
},
256+
{
257+
id: "search",
258+
name: "Search marketplace",
259+
method: "GET",
260+
path: "/api/search?q=react%20api%20benchmark",
261+
routePath: "/api/search/",
262+
expectedStatuses: [200],
263+
payloadProfile: "search query"
264+
},
265+
{
266+
id: "admin-metrics",
267+
name: "Admin metrics",
268+
method: "GET",
269+
path: "/api/admin/metrics",
270+
expectedStatuses: [200],
271+
auth: "benchmark-admin",
272+
payloadProfile: "protected metrics read"
273+
}
274+
];
275+
276+
export function endpointRouteKey(endpoint) {
277+
return `${endpoint.method.toUpperCase()} ${endpoint.routePath ?? endpoint.path.split("?")[0]}`;
278+
}

0 commit comments

Comments
 (0)