// k6 load test pour les endpoints d'upload de veza-backend-api // Installation: https://k6.io/docs/get-started/installation/ // Usage: k6 run scripts/loadtest/k6_upload_load_test.js // // Variables d'environnement: // BASE_URL: URL de base de l'API (défaut: http://localhost:8080) // AUTH_TOKEN: Token JWT pour authentification (requis pour uploads) // CHUNK_SIZE: Taille des chunks en bytes (défaut: 1024 * 1024 = 1MB) // TOTAL_CHUNKS: Nombre de chunks par upload (défaut: 5) import http from 'k6/http'; import { check, sleep } from 'k6'; import { Rate, Trend, Counter } from 'k6/metrics'; // Métriques custom const errorRate = new Rate('errors'); const uploadDuration = new Trend('upload_duration'); const chunkedUploadDuration = new Trend('chunked_upload_duration'); const concurrentUploads = new Counter('concurrent_uploads'); const chunkedUploads = new Counter('chunked_uploads'); const uploadFailures = new Counter('upload_failures'); // Configuration export const options = { stages: [ { duration: '30s', target: 5 }, // Ramp-up: 0 à 5 VUs en 30s { duration: '2m', target: 10 }, // Augmentation: 5 à 10 VUs en 2m { duration: '2m', target: 10 }, // Stabilité: 10 VUs pendant 2m { duration: '30s', target: 0 }, // Ramp-down: 10 à 0 VUs en 30s ], thresholds: { 'http_req_duration': ['p(95)<5000', 'p(99)<10000'], // Uploads peuvent être plus lents 'errors': ['rate<0.10'], // < 10% d'erreurs (uploads plus fragiles) 'upload_duration': ['p(95)<3000'], // Upload simple < 3s 'chunked_upload_duration': ['p(95)<8000'], // Upload chunked < 8s }, }; // Base URL (configurable via env) const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; const AUTH_TOKEN = __ENV.AUTH_TOKEN || ''; const CHUNK_SIZE = parseInt(__ENV.CHUNK_SIZE || '1048576'); // 1MB par défaut const TOTAL_CHUNKS = parseInt(__ENV.TOTAL_CHUNKS || '5'); // Générer un fichier de test (simulation) // k6 utilise des Uint8Array pour les données binaires function generateTestFile(size) { const buffer = new Uint8Array(size); // Remplir avec des données pseudo-aléatoires for (let i = 0; i < size; i++) { buffer[i] = Math.floor(Math.random() * 256); } return buffer; } // Créer un multipart form data manuellement pour k6 function createMultipartBody(fields, fileField, fileData, filename, contentType) { const boundary = `----WebKitFormBoundary${Date.now()}${Math.random().toString(36)}`; let body = ''; // Ajouter les champs de formulaire 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`; } // Ajouter le fichier body += `--${boundary}\r\n`; body += `Content-Disposition: form-data; name="${fileField}"; filename="${filename}"\r\n`; body += `Content-Type: ${contentType}\r\n\r\n`; // Convertir Uint8Array en string binaire (approximation) // Note: Pour un vrai test, on devrait utiliser une approche plus robuste const fileString = String.fromCharCode.apply(null, fileData); body += fileString; body += `\r\n--${boundary}--\r\n`; return { body: body, contentType: `multipart/form-data; boundary=${boundary}`, }; } // Test upload simple function testSimpleUpload() { const filename = `test_${Date.now()}_${Math.random().toString(36).substring(7)}.mp3`; const fileSize = Math.min(CHUNK_SIZE * 2, 1024 * 1024); // Max 1MB pour éviter problèmes mémoire const fileData = generateTestFile(fileSize); const startTime = Date.now(); // Créer un multipart form manuellement const fields = { title: `Test Track ${Date.now()}`, artist: 'Test Artist', file_type: 'audio', }; const multipart = createMultipartBody(fields, 'file', fileData, filename, 'audio/mpeg'); const params = { headers: { 'Authorization': `Bearer ${AUTH_TOKEN}`, 'Content-Type': multipart.contentType, }, }; // POST /api/v1/tracks (upload simple) const res = http.post(`${BASE_URL}/api/v1/tracks`, multipart.body, params); const duration = Date.now() - startTime; uploadDuration.add(duration); concurrentUploads.add(1); const success = check(res, { 'simple upload status is 201 or 200': (r) => r.status === 201 || r.status === 200, 'simple upload has response data': (r) => { try { const body = JSON.parse(r.body); return body.success !== false && body.data !== undefined; } catch (e) { return false; } }, }); errorRate.add(!success); if (!success) { uploadFailures.add(1); } return success; } // Test upload par chunks 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(); // Step 1: Initiate chunked upload const initiatePayload = JSON.stringify({ total_chunks: TOTAL_CHUNKS, total_size: totalSize, filename: filename, }); const initiateRes = http.post( `${BASE_URL}/api/v1/tracks/initiate`, initiatePayload, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${AUTH_TOKEN}`, }, } ); const initiateCheck = check(initiateRes, { 'initiate returns 200': (r) => r.status === 200, 'initiate returns upload_id': (r) => { try { const body = JSON.parse(r.body); return body.success === true && body.data && body.data.upload_id; } catch (e) { return false; } }, }); if (!initiateCheck) { errorRate.add(true); uploadFailures.add(1); return false; } const initiateBody = JSON.parse(initiateRes.body); const uploadID = initiateBody.data.upload_id; // Step 2: Upload chunks for (let chunkNum = 1; chunkNum <= TOTAL_CHUNKS; chunkNum++) { const chunkData = generateTestFile(Math.min(CHUNK_SIZE, 1024 * 1024)); // Max 1MB par chunk // Créer multipart form manuellement const fields = { upload_id: uploadID, chunk_number: chunkNum.toString(), total_chunks: TOTAL_CHUNKS.toString(), total_size: totalSize.toString(), filename: 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, }, } ); const chunkCheck = check(chunkRes, { [`chunk ${chunkNum} returns 200`]: (r) => r.status === 200, [`chunk ${chunkNum} has progress`]: (r) => { try { const body = JSON.parse(r.body); return body.success === true && body.data && body.data.progress !== undefined; } catch (e) { return false; } }, }); if (!chunkCheck) { errorRate.add(true); uploadFailures.add(1); return false; } // Petit délai entre chunks pour simuler un comportement réaliste sleep(0.1); } // Step 3: Complete chunked upload const completePayload = JSON.stringify({ upload_id: uploadID, }); const completeRes = http.post( `${BASE_URL}/api/v1/tracks/complete`, completePayload, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${AUTH_TOKEN}`, }, } ); const duration = Date.now() - startTime; chunkedUploadDuration.add(duration); chunkedUploads.add(1); const completeCheck = check(completeRes, { 'complete returns 201 or 200': (r) => r.status === 201 || r.status === 200, 'complete has track data': (r) => { try { const body = JSON.parse(r.body); return body.success === true && body.data && body.data.track; } catch (e) { return false; } }, }); errorRate.add(!completeCheck); if (!completeCheck) { uploadFailures.add(1); } return completeCheck; } // Test upload batch function testBatchUpload() { // Note: Batch upload peut nécessiter plusieurs fichiers // Pour simplifier, on teste juste l'endpoint avec un fichier const filename = `test_batch_${Date.now()}_${Math.random().toString(36).substring(7)}.mp3`; const fileSize = Math.min(CHUNK_SIZE, 1024 * 1024); // Max 1MB const fileData = generateTestFile(fileSize); // Créer multipart form manuellement const fields = {}; const multipart = createMultipartBody(fields, 'files', fileData, filename, 'audio/mpeg'); const params = { headers: { 'Authorization': `Bearer ${AUTH_TOKEN}`, 'Content-Type': multipart.contentType, }, }; const res = http.post(`${BASE_URL}/api/v1/uploads/batch`, multipart.body, params); const success = check(res, { 'batch upload status is 200 or 201': (r) => r.status === 200 || r.status === 201, }); errorRate.add(!success); if (!success) { uploadFailures.add(1); } return success; } // Fonction principale exécutée par chaque VU export default function () { if (!AUTH_TOKEN) { console.error('AUTH_TOKEN is required for upload tests'); return; } // Distribuer les tests: 50% upload simple, 40% chunked, 10% batch const rand = Math.random(); if (rand < 0.5) { // 50% upload simple testSimpleUpload(); } else if (rand < 0.9) { // 40% upload chunked testChunkedUpload(); } else { // 10% batch upload testBatchUpload(); } // Délai entre requêtes pour simuler un comportement utilisateur sleep(2); } // Résumé des résultats export function handleSummary(data) { return { 'stdout': textSummary(data, { indent: ' ', enableColors: true }), 'scripts/loadtest/k6_upload_summary.json': JSON.stringify(data), }; } function textSummary(data, options) { const indent = options.indent || ''; const enableColors = options.enableColors || false; let summary = '\n'; summary += `${indent}Upload Load Test Summary\n`; summary += `${indent}========================\n\n`; // HTTP Requests summary += `${indent}HTTP Requests:\n`; summary += `${indent} Total: ${data.metrics.http_reqs.values.count}\n`; summary += `${indent} Failed: ${(data.metrics.http_req_failed.values.rate * 100).toFixed(2)}%\n\n`; // Durations summary += `${indent}Durations:\n`; summary += `${indent} P95: ${data.metrics.http_req_duration.values['p(95)']}ms\n`; summary += `${indent} P99: ${data.metrics.http_req_duration.values['p(99)']}ms\n\n`; // Upload metrics if (data.metrics.upload_duration) { summary += `${indent}Simple Upload Duration:\n`; summary += `${indent} P95: ${data.metrics.upload_duration.values['p(95)']}ms\n`; summary += `${indent} P99: ${data.metrics.upload_duration.values['p(99)']}ms\n`; } if (data.metrics.chunked_upload_duration) { summary += `${indent}Chunked Upload Duration:\n`; summary += `${indent} P95: ${data.metrics.chunked_upload_duration.values['p(95)']}ms\n`; summary += `${indent} P99: ${data.metrics.chunked_upload_duration.values['p(99)']}ms\n`; } if (data.metrics.concurrent_uploads) { summary += `${indent}Concurrent Uploads: ${data.metrics.concurrent_uploads.values.count}\n`; } if (data.metrics.chunked_uploads) { summary += `${indent}Chunked Uploads: ${data.metrics.chunked_uploads.values.count}\n`; } if (data.metrics.upload_failures) { summary += `${indent}Upload Failures: ${data.metrics.upload_failures.values.count}\n`; } return summary; }