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
166 changes: 166 additions & 0 deletions infra/k6/common/api-client.js
Original file line number Diff line number Diff line change
@@ -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.<string, string>}
*/
_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.<string, string>} [options.headers] - Доп. заголовки.
* @param {Object.<string, string>} [options.cookies] - Cookies для запроса.
* @param {Object.<string, string>} [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.<string, string|number|boolean>} [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.<string, string>} [params] - Query-параметры.
* @returns {import('k6/http').RefinedResponse<any>}
*/
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<any>}
*/
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<any>}
*/
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<any>}
*/
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<any>}
*/
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<any>} 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}`);
}
}
}
119 changes: 0 additions & 119 deletions infra/k6/common/client.js

This file was deleted.

5 changes: 4 additions & 1 deletion infra/k6/common/config.js
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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,
};
};
64 changes: 64 additions & 0 deletions infra/k6/common/redis-client.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
2 changes: 1 addition & 1 deletion infra/k6/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading