From 15c93f81f2fc38c3c3e585536a22c10acb2ba541 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 29 Apr 2026 00:57:27 +0300 Subject: [PATCH 1/6] chore(load-test): update config and client --- infra/k6/common/client.js | 111 +++++++++++++++++++++++++++----------- infra/k6/common/config.js | 4 +- 2 files changed, 82 insertions(+), 33 deletions(-) diff --git a/infra/k6/common/client.js b/infra/k6/common/client.js index 811e11c..9fae98b 100644 --- a/infra/k6/common/client.js +++ b/infra/k6/common/client.js @@ -1,5 +1,6 @@ import http from 'k6/http'; import { check } from 'k6'; +import { BASE_URL } from './config.js'; /** * Обертка над стандартным HTTP-клиентом k6. @@ -9,7 +10,7 @@ export class ApiClient { * @param {string} baseUrl - Базовый адрес API. * @param {string|null} [token=null] - Bearer токен для авторизации. */ - constructor(baseUrl, token = null) { + constructor({ baseUrl = BASE_URL, token = null } = {}) { this.baseUrl = baseUrl; this.token = token; } @@ -19,29 +20,65 @@ export class ApiClient { * @private * @returns {Object.} */ - _getHeaders() { - const headers = { 'Content-Type': 'application/json' }; + _getHeaders(useJsonDefault = true, extraHeaders = {}) { + const headers = {}; if (this.token) { headers['Authorization'] = `Bearer ${this.token}`; } - return headers; + if (useJsonDefault) { + headers['Content-Type'] = 'application/json'; + } + return Object.assign(headers, extraHeaders); } /** - * Выполняет GET запрос. - * @param {string} path - Относительный путь (напр. '/tasks'). - * @param {Object.} [params] - Query-параметры. - * @returns {import('k6/http').RefinedResponse} + * Формирует параметры запроса (headers/cookies/tags). + * @private + * @param {Object} [options] - Доп. параметры запроса. + * @param {Object.} [options.headers] - Доп. заголовки. + * @param {Object.} [options.cookies] - Cookies для запроса. + * @param {Object.} [options.tags] - Tags для метрик k6. + * @param {boolean} [useJsonDefault=true] - Добавлять ли JSON Content-Type по умолчанию. + * @returns {Object} + */ + _buildOptions(options = {}, useJsonDefault = true) { + const headers = this._getHeaders(useJsonDefault, options.headers || {}); + const reqOptions = { headers }; + + if (options.cookies) { + reqOptions.cookies = options.cookies; + } + if (options.tags) { + reqOptions.tags = options.tags; + } + + return reqOptions; + } + + /** + * Формирует строку query-параметров. + * @private + * @param {Object.} [params] - Query-параметры. + * @returns {string} */ - get(path, params = {}) { - const query = Object.keys(params).length + _buildQuery(params = {}) { + return Object.keys(params).length ? `?${Object.entries(params) .map(([k, v]) => `${k}=${v}`) .join('&')}` : ''; + } - const res = http.get(`${this.baseUrl}${path}`, { headers: this._getHeaders() }); - this._logError(res, path); + /** + * Выполняет GET запрос. + * @param {string} path - Относительный путь (напр. '/tasks'). + * @param {Object.} [params] - Query-параметры. + * @returns {import('k6/http').RefinedResponse} + */ + get(path, params = {}, options = {}) { + const query = this._buildQuery(params); + const res = http.get(`${this.baseUrl}${path}${query}`, this._buildOptions(options)); + this._logError(res, 'GET', path); return res; } @@ -51,11 +88,15 @@ export class ApiClient { * @param {Object} body - Объект данных (будет преобразован в JSON). * @returns {import('k6/http').RefinedResponse} */ - post(path, body) { - const res = http.post(`${this.baseUrl}${path}`, JSON.stringify(body), { - headers: this._getHeaders(), - }); - this._logError(res, path); + post(path, body, options = {}) { + const useJsonDefault = !options.rawBody; + const payload = options.rawBody ? body : JSON.stringify(body); + const res = http.post( + `${this.baseUrl}${path}`, + payload, + this._buildOptions(options, useJsonDefault), + ); + this._logError(res, 'POST', path); return res; } @@ -65,10 +106,14 @@ export class ApiClient { * @param {Object} body - Данные для частичного обновления. * @returns {import('k6/http').RefinedResponse} */ - patch(path, body) { - const res = http.patch(`${this.baseUrl}${path}`, JSON.stringify(body), { - headers: this._getHeaders(), - }); + patch(path, body, options = {}) { + const useJsonDefault = !options.rawBody; + const payload = options.rawBody ? body : JSON.stringify(body); + const res = http.patch( + `${this.baseUrl}${path}`, + payload, + this._buildOptions(options, useJsonDefault), + ); this._logError(res, 'PATCH', path); return res; } @@ -79,10 +124,14 @@ export class ApiClient { * @param {Object} body - Данные для полного обновления. * @returns {import('k6/http').RefinedResponse} */ - put(path, body) { - const res = http.put(`${this.baseUrl}${path}`, JSON.stringify(body), { - headers: this._getHeaders(), - }); + put(path, body, options = {}) { + const useJsonDefault = !options.rawBody; + const payload = options.rawBody ? body : JSON.stringify(body); + const res = http.put( + `${this.baseUrl}${path}`, + payload, + this._buildOptions(options, useJsonDefault), + ); this._logError(res, 'PUT', path); return res; } @@ -92,10 +141,8 @@ export class ApiClient { * @param {string} path - Относительный путь. * @returns {import('k6/http').RefinedResponse} */ - del(path) { - const res = http.del(`${this.baseUrl}${path}`, null, { - headers: this._getHeaders(), - }); + delete(path, options = {}) { + const res = http.del(`${this.baseUrl}${path}`, null, this._buildOptions(options, false)); this._logError(res, 'DELETE', path); return res; } @@ -107,13 +154,13 @@ export class ApiClient { * @param {string} method - Название HTTP метода для лога. * @param {string} path - Путь запроса для лога. */ - _logError(res, path) { + _logError(res, method, path) { check(res, { - [`${path} status is 2xx`]: (r) => r.status >= 200 && r.status < 300, + [`${method} ${path} status is 2xx`]: (r) => r.status >= 200 && r.status < 300, }); if (res.status >= 400) { - console.error(`Error on ${path}: [${res.status}] ${res.body}`); + console.error(`Error on ${method} ${path}: [${res.status}] ${res.body}`); } } } diff --git a/infra/k6/common/config.js b/infra/k6/common/config.js index 8d55888..a6b8ccc 100644 --- a/infra/k6/common/config.js +++ b/infra/k6/common/config.js @@ -64,7 +64,9 @@ export const GET_OPTIONS = () => { const profile = PROFILES[profileName] || PROFILES.smoke; return { - ...profile, + vus: profile.vus, + duration: profile.duration, + stages: profile.stages, thresholds: THRESHOLDS, }; }; From 1e8d90bf55a15a6dd029128e4f3cf1b2f06fb76e Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 29 Apr 2026 00:58:10 +0300 Subject: [PATCH 2/6] refactor(load-test): simplify scenarios by using centralized ApiClient --- infra/k6/scenarios/auth.js | 61 +++----- infra/k6/scenarios/users.js | 244 ++++++++++--------------------- infra/k6/shared/get-auth-user.js | 36 +++++ infra/k6/shared/sign-in.js | 30 ---- 4 files changed, 133 insertions(+), 238 deletions(-) create mode 100644 infra/k6/shared/get-auth-user.js delete mode 100644 infra/k6/shared/sign-in.js diff --git a/infra/k6/scenarios/auth.js b/infra/k6/scenarios/auth.js index e6a38da..5a76e9c 100644 --- a/infra/k6/scenarios/auth.js +++ b/infra/k6/scenarios/auth.js @@ -1,44 +1,31 @@ import { SharedArray } from 'k6/data'; -import http from 'k6/http'; -import { check, sleep } from 'k6'; -import signIn from '../shared/sign-in.js'; +import { sleep } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; const users = new SharedArray('test users', function () { return JSON.parse(open('../data/users.json')); }); -const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000/api/v1'; -const VUS = parseInt(__ENV.VUS) || 10; -const DURATION = __ENV.DURATION || '1m'; +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:auth-refresh}': ['p(95)<333'], + 'http_req_duration{name:auth-sign-out}': ['p(95)<333'], +}); -export const options = { - thresholds: { - 'http_req_duration{name:sign-in}': ['p(95)<800'], - 'http_req_duration{name:refresh}': ['p(95)<200'], - 'http_req_duration{name:sign-out}': ['p(95)<200'], - http_req_failed: ['rate<0.1'], - }, - scenarios: { - auth_load_test: { - executor: 'constant-vus', - vus: VUS, - duration: DURATION, - }, - }, -}; +export const options = baseOptions; export default function () { const user = users[(__VU - 1) % users.length]; - - // --- SIGN-IN --- - const { signInToken, signInCookie } = signIn(BASE_URL, user); + const { client, token, refreshCookie } = getAuthUser(user); sleep(1); // --- REFRESH --- - const refreshRes = http.post(`${BASE_URL}/auth/refresh`, null, { - tags: { name: 'refresh' }, - cookies: { refresh: signInCookie }, + const refreshRes = client.post('/auth/refresh', null, { + cookies: { refresh: refreshCookie }, + tags: { name: 'auth-refresh' }, }); const newAccessToken = refreshRes.json().token; @@ -46,31 +33,23 @@ export default function () { ? refreshRes.cookies.refresh[0].value : 'NOT_ROTATED'; - check(refreshRes, { - 'refresh: status is 200': (r) => r.status === 200, - }); - sleep(1); // --- SIGN OUT --- - const refreshToken = newAccessToken || signInToken; - const refreshCookie = newRefreshCookie !== 'NOT_ROTATED' ? newRefreshCookie : signInCookie; + const refreshToken = newAccessToken || token; + const signOutCookie = newRefreshCookie !== 'NOT_ROTATED' ? newRefreshCookie : refreshCookie; - const signOutRes = http.post( - `${BASE_URL}/auth/sign-out`, + client.post( + '/auth/sign-out', {}, { headers: { Authorization: `Bearer ${refreshToken}`, }, - tags: { name: 'sign-out' }, - cookies: { refresh: refreshCookie }, + cookies: { refresh: signOutCookie }, + tags: { name: 'auth-sign-out' }, }, ); - check(signOutRes, { - 'sign-out: status is 200': (r) => r.status === 200, - }); - sleep(1); } diff --git a/infra/k6/scenarios/users.js b/infra/k6/scenarios/users.js index bdb87ae..b025bfe 100644 --- a/infra/k6/scenarios/users.js +++ b/infra/k6/scenarios/users.js @@ -1,45 +1,25 @@ import { SharedArray } from 'k6/data'; import http from 'k6/http'; -import { check, sleep } from 'k6'; +import { sleep } from 'k6'; import { FormData } from 'https://jslib.k6.io/formdata/0.0.2/index.js'; -import signIn from '../shared/sign-in.js'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.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 baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:users-me}': ['p(95)<333'], + 'http_req_duration{name:users-activity}': ['p(95)<333'], + 'http_req_duration{name:users-patch}': ['p(95)<333'], + 'http_req_duration{name:users-avatar}': ['p(95)<333'], + 'http_req_duration{name:users-notifications}': ['p(95)<333'], +}); + +export const options = baseOptions; const avatar = open('../data/user-avatar.png', 'b'); const randomBool = () => Math.random() < 0.5; @@ -47,141 +27,71 @@ 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]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- GET /me --- + client.get('/users/me', {}, { tags: { name: 'users-me' } }); + + sleep(1); + + // --- GET /me/activity --- + const randomPage = Math.floor(Math.random() * 5) + 1; + const randomLimit = Math.floor(Math.random() * 15) + 5; + client.get( + '/users/me/activity', + { page: randomPage, limit: randomLimit }, + { tags: { name: 'users-activity' } }, + ); + + sleep(1); + + // --- PATCH /me --- + const meBody = { + firstName: `Name_${randomStr(5)}`, + lastName: `Surname_${randomStr(5)}`, + bio: `Testing bio with random data: ${randomStr(30)}`, + language: Math.random() > 0.5 ? 'ru' : 'en', + }; + + client.patch('/users/me', meBody, { tags: { name: 'users-patch' } }); + + sleep(1); + + // --- POST /me/avatar --- + const fd = new FormData(); + fd.append('file', http.file(avatar, 'avatar.png', 'image/png')); + + client.post('/users/me/avatar', fd.body(), { + rawBody: true, + headers: { + 'Content-Type': `multipart/form-data; boundary=${fd.boundary}`, + }, + tags: { name: 'users-avatar' }, + }); + + sleep(1); + + // --- PATCH /me/notifications --- + const notificationsBody = { + email: { + task_assigned: randomBool(), + mentions: randomBool(), + daily_summary: randomBool(), + }, + push: { + task_assigned: randomBool(), + reminders: randomBool(), + }, + }; + + client.patch('/users/me/notifications', notificationsBody, { + tags: { name: 'users-notifications' }, + }); - 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); - } + sleep(1); } diff --git a/infra/k6/shared/get-auth-user.js b/infra/k6/shared/get-auth-user.js new file mode 100644 index 0000000..596ac03 --- /dev/null +++ b/infra/k6/shared/get-auth-user.js @@ -0,0 +1,36 @@ +import { check } from 'k6'; +import { ApiClient } from '../common/client.js'; + +export default function getAuthUser(user, options = {}) { + const client = new ApiClient(); + const requestOptions = Object.assign({}, options); + + if (!requestOptions.tags) { + requestOptions.tags = { name: 'auth-sign-in' }; + } + + const signInRes = client.post( + '/auth/sign-in', + { + email: user.email, + password: user.password, + }, + requestOptions, + ); + + check(signInRes, { + 'POST /auth/sign-in has token': (r) => r.json().token !== undefined, + }); + + const token = signInRes.json().token; + const refreshCookie = signInRes.cookies.refresh + ? signInRes.cookies.refresh[0].value + : 'MISSING'; + + return { + client: new ApiClient({ token }), + token, + refreshCookie, + signInRes, + }; +} diff --git a/infra/k6/shared/sign-in.js b/infra/k6/shared/sign-in.js deleted file mode 100644 index 8e2c310..0000000 --- a/infra/k6/shared/sign-in.js +++ /dev/null @@ -1,30 +0,0 @@ -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, - }; -} From 0b98087664ebd81a9e1135ef512a3def54526242 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 29 Apr 2026 00:58:30 +0300 Subject: [PATCH 3/6] chore(load-test): enhance db seeding with teams and tags, separate seed and clear --- infra/k6/scripts/db-seed.ts | 133 ++++++++++++++++++++++++++---------- 1 file changed, 96 insertions(+), 37 deletions(-) diff --git a/infra/k6/scripts/db-seed.ts b/infra/k6/scripts/db-seed.ts index 20e7268..f233861 100644 --- a/infra/k6/scripts/db-seed.ts +++ b/infra/k6/scripts/db-seed.ts @@ -3,24 +3,22 @@ dotenv.config(); import { createId } from '@paralleldrive/cuid2'; import * as argon from 'argon2'; -import { drizzle } from 'drizzle-orm/node-postgres'; +import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; import { writeFileSync, mkdirSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; -import * as sc from '../../../src/modules/user/entities'; +import * as sc from '../../../src/shared/entities/index'; import { sql } from 'drizzle-orm'; -async function seed() { - const DB_URL = process.env.DATABASE_URL; - if (!DB_URL) throw new Error('DATABASE_URL is not defined in .env'); +const DB_URL = process.env.DATABASE_URL; +async function seed(db: NodePgDatabase) { const COUNT = 1000; - const OUT_FILE = resolve(process.cwd(), 'infra/k6/data/users.json'); - - console.log(`Start seeding ${COUNT} users using pg driver...`); + const OUT_USERS_FILE = resolve(process.cwd(), 'infra/k6/data/users.json'); + const OUT_TEAMS_FILE = resolve(process.cwd(), 'infra/k6/data/teams.json'); + const OUT_TAGS_FILE = resolve(process.cwd(), 'infra/k6/data/tags.json'); - const pool = new Pool({ connectionString: DB_URL }); - const db = drizzle(pool, { schema: sc }); + console.log(`Start seeding using pg driver...`); const password = 'TestPassword123!'; const passwordHash = await argon.hash(password); @@ -29,26 +27,61 @@ async function seed() { const securityToInsert = []; const notificationsToInsert = []; const activitiesToInsert = []; - const k6Data = []; + const usersData = []; + const teamsData = []; + const tagsData = []; + const teamsToInsert = []; + const tagsToInsert = []; + const teamsToTagsToInsert = []; + const teamMembersToInsert = []; for (let i = 0; i < COUNT; i++) { const userId = createId(); + const teamId = createId(); + const tagId = createId(); const email = `k6_user_${i}@tasktracker.com`; - usersToInsert.push({ + const user = { id: userId, email, firstName: 'K6', lastName: `User ${i}`, timezone: 'UTC', language: 'ru', + }; + const team = { + id: teamId, + ownerId: userId, + name: `k6_team_${i}`, + slug: `k6_team_${i}`, + description: `description team - ${i}`, + }; + const tag = { + id: tagId, + name: `k6_tag_${i}`, + }; + const teamMember = { + teamId: teamId, + userId: userId, + role: 'owner', + status: 'active', + joinedAt: new Date(), + }; + + usersToInsert.push(user); + teamsToInsert.push(team); + tagsToInsert.push(tag); + teamsToTagsToInsert.push({ + teamId, + tagId, }); - + teamMembersToInsert.push(teamMember); securityToInsert.push({ userId, passwordHash }); - notificationsToInsert.push({ userId }); - k6Data.push({ email, password }); + usersData.push({ email, password }); + teamsData.push(team); + tagsData.push(tag); for (let j = 0; j < 10; j++) { activitiesToInsert.push({ @@ -66,36 +99,62 @@ async function seed() { } } - console.log('Cleaning up ONLY k6 test users...'); await db.transaction(async (tx) => { - await tx.delete(sc.users).where(sql`${sc.users.email} LIKE 'k6_user_%'`); + 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); + await tx.insert(sc.userActivity).values(chunk); + } + await tx.insert(sc.teams).values(teamsToInsert); + await tx.insert(sc.tags).values(tagsToInsert); + await tx.insert(sc.teamsToTags).values(teamsToTagsToInsert); + await tx.insert(sc.teamMembers).values(teamMembersToInsert); }); - console.log('Inserting new test users'); - try { - await db.transaction(async (tx) => { - 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); - } - }); + const filesToSave = [ + { path: OUT_USERS_FILE, data: usersData }, + { path: OUT_TEAMS_FILE, data: teamsData }, + { path: OUT_TAGS_FILE, data: tagsData }, + ]; + + for (const { path, data } of filesToSave) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(data, null, 2)); + } + + console.log(`Success! Created ${COUNT} entries for each entity`); + console.log(`User data saved to: ${OUT_USERS_FILE}`); + console.log(`Teams data saved to: ${OUT_TEAMS_FILE}`); + console.log(`Tags data saved to: ${OUT_TAGS_FILE}`); +} - mkdirSync(dirname(OUT_FILE), { recursive: true }); - writeFileSync(OUT_FILE, JSON.stringify(k6Data, null, 2)); +async function clearDB(db) { + console.log('Cleaning up ONLY k6 test data...'); + return await db.transaction(async (tx) => { + await tx.delete(sc.users).where(sql`${sc.users.email} LIKE 'k6_user_%'`); + await tx.delete(sc.teams).where(sql`${sc.teams.name} LIKE 'k6_team_%'`); + await tx.delete(sc.tags).where(sql`${sc.tags.name} LIKE 'k6_tag_%'`); + }); +} - console.log(`Success! ${COUNT} users created.`); - console.log(`Credentials saved to: ${OUT_FILE}`); +async function main() { + if (!DB_URL) throw new Error('DATABASE_URL is not defined in .env'); + + const pool = new Pool({ connectionString: DB_URL }); + const db = drizzle(pool, { schema: sc }); + try { + await clearDB(db); + await seed(db); } catch (e) { - console.error('Seed failed:', e); + console.error('Error:', e); + process.exit(1); } finally { await pool.end(); } } -seed(); +main(); From cc17cf6930f9e42ee29d818e37d66ad662568409 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 29 Apr 2026 01:00:13 +0300 Subject: [PATCH 4/6] feat(load-test): add teams load testing scenarios for teams.controller endpoints --- infra/k6/package.json | 2 +- infra/k6/scenarios/teams.js | 68 +++++++++++++++++++++++++++++++++++++ package.json | 2 +- 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 infra/k6/scenarios/teams.js diff --git a/infra/k6/package.json b/infra/k6/package.json index ee27805..b82ea65 100644 --- a/infra/k6/package.json +++ b/infra/k6/package.json @@ -5,7 +5,7 @@ "scripts": { "test:all": "k6 run scenarios/stress-full.js", "test:auth": "k6 run scenarios/auth.js", - "test:team": "k6 run scenarios/team.js", + "test:teams": "k6 run scenarios/teams.js", "test:projects": "k6 run scenarios/projects.js", "test:users": "k6 run scenarios/users.js", "test:board": "k6 run scenarios/board-full.js", diff --git a/infra/k6/scenarios/teams.js b/infra/k6/scenarios/teams.js new file mode 100644 index 0000000..7a4060c --- /dev/null +++ b/infra/k6/scenarios/teams.js @@ -0,0 +1,68 @@ +import { SharedArray } from 'k6/data'; +import { sleep } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:teams-create}': ['p(95)<333'], + 'http_req_duration{name:teams-check-slug}': ['p(95)<333'], + 'http_req_duration{name:teams-find-one}': ['p(95)<333'], + 'http_req_duration{name:teams-update}': ['p(95)<333'], + 'http_req_duration{name:teams-delete}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- POST /teams --- + const slug = randomStr(10); + const team = { + name: 'k6_test_team_' + slug, + description: randomStr(15), + slug: slug, + }; + client.post('/teams', team, { tags: { name: 'teams-create' } }); + + sleep(1); + + // --- GET /check-slug/:slug --- + client.get(`/teams/check-slug/${slug}`, {}, { tags: { name: 'teams-check-slug' } }); + + sleep(1); + + // --- GET /:slug --- + client.get(`/teams/${slug}`, {}, { tags: { name: 'teams-find-one' } }); + + sleep(1); + + // --- PATCH /:slug --- + const updatedTeam = { + description: randomStr(25), + }; + client.patch(`/teams/${slug}`, updatedTeam, { + tags: { name: 'teams-update' }, + }); + + sleep(1); + + // --- DELETE /:slug --- + client.delete(`/teams/${slug}`, { + tags: { name: 'teams-delete' }, + }); + + sleep(1); +} diff --git a/package.json b/package.json index 85573ad..9647507 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "prepare": "husky", "k6:all": "pnpm --filter @project/performance-tests test:all", "k6:auth": "pnpm --filter @project/performance-tests test:auth", - "k6:team": "pnpm --filter @project/performance-tests test:team", + "k6:teams": "pnpm --filter @project/performance-tests test:teams", "k6:projects": "pnpm --filter @project/performance-tests test:projects", "k6:users": "pnpm --filter @project/performance-tests test:users", "k6:board": "pnpm --filter @project/performance-tests test:board", From dcf9e9c463ccd81f949ccdbebc2d4d19f1ad73f9 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 29 Apr 2026 19:46:45 +0300 Subject: [PATCH 5/6] feat(load-test): rename seed script and add redis seed --- .../scripts/{db-seed.ts => seed-k6-data.ts} | 103 ++++++++++++++++-- package.json | 2 +- 2 files changed, 96 insertions(+), 9 deletions(-) rename infra/k6/scripts/{db-seed.ts => seed-k6-data.ts} (59%) diff --git a/infra/k6/scripts/db-seed.ts b/infra/k6/scripts/seed-k6-data.ts similarity index 59% rename from infra/k6/scripts/db-seed.ts rename to infra/k6/scripts/seed-k6-data.ts index f233861..190d32e 100644 --- a/infra/k6/scripts/db-seed.ts +++ b/infra/k6/scripts/seed-k6-data.ts @@ -5,14 +5,21 @@ import { createId } from '@paralleldrive/cuid2'; import * as argon from 'argon2'; import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; -import { writeFileSync, mkdirSync } from 'node:fs'; +import { writeFileSync, mkdirSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import * as sc from '../../../src/shared/entities/index'; import { sql } from 'drizzle-orm'; +import Redis from 'ioredis'; const DB_URL = process.env.DATABASE_URL; - -async function seed(db: NodePgDatabase) { +const REDIS_URL = `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`; +const KEYS = { + INVITE: (code: string) => `inv:code:${code}`, + TEAM_INVITES: (teamId: string) => `team:invites:${teamId}`, + USER_INVITES: (email: string) => `user:invites:${email.toLowerCase()}`, +}; + +async function seed_db(db: NodePgDatabase) { const COUNT = 1000; const OUT_USERS_FILE = resolve(process.cwd(), 'infra/k6/data/users.json'); const OUT_TEAMS_FILE = resolve(process.cwd(), 'infra/k6/data/teams.json'); @@ -132,8 +139,69 @@ async function seed(db: NodePgDatabase) { console.log(`Tags data saved to: ${OUT_TAGS_FILE}`); } -async function clearDB(db) { - console.log('Cleaning up ONLY k6 test data...'); +async function seed_redis(redis: Redis) { + console.log('Seeding Redis with OTP codes...'); + const multi = redis.multi(); + + const dataDir = resolve(process.cwd(), 'infra/k6/data'); + const users = JSON.parse(readFileSync(`${dataDir}/users.json`, 'utf-8')) as { + email: string; + }[]; + const teams = JSON.parse(readFileSync(`${dataDir}/teams.json`, 'utf-8')) as { + id: string; + ownerId: string; + name: string; + slug: string; + description: string; + }[]; + + const INVITE_TTL = 86400; + const INVITES_PER_TEAM = 10; + + const invitesData = []; + teams.forEach((team, teamIdx) => { + for (let j = 1; j <= INVITES_PER_TEAM; j++) { + const inviteeIdx = (teamIdx + j) % users.length; + const invitee = users[inviteeIdx]; + + const code = `INV_${teamIdx}_${inviteeIdx}`; + + const inviteData = { + teamId: team.id, + teamName: team.name, + teamAvatar: + 'https://cdn.pixabay.com/photo/2016/08/08/09/17/avatar-1577909_1280.png', + email: invitee.email, + role: 'member', + inviterId: team.ownerId, + inviterName: `Owner of ${team.name}`, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + INVITE_TTL * 1000).toISOString(), + }; + + multi.set(KEYS.INVITE(code), JSON.stringify(inviteData), 'EX', INVITE_TTL); + multi.sadd(KEYS.TEAM_INVITES(team.id), code); + multi.sadd(KEYS.USER_INVITES(invitee.email), code); + + invitesData.push({ + code, + email: invitee.email, + teamSlug: team.slug, + }); + } + }); + + await multi.exec(); + + const OUT_FILE = `${dataDir}/invites.json`; + writeFileSync(OUT_FILE, JSON.stringify(invitesData, null, 2)); + + console.log(`Success! Redis seeded. Created ${invitesData.length} unique invites.`); + console.log(`Invites data saved to: ${OUT_FILE}`); +} + +async function clearDB(db: NodePgDatabase) { + console.log('Cleaning up ONLY k6 test data from DB...'); return await db.transaction(async (tx) => { await tx.delete(sc.users).where(sql`${sc.users.email} LIKE 'k6_user_%'`); await tx.delete(sc.teams).where(sql`${sc.teams.name} LIKE 'k6_team_%'`); @@ -141,19 +209,38 @@ async function clearDB(db) { }); } -async function main() { - if (!DB_URL) throw new Error('DATABASE_URL is not defined in .env'); +async function clearRedis(redis: Redis) { + console.log('Cleaning up ONLY k6 test data from Redis...'); + const SCAN_PATTERNS = Object.values(KEYS).map((fn) => fn('*')); + + for (const pattern of SCAN_PATTERNS) { + let cursor = '0'; + do { + const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + cursor = nextCursor; + if (keys.length > 0) await redis.del(...keys); + } while (cursor !== '0'); + } +} +async function main() { + if (!DB_URL || !REDIS_URL) + throw new Error('DATABASE_URL OR REDIS_HOST, REDIS_PORT is not defined in .env'); + const redis = new Redis(REDIS_URL); const pool = new Pool({ connectionString: DB_URL }); const db = drizzle(pool, { schema: sc }); + try { await clearDB(db); - await seed(db); + await clearRedis(redis); + await seed_db(db); + await seed_redis(redis); } catch (e) { console.error('Error:', e); process.exit(1); } finally { await pool.end(); + await redis.quit(); } } diff --git a/package.json b/package.json index 9647507..f486a8c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "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:db-seed": "npx tsx infra/k6/scripts/db-seed.ts" + "k6:seed": "npx tsx infra/k6/scripts/seed-k6-data.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.1029.0", From 5e63365e1640a9a66ad2c92c19f3dae099e14a91 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 29 Apr 2026 19:47:24 +0300 Subject: [PATCH 6/6] feat(load-test): rename client to api-client and add redis-client --- infra/k6/common/{client.js => api-client.js} | 0 infra/k6/common/config.js | 1 + infra/k6/common/redis-client.js | 64 ++++++++++++++++++++ infra/k6/shared/get-auth-user.js | 2 +- 4 files changed, 66 insertions(+), 1 deletion(-) rename infra/k6/common/{client.js => api-client.js} (100%) create mode 100644 infra/k6/common/redis-client.js diff --git a/infra/k6/common/client.js b/infra/k6/common/api-client.js similarity index 100% rename from infra/k6/common/client.js rename to infra/k6/common/api-client.js diff --git a/infra/k6/common/config.js b/infra/k6/common/config.js index a6b8ccc..c4388ca 100644 --- a/infra/k6/common/config.js +++ b/infra/k6/common/config.js @@ -1,4 +1,5 @@ export const BASE_URL = __ENV.BASE_URL || 'http://0.0.0.0:3000/api/v1'; +export const REDIS_URL = __ENV.REDIS_URL || 'http://localhost:7000'; /** * Профили нагрузки (Workload Profiles). diff --git a/infra/k6/common/redis-client.js b/infra/k6/common/redis-client.js new file mode 100644 index 0000000..4644f88 --- /dev/null +++ b/infra/k6/common/redis-client.js @@ -0,0 +1,64 @@ +import redis from 'k6/x/redis'; +import { REDIS_URL } from './config.js'; + +/** + * Обертка для работы с Redis в k6. + */ +export class RedisClient { + /** + * @param {string} url - URL редиса (напр. 'redis://localhost:6379'). + */ + constructor(url = REDIS_URL) { + this.client = redis.connect(url); + } + + /** + * Формирует ключи по тем же правилам, что и бэкенд/сидер. + * @private + */ + _keys = { + invite: (code) => `inv:code:${code}`, + teamInvites: (teamId) => `team:invites:${teamId}`, + userInvites: (email) => `user:invites:${email.toLowerCase()}`, + otp: (email) => `otp:${email.toLowerCase()}`, + }; + + /** + * Получает OTP код для юзера. + * @param {string} email + * @returns {string|null} + */ + getOtp(email) { + return redis.get(this.client, this._keys.otp(email)); + } + + /** + * Получает данные инвайта по коду. + * @param {string} code + * @returns {Object|null} + */ + getInvite(code) { + const data = redis.get(this.client, this._keys.invite(code)); + return data ? JSON.parse(data) : null; + } + + /** + * Получает все коды инвайтов для конкретной команды (из Set). + * @param {string} teamId + * @returns {string[]} + */ + getTeamInvitesCodes(teamId) { + return redis.smembers(this.client, this._keys.teamInvites(teamId)); + } + + getUserInvitesCodes(email) { + return redis.smembers(this.client, this._keys.userInvites(email)); + } + + /** + * Удаляет ключ + */ + del(key) { + redis.del(this.client, key); + } +} diff --git a/infra/k6/shared/get-auth-user.js b/infra/k6/shared/get-auth-user.js index 596ac03..c6e813b 100644 --- a/infra/k6/shared/get-auth-user.js +++ b/infra/k6/shared/get-auth-user.js @@ -1,5 +1,5 @@ import { check } from 'k6'; -import { ApiClient } from '../common/client.js'; +import { ApiClient } from '../common/api-client.js'; export default function getAuthUser(user, options = {}) { const client = new ApiClient();