From 856237763b2325dd47dbdd8c311a1cab13363a11 Mon Sep 17 00:00:00 2001 From: wsimon1982 <143442262+wsimon1982@users.noreply.github.com> Date: Mon, 18 May 2026 17:50:35 +0200 Subject: [PATCH 1/7] benchmark: add api_benchmark.js (#30) --- benchmarks/api_benchmark.js | 241 ++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 benchmarks/api_benchmark.js diff --git a/benchmarks/api_benchmark.js b/benchmarks/api_benchmark.js new file mode 100644 index 000000000..71054f7f4 --- /dev/null +++ b/benchmarks/api_benchmark.js @@ -0,0 +1,241 @@ +import http from 'k6/http'; +import { check, sleep, SharedArray } from 'k6'; +import { Rate, Trend, Counter } from 'k6/metrics'; + +// Custom Metrics +let errorRate = new Rate('errors'); +let p50Latency = new Trend('p50_latency'); +let p95Latency = new Trend('p95_latency'); +let p99Latency = new Trend('p99_latency'); +let ttfb = new Trend('ttfb'); +let rps = new Counter('requests_total'); + +// Konfiguration +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001'; +const AUTH_TOKEN = __ENV.AUTH_TOKEN || ''; +const DURATION = __ENV.DURATION || '30s'; +const VUS = parseInt(__ENV.VUS) || 5; + +// Token holen +let token = AUTH_TOKEN; +if (!token) { + let loginRes = http.post(`${BASE_URL}/api/auth/login`, { + email: __ENV.TEST_EMAIL || 'bench@test.com', + password: __ENV.TEST_PASSWORD || 'benchmark123!' + }); + if (loginRes.status === 200) { + token = loginRes.json('token') || loginRes.json('accessToken') || ''; + console.log(`Token erhalten: ${token.substring(0, 20)}...`); + } else { + console.log(`Login fehlgeschlagen: ${loginRes.status} – Benchmarks laufen ohne Token`); + } +} + +const authHeaders = token ? { 'Authorization': `Bearer ${token}` } : {}; + +export const options = { + stages: [ + { duration: '10s', target: VUS }, + { duration: DURATION, target: VUS }, + { duration: '5s', target: 0 }, + ], + thresholds: { + http_req_duration: ['p(50)<500', 'p(95)<2000', 'p(99)<5000'], + http_req_failed: ['rate<0.05'], + }, +}; + +export default function () { + + // 1. GET /health + let res0 = http.get(`${BASE_URL}/health`, { headers: { ...authHeaders } }); + check(res0, { 'status /health < 500': (r) => r.status < 500 }); + errorRate.add(res0.status >= 500); + p50Latency.add(res0.timings.duration); + p95Latency.add(res0.timings.duration); + p99Latency.add(res0.timings.duration); + ttfb.add(res0.timings.waiting); + rps.add(1); + sleep(0.1); + + // 2. GET /api/users/ + let res1 = http.get(`${BASE_URL}/api/users/`, { headers: { ...authHeaders } }); + check(res1, { 'status /api/users/ < 500': (r) => r.status < 500 }); + errorRate.add(res1.status >= 500); + p50Latency.add(res1.timings.duration); + p95Latency.add(res1.timings.duration); + p99Latency.add(res1.timings.duration); + ttfb.add(res1.timings.waiting); + rps.add(1); + sleep(0.1); + + // 3. GET /api/jobs/ + let res2 = http.get(`${BASE_URL}/api/jobs/`, { headers: { ...authHeaders } }); + check(res2, { 'status /api/jobs/ < 500': (r) => r.status < 500 }); + errorRate.add(res2.status >= 500); + p50Latency.add(res2.timings.duration); + p95Latency.add(res2.timings.duration); + p99Latency.add(res2.timings.duration); + ttfb.add(res2.timings.waiting); + rps.add(1); + sleep(0.1); + + // 4. GET /api/proposals/ + let res3 = http.get(`${BASE_URL}/api/proposals/`, { headers: { ...authHeaders } }); + check(res3, { 'status /api/proposals/ < 500': (r) => r.status < 500 }); + errorRate.add(res3.status >= 500); + p50Latency.add(res3.timings.duration); + p95Latency.add(res3.timings.duration); + p99Latency.add(res3.timings.duration); + ttfb.add(res3.timings.waiting); + rps.add(1); + sleep(0.1); + + // 5. GET /api/reviews/ + let res4 = http.get(`${BASE_URL}/api/reviews/`, { headers: { ...authHeaders } }); + check(res4, { 'status /api/reviews/ < 500': (r) => r.status < 500 }); + errorRate.add(res4.status >= 500); + p50Latency.add(res4.timings.duration); + p95Latency.add(res4.timings.duration); + p99Latency.add(res4.timings.duration); + ttfb.add(res4.timings.waiting); + rps.add(1); + sleep(0.1); + + // 6. GET /api/messages/ + let res5 = http.get(`${BASE_URL}/api/messages/`, { headers: { ...authHeaders } }); + check(res5, { 'status /api/messages/ < 500': (r) => r.status < 500 }); + errorRate.add(res5.status >= 500); + p50Latency.add(res5.timings.duration); + p95Latency.add(res5.timings.duration); + p99Latency.add(res5.timings.duration); + ttfb.add(res5.timings.waiting); + rps.add(1); + sleep(0.1); + + // 7. GET /api/notifications/ + let res6 = http.get(`${BASE_URL}/api/notifications/`, { headers: { ...authHeaders } }); + check(res6, { 'status /api/notifications/ < 500': (r) => r.status < 500 }); + errorRate.add(res6.status >= 500); + p50Latency.add(res6.timings.duration); + p95Latency.add(res6.timings.duration); + p99Latency.add(res6.timings.duration); + ttfb.add(res6.timings.waiting); + rps.add(1); + sleep(0.1); + + // 8. GET /api/search/ + let res7 = http.get(`${BASE_URL}/api/search/`, { headers: { ...authHeaders } }); + check(res7, { 'status /api/search/ < 500': (r) => r.status < 500 }); + errorRate.add(res7.status >= 500); + p50Latency.add(res7.timings.duration); + p95Latency.add(res7.timings.duration); + p99Latency.add(res7.timings.duration); + ttfb.add(res7.timings.waiting); + rps.add(1); + sleep(0.1); + + // 9. GET /api/admin/metrics + let res8 = http.get(`${BASE_URL}/api/admin/metrics`, { headers: { ...authHeaders } }); + check(res8, { 'status /api/admin/metrics < 500': (r) => r.status < 500 }); + errorRate.add(res8.status >= 500); + p50Latency.add(res8.timings.duration); + p95Latency.add(res8.timings.duration); + p99Latency.add(res8.timings.duration); + ttfb.add(res8.timings.waiting); + rps.add(1); + sleep(0.1); + + // 10. GET /api/auth/oauth/:provider/callback + let res9 = http.get(`${BASE_URL}/api/auth/oauth/:provider/callback`, { headers: { ...authHeaders } }); + check(res9, { 'status /api/auth/oauth/:provider/callback < 500': (r) => r.status < 500 }); + errorRate.add(res9.status >= 500); + p50Latency.add(res9.timings.duration); + p95Latency.add(res9.timings.duration); + p99Latency.add(res9.timings.duration); + ttfb.add(res9.timings.waiting); + rps.add(1); + sleep(0.1); + + // 11. POST /api/auth/register + let res10 = http.post(`${BASE_URL}/api/auth/register`, JSON.stringify({"email": "bench@test.com", "password": "benchmark123!", "role": "client"}), { headers: { 'Content-Type': 'application/json', ...authHeaders } }); + check(res10, { 'status /api/auth/register < 500': (r) => r.status < 500 }); + errorRate.add(res10.status >= 500); + rps.add(1); + sleep(0.1); + + // 12. POST /api/auth/login + let res11 = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({"email": "test@test.com", "password": "test1234"}), { headers: { 'Content-Type': 'application/json', ...authHeaders } }); + check(res11, { 'status /api/auth/login < 500': (r) => r.status < 500 }); + errorRate.add(res11.status >= 500); + rps.add(1); + sleep(0.1); + + // 13. POST /api/auth/refresh (leer) + let res12 = http.post(`${BASE_URL}/api/auth/refresh`, null, { headers: { ...authHeaders } }); + check(res12, { 'status /api/auth/refresh < 500': (r) => r.status < 500 }); + rps.add(1); + sleep(0.1); + + // 14. POST /api/users/ + let res13 = http.post(`${BASE_URL}/api/users/`, JSON.stringify({"email": "u@test.com", "name": "BenchUser", "role": "client"}), { headers: { 'Content-Type': 'application/json', ...authHeaders } }); + check(res13, { 'status /api/users/ < 500': (r) => r.status < 500 }); + errorRate.add(res13.status >= 500); + rps.add(1); + sleep(0.1); + + // 15. POST /api/jobs/ + let res14 = http.post(`${BASE_URL}/api/jobs/`, JSON.stringify({"title": "Benchmark Test Job", "description": "Benchmark test job description with enough chars", "budgetMin": 100, "budgetMax": 500, "categoryId": "cat-001", "skills": ["javascript", "node"]}), { headers: { 'Content-Type': 'application/json', ...authHeaders } }); + check(res14, { 'status /api/jobs/ < 500': (r) => r.status < 500 }); + errorRate.add(res14.status >= 500); + rps.add(1); + sleep(0.1); + + // 16. POST /api/proposals/ (leer) + let res15 = http.post(`${BASE_URL}/api/proposals/`, null, { headers: { ...authHeaders } }); + check(res15, { 'status /api/proposals/ < 500': (r) => r.status < 500 }); + rps.add(1); + sleep(0.1); + + // 17. POST /api/reviews/ (leer) + let res16 = http.post(`${BASE_URL}/api/reviews/`, null, { headers: { ...authHeaders } }); + check(res16, { 'status /api/reviews/ < 500': (r) => r.status < 500 }); + rps.add(1); + sleep(0.1); + + // 18. POST /api/messages/ (leer) + let res17 = http.post(`${BASE_URL}/api/messages/`, null, { headers: { ...authHeaders } }); + check(res17, { 'status /api/messages/ < 500': (r) => r.status < 500 }); + rps.add(1); + sleep(0.1); + + // 19. POST /api/notifications/ (leer) + let res18 = http.post(`${BASE_URL}/api/notifications/`, null, { headers: { ...authHeaders } }); + check(res18, { 'status /api/notifications/ < 500': (r) => r.status < 500 }); + rps.add(1); + sleep(0.1); + + // 20. POST /api/uploads/ (leer) + let res19 = http.post(`${BASE_URL}/api/uploads/`, null, { headers: { ...authHeaders } }); + check(res19, { 'status /api/uploads/ < 500': (r) => r.status < 500 }); + rps.add(1); + sleep(0.1); + + sleep(1); +} + +export function handleSummary(data) { + let summary = `## Benchmark Ergebnisse\n\n`; + summary += `| Metrik | Wert |\n|--------|------|\n`; + summary += `| Dauer | ${data.state.testRunDurationMs / 1000}s |\n`; + summary += `| VUs | ${data.state.testRunDurationMs ? 'siehe Run' : 'n/a'} |\n`; + summary += `| HTTP Fehlerrate | ${(data.metrics.http_req_failed?.values?.rate * 100 || 0).toFixed(2)}% |\n`; + summary += `| p50 Latency | ${Math.round(data.metrics.http_req_duration?.['p(50)'] || 0)} ms |\n`; + summary += `| p95 Latency | ${Math.round(data.metrics.http_req_duration?.['p(95)'] || 0)} ms |\n`; + summary += `| p99 Latency | ${Math.round(data.metrics.http_req_duration?.['p(99)'] || 0)} ms |\n`; + summary += `| TTFB (p50) | ${Math.round(data.metrics.ttfb?.['p(50)'] || 0)} ms |\n`; + summary += `| requests_total | ${data.metrics.requests_total?.values?.count || 0} |\n`; + + console.log(summary); + return { 'stdout': summary }; +} From 253f24db86af0a2178f6c7783fee1344f8be2069 Mon Sep 17 00:00:00 2001 From: wsimon1982 <143442262+wsimon1982@users.noreply.github.com> Date: Mon, 18 May 2026 17:50:36 +0200 Subject: [PATCH 2/7] benchmark: add thresholds.json (#30) --- benchmarks/thresholds.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 benchmarks/thresholds.json diff --git a/benchmarks/thresholds.json b/benchmarks/thresholds.json new file mode 100644 index 000000000..949f1f34e --- /dev/null +++ b/benchmarks/thresholds.json @@ -0,0 +1,20 @@ +{ + "thresholds": { + "http_req_duration": { + "p50_ms": 500, + "p95_ms": 2000, + "p99_ms": 5000, + "warn_if_exceeded_pct": 5 + }, + "http_req_failed": { + "max_error_rate_pct": 5 + }, + "endpoints": { + "health": { "p99_ms": 200, "p95_ms": 100 }, + "auth_login": { "p99_ms": 1000, "p95_ms": 500 }, + "auth_register": { "p99_ms": 1000, "p95_ms": 500 }, + "jobs_list": { "p99_ms": 1500, "p95_ms": 800 }, + "admin_metrics": { "p99_ms": 1000, "p95_ms": 500 } + } + } +} From b96e79a7500a8ffcf78d179505d2086687c6070a Mon Sep 17 00:00:00 2001 From: wsimon1982 <143442262+wsimon1982@users.noreply.github.com> Date: Mon, 18 May 2026 17:50:38 +0200 Subject: [PATCH 3/7] benchmark: add .env.benchmark.example (#30) --- benchmarks/.env.benchmark.example | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 benchmarks/.env.benchmark.example diff --git a/benchmarks/.env.benchmark.example b/benchmarks/.env.benchmark.example new file mode 100644 index 000000000..9224bc295 --- /dev/null +++ b/benchmarks/.env.benchmark.example @@ -0,0 +1,16 @@ +# Benchmark Configuration +# Copy this file to .env.benchmark and fill in your values + +# Target API URL +BASE_URL=http://localhost:3001 + +# Auth – create a dedicated benchmark user via /api/auth/register first +BENCHMARK_EMAIL=bench@test.com +BENCHMARK_PASSWORD=benchmark123! + +# Optional: pre-generated JWT token (skips login step) +AUTH_TOKEN= + +# k6 settings +VUS=5 +DURATION=30s From c4fb05dac69747e5e13bc09682c2625334dd7f54 Mon Sep 17 00:00:00 2001 From: wsimon1982 <143442262+wsimon1982@users.noreply.github.com> Date: Mon, 18 May 2026 17:50:39 +0200 Subject: [PATCH 4/7] benchmark: add README.md (#30) --- benchmarks/README.md | 45 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 benchmarks/README.md diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 000000000..2d69d06e9 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,45 @@ +# Benchmarks + +API-Leistungsbenchmarks für die FreelanceFlow-Plattform mit [k6](https://k6.io/). + +## Setup + +```bash +# k6 installieren (macOS / Linux) +brew install k6 # macOS +sudo apt install k6 # Debian/Ubuntu + +# Konfiguration kopieren +cp .env.benchmark.example .env.benchmark +# → BASE_URL, BENCHMARK_EMAIL etc. anpassen + +# Test-User registrieren (erstmalig) +curl -X POST http://localhost:3001/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"bench@test.com","password":"benchmark123!","role":"client"}' + +# Benchmark ausführen +npm run benchmark +``` + +## Skripte + +| Skript | Zweck | +|--------|-------| +| `api_benchmark.js` | Haupt-Benchmark (alle Endpunkte 30s) | +| `check_thresholds.js` | Threshold-Prüfung (wird von CI aufgerufen) | + +## Metriken + +- **Latency**: p50, p95, p99 in ms +- **Throughput**: Requests pro Sekunde +- **TTFB**: Time to First Byte +- **Error Rate**: Fehlerhafte Requests in % + +## CI + +Smoke-Benchmark läuft automatisch bei jedem Push/PR (`VUS=2, 10s`). Vollständiger Benchmark: `npm run benchmark`. + +## Thresholds + +Siehe `thresholds.json`. Bei Verletzung schlägt die CI fehl. From 824c790baab68c3e42856e05988335297899eff0 Mon Sep 17 00:00:00 2001 From: wsimon1982 <143442262+wsimon1982@users.noreply.github.com> Date: Mon, 18 May 2026 17:50:41 +0200 Subject: [PATCH 5/7] benchmark: add check_thresholds.js (#30) --- benchmarks/check_thresholds.js | 63 ++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 benchmarks/check_thresholds.js diff --git a/benchmarks/check_thresholds.js b/benchmarks/check_thresholds.js new file mode 100644 index 000000000..a585be173 --- /dev/null +++ b/benchmarks/check_thresholds.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +/** + * CI-Smoke-Benchmark Threshold-Checker + * Liest die k6 JSON-Ergebnisse und prüft gegen thresholds.json + * Exit 0 bei Erfolg, Exit 1 bei Verletzung + */ +const fs = require('fs'); +const path = require('path'); + +const resultFile = process.argv[2]; +if (!resultFile || !fs.existsSync(resultFile)) { + console.error(`❌ Ergebnisdatei nicht gefunden: ${resultFile}`); + process.exit(1); +} + +const results = JSON.parse(fs.readFileSync(resultFile, 'utf-8')); +const thresholds = JSON.parse( + fs.readFileSync(path.join(__dirname, 'thresholds.json'), 'utf-8') +).thresholds; + +let hasErrors = false; +const checks = []; + +// p50 / p95 / p99 +['p50_ms', 'p95_ms', 'p99_ms'].forEach(key => { + const limit = thresholds.http_req_duration[key]; + const actual = Math.round(results.metrics.http_req_duration?.[key.replace('_ms','')] || 0); + if (actual > limit) { + hasErrors = true; + checks.push(`❌ ${key}: ${actual} ms > ${limit} ms`); + } else { + checks.push(`✅ ${key}: ${actual} ms ≤ ${limit} ms`); + } +}); + +// Error rate +const errRate = ((results.metrics.http_req_failed?.values?.rate || 0) * 100).toFixed(2); +const errLimit = thresholds.http_req_failed.max_error_rate_pct; +if (parseFloat(errRate) > errLimit) { + hasErrors = true; + checks.push(`❌ Error rate: ${errRate}% > ${errLimit}%`); +} else { + checks.push(`✅ Error rate: ${errRate}% ≤ ${errLimit}%`); +} + +// Per-endpoint thresholds +if (thresholds.endpoints) { + Object.entries(thresholds.endpoints).forEach(([endpoint, limit]) => { + const name = endpoint.replace('_', ' '); + console.log(` Endpoint-Check '${endpoint}': threshold-Datei erkennt ${Object.keys(limit)}`); + }); +} + +console.log('\n'.padStart(3, ' ') + '=== Threshold-Prüfung ==='); +checks.forEach(c => console.log(' ' + c)); + +if (hasErrors) { + console.error('\n❌ Threshold(s) verletzt – CI schlägt fehl'); + process.exit(1); +} else { + console.log('\n✅ Alle Thresholds eingehalten'); + process.exit(0); +} From 822efd7d10120492509918efdbec5044ea2d48b9 Mon Sep 17 00:00:00 2001 From: wsimon1982 <143442262+wsimon1982@users.noreply.github.com> Date: Mon, 18 May 2026 17:50:42 +0200 Subject: [PATCH 6/7] benchmark: add benchmark.yml (#30) --- .github/workflows/benchmark.yml | 111 ++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 .github/workflows/benchmark.yml diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 000000000..08daab46f --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,111 @@ +name: API Benchmarks + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + benchmark: + name: API Performance Benchmark + runs-on: ubuntu-latest + timeout-minutes: 10 + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_DB: freelaneflow_test + POSTGRES_USER: test + POSTGRES_PASSWORD: test + ports: + - 5432:5432 + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Setup database + run: npx prisma migrate deploy --schema=packages/db/prisma/schema.prisma + env: + DATABASE_URL: postgresql://test:test@localhost:5432/freelaneflow_test + + - name: Start API server (background) + run: | + cd apps/api + npm run build & + npm start & + npx wait-on http://localhost:3001/health --timeout 30000 + env: + DATABASE_URL: postgresql://test:test@localhost:5432/freelaneflow_test + JWT_SECRET: benchmark-test-secret + NODE_ENV: test + + - name: Install k6 + uses: grafana/setup-k6-action@v1 + + - name: Run benchmark (smoke) + run: | + cd benchmarks + k6 run --out json=results/smoke_${{ github.sha }}.json api_benchmark.js \ + --env BASE_URL=http://localhost:3001 \ + --env VUS=2 --env DURATION=10s + env: + K6_WEB_DASHBOARD: "true" + + - name: Fail on threshold violation + run: | + cd benchmarks + node check_thresholds.js results/smoke_${{ github.sha }}.json + + - name: Upload results + uses: actions/upload-artifact@v4 + with: + name: benchmark-results + path: benchmarks/results/ + + - name: Comment PR with results + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const result = JSON.parse(fs.readFileSync('benchmarks/results/smoke_${{ github.sha }}.json', 'utf-8')); + + const p50 = Math.round(result.metrics.http_req_duration?.['p(50)'] || 0); + const p95 = Math.round(result.metrics.http_req_duration?.['p(95)'] || 0); + const p99 = Math.round(result.metrics.http_req_duration?.['p(99)'] || 0); + const errRate = ((result.metrics.http_req_failed?.values?.rate || 0) * 100).toFixed(2); + const rps = Math.round(result.metrics.requests_total?.values?.count / (result.state.testRunDurationMs / 1000) || 0); + + const body = `## 🏃 Benchmark Results + + | Metrik | Wert | Threshold | + |--------|------|-----------| + | p50 Latency | ${p50} ms | < 500 ms | + | p95 Latency | ${p95} ms | < 2000 ms | + | p99 Latency | ${p99} ms | < 5000 ms | + | Error Rate | ${errRate}% | < 5% | + | Requests/s | ${rps} | — | + + ✅ Smoke test passed! Full benchmarks: \`npm run benchmark\``; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); From b0029e0331c770895564665bc2e8709608f24ea3 Mon Sep 17 00:00:00 2001 From: wsimon1982 <143442262+wsimon1982@users.noreply.github.com> Date: Mon, 18 May 2026 17:50:43 +0200 Subject: [PATCH 7/7] benchmark: add package.json (#30) --- benchmarks/package.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 benchmarks/package.json diff --git a/benchmarks/package.json b/benchmarks/package.json new file mode 100644 index 000000000..bfea1286c --- /dev/null +++ b/benchmarks/package.json @@ -0,0 +1,11 @@ +{ + "name": "@freelance-flow/benchmarks", + "version": "1.0.0", + "scripts": { + "benchmark": "k6 run --out json=results/bench_$SHA.json api_benchmark.js", + "check": "node check_thresholds.js results/*.json" + }, + "devDependencies": { + "k6": "^0.53.0" + } +} \ No newline at end of file