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 |
|
||||
|---|--------|--------|--------|
|
||||
| 3.1 | Implémenter circuit breaker entre services | **L** | Résilience inter-services |
|
||||
| 3.2 | Ajouter tests de charge | **L** | k6 ou Locust |
|
||||
| 3.1 | ~~Implémenter circuit breaker entre services~~ | **L** | **✅ Fait** — WebhookService, Hyperswitch (Backend Go) |
|
||||
| 3.2 | ~~Ajouter tests de charge~~ | **L** | **✅ Fait** — k6 (backend, stream, chat, marketplace, playlists), CI nightly |
|
||||
| 3.3 | Créer une documentation d'architecture (C4, ADR) | **M** | Pour onboarding et due diligence |
|
||||
| 3.4 | Ajouter un `GETTING_STARTED.md` clair | **S** | Guide d'onboarding développeur |
|
||||
| 3.5 | Mettre en place database sharding strategy | **XL** | Si > 100K utilisateurs |
|
||||
|
|
|
|||
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: load-test-smoke load-test-backend load-test-all
|
||||
.PHONY: lint-web lint-backend-api lint-chat-server lint-stream-server
|
||||
|
||||
# Env vars for backend tests (align with docker-compose ports: Redis 16379, RabbitMQ 15672)
|
||||
|
|
@ -76,6 +77,18 @@ fmt: ## [MID] Format everything
|
|||
@(cd $(ROOT)/$(SERVICE_DIR_stream-server) && cargo fmt)
|
||||
@(cd $(ROOT)/$(SERVICE_DIR_web) && npm run format) || true
|
||||
|
||||
load-test-smoke: ## [MID] Run k6 smoke load test
|
||||
@command -v k6 >/dev/null 2>&1 || { $(ECHO_CMD) "${RED}❌ k6 missing. Install: brew install k6${NC}"; exit 1; }
|
||||
@k6 run $(ROOT)/loadtests/smoke.js
|
||||
|
||||
load-test-backend: ## [MID] Run k6 backend full load test
|
||||
@command -v k6 >/dev/null 2>&1 || { $(ECHO_CMD) "${RED}❌ k6 missing. Install: brew install k6${NC}"; exit 1; }
|
||||
@k6 run $(ROOT)/loadtests/backend/full.js
|
||||
|
||||
load-test-all: load-test-backend ## [MID] Run all k6 load tests (backend, stream, chat)
|
||||
@k6 run $(ROOT)/loadtests/stream/http.js || true
|
||||
@k6 run $(ROOT)/loadtests/chat/websocket.js || true
|
||||
|
||||
status: ## [MID] Show system health & stats
|
||||
@$(ECHO_CMD) "${BOLD}DOCKER STATS:${NC}"
|
||||
@docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}" 2>/dev/null | grep -E "NAME|veza" || echo "No containers running"
|
||||
|
|
|
|||
|
|
@ -201,9 +201,13 @@ docker-scan-full: docker-build ## Scan complet de l'image Docker (toutes sévér
|
|||
fi
|
||||
|
||||
# Performance
|
||||
load-test: ## Exécute le test de charge k6
|
||||
@echo "$(GREEN)🔥 Exécution du test de charge (Uploads)...$(NC)"
|
||||
@docker run --rm -i --network=host grafana/k6 run - < scripts/load_test_uploads.js
|
||||
load-test: ## Exécute le test de charge k6 (smoke)
|
||||
@echo "$(GREEN)🔥 Exécution du test de charge (smoke)...$(NC)"
|
||||
@k6 run ../loadtests/smoke.js 2>/dev/null || docker run --rm -i --network=host -v $(CURDIR)/../loadtests:/scripts grafana/k6 run /scripts/smoke.js
|
||||
|
||||
load-test-uploads: ## Exécute le test de charge uploads (requiert AUTH_TOKEN)
|
||||
@echo "$(GREEN)🔥 Exécution du test de charge (uploads)...$(NC)"
|
||||
@k6 run ../loadtests/backend/uploads.js 2>/dev/null || docker run --rm -i --network=host -e AUTH_TOKEN=$${AUTH_TOKEN} -v $(CURDIR)/../loadtests:/scripts grafana/k6 run /scripts/backend/uploads.js
|
||||
|
||||
benchmark: ## Exécute les benchmarks
|
||||
@echo "$(GREEN)⚡ Exécution des benchmarks...$(NC)"
|
||||
|
|
|
|||
Loading…
Reference in a new issue