feat(loadtests): audit 3.2 — tests de charge k6 complets

- 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)
This commit is contained in:
senke 2026-02-15 15:22:48 +01:00
parent b9875c5e92
commit fef7e7fc7c
16 changed files with 1295 additions and 5 deletions

81
.github/workflows/load-test-nightly.yml vendored Normal file
View file

@ -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()

View file

@ -677,8 +677,8 @@ veza/
| # | Action | Effort | Détail | | # | Action | Effort | Détail |
|---|--------|--------|--------| |---|--------|--------|--------|
| 3.1 | Implémenter circuit breaker entre services | **L** | Résilience inter-services | | 3.1 | ~~Implémenter circuit breaker entre services~~ | **L** | **✅ Fait** — WebhookService, Hyperswitch (Backend Go) |
| 3.2 | Ajouter tests de charge | **L** | k6 ou Locust | | 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.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.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 | | 3.5 | Mettre en place database sharding strategy | **XL** | Si > 100K utilisateurs |

102
loadtests/README.md Normal file
View file

@ -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
```

165
loadtests/backend/auth.js Normal file
View file

@ -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);
}

154
loadtests/backend/full.js Normal file
View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

150
loadtests/chat/websocket.js Normal file
View file

@ -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);
}
}

15
loadtests/config.js Normal file
View file

@ -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-',
};

42
loadtests/smoke.js Normal file
View file

@ -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);
}

52
loadtests/stream/http.js Normal file
View file

@ -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);
}

View file

@ -3,6 +3,7 @@
# ============================================================================== # ==============================================================================
.PHONY: test test-tmt lint fmt status test-web test-backend-api test-chat-server test-stream-server .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 .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) # 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_stream-server) && cargo fmt)
@(cd $(ROOT)/$(SERVICE_DIR_web) && npm run format) || true @(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 status: ## [MID] Show system health & stats
@$(ECHO_CMD) "${BOLD}DOCKER STATS:${NC}" @$(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" @docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}" 2>/dev/null | grep -E "NAME|veza" || echo "No containers running"

View file

@ -201,9 +201,13 @@ docker-scan-full: docker-build ## Scan complet de l'image Docker (toutes sévér
fi fi
# Performance # Performance
load-test: ## Exécute le test de charge k6 load-test: ## Exécute le test de charge k6 (smoke)
@echo "$(GREEN)🔥 Exécution du test de charge (Uploads)...$(NC)" @echo "$(GREEN)🔥 Exécution du test de charge (smoke)...$(NC)"
@docker run --rm -i --network=host grafana/k6 run - < scripts/load_test_uploads.js @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 benchmark: ## Exécute les benchmarks
@echo "$(GREEN)⚡ Exécution des benchmarks...$(NC)" @echo "$(GREEN)⚡ Exécution des benchmarks...$(NC)"