228 lines
7.4 KiB
JavaScript
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);
|
|
}
|