veza/loadtests/backend/uploads.js
senke da837fc085
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
chore(release): v0.951 — Loadtest (500 req/s, 1000 WS, 50 uploads, perf indexes)
2026-03-02 19:22:38 +01:00

228 lines
7.4 KiB
JavaScript

/**
* 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);
}