Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added infra/k6/data/user-avatar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion infra/k6/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"test:auth": "k6 run scenarios/auth.js",
"test:team": "k6 run scenarios/team.js",
"test:projects": "k6 run scenarios/projects.js",
"test:user": "k6 run scenarios/user.js",
"test:users": "k6 run scenarios/users.js",
"test:board": "k6 run scenarios/board-full.js",
"test:tasks": "k6 run scenarios/tasks.js",
"smoke": "k6 run smoke.js"
Expand Down
20 changes: 2 additions & 18 deletions infra/k6/scenarios/auth.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { SharedArray } from 'k6/data';
import http from 'k6/http';
import { check, sleep } from 'k6';
import signIn from '../shared/sign-in.js';

const users = new SharedArray('test users', function () {
return JSON.parse(open('../data/users.json'));
Expand Down Expand Up @@ -28,26 +29,9 @@ export const options = {

export default function () {
const user = users[(__VU - 1) % users.length];
const params = {
headers: {
'Content-Type': 'application/json',
},
};

// --- SIGN-IN ---
const signInRes = http.post(
`${BASE_URL}/auth/sign-in`,
JSON.stringify({ email: user.email, password: user.password }),
Object.assign({}, params, { tags: { name: 'sign-in' } }),
);

const signInToken = signInRes.json().token;
const signInCookie = signInRes.cookies.refresh ? signInRes.cookies.refresh[0].value : 'MISSING';

check(signInRes, {
'login: status is 201': (r) => r.status === 201,
'login: has access token': (r) => r.json().token !== undefined,
});
const { signInToken, signInCookie } = signIn(BASE_URL, user);

sleep(1);

Expand Down
187 changes: 187 additions & 0 deletions infra/k6/scenarios/users.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { SharedArray } from 'k6/data';
import http from 'k6/http';
import { check, sleep } from 'k6';
import { FormData } from 'https://jslib.k6.io/formdata/0.0.2/index.js';
import signIn from '../shared/sign-in.js';

const users = new SharedArray('test users', function () {
return JSON.parse(open('../data/users.json'));
});

const BASE_URL = __ENV.BASE_URL;
const VUS = parseInt(__ENV.VUS);
const DURATION = __ENV.DURATION;

const LOGIN_WINDOW = `${Math.ceil(VUS * 0.15)}s`;

export const options = {
thresholds: {
'http_req_duration{name:get-me}': ['p(95)<150'],
'http_req_duration{name:get-activity}': ['p(95)<250'],
'http_req_duration{name:patch-me}': ['p(95)<300'],
'http_req_duration{name:post-avatar}': ['p(95)<300'],
'http_req_duration{name:patch-notifications}': ['p(95)<300'],
http_req_failed: ['rate<0.1'],
},
scenarios: {
login_phase: {
executor: 'per-vu-iterations',
vus: VUS,
iterations: 1,
maxDuration: LOGIN_WINDOW,
gracefulStop: '0s',
},
users_load_test: {
executor: 'constant-vus',
vus: VUS,
duration: DURATION,
startTime: LOGIN_WINDOW,
gracefulStop: '0s',
},
},
};

const avatar = open('../data/user-avatar.png', 'b');
const randomBool = () => Math.random() < 0.5;
const randomStr = (len = 8) =>
Math.random()
.toString(36)
.substring(2, 2 + len);
let authContext = null;

export default function () {
const user = users[(__VU - 1) % users.length];

if (!authContext) {
const loginDelay = (__VU - 1) * 0.1;
sleep(loginDelay);

const { signInToken, signInCookie, signInStatus } = signIn(BASE_URL, user);

if (signInStatus !== 201) {
console.error(`VU ${__VU} failed to login: Status ${signInStatus}`);
sleep(1);
return;
}

authContext = {
token: signInToken,
cookie: signInCookie,
};
}

if (authContext && __ITER > 0) {
const params = {
headers: {
Authorization: `Bearer ${authContext.token}`,
'Content-Type': 'application/json',
},
cookies: { refresh: authContext.cookie },
};

// --- GET /me ---
const meRes = http.get(
`${BASE_URL}/users/me`,
Object.assign({}, params, {
tags: { name: 'get-me' },
}),
);

check(meRes, {
'get | me: status is 200': (r) => r.status === 200,
'get | me: has id': (r) => r.json().id !== undefined,
});

sleep(1);

// --- GET /me/activity ---
const randomPage = Math.floor(Math.random() * 5) + 1;
const randomLimit = Math.floor(Math.random() * 15) + 5;

const activityRes = http.get(
`${BASE_URL}/users/me/activity?page=${randomPage}&limit=${randomLimit}`,
Object.assign({}, params, {
tags: { name: 'get-activity' },
}),
);

if (activityRes.status !== 200) {
console.log(`Activity failed: Status ${activityRes.status}, Body: ${activityRes.body}`);
}

check(activityRes, {
'get | me/activity: status is 200': (r) => r.status === 200,
});

sleep(1);

// --- PATCH /me ---
const meBody = JSON.stringify({
firstName: `Name_${randomStr(5)}`,
lastName: `Surname_${randomStr(5)}`,
bio: `Testing bio with random data: ${randomStr(30)}`,
language: Math.random() > 0.5 ? 'ru' : 'en',
});
const updateProfileRes = http.patch(
`${BASE_URL}/users/me`,
meBody,
Object.assign({}, params, {
tags: { name: 'patch-me' },
}),
);

check(updateProfileRes, {
'patch | me: status is 200': (r) => r.status === 200,
'patch | me: success in response': (r) => r.json().success === true,
});

sleep(1);

// --- POST /me/avatar ---
const fd = new FormData();
fd.append('file', http.file(avatar, 'avatar.png', 'image/png'));

const avatarRes = http.post(`${BASE_URL}/users/me/avatar`, fd.body(), {
headers: {
Authorization: `Bearer ${authContext.token}`,
'Content-Type': `multipart/form-data; boundary=${fd.boundary}`,
},
tags: { name: 'post-avatar' },
});

check(avatarRes, {
'post | me/avatar: status is 201': (r) => r.status === 201,
'post | me/avatar: success in response': (r) => r.json().success === true,
});

sleep(1);

// --- PATCH /me/notifications ---
const notificationsBody = JSON.stringify({
email: {
task_assigned: randomBool(),
mentions: randomBool(),
daily_summary: randomBool(),
},
push: {
task_assigned: randomBool(),
reminders: randomBool(),
},
});

const notificationsRes = http.patch(
`${BASE_URL}/users/me/notifications`,
notificationsBody,
Object.assign({}, params, {
tags: { name: 'patch-notifications' },
}),
);

check(notificationsRes, {
'patch | me/notifications: status is 200': (r) => r.status === 200,
'patch | me/notifications: success in response': (r) => r.json().success === true,
});

sleep(1);
}
}
25 changes: 24 additions & 1 deletion infra/k6/scripts/seed-users.ts → infra/k6/scripts/db-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ async function seed() {
const DB_URL = process.env.DATABASE_URL;
if (!DB_URL) throw new Error('DATABASE_URL is not defined in .env');

const COUNT = 500;
const COUNT = 1000;
const OUT_FILE = resolve(process.cwd(), 'infra/k6/data/users.json');

console.log(`Start seeding ${COUNT} users using pg driver...`);
Expand All @@ -28,6 +28,7 @@ async function seed() {
const usersToInsert = [];
const securityToInsert = [];
const notificationsToInsert = [];
const activitiesToInsert = [];
const k6Data = [];

for (let i = 0; i < COUNT; i++) {
Expand All @@ -48,6 +49,21 @@ async function seed() {
notificationsToInsert.push({ userId });

k6Data.push({ email, password });

for (let j = 0; j < 10; j++) {
activitiesToInsert.push({
id: createId(),
userId: userId,
eventType: 'SIGN_IN',
entityId: userId,
metadata: {
description: `K6 Load Test Iteration ${j}`,
ip: '127.0.0.1',
userAgent: 'k6-test-agent',
},
createdAt: new Date(Date.now() - j * 1000 * 60 * 60),
});
}
}

console.log('Cleaning up ONLY k6 test users...');
Expand All @@ -61,6 +77,13 @@ async function seed() {
await tx.insert(sc.users).values(usersToInsert);
await tx.insert(sc.userSecurity).values(securityToInsert);
await tx.insert(sc.userNotifications).values(notificationsToInsert);

const chunkSize = 1000;
for (let i = 0; i < activitiesToInsert.length; i += chunkSize) {
const chunk = activitiesToInsert.slice(i, i + chunkSize);
console.log(`Inserting activities chunk: ${i} to ${i + chunkSize}...`);
await tx.insert(sc.userActivity).values(chunk);
}
});

mkdirSync(dirname(OUT_FILE), { recursive: true });
Expand Down
30 changes: 30 additions & 0 deletions infra/k6/shared/sign-in.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import http from 'k6/http';
import { check } from 'k6';

export default function signIn(baseUrl, user) {
const params = {
headers: {
'Content-Type': 'application/json',
},
};

const signInRes = http.post(
`${baseUrl}/auth/sign-in`,
JSON.stringify({ email: user.email, password: user.password }),
Object.assign({}, params, { tags: { name: 'sign-in' } }),
);

const signInToken = signInRes.json().token;
const signInCookie = signInRes.cookies.refresh ? signInRes.cookies.refresh[0].value : 'MISSING';

check(signInRes, {
'sign-in: status is 201': (r) => r.status === 201,
'sign-in: has access token': (r) => r.json().token !== undefined,
});

return {
signInToken,
signInCookie,
signInStatus: signInRes.status,
};
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@
"k6:auth": "pnpm --filter @project/performance-tests test:auth",
"k6:team": "pnpm --filter @project/performance-tests test:team",
"k6:projects": "pnpm --filter @project/performance-tests test:projects",
"k6:user": "pnpm --filter @project/performance-tests test:user",
"k6:users": "pnpm --filter @project/performance-tests test:users",
"k6:board": "pnpm --filter @project/performance-tests test:board",
"k6:tasks": "pnpm --filter @project/performance-tests test:tasks",
"k6:smoke": "pnpm --filter @project/performance-tests smoke",
"k6:seed-users": "npx tsx infra/k6/scripts/seed-users.ts"
"k6:db-seed": "npx tsx infra/k6/scripts/db-seed.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1029.0",
Expand Down
Loading