diff --git a/infra/k6/data/user-avatar.png b/infra/k6/data/user-avatar.png new file mode 100644 index 0000000..2386e31 Binary files /dev/null and b/infra/k6/data/user-avatar.png differ diff --git a/infra/k6/package.json b/infra/k6/package.json index b59aa50..ee27805 100644 --- a/infra/k6/package.json +++ b/infra/k6/package.json @@ -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" diff --git a/infra/k6/scenarios/auth.js b/infra/k6/scenarios/auth.js index f5e8052..e6a38da 100644 --- a/infra/k6/scenarios/auth.js +++ b/infra/k6/scenarios/auth.js @@ -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')); @@ -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); diff --git a/infra/k6/scenarios/users.js b/infra/k6/scenarios/users.js new file mode 100644 index 0000000..bdb87ae --- /dev/null +++ b/infra/k6/scenarios/users.js @@ -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); + } +} diff --git a/infra/k6/scripts/seed-users.ts b/infra/k6/scripts/db-seed.ts similarity index 72% rename from infra/k6/scripts/seed-users.ts rename to infra/k6/scripts/db-seed.ts index 2017391..20e7268 100644 --- a/infra/k6/scripts/seed-users.ts +++ b/infra/k6/scripts/db-seed.ts @@ -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...`); @@ -28,6 +28,7 @@ async function seed() { const usersToInsert = []; const securityToInsert = []; const notificationsToInsert = []; + const activitiesToInsert = []; const k6Data = []; for (let i = 0; i < COUNT; i++) { @@ -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...'); @@ -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 }); diff --git a/infra/k6/shared/sign-in.js b/infra/k6/shared/sign-in.js new file mode 100644 index 0000000..8e2c310 --- /dev/null +++ b/infra/k6/shared/sign-in.js @@ -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, + }; +} diff --git a/package.json b/package.json index 6be9d11..85573ad 100644 --- a/package.json +++ b/package.json @@ -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",