From fef7e7fc7ce31afc7c11c3c08d7725ea34d91d30 Mon Sep 17 00:00:00 2001 From: senke Date: Sun, 15 Feb 2026 15:22:48 +0100 Subject: [PATCH] =?UTF-8?q?feat(loadtests):=20audit=203.2=20=E2=80=94=20te?= =?UTF-8?q?sts=20de=20charge=20k6=20complets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loadtests: centraliser scripts (backend, stream, chat) - backend: health, auth, tracks, uploads, playlists, marketplace - stream: http health, healthz, readyz - chat: WebSocket load (register -> login -> chat token -> WS) - ci: workflow nightly load-test-nightly.yml - docs: README loadtests - make: load-test-smoke, load-test-backend, load-test-all - fix: veza-backend-api Makefile load-test (scripts/load_test_uploads.js -> loadtests) --- .github/workflows/load-test-nightly.yml | 81 +++++++++++ AUDIT_TECHNIQUE_INTEGRAL_2026_02_15.md | 4 +- loadtests/README.md | 102 +++++++++++++ loadtests/backend/auth.js | 165 +++++++++++++++++++++ loadtests/backend/full.js | 154 ++++++++++++++++++++ loadtests/backend/health.js | 65 +++++++++ loadtests/backend/marketplace.js | 89 ++++++++++++ loadtests/backend/playlists.js | 109 ++++++++++++++ loadtests/backend/tracks.js | 66 +++++++++ loadtests/backend/uploads.js | 183 ++++++++++++++++++++++++ loadtests/chat/websocket.js | 150 +++++++++++++++++++ loadtests/config.js | 15 ++ loadtests/smoke.js | 42 ++++++ loadtests/stream/http.js | 52 +++++++ make/test.mk | 13 ++ veza-backend-api/Makefile | 10 +- 16 files changed, 1295 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/load-test-nightly.yml create mode 100644 loadtests/README.md create mode 100644 loadtests/backend/auth.js create mode 100644 loadtests/backend/full.js create mode 100644 loadtests/backend/health.js create mode 100644 loadtests/backend/marketplace.js create mode 100644 loadtests/backend/playlists.js create mode 100644 loadtests/backend/tracks.js create mode 100644 loadtests/backend/uploads.js create mode 100644 loadtests/chat/websocket.js create mode 100644 loadtests/config.js create mode 100644 loadtests/smoke.js create mode 100644 loadtests/stream/http.js diff --git a/.github/workflows/load-test-nightly.yml b/.github/workflows/load-test-nightly.yml new file mode 100644 index 000000000..6784c8861 --- /dev/null +++ b/.github/workflows/load-test-nightly.yml @@ -0,0 +1,81 @@ +name: Load Tests (Nightly) + +on: + schedule: + - cron: '0 2 * * *' + workflow_dispatch: + +jobs: + load-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install k6 + run: | + sudo gpg -k + sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 + echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list + sudo apt-get update && sudo apt-get install -y k6 + + - name: Start infrastructure + run: | + docker-compose -f docker-compose.yml up -d postgres redis rabbitmq + sleep 15 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.23" + cache: true + + - name: Run migrations + working-directory: veza-backend-api + env: + DATABASE_URL: postgresql://veza:devpassword@localhost:15432/veza?sslmode=disable + REDIS_URL: redis://localhost:16379 + JWT_SECRET: test-jwt-secret-for-load-test + APP_ENV: test + run: | + go mod download + go run cmd/migrate_tool/main.go || true + + - name: Start backend API + working-directory: veza-backend-api + env: + DATABASE_URL: postgresql://veza:devpassword@localhost:15432/veza?sslmode=disable + REDIS_URL: redis://localhost:16379 + RABBITMQ_URL: amqp://veza:devpassword@localhost:15672/ + JWT_SECRET: test-jwt-secret-for-load-test + APP_ENV: test + PORT: 8080 + run: | + go run cmd/api/main.go & + sleep 15 + + - name: Wait for backend + run: | + for i in 1 2 3 4 5 6 7 8 9 10; do + if curl -sf http://localhost:8080/health; then + echo "Backend ready" + exit 0 + fi + sleep 3 + done + echo "Backend not ready" + exit 1 + + - name: Run smoke load test + run: k6 run loadtests/smoke.js + + - name: Run backend load test + run: | + k6 run --out json=load-results.json loadtests/backend/full.js || true + continue-on-error: true + + - name: Upload results + uses: actions/upload-artifact@v4 + with: + name: load-test-results + path: load-results.json + if: always() diff --git a/AUDIT_TECHNIQUE_INTEGRAL_2026_02_15.md b/AUDIT_TECHNIQUE_INTEGRAL_2026_02_15.md index 47208c566..87dc1a140 100644 --- a/AUDIT_TECHNIQUE_INTEGRAL_2026_02_15.md +++ b/AUDIT_TECHNIQUE_INTEGRAL_2026_02_15.md @@ -677,8 +677,8 @@ veza/ | # | Action | Effort | Détail | |---|--------|--------|--------| -| 3.1 | Implémenter circuit breaker entre services | **L** | Résilience inter-services | -| 3.2 | Ajouter tests de charge | **L** | k6 ou Locust | +| 3.1 | ~~Implémenter circuit breaker entre services~~ | **L** | **✅ Fait** — WebhookService, Hyperswitch (Backend Go) | +| 3.2 | ~~Ajouter tests de charge~~ | **L** | **✅ Fait** — k6 (backend, stream, chat, marketplace, playlists), CI nightly | | 3.3 | Créer une documentation d'architecture (C4, ADR) | **M** | Pour onboarding et due diligence | | 3.4 | Ajouter un `GETTING_STARTED.md` clair | **S** | Guide d'onboarding développeur | | 3.5 | Mettre en place database sharding strategy | **XL** | Si > 100K utilisateurs | diff --git a/loadtests/README.md b/loadtests/README.md new file mode 100644 index 000000000..0a0c4fd02 --- /dev/null +++ b/loadtests/README.md @@ -0,0 +1,102 @@ +# Load Tests (k6) + +Tests de charge pour la plateforme Veza. Audit 3.2. + +## Prérequis + +- [k6](https://k6.io/docs/getting-started/installation/) installé (`brew install k6` ou `sudo apt install k6`) + +## Variables d'environnement + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `BASE_URL` | `http://localhost:8080` | URL de l'API backend | +| `API_ORIGIN` | idem | Alias pour BASE_URL | +| `STREAM_ORIGIN` | `http://localhost:8082` | URL du stream server | +| `CHAT_ORIGIN` | `ws://localhost:8081` | URL WebSocket du chat server | +| `AUTH_TOKEN` | (vide) | JWT pour tests upload (optionnel) | + +## Exécution locale + +### Smoke test (validation rapide, ~30s) + +```bash +k6 run loadtests/smoke.js +``` + +### Backend API + +```bash +# Health, readyz, status +k6 run loadtests/backend/health.js + +# Auth (register, login, /me, refresh) +k6 run loadtests/backend/auth.js + +# Tracks (liste, search, détail) +k6 run loadtests/backend/tracks.js + +# Uploads (requiert AUTH_TOKEN) +AUTH_TOKEN=xxx k6 run loadtests/backend/uploads.js + +# Playlists +k6 run loadtests/backend/playlists.js + +# Marketplace +k6 run loadtests/backend/marketplace.js + +# Scénario combiné (health, auth, tracks, playlists, marketplace) +k6 run loadtests/backend/full.js +``` + +### Stream server + +```bash +# Health, healthz, readyz (stream server doit tourner) +k6 run loadtests/stream/http.js +``` + +### Chat WebSocket + +```bash +# Backend API + Chat server requis +k6 run loadtests/chat/websocket.js +``` + +## Docker + +```bash +docker run --rm -i --network=host \ + -v $(pwd)/loadtests:/scripts \ + -e BASE_URL=http://localhost:8080 \ + grafana/k6 run /scripts/smoke.js +``` + +## CI + +Le workflow `.github/workflows/load-test-nightly.yml` s'exécute : +- **Schedule** : 2h UTC chaque nuit +- **Manual** : `workflow_dispatch` + +Il lance smoke + backend full, puis uploade les résultats en artifact. + +## Structure + +``` +loadtests/ +├── README.md +├── config.js +├── smoke.js +├── backend/ +│ ├── health.js +│ ├── auth.js +│ ├── tracks.js +│ ├── uploads.js +│ ├── playlists.js +│ ├── marketplace.js +│ └── full.js +├── stream/ +│ └── http.js +└── chat/ + └── websocket.js +``` diff --git a/loadtests/backend/auth.js b/loadtests/backend/auth.js new file mode 100644 index 000000000..9ebde1e26 --- /dev/null +++ b/loadtests/backend/auth.js @@ -0,0 +1,165 @@ +/** + * Load test: register, login, /me, refresh + * Chemins corrigés: /api/v1/auth/* + * Usage: k6 run loadtests/backend/auth.js + */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; +import { randomString } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; + +const API_ORIGIN = __ENV.API_ORIGIN || __ENV.BASE_URL || 'http://localhost:8080'; +const TEST_EMAIL_PREFIX = __ENV.TEST_EMAIL_PREFIX || 'user+load'; +const TEST_EMAIL_DOMAIN = __ENV.TEST_EMAIL_DOMAIN || 'example.com'; +const TEST_PASSWORD_PREFIX = __ENV.TEST_PASSWORD_PREFIX || 'V3za!load-'; + +const loginDuration = new Trend('login_duration'); +const loginFailureRate = new Rate('login_failures'); +const registerDuration = new Trend('register_duration'); +const registerFailureRate = new Rate('register_failures'); + +export const options = { + scenarios: { + auth_flow: { + executor: 'ramping-vus', + stages: [ + { duration: '30s', target: 5 }, + { duration: '1m', target: 10 }, + { duration: '30s', target: 0 }, + ], + gracefulRampDown: '30s', + }, + }, + thresholds: { + http_req_duration: ['p(95)<300', 'p(99)<500'], + login_duration: ['p(95)<300', 'p(99)<500'], + register_duration: ['p(95)<800', 'p(99)<1000'], + login_failures: ['rate<0.1'], + register_failures: ['rate<0.1'], + http_req_failed: ['rate<0.1'], + }, +}; + +function generateTestUser() { + const rand = randomString(8); + const pwd = `${TEST_PASSWORD_PREFIX}${rand}`; + return { + email: `${TEST_EMAIL_PREFIX}${rand}@${TEST_EMAIL_DOMAIN}`, + password: pwd, + password_confirmation: pwd, + username: `user${rand}`, + }; +} + +export function setup() { + const users = []; + const baseURL = `${API_ORIGIN}/api/v1/auth`; + for (let i = 0; i < 20; i++) { + const user = generateTestUser(); + const registerRes = http.post(`${baseURL}/register`, JSON.stringify(user), { + headers: { 'Content-Type': 'application/json' }, + }); + if (registerRes.status === 201) { + users.push(user); + } + } + return { users }; +} + +export default function (data) { + const { users } = data; + if (users.length === 0) return; + const user = users[Math.floor(Math.random() * users.length)]; + const baseURL = `${API_ORIGIN}/api/v1/auth`; + + const loginPayload = JSON.stringify({ + email: user.email, + password: user.password, + }); + + const loginStart = Date.now(); + const loginRes = http.post(`${baseURL}/login`, loginPayload, { + headers: { 'Content-Type': 'application/json' }, + }); + loginDuration.add(Date.now() - loginStart); + loginFailureRate.add(loginRes.status !== 200); + + check(loginRes, { + 'login status is 200': (r) => r.status === 200, + 'login has access_token': (r) => { + try { + const body = JSON.parse(r.body); + return body.success && body.data?.token?.access_token; + } catch { + return false; + } + }, + }); + + let accessToken = ''; + if (loginRes.status === 200) { + try { + const body = JSON.parse(loginRes.body); + accessToken = body.data?.token?.access_token || ''; + } catch (e) {} + } + + sleep(1); + + if (accessToken) { + const meRes = http.get(`${baseURL}/me`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + check(meRes, { + 'profile status is 200': (r) => r.status === 200, + 'profile has user data': (r) => { + try { + const body = JSON.parse(r.body); + return body.success && body.data?.id && body.data?.email; + } catch { + return false; + } + }, + }); + } + + sleep(1); + + if (Math.random() < 0.1) { + const invalidRes = http.post( + `${baseURL}/login`, + JSON.stringify({ email: user.email, password: 'WrongPassword123!' }), + { headers: { 'Content-Type': 'application/json' } } + ); + check(invalidRes, { 'invalid login returns 401': (r) => r.status === 401 }); + } + + sleep(1); + + if (loginRes.status === 200) { + try { + const body = JSON.parse(loginRes.body); + const refreshToken = body.data?.token?.refresh_token; + if (refreshToken) { + const refreshRes = http.post( + `${baseURL}/refresh`, + JSON.stringify({ refresh_token: refreshToken }), + { headers: { 'Content-Type': 'application/json' } } + ); + check(refreshRes, { + 'refresh status is 200': (r) => r.status === 200, + 'refresh has new tokens': (r) => { + try { + const rb = JSON.parse(r.body); + return rb.success && rb.data?.access_token; + } catch { + return false; + } + }, + }); + } + } catch (e) {} + } + + sleep(2); +} diff --git a/loadtests/backend/full.js b/loadtests/backend/full.js new file mode 100644 index 000000000..6e2477dea --- /dev/null +++ b/loadtests/backend/full.js @@ -0,0 +1,154 @@ +/** + * Scénario combiné: health, auth, tracks, playlists, marketplace + * Usage: k6 run loadtests/backend/full.js + */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { randomString } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; + +const BASE_URL = __ENV.BASE_URL || __ENV.API_ORIGIN || 'http://localhost:8080'; +const TEST_EMAIL_PREFIX = __ENV.TEST_EMAIL_PREFIX || 'user+full'; +const TEST_EMAIL_DOMAIN = __ENV.TEST_EMAIL_DOMAIN || 'example.com'; +const TEST_PASSWORD_PREFIX = __ENV.TEST_PASSWORD_PREFIX || 'V3za!full-'; + +function generateTestUser() { + const rand = randomString(8); + const pwd = `${TEST_PASSWORD_PREFIX}${rand}`; + return { + email: `${TEST_EMAIL_PREFIX}${rand}@${TEST_EMAIL_DOMAIN}`, + password: pwd, + password_confirmation: pwd, + username: `fu${rand}`, + }; +} + +export const options = { + scenarios: { + health: { + executor: 'constant-vus', + vus: 5, + duration: '1m', + startTime: '0s', + exec: 'healthScenario', + }, + auth: { + executor: 'ramping-vus', + stages: [ + { duration: '30s', target: 10 }, + { duration: '1m', target: 10 }, + { duration: '30s', target: 0 }, + ], + startTime: '10s', + exec: 'authScenario', + }, + tracks: { + executor: 'ramping-vus', + stages: [ + { duration: '30s', target: 15 }, + { duration: '2m', target: 15 }, + { duration: '30s', target: 0 }, + ], + startTime: '20s', + exec: 'tracksScenario', + }, + playlists: { + executor: 'ramping-vus', + stages: [ + { duration: '30s', target: 5 }, + { duration: '1m', target: 5 }, + { duration: '30s', target: 0 }, + ], + startTime: '30s', + exec: 'playlistsScenario', + }, + marketplace: { + executor: 'ramping-vus', + stages: [ + { duration: '30s', target: 5 }, + { duration: '1m', target: 5 }, + { duration: '30s', target: 0 }, + ], + startTime: '40s', + exec: 'marketplaceScenario', + }, + }, + thresholds: { + http_req_duration: ['p(95)<1000', 'p(99)<2000'], + http_req_failed: ['rate<0.15'], + }, +}; + +export function setup() { + const users = []; + const baseURL = `${BASE_URL}/api/v1/auth`; + for (let i = 0; i < 25; i++) { + const user = generateTestUser(); + const registerRes = http.post(`${baseURL}/register`, JSON.stringify(user), { + headers: { 'Content-Type': 'application/json' }, + }); + if (registerRes.status === 201) { + const loginRes = http.post(`${baseURL}/login`, JSON.stringify({ email: user.email, password: user.password }), { + headers: { 'Content-Type': 'application/json' }, + }); + if (loginRes.status === 200) { + try { + const body = JSON.parse(loginRes.body); + const token = body.data?.token?.access_token; + if (token) users.push({ ...user, token }); + } catch (e) {} + } + } + } + return { users }; +} + +export function healthScenario() { + const healthRes = http.get(`${BASE_URL}/health`); + check(healthRes, { 'health 200': (r) => r.status === 200 }); + sleep(0.5); + const readyzRes = http.get(`${BASE_URL}/readyz`); + check(readyzRes, { 'readyz 200': (r) => r.status === 200 }); + sleep(1); +} + +export function authScenario(data) { + const { users } = data; + if (users.length === 0) return; + const user = users[Math.floor(Math.random() * users.length)]; + const loginRes = http.post( + `${BASE_URL}/api/v1/auth/login`, + JSON.stringify({ email: user.email, password: user.password }), + { headers: { 'Content-Type': 'application/json' } } + ); + check(loginRes, { 'login 200': (r) => r.status === 200 }); + sleep(1); +} + +export function tracksScenario(data) { + const { users } = data; + const headers = users.length > 0 + ? { Authorization: `Bearer ${users[Math.floor(Math.random() * users.length)].token}` } + : {}; + const tracksRes = http.get(`${BASE_URL}/api/v1/tracks`, { headers }); + check(tracksRes, { 'tracks 200 or 401': (r) => r.status === 200 || r.status === 401 }); + sleep(0.5); + const searchRes = http.get(`${BASE_URL}/api/v1/tracks/search?q=test`, { headers }); + check(searchRes, { 'search 200 or 401': (r) => r.status === 200 || r.status === 401 }); + sleep(1); +} + +export function playlistsScenario(data) { + const { users } = data; + if (users.length === 0) return; + const user = users[Math.floor(Math.random() * users.length)]; + const headers = { Authorization: `Bearer ${user.token}` }; + const listRes = http.get(`${BASE_URL}/api/v1/playlists`, { headers }); + check(listRes, { 'playlists 200': (r) => r.status === 200 }); + sleep(1); +} + +export function marketplaceScenario() { + const productsRes = http.get(`${BASE_URL}/api/v1/marketplace/products`); + check(productsRes, { 'products 200': (r) => r.status === 200 }); + sleep(1); +} diff --git a/loadtests/backend/health.js b/loadtests/backend/health.js new file mode 100644 index 000000000..06036eea9 --- /dev/null +++ b/loadtests/backend/health.js @@ -0,0 +1,65 @@ +/** + * Load test: health, readyz, status + * Usage: k6 run loadtests/backend/health.js + */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; + +const errorRate = new Rate('errors'); +const healthCheckDuration = new Trend('health_check_duration'); +const readyzCheckDuration = new Trend('readyz_check_duration'); + +const BASE_URL = __ENV.BASE_URL || __ENV.API_ORIGIN || 'http://localhost:8080'; + +export const options = { + stages: [ + { duration: '30s', target: 10 }, + { duration: '1m', target: 10 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], + errors: ['rate<0.05'], + health_check_duration: ['p(95)<100'], + readyz_check_duration: ['p(95)<200'], + }, +}; + +export default function () { + const healthRes = http.get(`${BASE_URL}/health`); + const healthCheck = check(healthRes, { + 'health status is 200': (r) => r.status === 200, + 'health response has status': (r) => { + try { + const body = JSON.parse(r.body); + return body.success === true && body.data && body.data.status; + } catch (e) { + return false; + } + }, + }); + errorRate.add(!healthCheck); + healthCheckDuration.add(healthRes.timings.duration); + sleep(0.5); + + const readyzRes = http.get(`${BASE_URL}/readyz`); + const readyzCheck = check(readyzRes, { + 'readyz status is 200': (r) => r.status === 200, + 'readyz response has status': (r) => { + try { + const body = JSON.parse(r.body); + return body.success === true && body.data && body.data.status; + } catch (e) { + return false; + } + }, + }); + errorRate.add(!readyzCheck); + readyzCheckDuration.add(readyzRes.timings.duration); + sleep(0.5); + + const statusRes = http.get(`${BASE_URL}/api/v1/status`); + check(statusRes, { 'status returns 200 or 503': (r) => r.status === 200 || r.status === 503 }); + sleep(1); +} diff --git a/loadtests/backend/marketplace.js b/loadtests/backend/marketplace.js new file mode 100644 index 000000000..b2dc0ad96 --- /dev/null +++ b/loadtests/backend/marketplace.js @@ -0,0 +1,89 @@ +/** + * Load test: GET marketplace/products, GET marketplace/orders + * Usage: k6 run loadtests/backend/marketplace.js + */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate } from 'k6/metrics'; +import { randomString } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; + +const errorRate = new Rate('errors'); +const API_ORIGIN = __ENV.API_ORIGIN || __ENV.BASE_URL || 'http://localhost:8080'; +const TEST_EMAIL_PREFIX = __ENV.TEST_EMAIL_PREFIX || 'user+market'; +const TEST_EMAIL_DOMAIN = __ENV.TEST_EMAIL_DOMAIN || 'example.com'; +const TEST_PASSWORD_PREFIX = __ENV.TEST_PASSWORD_PREFIX || 'V3za!market-'; + +function generateTestUser() { + const rand = randomString(8); + const pwd = `${TEST_PASSWORD_PREFIX}${rand}`; + return { + email: `${TEST_EMAIL_PREFIX}${rand}@${TEST_EMAIL_DOMAIN}`, + password: pwd, + password_confirmation: pwd, + username: `mk${rand}`, + }; +} + +export const options = { + scenarios: { + marketplace: { + executor: 'ramping-vus', + stages: [ + { duration: '30s', target: 5 }, + { duration: '1m', target: 5 }, + { duration: '30s', target: 0 }, + ], + }, + }, + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], + errors: ['rate<0.1'], + }, +}; + +export function setup() { + const users = []; + const baseURL = `${API_ORIGIN}/api/v1/auth`; + for (let i = 0; i < 10; i++) { + const user = generateTestUser(); + const registerRes = http.post(`${baseURL}/register`, JSON.stringify(user), { + headers: { 'Content-Type': 'application/json' }, + }); + if (registerRes.status === 201) { + const loginRes = http.post(`${baseURL}/login`, JSON.stringify({ email: user.email, password: user.password }), { + headers: { 'Content-Type': 'application/json' }, + }); + if (loginRes.status === 200) { + try { + const body = JSON.parse(loginRes.body); + const token = body.data?.token?.access_token; + if (token) users.push({ ...user, token }); + } catch (e) {} + } + } + } + return { users }; +} + +export default function (data) { + const { users } = data; + const baseURL = `${API_ORIGIN}/api/v1/marketplace`; + + const productsRes = http.get(`${baseURL}/products`); + const productsOk = check(productsRes, { + 'products returns 200': (r) => r.status === 200, + }); + errorRate.add(!productsOk); + sleep(0.5); + + if (users.length > 0 && Math.random() < 0.5) { + const user = users[Math.floor(Math.random() * users.length)]; + const ordersRes = http.get(`${baseURL}/orders`, { + headers: { Authorization: `Bearer ${user.token}` }, + }); + check(ordersRes, { + 'orders returns 200 or 401': (r) => r.status === 200 || r.status === 401, + }); + } + sleep(1); +} diff --git a/loadtests/backend/playlists.js b/loadtests/backend/playlists.js new file mode 100644 index 000000000..3e6e5c731 --- /dev/null +++ b/loadtests/backend/playlists.js @@ -0,0 +1,109 @@ +/** + * Load test: GET playlists, GET playlists/:id, POST playlists, search + * Usage: k6 run loadtests/backend/playlists.js + * Requires: setup creates auth users + */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate } from 'k6/metrics'; +import { randomString } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; + +const errorRate = new Rate('errors'); +const API_ORIGIN = __ENV.API_ORIGIN || __ENV.BASE_URL || 'http://localhost:8080'; +const TEST_EMAIL_PREFIX = __ENV.TEST_EMAIL_PREFIX || 'user+playlist'; +const TEST_EMAIL_DOMAIN = __ENV.TEST_EMAIL_DOMAIN || 'example.com'; +const TEST_PASSWORD_PREFIX = __ENV.TEST_PASSWORD_PREFIX || 'V3za!playlist-'; + +function generateTestUser() { + const rand = randomString(8); + const pwd = `${TEST_PASSWORD_PREFIX}${rand}`; + return { + email: `${TEST_EMAIL_PREFIX}${rand}@${TEST_EMAIL_DOMAIN}`, + password: pwd, + password_confirmation: pwd, + username: `pl${rand}`, + }; +} + +export const options = { + scenarios: { + playlists: { + executor: 'ramping-vus', + stages: [ + { duration: '30s', target: 5 }, + { duration: '1m', target: 5 }, + { duration: '30s', target: 0 }, + ], + }, + }, + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], + errors: ['rate<0.1'], + }, +}; + +export function setup() { + const users = []; + const baseURL = `${API_ORIGIN}/api/v1/auth`; + for (let i = 0; i < 15; i++) { + const user = generateTestUser(); + const registerRes = http.post(`${baseURL}/register`, JSON.stringify(user), { + headers: { 'Content-Type': 'application/json' }, + }); + if (registerRes.status === 201) { + const loginRes = http.post(`${baseURL}/login`, JSON.stringify({ email: user.email, password: user.password }), { + headers: { 'Content-Type': 'application/json' }, + }); + if (loginRes.status === 200) { + try { + const body = JSON.parse(loginRes.body); + const token = body.data?.token?.access_token; + if (token) users.push({ ...user, token }); + } catch (e) {} + } + } + } + return { users }; +} + +export default function (data) { + const { users } = data; + if (users.length === 0) return; + const user = users[Math.floor(Math.random() * users.length)]; + const headers = { Authorization: `Bearer ${user.token}` }; + + const listRes = http.get(`${API_ORIGIN}/api/v1/playlists`, { headers }); + const listOk = check(listRes, { + 'playlists list returns 200': (r) => r.status === 200, + }); + errorRate.add(!listOk); + sleep(0.5); + + if (listRes.status === 200) { + try { + const body = JSON.parse(listRes.body); + const playlists = body.data?.playlists || body.data || body.playlists || []; + if (Array.isArray(playlists) && playlists.length > 0) { + const pl = playlists[0]; + const plId = pl.id || pl.ID; + if (plId) { + const detailRes = http.get(`${API_ORIGIN}/api/v1/playlists/${plId}`, { headers }); + check(detailRes, { 'playlist detail returns 200': (r) => r.status === 200 }); + } + } + } catch (e) {} + } + + if (Math.random() < 0.3) { + const createRes = http.post( + `${API_ORIGIN}/api/v1/playlists`, + JSON.stringify({ name: `LoadTest ${Date.now()}`, description: 'k6 load test' }), + { headers: { ...headers, 'Content-Type': 'application/json' } } + ); + check(createRes, { 'create playlist returns 201 or 200': (r) => r.status === 201 || r.status === 200 }); + } + + const searchRes = http.get(`${API_ORIGIN}/api/v1/playlists/search?q=test`, { headers }); + check(searchRes, { 'playlists search returns 200': (r) => r.status === 200 }); + sleep(1); +} diff --git a/loadtests/backend/tracks.js b/loadtests/backend/tracks.js new file mode 100644 index 000000000..65e3442cd --- /dev/null +++ b/loadtests/backend/tracks.js @@ -0,0 +1,66 @@ +/** + * Load test: GET tracks, search, track detail + * Usage: k6 run loadtests/backend/tracks.js + * Option: AUTH_TOKEN for authenticated requests + */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate } from 'k6/metrics'; + +const errorRate = new Rate('errors'); +const BASE_URL = __ENV.BASE_URL || __ENV.API_ORIGIN || 'http://localhost:8080'; +const AUTH_TOKEN = __ENV.AUTH_TOKEN || ''; + +export const options = { + stages: [ + { duration: '30s', target: 10 }, + { duration: '1m', target: 15 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], + errors: ['rate<0.05'], + }, +}; + +export default function () { + const headers = AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : {}; + + const tracksRes = http.get(`${BASE_URL}/api/v1/tracks`, { headers }); + const tracksOk = check(tracksRes, { + 'tracks returns 200 or 401': (r) => r.status === 200 || r.status === 401, + }); + errorRate.add(!tracksOk && tracksRes.status >= 500); + sleep(0.5); + + const searchRes = http.get( + `${BASE_URL}/api/v1/tracks/search?q=test&limit=10`, + { headers } + ); + check(searchRes, { + 'search returns 200 or 401': (r) => r.status === 200 || r.status === 401, + }); + errorRate.add(searchRes.status >= 500); + sleep(0.5); + + if (tracksRes.status === 200) { + try { + const body = JSON.parse(tracksRes.body); + const tracks = body.data?.tracks || body.data || body.tracks || []; + if (Array.isArray(tracks) && tracks.length > 0) { + const trackId = tracks[0].id || tracks[0].ID; + if (trackId) { + const detailRes = http.get( + `${BASE_URL}/api/v1/tracks/${trackId}`, + { headers } + ); + check(detailRes, { + 'track detail returns 200 or 404': (r) => + r.status === 200 || r.status === 404, + }); + } + } + } catch (e) {} + } + sleep(1); +} diff --git a/loadtests/backend/uploads.js b/loadtests/backend/uploads.js new file mode 100644 index 000000000..b6b0bbb5f --- /dev/null +++ b/loadtests/backend/uploads.js @@ -0,0 +1,183 @@ +/** + * Load test: upload simple, chunked, batch + * Usage: k6 run loadtests/backend/uploads.js + * Requires: AUTH_TOKEN (JWT) + */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend, Counter } from 'k6/metrics'; + +const errorRate = new Rate('errors'); +const uploadDuration = new Trend('upload_duration'); +const chunkedUploadDuration = new Trend('chunked_upload_duration'); +const uploadFailures = new Counter('upload_failures'); + +const BASE_URL = __ENV.BASE_URL || __ENV.API_ORIGIN || 'http://localhost:8080'; +const AUTH_TOKEN = __ENV.AUTH_TOKEN || ''; +const CHUNK_SIZE = parseInt(__ENV.CHUNK_SIZE || '1048576'); +const TOTAL_CHUNKS = parseInt(__ENV.TOTAL_CHUNKS || '5'); + +export const options = { + stages: [ + { duration: '30s', target: 5 }, + { duration: '2m', target: 10 }, + { duration: '2m', target: 10 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_duration: ['p(95)<5000', 'p(99)<10000'], + errors: ['rate<0.10'], + upload_duration: ['p(95)<3000'], + chunked_upload_duration: ['p(95)<8000'], + }, +}; + +function generateTestFile(size) { + const buffer = new Uint8Array(size); + for (let i = 0; i < size; i++) { + buffer[i] = Math.floor(Math.random() * 256); + } + return buffer; +} + +function createMultipartBody(fields, fileField, fileData, filename, contentType) { + const boundary = `----WebKitFormBoundary${Date.now()}${Math.random().toString(36)}`; + let body = ''; + for (const [key, value] of Object.entries(fields)) { + body += `--${boundary}\r\n`; + body += `Content-Disposition: form-data; name="${key}"\r\n\r\n`; + body += `${value}\r\n`; + } + body += `--${boundary}\r\n`; + body += `Content-Disposition: form-data; name="${fileField}"; filename="${filename}"\r\n`; + body += `Content-Type: ${contentType}\r\n\r\n`; + const fileString = String.fromCharCode.apply(null, fileData); + body += fileString; + body += `\r\n--${boundary}--\r\n`; + return { + body, + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} + +function testSimpleUpload() { + const filename = `test_${Date.now()}_${Math.random().toString(36).substring(7)}.mp3`; + const fileSize = Math.min(CHUNK_SIZE * 2, 1024 * 1024); + const fileData = generateTestFile(fileSize); + const fields = { + title: `Test Track ${Date.now()}`, + artist: 'Test Artist', + file_type: 'audio', + }; + const multipart = createMultipartBody(fields, 'file', fileData, filename, 'audio/mpeg'); + const startTime = Date.now(); + const res = http.post(`${BASE_URL}/api/v1/tracks`, multipart.body, { + headers: { + Authorization: `Bearer ${AUTH_TOKEN}`, + 'Content-Type': multipart.contentType, + }, + }); + uploadDuration.add(Date.now() - startTime); + const success = check(res, { + 'simple upload status is 201 or 200': (r) => r.status === 201 || r.status === 200, + }); + errorRate.add(!success); + if (!success) uploadFailures.add(1); + return success; +} + +function testChunkedUpload() { + const filename = `test_chunked_${Date.now()}_${Math.random().toString(36).substring(7)}.mp3`; + const totalSize = CHUNK_SIZE * TOTAL_CHUNKS; + const startTime = Date.now(); + const initiateRes = http.post( + `${BASE_URL}/api/v1/tracks/initiate`, + JSON.stringify({ total_chunks: TOTAL_CHUNKS, total_size: totalSize, filename }), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + } + ); + if (initiateRes.status !== 200) { + errorRate.add(true); + uploadFailures.add(1); + return false; + } + let uploadID; + try { + const b = JSON.parse(initiateRes.body); + uploadID = b.data?.upload_id; + } catch (e) { + return false; + } + if (!uploadID) return false; + + for (let chunkNum = 1; chunkNum <= TOTAL_CHUNKS; chunkNum++) { + const chunkData = generateTestFile(Math.min(CHUNK_SIZE, 1024 * 1024)); + const fields = { + upload_id: uploadID, + chunk_number: chunkNum.toString(), + total_chunks: TOTAL_CHUNKS.toString(), + total_size: totalSize.toString(), + filename, + }; + const multipart = createMultipartBody(fields, 'chunk', chunkData, `chunk${chunkNum}.bin`, 'application/octet-stream'); + const chunkRes = http.post(`${BASE_URL}/api/v1/tracks/chunk`, multipart.body, { + headers: { + Authorization: `Bearer ${AUTH_TOKEN}`, + 'Content-Type': multipart.contentType, + }, + }); + if (chunkRes.status !== 200) { + errorRate.add(true); + uploadFailures.add(1); + return false; + } + sleep(0.1); + } + + const completeRes = http.post( + `${BASE_URL}/api/v1/tracks/complete`, + JSON.stringify({ upload_id: uploadID }), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + } + ); + chunkedUploadDuration.add(Date.now() - startTime); + const success = check(completeRes, { + 'complete returns 201 or 200': (r) => r.status === 201 || r.status === 200, + }); + errorRate.add(!success); + if (!success) uploadFailures.add(1); + return success; +} + +export default function () { + if (!AUTH_TOKEN) { + console.error('AUTH_TOKEN is required for upload tests'); + return; + } + const rand = Math.random(); + if (rand < 0.5) { + testSimpleUpload(); + } else if (rand < 0.9) { + testChunkedUpload(); + } else { + const filename = `test_batch_${Date.now()}.mp3`; + const fileData = generateTestFile(Math.min(CHUNK_SIZE, 1024 * 1024)); + const multipart = createMultipartBody({}, 'files', fileData, filename, 'audio/mpeg'); + const res = http.post(`${BASE_URL}/api/v1/uploads/batch`, multipart.body, { + headers: { + Authorization: `Bearer ${AUTH_TOKEN}`, + 'Content-Type': multipart.contentType, + }, + }); + check(res, { 'batch status 200 or 201': (r) => r.status === 200 || r.status === 201 }); + } + sleep(2); +} diff --git a/loadtests/chat/websocket.js b/loadtests/chat/websocket.js new file mode 100644 index 000000000..c9a279671 --- /dev/null +++ b/loadtests/chat/websocket.js @@ -0,0 +1,150 @@ +/** + * Load test: WebSocket chat + * Usage: k6 run loadtests/chat/websocket.js + * Requires: Backend API + Chat server running + * Flow: register -> login -> GET /api/v1/chat/token -> connect WS with token + */ +import ws from 'k6/ws'; +import { check, sleep } from 'k6'; +import { Rate, Trend, Counter } from 'k6/metrics'; +import { randomString } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; +import http from 'k6/http'; + +const API_ORIGIN = __ENV.API_ORIGIN || __ENV.BASE_URL || 'http://localhost:8080'; +const CHAT_ORIGIN = __ENV.CHAT_ORIGIN || 'ws://localhost:8081'; +const WS_BASE = CHAT_ORIGIN.startsWith('ws') ? CHAT_ORIGIN : `ws://${CHAT_ORIGIN.replace(/^https?:\/\//, '')}`; +const WS_URL = WS_BASE.endsWith('/ws') ? WS_BASE : `${WS_BASE.replace(/\/?$/, '')}/ws`; +const TEST_EMAIL_PREFIX = __ENV.TEST_EMAIL_PREFIX || 'user+ws'; +const TEST_EMAIL_DOMAIN = __ENV.TEST_EMAIL_DOMAIN || 'example.com'; +const TEST_PASSWORD_PREFIX = __ENV.TEST_PASSWORD_PREFIX || 'V3za!ws-'; + +const wsConnectionTime = new Trend('ws_connection_time'); +const wsConnectionFailures = new Rate('ws_connection_failures'); +const wsMessageFailures = new Rate('ws_message_failures'); +const messagesReceived = new Counter('messages_received'); +const messagesSent = new Counter('messages_sent'); + +export const options = { + scenarios: { + websocket_chat: { + executor: 'ramping-vus', + stages: [ + { duration: '30s', target: 10 }, + { duration: '2m', target: 30 }, + { duration: '1m', target: 30 }, + { duration: '30s', target: 0 }, + ], + gracefulRampDown: '30s', + }, + }, + thresholds: { + ws_connection_time: ['p(95)<500', 'p(99)<1000'], + ws_connection_failures: ['rate<0.05'], + ws_message_failures: ['rate<0.05'], + }, +}; + +function createAuthenticatedUser() { + const rand = randomString(8); + const pwd = `${TEST_PASSWORD_PREFIX}${rand}`; + const user = { + email: `${TEST_EMAIL_PREFIX}${rand}@${TEST_EMAIL_DOMAIN}`, + password: pwd, + password_confirmation: pwd, + username: `ws${rand}`, + }; + + const registerRes = http.post(`${API_ORIGIN}/api/v1/auth/register`, JSON.stringify(user), { + headers: { 'Content-Type': 'application/json' }, + }); + if (registerRes.status !== 201) return null; + + const loginRes = http.post(`${API_ORIGIN}/api/v1/auth/login`, JSON.stringify({ email: user.email, password: user.password }), { + headers: { 'Content-Type': 'application/json' }, + }); + if (loginRes.status !== 200) return null; + + let accessToken = ''; + try { + const body = JSON.parse(loginRes.body); + accessToken = body.data?.token?.access_token || ''; + } catch (e) { + return null; + } + if (!accessToken) return null; + + const chatTokenRes = http.get(`${API_ORIGIN}/api/v1/chat/token`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (chatTokenRes.status !== 200) return null; + + let chatToken = ''; + try { + const ctBody = JSON.parse(chatTokenRes.body); + chatToken = ctBody.data?.token || ctBody.token || ''; + } catch (e) { + return null; + } + if (!chatToken) return null; + + return { ...user, chatToken }; +} + +export function setup() { + const users = []; + for (let i = 0; i < 40; i++) { + const user = createAuthenticatedUser(); + if (user) users.push(user); + sleep(0.1); + } + return { users }; +} + +export default function (data) { + const { users } = data; + if (users.length === 0) return; + + const user = users[Math.floor(Math.random() * users.length)]; + const url = `${WS_URL}?token=${user.chatToken}`; + const connectionStart = Date.now(); + + const res = ws.connect(url, {}, function (socket) { + socket.on('open', () => { + wsConnectionTime.add(Date.now() - connectionStart); + + socket.send(JSON.stringify({ type: 'join', room: 'general' })); + + socket.setInterval(() => { + socket.send(JSON.stringify({ + type: 'message', + room: 'general', + content: `Load test ${Date.now()}`, + })); + messagesSent.add(1); + }, 5000); + + socket.setTimeout(() => { + socket.close(); + }, 20000 + Math.random() * 10000); + }); + + socket.on('message', (data) => { + messagesReceived.add(1); + try { + const msg = JSON.parse(data); + if (msg.error) wsMessageFailures.add(1); + } catch (e) { + wsMessageFailures.add(1); + } + }); + + socket.on('error', () => { + wsConnectionFailures.add(1); + }); + }); + + check(res, { 'WebSocket connection successful': (r) => r && r.status === 101 }); + if (!res || res.status !== 101) { + wsConnectionFailures.add(1); + } +} diff --git a/loadtests/config.js b/loadtests/config.js new file mode 100644 index 000000000..68cfad202 --- /dev/null +++ b/loadtests/config.js @@ -0,0 +1,15 @@ +/** + * Configuration commune pour les tests de charge k6 + * Variables d'environnement (k6 __ENV) : + * BASE_URL, API_ORIGIN, STREAM_ORIGIN, CHAT_ORIGIN, AUTH_TOKEN + */ +export const config = { + BASE_URL: __ENV.BASE_URL || __ENV.API_ORIGIN || 'http://localhost:8080', + API_ORIGIN: __ENV.API_ORIGIN || __ENV.BASE_URL || 'http://localhost:8080', + STREAM_ORIGIN: __ENV.STREAM_ORIGIN || 'http://localhost:8082', + CHAT_ORIGIN: __ENV.CHAT_ORIGIN || 'ws://localhost:8081', + AUTH_TOKEN: __ENV.AUTH_TOKEN || '', + TEST_EMAIL_PREFIX: __ENV.TEST_EMAIL_PREFIX || 'user+load', + TEST_EMAIL_DOMAIN: __ENV.TEST_EMAIL_DOMAIN || 'example.com', + TEST_PASSWORD_PREFIX: __ENV.TEST_PASSWORD_PREFIX || 'V3za!load-', +}; diff --git a/loadtests/smoke.js b/loadtests/smoke.js new file mode 100644 index 000000000..83cf314e7 --- /dev/null +++ b/loadtests/smoke.js @@ -0,0 +1,42 @@ +/** + * Smoke test rapide pour validation (< 1 min) + * Usage: k6 run loadtests/smoke.js + */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +const BASE_URL = __ENV.BASE_URL || __ENV.API_ORIGIN || 'http://localhost:8080'; + +export const options = { + vus: 2, + duration: '30s', + thresholds: { + http_req_failed: ['rate<0.2'], + http_req_duration: ['p(95)<2000'], + }, +}; + +export default function () { + // 1. Health check + const healthRes = http.get(`${BASE_URL}/health`); + check(healthRes, { 'health status 200': (r) => r.status === 200 }); + sleep(0.5); + + // 2. Readiness check + const readyzRes = http.get(`${BASE_URL}/readyz`); + check(readyzRes, { 'readyz status 200': (r) => r.status === 200 }); + sleep(0.5); + + // 3. Auth login (invalid credentials -> 401 attendu) + const loginPayload = JSON.stringify({ + email: 'test@example.com', + password: 'invalid_password', + }); + const loginRes = http.post(`${BASE_URL}/api/v1/auth/login`, loginPayload, { + headers: { 'Content-Type': 'application/json' }, + }); + check(loginRes, { + 'login returns 401 or 400': (r) => r.status === 401 || r.status === 400, + }); + sleep(1); +} diff --git a/loadtests/stream/http.js b/loadtests/stream/http.js new file mode 100644 index 000000000..d73e2bfc1 --- /dev/null +++ b/loadtests/stream/http.js @@ -0,0 +1,52 @@ +/** + * Load test: Stream server health, healthz, readyz + * Usage: k6 run loadtests/stream/http.js + * Requires: Stream server running (default localhost:8082) + */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; + +const errorRate = new Rate('stream_errors'); +const healthDuration = new Trend('stream_health_duration'); + +const STREAM_ORIGIN = __ENV.STREAM_ORIGIN || 'http://localhost:8082'; + +export const options = { + scenarios: { + stream_health: { + executor: 'constant-arrival-rate', + rate: 10, + timeUnit: '1s', + duration: '2m', + preAllocatedVUs: 20, + maxVUs: 50, + }, + }, + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], + stream_errors: ['rate<0.05'], + stream_health_duration: ['p(95)<200', 'p(99)<500'], + }, +}; + +export default function () { + const start = Date.now(); + const healthRes = http.get(`${STREAM_ORIGIN}/health`); + healthDuration.add(Date.now() - start); + + const ok = check(healthRes, { + 'stream health returns 200': (r) => r.status === 200, + }); + errorRate.add(!ok); + sleep(0.5); + + const healthzRes = http.get(`${STREAM_ORIGIN}/healthz`); + check(healthzRes, { 'stream healthz returns 200': (r) => r.status === 200 }); + errorRate.add(healthzRes.status !== 200); + sleep(0.5); + + const readyzRes = http.get(`${STREAM_ORIGIN}/readyz`); + check(readyzRes, { 'stream readyz returns 200': (r) => r.status === 200 }); + sleep(1); +} diff --git a/make/test.mk b/make/test.mk index a591800aa..d1fd8bbcd 100644 --- a/make/test.mk +++ b/make/test.mk @@ -3,6 +3,7 @@ # ============================================================================== .PHONY: test test-tmt lint fmt status test-web test-backend-api test-chat-server test-stream-server +.PHONY: load-test-smoke load-test-backend load-test-all .PHONY: lint-web lint-backend-api lint-chat-server lint-stream-server # Env vars for backend tests (align with docker-compose ports: Redis 16379, RabbitMQ 15672) @@ -76,6 +77,18 @@ fmt: ## [MID] Format everything @(cd $(ROOT)/$(SERVICE_DIR_stream-server) && cargo fmt) @(cd $(ROOT)/$(SERVICE_DIR_web) && npm run format) || true +load-test-smoke: ## [MID] Run k6 smoke load test + @command -v k6 >/dev/null 2>&1 || { $(ECHO_CMD) "${RED}❌ k6 missing. Install: brew install k6${NC}"; exit 1; } + @k6 run $(ROOT)/loadtests/smoke.js + +load-test-backend: ## [MID] Run k6 backend full load test + @command -v k6 >/dev/null 2>&1 || { $(ECHO_CMD) "${RED}❌ k6 missing. Install: brew install k6${NC}"; exit 1; } + @k6 run $(ROOT)/loadtests/backend/full.js + +load-test-all: load-test-backend ## [MID] Run all k6 load tests (backend, stream, chat) + @k6 run $(ROOT)/loadtests/stream/http.js || true + @k6 run $(ROOT)/loadtests/chat/websocket.js || true + status: ## [MID] Show system health & stats @$(ECHO_CMD) "${BOLD}DOCKER STATS:${NC}" @docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}" 2>/dev/null | grep -E "NAME|veza" || echo "No containers running" diff --git a/veza-backend-api/Makefile b/veza-backend-api/Makefile index ed6a06c5b..47b1e9525 100644 --- a/veza-backend-api/Makefile +++ b/veza-backend-api/Makefile @@ -201,9 +201,13 @@ docker-scan-full: docker-build ## Scan complet de l'image Docker (toutes sévér fi # Performance -load-test: ## Exécute le test de charge k6 - @echo "$(GREEN)🔥 Exécution du test de charge (Uploads)...$(NC)" - @docker run --rm -i --network=host grafana/k6 run - < scripts/load_test_uploads.js +load-test: ## Exécute le test de charge k6 (smoke) + @echo "$(GREEN)🔥 Exécution du test de charge (smoke)...$(NC)" + @k6 run ../loadtests/smoke.js 2>/dev/null || docker run --rm -i --network=host -v $(CURDIR)/../loadtests:/scripts grafana/k6 run /scripts/smoke.js + +load-test-uploads: ## Exécute le test de charge uploads (requiert AUTH_TOKEN) + @echo "$(GREEN)🔥 Exécution du test de charge (uploads)...$(NC)" + @k6 run ../loadtests/backend/uploads.js 2>/dev/null || docker run --rm -i --network=host -e AUTH_TOKEN=$${AUTH_TOKEN} -v $(CURDIR)/../loadtests:/scripts grafana/k6 run /scripts/backend/uploads.js benchmark: ## Exécute les benchmarks @echo "$(GREEN)⚡ Exécution des benchmarks...$(NC)"