/** * Load test: upload simple, chunked, batch * Usage: k6 run loadtests/backend/uploads.js * Option: AUTH_TOKEN (JWT) — if not set, setup() registers users and uses tokens * v0.951: 50 VUs concurrent for 2 min (stress 50 uploads) */ import http from 'k6/http'; 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'; 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'); const TEST_EMAIL_PREFIX = __ENV.TEST_EMAIL_PREFIX || 'user+upl'; const TEST_EMAIL_DOMAIN = __ENV.TEST_EMAIL_DOMAIN || 'example.com'; const TEST_PASSWORD_PREFIX = __ENV.TEST_PASSWORD_PREFIX || 'V3za!upl-'; export const options = { stages: [ { duration: '1m', target: 50 }, { duration: '2m', target: 50 }, { 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'], }, }; export function setup() { if (AUTH_TOKEN) return { tokens: [AUTH_TOKEN] }; const tokens = []; const baseURL = `${BASE_URL}/api/v1/auth`; for (let i = 0; i < 60; i++) { 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: `upl${rand}`, }; 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) tokens.push(token); } catch (e) {} } } if (i % 10 === 0) sleep(0.1); } return { tokens }; } 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 getToken(data) { if (AUTH_TOKEN) return AUTH_TOKEN; const tokens = data?.tokens || []; if (tokens.length === 0) return ''; return tokens[Math.floor(Math.random() * tokens.length)]; } function testSimpleUpload(token) { 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 ${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(token) { 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 ${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 ${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 ${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 (data) { const token = getToken(data); if (!token) { if (!AUTH_TOKEN) console.error('AUTH_TOKEN or setup users required for upload tests'); return; } const rand = Math.random(); if (rand < 0.5) { testSimpleUpload(token); } else if (rand < 0.9) { testChunkedUpload(token); } 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 ${token}`, 'Content-Type': multipart.contentType, }, }); check(res, { 'batch status 200 or 201': (r) => r.status === 200 || r.status === 201 }); } sleep(1); }