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:
parent
b9875c5e92
commit
fef7e7fc7c
16 changed files with 1295 additions and 5 deletions
81
.github/workflows/load-test-nightly.yml
vendored
Normal file
81
.github/workflows/load-test-nightly.yml
vendored
Normal 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()
|
||||||
|
|
@ -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
102
loadtests/README.md
Normal 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
165
loadtests/backend/auth.js
Normal 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
154
loadtests/backend/full.js
Normal 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);
|
||||||
|
}
|
||||||
65
loadtests/backend/health.js
Normal file
65
loadtests/backend/health.js
Normal 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);
|
||||||
|
}
|
||||||
89
loadtests/backend/marketplace.js
Normal file
89
loadtests/backend/marketplace.js
Normal 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);
|
||||||
|
}
|
||||||
109
loadtests/backend/playlists.js
Normal file
109
loadtests/backend/playlists.js
Normal 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);
|
||||||
|
}
|
||||||
66
loadtests/backend/tracks.js
Normal file
66
loadtests/backend/tracks.js
Normal 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);
|
||||||
|
}
|
||||||
183
loadtests/backend/uploads.js
Normal file
183
loadtests/backend/uploads.js
Normal 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
150
loadtests/chat/websocket.js
Normal 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
15
loadtests/config.js
Normal 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
42
loadtests/smoke.js
Normal 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
52
loadtests/stream/http.js
Normal 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);
|
||||||
|
}
|
||||||
13
make/test.mk
13
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: 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"
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue