diff --git a/.dockerignore b/.dockerignore index dbafb72..6918d36 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ node_modules npm-debug.log yarn-error.log pnpm-debug.log +infra/k6/node_modules # Build output dist diff --git a/.env.example b/.env.example index 1e287c3..d858d8f 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,8 @@ PORT=3000 NODE_ENV=development COOKIE_SECRET=same-serious-secret CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +THROTTLE_LIMIT=100 +THROTTLE_TTL=60000 # --- POSTGRES --- DB_SCHEMA=base diff --git a/.gitignore b/.gitignore index 5866f4e..b0b2412 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,6 @@ pids *.pid *.seed *.pid.lock -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json \ No newline at end of file +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +infra/k6/data/*.json \ No newline at end of file diff --git a/Dockerfile.prod b/Dockerfile.prod index c68977f..e645b06 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -13,6 +13,7 @@ COPY pnpm-lock.yaml ./ # Загружаем всё в виртуальное хранилище. # Если lock-файл не менялся, этот слой будет взят из кэша +ENV CI=true RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm fetch diff --git a/infra/README.md b/infra/README.md index 57e9bf6..faec638 100644 --- a/infra/README.md +++ b/infra/README.md @@ -1,7 +1,32 @@ -# Command to run infra at dev mode +# Инфраструктура проекта -Run it by pwd at root! Not include at this dir +Данный каталог содержит конфигурации для локальной разработки и инструменты для нагрузочного тестирования. + +## Модули инфраструктуры + +### dev + +Конфигурации Docker Compose для поднятия окружения разработки (базы данных, очереди, кеш). + +Команда для запуска из корня проекта: ```sh docker compose -f ./infra/dev/compose.dev.yaml --env-file .env --profile infra up --build -d -V ``` + +### k6 + +Сценарии нагрузочного и стресс-тестирования модулей API. Инструкции по установке и запуску находятся в infra/k6/README.md. + +Команды запуска из **корня** **(../cwd)** проекта: + +```sh + pnpm run k6:all + pnpm run k6:auth + pnpm run k6:team + pnpm run k6:projects + pnpm run k6:user + pnpm run k6:board + pnpm run k6:tasks + pnpm run k6:smoke +``` diff --git a/infra/dev/compose.dev.yaml b/infra/dev/compose.dev.yaml index 50ce996..3836bf7 100644 --- a/infra/dev/compose.dev.yaml +++ b/infra/dev/compose.dev.yaml @@ -1,4 +1,4 @@ -version: '3.9' +version: "3.9" name: task-tracker-api @@ -10,7 +10,7 @@ services: env_file: - .env ports: - - '3000:3000' + - "3000:3000" depends_on: database: condition: service_healthy @@ -21,10 +21,10 @@ services: deploy: resources: limits: - cpus: '2.0' + cpus: "2.0" memory: 1024M reservations: - cpus: '0.5' + cpus: "0.5" memory: 256M database: @@ -39,21 +39,22 @@ services: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: ${DB_DATABASE} ports: - - '6000:5432' + - "6000:5432" volumes: - postgres_data:/var/lib/postgresql/data networks: - backend healthcheck: - test: [ - 'CMD-SHELL', - 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -q - || exit 1', + test: + [ + "CMD-SHELL", + "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\" -q + || exit 1", ] interval: 5s timeout: 5s retries: 5 - profiles: ['infra'] + profiles: [ "infra" ] redis: hostname: redis @@ -61,18 +62,18 @@ services: image: redis:7-alpine restart: always ports: - - '${REDIS_PORT:-6999}:6379' + - "${REDIS_PORT:-6999}:6379" command: redis-server --save 60 1 --loglevel notice volumes: - redis_data:/data networks: - backend healthcheck: - test: ['CMD', 'redis-cli', 'ping'] + test: [ "CMD", "redis-cli", "ping" ] interval: 5s timeout: 3s retries: 5 - profiles: ['infra'] + profiles: [ "infra" ] minio: hostname: minio @@ -83,14 +84,14 @@ services: MINIO_ROOT_USER: ${S3_ACCESS_KEY} MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} ports: - - '9000:9000' # API - - '9001:9001' # Console (UI) + - "9000:9000" # API + - "9001:9001" # Console (UI) command: server /data --console-address ":9001" volumes: - minio_data:/data networks: - backend - profiles: ['infra'] + profiles: [ "infra" ] minio-init: image: minio/mc:latest @@ -101,7 +102,7 @@ services: MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} networks: - backend - profiles: ['infra'] + profiles: [ "infra" ] entrypoint: > /bin/sh -c " sleep 5; mc alias set myminio http://minio:9000 ${S3_ACCESS_KEY} ${S3_SECRET_KEY}; mc mb myminio/${S3_BUCKET_NAME} diff --git a/infra/k6/README.md b/infra/k6/README.md new file mode 100644 index 0000000..c7ad017 --- /dev/null +++ b/infra/k6/README.md @@ -0,0 +1,78 @@ +# Нагрузочное тестирование k6 + +В данном каталоге расположены сценарии для проведения нагрузочного и стресс-тестирования API task-backend. Инструментарий базируется на k6 и интегрирован в монорепозиторий как отдельный пакет воркспейса. + +## Предварительные требования + +Для выполнения сценариев необходимо наличие установленного бинарного файла k6 в операционной системе. Пакет не устанавливается через менеджеры пакетов Node.js автоматически. + +### Инструкции по установке + +- Windows: winget install k6 +- macOS: brew install k6 +- Linux: sudo apt-get install k6 + +После установки необходимо перезапустить терминал для обновления переменных окружения. + +## Структура каталогов + +- modules/ — Атомарные функции для взаимодействия с конкретными модулями системы (auth, team, projects, user, board, board-columns, tasks). Содержат логику запросов и проверки статусов. +- scenarios/ — Комплексные пользовательские сценарии, имитирующие реальное поведение (например, создание полной структуры от проекта до задачи). +- scripts/ — Вспомогательные скрипты и быстрые тесты (Smoke tests). +- common/ — Общая конфигурация, параметры нагрузки (stages), пороговые значения (thresholds) и функции генерации тестовых данных. + +## Команды запуска + +Все команды должны запускаться из корневой директории проекта. + +- pnpm k6:seed — Сидинг тестовых данных для k6 (PostgreSQL + Redis). +- pnpm k6:clear — Удаление k6-тестовых данных из PostgreSQL и Redis. +- pnpm k6:smoke — Запуск проверочного теста с минимальной нагрузкой. +- pnpm k6:all — Проведение полного стресс-теста всех модулей API. +- pnpm k6:auth — Тестирование производительности модуля авторизации. +- pnpm k6:team — Тестирование производительности модуля команд. +- pnpm k6:projects — Тестирование производительности модуля проектов. +- pnpm k6:user — Тестирование производительности модуля пользователей. +- pnpm k6:board — Тестирование производительности модуля досок. +- pnpm k6:tasks — Тестирование производительности модуля задач. + +## Использование переменных окружения + +Для смены целевого адреса сервера без изменения кода сценариев используется флаг -e: + +pnpm --filter @project/performance-tests exec k6 run -e BASE_URL=https://api.example.com scenarios/stress-full.js + +## Анализ результатов + +При анализе отчетов следует ориентироваться на следующие метрики: + +- http_req_duration: Общее время обработки запроса сервером. +- http_req_failed: Процент запросов, завершившихся ошибкой. +- vus: Количество виртуальных пользователей в активной фазе теста. +- thresholds: Статус выполнения установленных критериев качества (SLA). + +## Конфигурация и профили нагрузки + +Система поддерживает динамическое переключение интенсивности тестирования с помощью переменных окружения. + +### Доступные профили (PROFILE) + +В `common/config.js` определены следующие уровни нагрузки: + +| Профиль | Описание | Нагрузка | +| :------- | :--------------------------- | :------------------- | +| `smoke` | Быстрая проверка доступности | 1 VU, 10 секунд | +| `low` | Базовая стабильность | 10 VUs, разгон 30с | +| `medium` | Стандартная рабочая нагрузка | 50 VUs, плато 3 мин | +| `high` | Проверка предела прочности | 300 VUs, плато 5 мин | + +### Примеры запуска с профилями + +Для управления нагрузкой передайте переменную `PROFILE`: + +```sh +pnpm k6:tasks -e PROFILE=medium + +# Запуск стресс-теста на кастомный URL +pnpm k6:all -e PROFILE=high -e BASE_URL=[http://staging-api.local](http://staging-api.local) +``` 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/config.js b/infra/k6/common/config.js new file mode 100644 index 0000000..9b86f40 --- /dev/null +++ b/infra/k6/common/config.js @@ -0,0 +1,73 @@ +export const BASE_URL = __ENV.BASE_URL || 'http://0.0.0.0:3000/v1'; +export const REDIS_URL = __ENV.REDIS_URL || 'http://localhost:7000'; + +/** + * Профили нагрузки (Workload Profiles). + * Описывают поведение виртуальных пользователей (VUs) во времени. + * * @typedef {Object} Stage + * @property {string} duration - Продолжительность этапа (напр. '2m') + * @property {number} target - Целевое количество активных пользователей + * * @typedef {Object} Profile + * @property {number} [vus] - Фиксированное количество пользователей + * @property {string} [duration] - Общая продолжительность теста + * @property {Stage[]} [stages] - Этапы изменения нагрузки + */ +/** @type {Object.} */ +export const PROFILES = { + /** Минимальная проверка доступности: 1 юзер, 10 секунд */ + smoke: { + vus: 1, + duration: '10s', + }, + /** Низкая нагрузка: проверка базовой стабильности (10 юзеров) */ + low: { + stages: [ + { duration: '30s', target: 10 }, + { duration: '1m', target: 10 }, + { duration: '30s', target: 0 }, + ], + }, + /** Средняя нагрузка: имитация нормальной рабочей нагрузки (50 юзеров) */ + medium: { + stages: [ + { duration: '1m', target: 50 }, + { duration: '3m', target: 50 }, + { duration: '1m', target: 0 }, + ], + }, + /** Высокая нагрузка: поиск предела производительности (300 юзеров) */ + high: { + stages: [ + { duration: '2m', target: 300 }, + { duration: '5m', target: 300 }, + { duration: '2m', target: 0 }, + ], + }, +}; + +/** * Критерии успеха (Thresholds). + * Если метрики выходят за эти пределы, k6 завершает тест с ошибкой. + * @type {Object.} + */ +export const THRESHOLDS = { + /** Допустимый процент ошибок: менее 1% */ + http_req_failed: ['rate<0.01'], + /** Допустимое время ответа: 95-й перцентиль должен быть быстрее 200мс */ + http_req_duration: ['p(95)<200'], +}; + +/** + * Автоматически выбирает профиль на основе переменной окружения. + * Использование в сценарии: export const options = GET_OPTIONS(); + */ +export const GET_OPTIONS = () => { + const profileName = __ENV.PROFILE || 'smoke'; + const profile = PROFILES[profileName] || PROFILES.smoke; + + return { + 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/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/modules/.gitkeep b/infra/k6/modules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/infra/k6/package.json b/infra/k6/package.json new file mode 100644 index 0000000..b82ea65 --- /dev/null +++ b/infra/k6/package.json @@ -0,0 +1,16 @@ +{ + "name": "@project/performance-tests", + "version": "0.0.1", + "description": "Нагрузочные и стресс-тесты API бэкенда с использованием k6", + "scripts": { + "test:all": "k6 run scenarios/stress-full.js", + "test:auth": "k6 run scenarios/auth.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", + "test:tasks": "k6 run scenarios/tasks.js", + "smoke": "k6 run smoke.js" + }, + "packageManager": "pnpm@10.33.0" +} diff --git a/infra/k6/scenarios/.gitkeep b/infra/k6/scenarios/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/infra/k6/scenarios/auth.js b/infra/k6/scenarios/auth.js new file mode 100644 index 0000000..5a76e9c --- /dev/null +++ b/infra/k6/scenarios/auth.js @@ -0,0 +1,55 @@ +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 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 = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const { client, token, refreshCookie } = getAuthUser(user); + + sleep(1); + + // --- REFRESH --- + const refreshRes = client.post('/auth/refresh', null, { + cookies: { refresh: refreshCookie }, + tags: { name: 'auth-refresh' }, + }); + + const newAccessToken = refreshRes.json().token; + const newRefreshCookie = refreshRes.cookies.refresh + ? refreshRes.cookies.refresh[0].value + : 'NOT_ROTATED'; + + sleep(1); + + // --- SIGN OUT --- + const refreshToken = newAccessToken || token; + const signOutCookie = newRefreshCookie !== 'NOT_ROTATED' ? newRefreshCookie : refreshCookie; + + client.post( + '/auth/sign-out', + {}, + { + headers: { + Authorization: `Bearer ${refreshToken}`, + }, + cookies: { refresh: signOutCookie }, + tags: { name: 'auth-sign-out' }, + }, + ); + + sleep(1); +} diff --git a/infra/k6/scenarios/projects.js b/infra/k6/scenarios/projects.js new file mode 100644 index 0000000..9fe8785 --- /dev/null +++ b/infra/k6/scenarios/projects.js @@ -0,0 +1,117 @@ +import { SharedArray } from 'k6/data'; +import { sleep, check } 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 teams = new SharedArray('test teams', function () { + return JSON.parse(open('../data/teams.json')); +}); +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); +const randomNum = (min = 1, max = 100) => Math.floor(Math.random() * (max - min + 1)) + min; +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:post-teams-projects}': ['p(95)<333'], + 'http_req_duration{name:teams-projects}': ['p(95)<333'], + 'http_req_duration{name:teams-projects-id}': ['p(95)<333'], + 'http_req_duration{name:teams-projects-generate-token}': ['p(95)<333'], + 'http_req_duration{name:teams-projects-archive}': ['p(95)<333'], + 'http_req_duration{name:delete-teams-projects}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const team = teams[(__VU - 1) % users.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- create project --- + const projectName = randomStr(); + const project = { + name: projectName, + key: `QWE${randomNum(1000, 9999)}`, + description: 'description for k6_test_project', + visibility: 'public', + }; + const createRes = client.post(`/teams/${team.slug}/projects`, project, { + tags: { name: 'post-teams-projects' }, + }); + const projectId = createRes.json().projectId; + + sleep(1); + + // --- update project --- + const newProjectName = randomStr(); + const updatedProject = { + name: newProjectName, + }; + client.patch(`/teams/${team.slug}/projects/${projectId}`, updatedProject, { + tags: { name: 'patch-teams-projects' }, + }); + + sleep(1); + + // --- get all teams projects --- + + const getAllRes = client.get( + `/teams/${team.slug}/projects`, + {}, + { tags: { name: 'get-teams-projects' } }, + ); + + check(getAllRes, { 'projects list has meta': (r) => r.json().meta !== undefined }); + + sleep(1); + + // --- get one team project --- + client.get( + `/teams/${team.slug}/projects/${projectId}`, + {}, + { tags: { name: 'teams-projects-id' } }, + ); + + sleep(1); + + // --- generate share token --- + const shareTokenRes = client.post( + `/teams/${team.slug}/projects/${projectId}/share`, + {}, + { tags: { name: 'teams-projects-generate-token' } }, + ); + + check(shareTokenRes, { + 'POST /teams/:slug/projects/:id/share: has token': (r) => + r.json().payload.token !== undefined, + }); + + sleep(1); + + // --- archive project --- + + client.post( + `/teams/${team.slug}/projects/${projectId}/archive`, + {}, + { tags: { name: 'teams-projects-archive' } }, + ); + + sleep(1); + + // --- delete project --- + + client.delete( + `/teams/${team.slug}/projects/${projectId}`, + {}, + { tags: { name: 'delete-teams-projects' } }, + ); + + sleep(1); +} diff --git a/infra/k6/scenarios/teams.js b/infra/k6/scenarios/teams.js new file mode 100644 index 0000000..96a385e --- /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_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 new file mode 100644 index 0000000..b025bfe --- /dev/null +++ b/infra/k6/scenarios/users.js @@ -0,0 +1,97 @@ +import { SharedArray } from 'k6/data'; +import http from 'k6/http'; +import { sleep } from 'k6'; +import { FormData } from 'https://jslib.k6.io/formdata/0.0.2/index.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 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; +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); + +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' }, + }); + + sleep(1); +} diff --git a/infra/k6/scripts/clear-k6-data.ts b/infra/k6/scripts/clear-k6-data.ts new file mode 100644 index 0000000..46518d3 --- /dev/null +++ b/infra/k6/scripts/clear-k6-data.ts @@ -0,0 +1,50 @@ +import Redis from 'ioredis'; +import { drizzle, NodePgDatabase } from 'drizzle-orm/node-postgres'; +import * as sc from '../../../src/shared/entities'; +import { sql } from 'drizzle-orm'; +import { Pool } from 'pg'; +import { assertEnv, DB_URL, REDIS_URL } from './k6-env'; +import { KEYS } from './k6-data-keys'; + +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() { + assertEnv(); + 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); + } catch (e) { + console.error('Error:', e); + process.exit(1); + } finally { + await pool.end(); + await redis.quit(); + } +} + +main(); diff --git a/infra/k6/scripts/k6-data-keys.ts b/infra/k6/scripts/k6-data-keys.ts new file mode 100644 index 0000000..025f0ea --- /dev/null +++ b/infra/k6/scripts/k6-data-keys.ts @@ -0,0 +1,5 @@ +export const KEYS = { + INVITE: (code: string) => `inv:code:${code}`, + TEAM_INVITES: (teamId: string) => `team:invites:${teamId}`, + USER_INVITES: (email: string) => `user:invites:${email.toLowerCase()}`, +}; diff --git a/infra/k6/scripts/k6-env.ts b/infra/k6/scripts/k6-env.ts new file mode 100644 index 0000000..11c909b --- /dev/null +++ b/infra/k6/scripts/k6-env.ts @@ -0,0 +1,15 @@ +import * as dotenv from 'dotenv'; + +dotenv.config(); + +export const DB_URL = process.env.DATABASE_URL; +export const REDIS_URL = + process.env.REDIS_HOST && process.env.REDIS_PORT + ? `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}` + : undefined; + +export function assertEnv() { + if (!DB_URL || !REDIS_URL) { + throw new Error('DATABASE_URL OR REDIS_HOST, REDIS_PORT is not defined in .env'); + } +} diff --git a/infra/k6/scripts/seed-k6-data.ts b/infra/k6/scripts/seed-k6-data.ts new file mode 100644 index 0000000..23b20b7 --- /dev/null +++ b/infra/k6/scripts/seed-k6-data.ts @@ -0,0 +1,211 @@ +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 Redis from 'ioredis'; +import { assertEnv, DB_URL, REDIS_URL } from './k6-env'; +import { KEYS } from './k6-data-keys'; + +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 main() { + assertEnv(); + const redis = new Redis(REDIS_URL); + const pool = new Pool({ connectionString: DB_URL }); + const db = drizzle(pool, { schema: sc }); + + try { + 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/smoke.js b/infra/k6/smoke.js new file mode 100644 index 0000000..db6cd61 --- /dev/null +++ b/infra/k6/smoke.js @@ -0,0 +1,12 @@ +import { sleep } from 'k6'; +import { ApiClient } from './common/client.js'; +import { BASE_URL, GET_OPTIONS } from './common/config.js'; + +export const options = GET_OPTIONS(); + +const client = new ApiClient(BASE_URL); + +export default function () { + client.get('/health'); + sleep(1); +} diff --git a/libs/bootstrap/src/configs/throttler.ts b/libs/bootstrap/src/configs/throttler.ts index 08f8cbe..64d1d19 100644 --- a/libs/bootstrap/src/configs/throttler.ts +++ b/libs/bootstrap/src/configs/throttler.ts @@ -1,9 +1,11 @@ +import * as dotenv from 'dotenv'; +dotenv.config(); import type { ThrottlerModuleOptions } from '@nestjs/throttler'; export const DEFAULT_THROTTLER_OPTIONS: ThrottlerModuleOptions = [ { - ttl: 60000, - limit: 100, + ttl: process.env.THROTTLE_TTL ? parseInt(process.env.THROTTLE_LIMIT) : 60000, + limit: process.env.THROTTLE_LIMIT ? parseInt(process.env.THROTTLE_LIMIT) : 100, skipIf: (context) => context.getType() !== 'http', }, ]; diff --git a/package.json b/package.json index 6cea55a..c3705c0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "task-backend", "version": "0.0.1", - "description": "", + "description": "Основной API-сервис управления задачами (NestJS + Fastify + Drizzle ORM)", "author": "", "private": true, "license": "MIT", @@ -19,8 +19,17 @@ "test:e2e": "vitest run -c vitest.config.e2e.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", - "db:studio": "drizzle-kit studio", - "prepare": "husky" + "prepare": "husky", + "k6:all": "pnpm --filter @project/performance-tests test:all", + "k6:auth": "pnpm --filter @project/performance-tests test:auth", + "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:seed": "npx tsx infra/k6/scripts/seed-k6-data.ts", + "k6:clear": "npx tsx infra/k6/scripts/clear-k6-data.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.1029.0", @@ -48,6 +57,7 @@ "@willsoto/nestjs-prometheus": "^6.1.0", "argon2": "^0.44.0", "bullmq": "^5.73.4", + "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "drizzle-zod": "^0.8.3", "fastify": "^5.8.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b3126f..64bc8af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: bullmq: specifier: ^5.73.4 version: 5.73.4 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.20.0) @@ -199,6 +202,8 @@ importers: specifier: ^4.1.4 version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.4)(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + infra/k6: {} + packages: '@angular-devkit/core@19.2.23': @@ -2634,6 +2639,10 @@ packages: resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} engines: {node: '>=12'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true @@ -7026,6 +7035,8 @@ snapshots: dotenv@17.4.1: {} + dotenv@17.4.2: {} + drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..7d1b24d --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - '.' + - 'infra/k6' diff --git a/src/modules/auth/controller/auth.controller.ts b/src/modules/auth/controller/auth.controller.ts index 0e1ae9c..9280fd5 100644 --- a/src/modules/auth/controller/auth.controller.ts +++ b/src/modules/auth/controller/auth.controller.ts @@ -1,5 +1,4 @@ -import { ApiBaseController } from '../../../shared/decorators'; -import { Body, HttpCode, Post, Req, Res, UseGuards } from '@nestjs/common'; +import { Body, HttpCode, HttpStatus, Post, Req, Res, UseGuards } from '@nestjs/common'; import { AuthService } from '../services'; import { PostLoginSwagger, @@ -12,6 +11,7 @@ import { SignInDto, SignUpDto, VerifyDto } from '../dtos'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { getDeviceMeta } from '../helpers'; import { BearerAuthGuard, CookieAuthGuard } from '@shared/guards'; +import { ApiBaseController } from '@shared/decorators'; @ApiBaseController('auth', 'Auth') export class AuthController { @@ -66,6 +66,7 @@ export class AuthController { } @Post('sign-out') + @HttpCode(HttpStatus.OK) @UseGuards(BearerAuthGuard) @PostLogoutSwagger() async logout(@Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest) {