diff --git a/infra/k6/common/api-client.js b/infra/k6/common/api-client.js new file mode 100644 index 0000000..9fae98b --- /dev/null +++ b/infra/k6/common/api-client.js @@ -0,0 +1,166 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { BASE_URL } from './config.js'; + +/** + * Обертка над стандартным HTTP-клиентом k6. + */ +export class ApiClient { + /** + * @param {string} baseUrl - Базовый адрес API. + * @param {string|null} [token=null] - Bearer токен для авторизации. + */ + constructor({ baseUrl = BASE_URL, token = null } = {}) { + this.baseUrl = baseUrl; + this.token = token; + } + + /** + * Формирует заголовки запроса. + * @private + * @returns {Object.} + */ + _getHeaders(useJsonDefault = true, extraHeaders = {}) { + const headers = {}; + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + if (useJsonDefault) { + headers['Content-Type'] = 'application/json'; + } + return Object.assign(headers, extraHeaders); + } + + /** + * Формирует параметры запроса (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} + */ + _buildQuery(params = {}) { + return Object.keys(params).length + ? `?${Object.entries(params) + .map(([k, v]) => `${k}=${v}`) + .join('&')}` + : ''; + } + + /** + * Выполняет 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; + } + + /** + * Выполняет POST запрос. + * @param {string} path - Относительный путь. + * @param {Object} body - Объект данных (будет преобразован в JSON). + * @returns {import('k6/http').RefinedResponse} + */ + 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; + } + + /** + * Выполняет PATCH запрос. + * @param {string} path - Относительный путь. + * @param {Object} body - Данные для частичного обновления. + * @returns {import('k6/http').RefinedResponse} + */ + 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; + } + + /** + * Выполняет PUT запрос. + * @param {string} path - Относительный путь. + * @param {Object} body - Данные для полного обновления. + * @returns {import('k6/http').RefinedResponse} + */ + 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; + } + + /** + * Выполняет DELETE запрос. + * @param {string} path - Относительный путь. + * @returns {import('k6/http').RefinedResponse} + */ + delete(path, options = {}) { + const res = http.del(`${this.baseUrl}${path}`, null, this._buildOptions(options, false)); + this._logError(res, 'DELETE', path); + return res; + } + + /** + * Внутренняя валидация ответа и логирование ошибок. + * @private + * @param {import('k6/http').RefinedResponse} res - Объект ответа k6. + * @param {string} method - Название HTTP метода для лога. + * @param {string} path - Путь запроса для лога. + */ + _logError(res, method, path) { + check(res, { + [`${method} ${path} status is 2xx`]: (r) => r.status >= 200 && r.status < 300, + }); + + if (res.status >= 400) { + console.error(`Error on ${method} ${path}: [${res.status}] ${res.body}`); + } + } +} diff --git a/infra/k6/common/client.js b/infra/k6/common/client.js deleted file mode 100644 index 811e11c..0000000 --- a/infra/k6/common/client.js +++ /dev/null @@ -1,119 +0,0 @@ -import http from 'k6/http'; -import { check } from 'k6'; - -/** - * Обертка над стандартным HTTP-клиентом k6. - */ -export class ApiClient { - /** - * @param {string} baseUrl - Базовый адрес API. - * @param {string|null} [token=null] - Bearer токен для авторизации. - */ - constructor(baseUrl, token = null) { - this.baseUrl = baseUrl; - this.token = token; - } - - /** - * Формирует заголовки запроса. - * @private - * @returns {Object.} - */ - _getHeaders() { - const headers = { 'Content-Type': 'application/json' }; - if (this.token) { - headers['Authorization'] = `Bearer ${this.token}`; - } - return headers; - } - - /** - * Выполняет GET запрос. - * @param {string} path - Относительный путь (напр. '/tasks'). - * @param {Object.} [params] - Query-параметры. - * @returns {import('k6/http').RefinedResponse} - */ - get(path, params = {}) { - const query = 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); - return res; - } - - /** - * Выполняет POST запрос. - * @param {string} path - Относительный путь. - * @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); - return res; - } - - /** - * Выполняет PATCH запрос. - * @param {string} path - Относительный путь. - * @param {Object} body - Данные для частичного обновления. - * @returns {import('k6/http').RefinedResponse} - */ - patch(path, body) { - const res = http.patch(`${this.baseUrl}${path}`, JSON.stringify(body), { - headers: this._getHeaders(), - }); - this._logError(res, 'PATCH', path); - return res; - } - - /** - * Выполняет PUT запрос. - * @param {string} path - Относительный путь. - * @param {Object} body - Данные для полного обновления. - * @returns {import('k6/http').RefinedResponse} - */ - put(path, body) { - const res = http.put(`${this.baseUrl}${path}`, JSON.stringify(body), { - headers: this._getHeaders(), - }); - this._logError(res, 'PUT', path); - return res; - } - - /** - * Выполняет DELETE запрос. - * @param {string} path - Относительный путь. - * @returns {import('k6/http').RefinedResponse} - */ - del(path) { - const res = http.del(`${this.baseUrl}${path}`, null, { - headers: this._getHeaders(), - }); - this._logError(res, 'DELETE', path); - return res; - } - - /** - * Внутренняя валидация ответа и логирование ошибок. - * @private - * @param {import('k6/http').RefinedResponse} res - Объект ответа k6. - * @param {string} method - Название HTTP метода для лога. - * @param {string} path - Путь запроса для лога. - */ - _logError(res, path) { - check(res, { - [`${path} status is 2xx`]: (r) => r.status >= 200 && r.status < 300, - }); - - if (res.status >= 400) { - console.error(`Error on ${path}: [${res.status}] ${res.body}`); - } - } -} diff --git a/infra/k6/common/config.js b/infra/k6/common/config.js index 8d55888..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). @@ -64,7 +65,9 @@ export const GET_OPTIONS = () => { const profile = PROFILES[profileName] || PROFILES.smoke; return { - ...profile, + vus: profile.vus, + duration: profile.duration, + stages: profile.stages, thresholds: THRESHOLDS, }; }; 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/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/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/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/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/scripts/db-seed.ts b/infra/k6/scripts/db-seed.ts deleted file mode 100644 index 20e7268..0000000 --- a/infra/k6/scripts/db-seed.ts +++ /dev/null @@ -1,101 +0,0 @@ -import * as dotenv from 'dotenv'; -dotenv.config(); - -import { createId } from '@paralleldrive/cuid2'; -import * as argon from 'argon2'; -import { drizzle } 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 { 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 COUNT = 1000; - const OUT_FILE = resolve(process.cwd(), 'infra/k6/data/users.json'); - - console.log(`Start seeding ${COUNT} users using pg driver...`); - - const pool = new Pool({ connectionString: DB_URL }); - const db = drizzle(pool, { schema: sc }); - - const password = 'TestPassword123!'; - const passwordHash = await argon.hash(password); - - const usersToInsert = []; - const securityToInsert = []; - const notificationsToInsert = []; - const activitiesToInsert = []; - const k6Data = []; - - for (let i = 0; i < COUNT; i++) { - const userId = createId(); - const email = `k6_user_${i}@tasktracker.com`; - - usersToInsert.push({ - id: userId, - email, - firstName: 'K6', - lastName: `User ${i}`, - timezone: 'UTC', - language: 'ru', - }); - - securityToInsert.push({ userId, passwordHash }); - - 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...'); - await db.transaction(async (tx) => { - await tx.delete(sc.users).where(sql`${sc.users.email} LIKE 'k6_user_%'`); - }); - - 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); - } - }); - - mkdirSync(dirname(OUT_FILE), { recursive: true }); - writeFileSync(OUT_FILE, JSON.stringify(k6Data, null, 2)); - - console.log(`Success! ${COUNT} users created.`); - console.log(`Credentials saved to: ${OUT_FILE}`); - } catch (e) { - console.error('Seed failed:', e); - } finally { - await pool.end(); - } -} - -seed(); diff --git a/infra/k6/scripts/seed-k6-data.ts b/infra/k6/scripts/seed-k6-data.ts new file mode 100644 index 0000000..190d32e --- /dev/null +++ b/infra/k6/scripts/seed-k6-data.ts @@ -0,0 +1,247 @@ +import * as dotenv from 'dotenv'; +dotenv.config(); + +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, 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; +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'); + const OUT_TAGS_FILE = resolve(process.cwd(), 'infra/k6/data/tags.json'); + + console.log(`Start seeding using pg driver...`); + + const password = 'TestPassword123!'; + const passwordHash = await argon.hash(password); + + const usersToInsert = []; + const securityToInsert = []; + const notificationsToInsert = []; + const activitiesToInsert = []; + 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`; + + 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 }); + + usersData.push({ email, password }); + teamsData.push(team); + tagsData.push(tag); + + 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), + }); + } + } + + 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); + 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); + }); + + 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}`); +} + +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_%'`); + await tx.delete(sc.tags).where(sql`${sc.tags.name} LIKE 'k6_tag_%'`); + }); +} + +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 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(); + } +} + +main(); diff --git a/infra/k6/shared/get-auth-user.js b/infra/k6/shared/get-auth-user.js new file mode 100644 index 0000000..c6e813b --- /dev/null +++ b/infra/k6/shared/get-auth-user.js @@ -0,0 +1,36 @@ +import { check } from 'k6'; +import { ApiClient } from '../common/api-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, - }; -} diff --git a/package.json b/package.json index 85573ad..f486a8c 100644 --- a/package.json +++ b/package.json @@ -22,13 +22,13 @@ "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", "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",