diff --git a/main-server/API.md b/main-server/API.md index 7862ca3..1b81044 100644 --- a/main-server/API.md +++ b/main-server/API.md @@ -2,6 +2,11 @@ Центральный сервер для браузерной онлайн-игры «Морской бой». Предоставляет REST API для регистрации, аутентификации и матчмейкинга. +## Дополнительная документация + +- [docs/matchmaking.md](docs/matchmaking.md) — детальное описание алгоритма матчмейкинга, структур данных Redis, формулы допуска рейтинга, Lua-скриптов и гарантий доставки. +- [docs/architecture.md](docs/architecture.md) — архитектура главного сервера, стек компонентов Rails, безопасность и взаимодействие с Game Server. + ## Базовый URL ``` @@ -10,13 +15,17 @@ http://localhost:3000/api ## Аутентификация -Все endpoints (кроме `/register` и `/login`) требуют JWT-токен в заголовке: - +Все endpoints (кроме `/register` и `/login`) требуют JWT-токен. Токен может передаваться двумя способами: +1. В HTTP-заголовке (рекомендуемый способ для REST API): ``` Authorization: Bearer ``` +2. В параметрах запроса query string (используется для WebSocket-подключений и поддерживается REST API): +``` +?token= +``` -Токен выдаётся при регистрации или входе и действителен **7 дней**. +Токен выдаётся при успешной регистрации или входе и действителен **7 дней**. Токен содержит идентификатор пользователя (`user_id`) и время истечения (`exp`). При обращении к защищённым endpoints сервер проверяет подпись токена, срок действия и существование пользователя в базе данных. --- @@ -209,65 +218,110 @@ curl -X DELETE http://localhost:3000/api/logout \ ### Поиск соперника (Матчмейкинг) -Поиск соперника происходит через **WebSocket** (`Action Cable`), а не через long-polling: HTTP-поток не блокируется, а результат доставляется сервером push-уведомлением, как только найден соперник. +Поиск соперника происходит через **WebSocket** на базе протокола `Action Cable`. Это устраняет необходимость long-polling: сокет не блокирует HTTP-потоки, а результат поиска доставляется сервером асинхронно push-уведомлением. + +> Подробное описание структур данных в Redis, формулы допуска рейтинга, двухфазного поиска и Lua-скриптов вынесено в отдельный документ: [docs/matchmaking.md](docs/matchmaking.md). **URL подключения:** `ws://localhost:3000/cable?token=` -**Auth:** токен передаётся параметром `token` в query string (тот же JWT, что и для HTTP API). +**Auth:** токен передаётся параметром `token` в query string (или в заголовке `Authorization: Bearer ` при поддержке клиентом). Если токен отсутствует, просрочен или пользователь не найден, сервер немедленно закрывает соединение (`reject_unauthorized_connection`). **Канал:** `MatchmakingChannel` -#### Поток взаимодействия +#### Протокол взаимодействия Action Cable -1. Подключиться к `/cable?token=...` и подписаться на канал `MatchmakingChannel`. -2. Отправить команду начала поиска: +Action Cable использует формат JSON-сообщений через WebSocket: -```json -{ "action": "start" } -``` +1. **Подключение и рукопожатие:** + При открытии сокета сервер отправляет приветственное сообщение: + ```json + { "type": "welcome" } + ``` + В процессе работы сервер регулярно отправляет heartbeat-сообщения (пинг): + ```json + { "type": "ping", "message": 1729864800 } + ``` -Рейтинг для подбора берётся из базы данных (`users.rating`). У новых пользователей он инициализируется нейтральным значением `100` (значение задано в БД); в будущем рейтинг будет пересчитываться по результатам матчей. Игроки подбираются по близости рейтинга; допуск расширяется с ростом времени ожидания. +2. **Подписка на канал:** + Клиент отправляет команду подписки на канал `MatchmakingChannel`: + ```json + { + "command": "subscribe", + "identifier": "{\"channel\":\"MatchmakingChannel\"}" + } + ``` + При успешной подписке сервер отвечает подтверждением: + ```json + { + "type": "confirm_subscription", + "identifier": "{\"channel\":\"MatchmakingChannel\"}" + } + ``` + *Примечание:* Если матч был сформирован в момент временного разрыва соединения (кратковременный реконнект), сервер в момент подписки выполняет операцию `claim` и немедленно передаёт клиенту сообщение `matched` (гарантия exactly-once доставки). -3. Дождаться одного из сообщений: +3. **Запуск поиска соперника:** + Для входа в очередь матчмейкинга клиент отправляет action `start`: + ```json + { + "command": "message", + "identifier": "{\"channel\":\"MatchmakingChannel\"}", + "data": "{\"action\":\"start\"}" + } + ``` + Рейтинг для подбора считывается из БД (`users.rating`, по умолчанию `100`). Игрок помещается в Redis (`mm:queue`). Сервер сразу выполняет попытку мгновенного подбора (Phase 1). Если подходящий соперник не найден сразу, фоновый воркер `MatchmakingSweepJob` каждые 2 секунды проверяет очередь (Phase 2), плавно расширяя допустимый диапазон рейтинга по мере ожидания. -**Матч найден:** +4. **Получение результатов поиска:** -```json -{ - "type": "matched", - "match_id": "145575d6-3a4b-4c5d-9e6f-7a8b9c0d1e2f", - "game_token": "eyJhbGciOiJIUzI1NiJ9.eyJtYXRjaF9pZCI6IjE...", - "game_server": "ws://localhost:8080", - "players": [1, 2] -} -``` + **Матч найден:** + ```json + { + "identifier": "{\"channel\":\"MatchmakingChannel\"}", + "message": { + "type": "matched", + "match_id": "145575d6-3a4b-4c5d-9e6f-7a8b9c0d1e2f", + "game_token": "eyJhbGciOiJIUzI1NiJ9.eyJtYXRjaF9pZCI6IjE...", + "game_server": "ws://localhost:8080", + "players": [1, 2] + } + } + ``` -| Поле | Описание | -|------|----------| -| `match_id` | Уникальный идентификатор матча (UUID) | -| `game_token` | JWT-токен для аутентификации на игровом сервере | -| `game_server` | WebSocket-адрес игрового сервера | -| `players` | ID обоих игроков `[player1_id, player2_id]` | + | Поле | Описание | + |------|----------| + | `match_id` | Уникальный UUID матча, сгенерированный сервером | + | `game_token` | Подписанный JWT-токен для авторизации на `game-server` (действителен `MATCHMAKING_TOKEN_TTL` сек) | + | `game_server` | WebSocket URL игрового сервера Go, на который должны подключиться оба игрока | + | `players` | Массив идентификаторов игроков матча `[player1_id, player2_id]` | -**Таймаут поиска** (соперник не найден за `MATCHMAKING_QUEUE_TTL` секунд): + **Таймаут поиска** (соперник не найден за `MATCHMAKING_QUEUE_TTL` секунд, по умолчанию 60 сек): + ```json + { + "identifier": "{\"channel\":\"MatchmakingChannel\"}", + "message": { + "type": "timeout" + } + } + ``` + После таймаута игрок удаляется из очереди поиска. При необходимости поиск можно начать заново командой `start`. -```json -{ "type": "timeout" } -``` - -4. Поиск отменяется автоматически при закрытии WebSocket-подписки. Также можно явно отправить: - -```json -{ "action": "stop" } -``` - -**Примечание:** `game_token` содержит `match_id`, `player1_id`, `player2_id`, `game_server` и действителен `MATCHMAKING_TOKEN_TTL` секунд. - ---- - -### Отмена поиска - -Отмена поиска происходит через WebSocket: достаточно отключиться от `MatchmakingChannel` либо отправить `{ "action": "stop" }`. +5. **Отмена поиска:** + Отменить поиск можно двумя способами: + - Отправить команду `stop`: + ```json + { + "command": "message", + "identifier": "{\"channel\":\"MatchmakingChannel\"}", + "data": "{\"action\":\"stop\"}" + } + ``` + - Отписаться от канала или закрыть WebSocket-соединение: + ```json + { + "command": "unsubscribe", + "identifier": "{\"channel\":\"MatchmakingChannel\"}" + } + ``` + Сервер атомарно удаляет игрока из всех очередей и хешей Redis. --- diff --git a/main-server/README.md b/main-server/README.md index 7db80e4..4e31bf6 100644 --- a/main-server/README.md +++ b/main-server/README.md @@ -1,24 +1,40 @@ -# README +# Ruby Sea Battle — Main Server -This README would normally document whatever steps are necessary to get the -application up and running. +Главный бэкенд многопользовательской онлайн-игры «Морской бой», написанный на Ruby on Rails 8. -Things you may want to cover: +## Документация -* Ruby version +- [API.md](API.md) — спецификация REST API и WebSocket Action Cable +- [docs/matchmaking.md](docs/matchmaking.md) — детальное описание алгоритма матчмейкинга, структур Redis и Lua-скриптов +- [docs/architecture.md](docs/architecture.md) — общая архитектура сервера, стек компонентов и модель безопасности -* System dependencies +## Требования -* Configuration +- Ruby 3.4+ / 4.0 +- SQLite 3 (для разработки и тестов) / PostgreSQL (production) +- Redis 6+ (для очередей матчмейкинга и Action Cable) -* Database creation +## Установка и запуск -* Database initialization +```bash +# 1. Установка зависимостей +bundle install -* How to run the test suite +# 2. Подготовка базы данных +bin/rails db:prepare -* Services (job queues, cache servers, search engines, etc.) +# 3. Запуск сервера разработки (Puma на порту 3000) +bin/rails server +``` -* Deployment instructions +## Тестирование -* ... +Проект полностью покрыт тестами на RSpec (модели, сервисы, контроллеры, Action Cable каналы и фоновые задачи). + +```bash +# Запуск полного набора тестов +bundle exec rspec + +# Запуск тестов со сбором отчета о покрытии кода (SimpleCov) +COVERAGE=true bundle exec rspec +``` diff --git a/main-server/app/controllers/api/sessions_controller.rb b/main-server/app/controllers/api/sessions_controller.rb index cab754b..f15f023 100644 --- a/main-server/app/controllers/api/sessions_controller.rb +++ b/main-server/app/controllers/api/sessions_controller.rb @@ -2,7 +2,9 @@ class Api::SessionsController < ApplicationController skip_before_action :authenticate_request!, only: [:create] def create - user = User.authenticate_by(params.permit(:nickname, :password)) + user = if params[:nickname].present? && params[:password].present? + User.authenticate_by(params.permit(:nickname, :password)) + end if user token = JwtService.encode(user_id: user.id) diff --git a/main-server/app/jobs/application_job.rb b/main-server/app/jobs/application_job.rb new file mode 100644 index 0000000..d394c3d --- /dev/null +++ b/main-server/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/main-server/docs/architecture.md b/main-server/docs/architecture.md new file mode 100644 index 0000000..cb2a3ae --- /dev/null +++ b/main-server/docs/architecture.md @@ -0,0 +1,135 @@ +# Архитектура Main Server (Ruby on Rails) + +Документ описывает архитектуру главного сервера проекта «Ruby Sea Battle», структуру компонентов Rails-приложения, модель безопасности и схему взаимодействия с клиентом и игровым сервером. + +--- + +## 1. Роль Main Server в общей системе + +Система «Ruby Sea Battle» состоит из трёх ключевых узлов: +1. **Frontend (React):** SPA-интерфейс в браузере. +2. **Main Server (Ruby on Rails):** центральный сервер бизнес-логики, аккаунтов и матчмейкинга. +3. **Game Server (Go):** сервер игровых комнат в реальном времени по WebSocket. + +```mermaid +flowchart LR + Client["React Frontend"] + MainServer["Main Server (Rails 8)"] + RedisStore[("Redis (Очереди и Pub/Sub)")] + DB[(База данных SQLite/PostgreSQL)] + GameServer["Game Server (Go)"] + + Client -- "REST API (Auth, Profile)" --> MainServer + Client -- "WebSocket /cable (Matchmaking)" --> MainServer + MainServer -- "State & Lua Scripts" --> RedisStore + MainServer -- "Пользователи и рейтинг" --> DB + MainServer -. "JWT с общим секретом" .-> GameServer + Client -- "WebSocket (Игровой процесс)" --> GameServer +``` + +### Основные обязанности Main Server: +- Регистрация и аутентификация пользователей с выдачей JWT. +- Хранение профилей и аватарок игроков (через Active Storage). +- Ведение базового рейтинга пользователей (`users.rating`). +- Очередь матчмейкинга и подбор соперников в реальном времени через Action Cable и Redis. +- Выпуск одноразовых билетов матча (`game_token`) для подключения к игровому серверу Go. + +--- + +## 2. Структура слоёв приложения + +Главный сервер построен на принципах Rails API-only приложения: + +``` +app/ +├── channels/ +│ ├── application_cable/ +│ │ ├── channel.rb # Базовый класс каналов +│ │ └── connection.rb # Аутентификация WebSocket по JWT +│ └── matchmaking_channel.rb # Канал поиска соперника (start/stop/claim) +├── controllers/ +│ ├── application_controller.rb # Базовый контроллер: проверка JWT и current_user +│ └── api/ +│ ├── registrations_controller.rb # Регистрация и загрузка аватара +│ ├── sessions_controller.rb # Вход, выход, профиль (/api/me) +│ └── matchmaking_controller.rb # HTTP-отмена поиска +├── jobs/ +│ ├── application_job.rb # Базовый класс фоновых задач +│ └── matchmaking_sweep_job.rb # Периодическая проверка очереди (Solid Queue) +├── models/ +│ ├── application_record.rb # Базовый класс моделей +│ └── user.rb # Модель игрока (никнейм, пароль, рейтинг, аватар) +└── services/ + ├── jwt_service.rb # Кодирование и валидация JWT + └── matchmaking_service.rb # Логика очереди и вызовы Lua в Redis +``` + +--- + +## 3. Компоненты приложения + +### 3.1. Контроллеры и фильтры аутентификации + +Все защищённые маршруты наследуются от `ApplicationController`. +- **`authenticate_request!`:** выполняет проверку токена перед каждым действием. +- **Извлечение токена (`extract_token`):** поддерживает как заголовок `Authorization: Bearer `, так и параметр строки запроса `params[:token]`. +- При отсутствии или невалидности токена клиенту возвращается стандартизированный ответ: + ```json + { "error": "Unauthorized" } + ``` + со статусом `401 Unauthorized`. + +### 3.2. Модель пользователя (`User`) + +- Использует механизм `has_secure_password` (BCrypt). +- **Нормализация никнейма:** автоматическая очистка крайних пробелов через `normalizes :nickname, with: ->(n) { n.strip }`. +- **Валидации:** + - Никнейм: обязателен, длина от 3 до 24 символов, регистронезависимая уникальность. + - Пароль: минимум 6 символов. + - Рейтинг: обязателен, значение по умолчанию в БД — `100`. + - Аватар (`has_one_attached :avatar`): проверка допустимых типов файлов (`image/png`, `image/jpeg`, `image/gif`, `image/webp`). + +### 3.3. Action Cable и WebSocket + +- **Подключение (`ApplicationCable::Connection`):** при коннекте к `/cable?token=...` сокет проверяет подпись токена. Если токен валиден и пользователь найден в базе, сокет идентифицируется как `current_user`. В противном случае вызывается `reject_unauthorized_connection`. +- **Канал `MatchmakingChannel`:** + - При подписке клиент слушает индивидуальный стрим `matchmaking:`. + - Метод `claim` обеспечивает атомарную выдачу уже найденного матча, если клиент переподключился. + - Действие `start` запускает подбор. + - Действие `stop` или закрытие соединения (`unsubscribed`) отменяет поиск. + +### 3.4. Сервисный слой + +- **`JwtService`:** + - Алгоритм: HMAC-SHA256 (`HS256`). + - Секретный ключ: `Rails.application.secret_key_base`. + - Стандартный срок действия токена авторизации: 7 дней. + - Обработка исключений `JWT::DecodeError` и `JWT::ExpiredSignature` с возвратом `nil`. +- **`MatchmakingService`:** + - Инкапсулирует вызовы Redis Lua-скриптов (`PAIR_SCRIPT`, `FINALIZE_SCRIPT`, `CANCEL_SCRIPT`). + - Выполняет создание матчей, подписание игровых токенов и бродкаст уведомлений через `MatchmakingChannel`. + +### 3.5. Фоновые задачи (Background Processing) + +- В качестве движка фоновых задач используется `Solid Queue` (на базе БД) с периодическим расписанием `config/recurring.yml`. +- `MatchmakingSweepJob` запускается каждые 2 секунды (`MATCHMAKING_SWEEP_INTERVAL`) и вызывает `MatchmakingService.sweep` для постепенного расширения диапазона поиска и очистки таймаутов. + +--- + +## 4. Модель токенов и безопасность + +В системе используются два вида JWT: + +| Параметр | User Auth Token | Game Match Token (`game_token`) | +|----------|-----------------|---------------------------------| +| **Назначение** | Авторизация запросов к REST API и Action Cable | Доступ к комнате матча на `game-server` | +| **Payload** | `{ "user_id": 1, "exp": ... }` | `{ "match_id": "...", "player1_id": 1, "player2_id": 2, "game_server": "...", "exp": ... }` | +| **Срок жизни** | 7 дней | `MATCHMAKING_TOKEN_TTL` (по умолчанию 120 сек) | +| **Секрет подписи** | `SECRET_KEY_BASE` | `SECRET_KEY_BASE` (общий для Rails и Go) | +| **Алгоритм** | HS256 | HS256 | + +### Безопасность делегирования на Game Server: +1. Игровой сервер Go не имеет прямого доступа к базе данных пользователей Main Server. +2. При подключении к Game Server игрок предъявляет `game_token`. +3. Game Server валидирует подпись токена тем же общим секретом `SECRET_KEY_BASE`, сверяет `match_id` и идентификатор подключившегося игрока (`player1_id` или `player2_id`). +4. Токен действует всего 120 секунд, что исключает возможность повторного использования после начала игры. diff --git a/main-server/docs/matchmaking.md b/main-server/docs/matchmaking.md new file mode 100644 index 0000000..ddd9ca7 --- /dev/null +++ b/main-server/docs/matchmaking.md @@ -0,0 +1,227 @@ +# Алгоритм матчмейкинга (Matchmaking Algorithm) + +Документ содержит подробное описание алгоритма подбора соперников в `Ruby Sea Battle`, структур данных Redis, математической модели допуска рейтинга, механизмов атомарности и отказоустойчивости. + +--- + +## 1. Цели и требования к системе + +Матчмейкинг отвечает за сведение двух игроков в онлайн-матч и решает следующие задачи: +1. **Баланс по навыку (рейтингу):** игроки со схожим рейтингом должны играть друг с другом. +2. **Минимизация времени ожидания:** если идеального соперника нет, допустимая разница в рейтинге со временем плавно увеличивается. +3. **Строгая атомарность и исключение race conditions:** ни один игрок не может быть одновременно взят в два разных матча при конкурентных запросах. +4. **Отказоустойчивость (Fault Tolerance):** сбой процесса сервера между нахождением пары и генерацией JWT-токенов не должен приводить к потере игроков из очереди. +5. **Надёжная доставка результата:** матч должен быть доставлен игроку даже при кратковременном разрыве WebSocket-соединения. + +--- + +## 2. Структуры данных в Redis + +Все данные матчмейкинга хранятся в Redis и имеют ограниченное время жизни (TTL): + +| Ключ | Тип данных Redis | Значение / Элемент | Назначение | +|------|-------------------|-------------------|------------| +| `mm:queue` | Sorted Set | `score` = `rating`, `member` = `user_id` | Основная очередь игроков, ожидающих матч. Отсортирована по рейтингу для быстрого диапазонного поиска (`ZRANGEBYSCORE`). | +| `mm:since` | Hash | `field` = `user_id`, `value` = `timestamp` (мс) | Время входа игрока в очередь в миллисекундах. Используется для расчёта времени ожидания. | +| `mm:rating` | Hash | `field` = `user_id`, `value` = `rating` | Сохранённый рейтинг игрока. Необходим для возврата игрока в очередь при сбое на этапе финализации (`requeue`). | +| `mm:pending` | Sorted Set | `score` = `timestamp` спаривания (мс), `member` = `user_id` | Игроки, для которых пара найдена, но генерация матча и JWT ещё выполняется в Ruby. | +| `mm:match:` | String (JSON) | Полный payload матча | Данные матча (TTL = `MATCHMAKING_MATCH_TTL`, по умолчанию 120 сек). | +| `mm:user:` | String (JSON) | Копия payload матча | Индивидуальный указатель для игрока для механизма `claim` при реконнекте (TTL = `MATCHMAKING_MATCH_TTL`). | + +--- + +## 3. Модель динамического допуска рейтинга (Rating Tolerance) + +Чтобы игроки быстро находили матч даже при небольшом онлайне, допуск по рейтингу плавно растёт с течением времени ожидания. + +### Формула допуска + +Для игрока с временем постановки в очередь $t_{\text{start}}$ в текущий момент времени $t_{\text{now}}$: + +$$\Delta t = \frac{t_{\text{now}} - t_{\text{start}}}{1000} \quad \text{(секунды ожидания)}$$ + +$$\text{tolerance}(\Delta t) = \min\Big(\text{base\_range} + \Delta t \times \text{range\_per\_second}, \; \text{max\_range}\Big)$$ + +### Конфигурационные параметры (из `Rails.application.config.x.matchmaking`) + +- `base_range` = `100` — начальный допуск рейтинга в момент входа в очередь. +- `range_per_second` = `50` — прирост допуска за каждую секунду ожидания. +- `max_range` = `1000` — верхний предел допуска рейтинга. +- `queue_ttl` = `60` сек — максимальное время нахождения в очереди до таймаута. + +### Динамика расширения диапазона + +| Время ожидания ($\Delta t$) | Допуск ($\pm$ очков рейтинга) | Диапазон при рейтинге 1000 | +|-----------------------------|-------------------------------|---------------------------| +| 0 секунд | $\pm 100$ | [900 ... 1100] | +| 2 секунды | $\pm 200$ | [800 ... 1200] | +| 6 секунд | $\pm 400$ | [600 ... 1400] | +| 10 секунд | $\pm 600$ | [400 ... 1600] | +| 18+ секунд | $\pm 1000$ (максимум) | [0 ... 2000] | + +### Правило симметрии для пары + +При периодической сверке очереди (`sweep`) пара $(A, B)$ считается совместимой, если: + +$$| \text{rating}_A - \text{rating}_B | \le \text{tolerance}\big(\min(t_{\text{since}}(A), t_{\text{since}}(B))\big)$$ + +То есть допуск рассчитывается по **дольше ожидающему** игроку из пары. Это предотвращает дискриминацию игроков, которые долго ждут в очереди. + +--- + +## 4. Двухфазный алгоритм подбора (Two-Phase Matchmaking) + +Подбор разделен на две взаимодополняющие фазы: + +``` +[Игрок отправляет start] + │ + ▼ + Фаза 1: Немедленный поиск (enqueue в Lua) + ├─► Подходящий соперник найден ──► [Перевод в pending] ──► [Генерация матча] + └─► Соперник не найден ──────────► [Добавление в mm:queue] + │ + ▼ + Фаза 2: Фоновая сверка (sweep) + (каждые 2 сек) + ├─► Расширение допуска и спаривание + ├─► Таймаут (waited > queue_ttl) + └─► Requeue зависших игроков +``` + +### Фаза 1: Синхронная проверка при входе (`enqueue`) + +Выполняется мгновенно в контексте команды WebSocket `start`: +1. Вызывается Redis Lua-скрипт `PAIR_SCRIPT` с аргументом `mode = "enqueue"`. +2. Из очереди принудительно удаляются предыдущие записи этого пользователя (сброс сессии ожидания). +3. Через `ZRANGEBYSCORE mm:queue (rating - max_range) (rating + max_range)` запрашиваются потенциальные кандидаты. +4. Среди кандидатов выбирается игрок с **минимальной разницей в рейтинге**, у которого: + $$| \text{rating} - \text{other\_rating} | \le \text{tolerance}(\text{other\_since})$$ +5. **Если кандидат найден:** + - Оба игрока атомарно удаляются из `mm:queue`, `mm:since`, `mm:rating`. + - Оба игрока добавляются в `mm:pending` с текущим временем. + - Скрипт возвращает строку вида `"P::"`. +6. **Если кандидат не найден:** + - Игрок сохраняется в `mm:queue` с весом `rating`. + - В `mm:since` записывается время входа. + - В `mm:rating` сохраняется рейтинг игрока. + +### Фаза 2: Периодическая фоновая сверка (`sweep`) + +Каждые `MATCHMAKING_SWEEP_INTERVAL` секунд (по умолчанию каждые 2 секунды) запускается фоновая задача `MatchmakingSweepJob`, вызывающая `MatchmakingService.sweep`: + +1. Вызывается Redis Lua-скрипт `PAIR_SCRIPT` с аргументом `mode = "sweep"`. +2. Из `mm:queue` считываются все ожидающие игроки со своими рейтингами и временем ожидания. +3. Массив сортируется по возрастанию рейтинга: $O(N \log N)$. +4. Выполняется жадный проход: + - Если текущий игрок ждёт дольше `queue_ttl` (60 сек): он помечается как экспирированный (`"E:"`). + - Иначе ищется ближайший по рейтингу свободный соперник в списке, для которого разница в рейтинге не превышает допуск по времени ожидания более старого игрока. + - При нахождении оба игрока помечаются как использованные и добавляются в результат (`"P::"`). +5. Все найденные пары атомарно переносятся из `mm:queue` в `mm:pending`, а экспирированные игроки удаляются из очереди. +6. **Requeue (защита от падений):** + - Запрашиваются все игроки из `mm:pending`, находящиеся там дольше `pending_ttl` (15 сек). + - Если для игрока ключ матча `mm:user:` так и не был создан (например, Ruby-процесс упал при генерации токена), игрок автоматически возвращается назад в `mm:queue` со своим исходным рейтингом. + +--- + +## 5. Создание матча и атомарная финализация + +Когда Lua-скрипт возвращает сформированную пару `"P::"`: + +1. **Генерация идентификаторов и токенов в Ruby:** + - Генерируется уникальный UUID матча: `match_id = SecureRandom.uuid`. + - Генерируется JWT-токен для `game-server`: + ```ruby + JwtService.encode( + match_id: match_id, + player1_id: player1_id, + player2_id: player2_id, + game_server: config.game_server_url, + exp: config.token_ttl.seconds.from_now.to_i + ) + ``` +2. **Атомарная финализация в Redis (`FINALIZE_SCRIPT`):** + - Сохраняет `mm:match:` с TTL. + - Сохраняет указатели `mm:user:` и `mm:user:` с тем же TTL. + - Удаляет обоих игроков из `mm:pending`, `mm:rating`, `mm:since`. +3. **Push-уведомление через Action Cable:** + - Для каждого игрока выполняется атомарное чтение и удаление указателя: `redis.getdel("mm:user:")`. + - Если указатель успешно прочитан, сообщение с типом `matched` отправляется в персональный поток пользователя: + `MatchmakingChannel.broadcast_to(user_id, payload)`. + +--- + +## 6. Гарантия доставки (At-most-once & Reconnection) + +Если в момент формирования матча у пользователя произошёл кратковременный разрыв соединения или переподключение: +- Сообщение broadcast могло быть отправлено в момент отсутствия активного WebSocket-клиента. +- Персональный ключ `mm:user:` сохраняется в Redis до момента подтверждённой доставки. +- При повторном подключении к WebSocket метод `MatchmakingChannel#subscribed` вызывает: + ```ruby + if match = MatchmakingService.claim(current_user.id) + transmit(match) + end + ``` +- Метод `claim` выполняет Redis-команду `GETDEL mm:user:`, что гарантирует: + 1. Матч будет немедленно доставлен клиенту при переподключении. + 2. Никакой матч не может быть получен дважды (атомарное удаление при первой же выдаче). + +--- + +## 7. Диаграммы взаимодействия + +### Sequence Diagram: Успешный подбор матча + +```mermaid +sequenceDiagram + autonumber + actor PlayerA as Игрок A + actor PlayerB as Игрок B + participant AC as ActionCable (MatchmakingChannel) + participant MS as MatchmakingService + participant Redis as Redis (Lua Scripts) + participant Game as Game Server (Go) + + PlayerA->>AC: subscribe + PlayerA->>AC: action: "start" + AC->>MS: enqueue(PlayerA) + MS->>Redis: eval PAIR_SCRIPT (enqueue) + Redis-->>MS: соперник не найден -> добавлен в mm:queue + + PlayerB->>AC: subscribe + PlayerB->>AC: action: "start" + AC->>MS: enqueue(PlayerB) + MS->>Redis: eval PAIR_SCRIPT (enqueue) + Redis-->>MS: пара сформирована! (P:PlayerB:PlayerA) + + MS->>MS: генерация match_id (UUID) и game_token (JWT) + MS->>Redis: eval FINALIZE_SCRIPT (сохранить матч, перевести из pending) + MS->>Redis: getdel mm:user:PlayerA & mm:user:PlayerB + MS->>AC: broadcast_to(PlayerA, matched) + MS->>AC: broadcast_to(PlayerB, matched) + AC-->>PlayerA: message { type: "matched", match_id, game_token, game_server } + AC-->>PlayerB: message { type: "matched", match_id, game_token, game_server } + + PlayerA->>Game: WebSocket connect(game_token) + PlayerB->>Game: WebSocket connect(game_token) +``` + +### State Diagram: Жизненный цикл игрока в матчмейкинге + +```mermaid +stateDiagram-v2 + [*] --> Idle: Пользователь авторизован + + Idle --> Queued: Отправка action "start" (enqueue) + Queued --> Pending: Найден соперник в Phase 1 или Phase 2 + Queued --> Idle: Отправка action "stop" / разрыв сокета (cancel) + Queued --> TimedOut: Ожидание > MATCHMAKING_QUEUE_TTL + + TimedOut --> Idle: Уведомление { type: "timeout" } + + Pending --> Matched: Успешная финализация (генерация токена) + Pending --> Queued: Сбой финализации (requeue через 15 сек) + + Matched --> InGame: Подключение к Game Server по game_token + InGame --> Idle: Завершение игры +``` diff --git a/main-server/spec/channels/application_cable/connection_spec.rb b/main-server/spec/channels/application_cable/connection_spec.rb new file mode 100644 index 0000000..a3c9486 --- /dev/null +++ b/main-server/spec/channels/application_cable/connection_spec.rb @@ -0,0 +1,44 @@ +require "rails_helper" + +RSpec.describe ApplicationCable::Connection, type: :channel do + include ActionCable::Connection::TestCase::Behavior + + let(:user) { User.create!(nickname: "CaptainCook", password: "password123") } + let(:token) { JwtService.encode(user_id: user.id) } + + describe "#connect" do + it "connects successfully with token in query params" do + connect "/cable", params: { token: token } + expect(connection.current_user).to eq(user) + end + + it "connects successfully with Bearer token in Authorization header" do + connect "/cable", headers: { "Authorization" => "Bearer #{token}" } + expect(connection.current_user).to eq(user) + end + + it "rejects connection when no token is provided" do + expect { connect "/cable" }.to have_rejected_connection + end + + it "rejects connection when token is invalid" do + expect { + connect "/cable", params: { token: "invalid.jwt.token" } + }.to have_rejected_connection + end + + it "rejects connection when token is expired" do + expired_token = JwtService.encode(user_id: user.id, exp: 1.hour.ago.to_i) + expect { + connect "/cable", params: { token: expired_token } + }.to have_rejected_connection + end + + it "rejects connection when user does not exist in the database" do + orphan_token = JwtService.encode(user_id: 999_999) + expect { + connect "/cable", params: { token: orphan_token } + }.to have_rejected_connection + end + end +end diff --git a/main-server/spec/channels/matchmaking_channel_spec.rb b/main-server/spec/channels/matchmaking_channel_spec.rb new file mode 100644 index 0000000..87c1c26 --- /dev/null +++ b/main-server/spec/channels/matchmaking_channel_spec.rb @@ -0,0 +1,68 @@ +require "rails_helper" + +RSpec.describe MatchmakingChannel, type: :channel do + let(:user) { User.create!(nickname: "Blackbeard", password: "password123", rating: 1250) } + + before do + stub_connection current_user: user + end + + describe "#subscribed" do + it "subscribes successfully and streams from the user's private channel" do + subscribe + expect(subscription).to be_confirmed + expect(subscription).to have_stream_from("matchmaking:#{user.id}") + expect(transmissions).to be_empty + end + + it "transmits a claimed match if one was ready before or during connection" do + match_payload = { + type: "matched", + match_id: "test-match-uuid", + game_token: "jwt-game-token", + game_server: "ws://localhost:8080", + players: [ user.id, 999 ] + } + allow(MatchmakingService).to receive(:claim).with(user.id).and_return(match_payload) + + subscribe + + expect(subscription).to be_confirmed + expect(transmissions.last).to eq(match_payload.as_json) + end + end + + describe "#start" do + it "calls MatchmakingService.enqueue with the user's id and current rating" do + subscribe + + expect(MatchmakingService).to receive(:enqueue).with(user.id, rating: 1250) + perform :start + end + + it "accepts extra parameters in data hash without error" do + subscribe + + expect(MatchmakingService).to receive(:enqueue).with(user.id, rating: 1250) + perform :start, { "custom" => "data" } + end + end + + describe "#stop" do + it "calls MatchmakingService.cancel for the current user" do + subscribe + + expect(MatchmakingService).to receive(:cancel).with(user.id) + perform :stop + end + end + + describe "#unsubscribed" do + it "cancels matchmaking search when unsubscribing" do + subscribe + + expect(MatchmakingService).to receive(:cancel).with(user.id) + unsubscribe + end + end +end diff --git a/main-server/spec/controllers/api/matchmaking_controller_spec.rb b/main-server/spec/controllers/api/matchmaking_controller_spec.rb new file mode 100644 index 0000000..6eb16b4 --- /dev/null +++ b/main-server/spec/controllers/api/matchmaking_controller_spec.rb @@ -0,0 +1,41 @@ +require "rails_helper" + +RSpec.describe Api::MatchmakingController, type: :controller do + routes do + ActionDispatch::Routing::RouteSet.new.tap do |r| + r.draw do + delete "destroy", to: "api/matchmaking#destroy" + end + end + end + + let(:user) { User.create!(nickname: "CaptainHook", password: "password123") } + let(:token) { JwtService.encode(user_id: user.id) } + + describe "DELETE #destroy" do + context "when authenticated" do + it "cancels matchmaking for current user and returns success message" do + request.headers["Authorization"] = "Bearer #{token}" + expect(MatchmakingService).to receive(:cancel).with(user.id) + + delete :destroy + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json["message"]).to eq("Matchmaking cancelled") + end + end + + context "when unauthenticated" do + it "returns 401 unauthorized and does not call cancel" do + expect(MatchmakingService).not_to receive(:cancel) + + delete :destroy + + expect(response).to have_http_status(:unauthorized) + json = JSON.parse(response.body) + expect(json["error"]).to eq("Unauthorized") + end + end + end +end diff --git a/main-server/spec/controllers/api/registrations_controller_spec.rb b/main-server/spec/controllers/api/registrations_controller_spec.rb new file mode 100644 index 0000000..79c54b3 --- /dev/null +++ b/main-server/spec/controllers/api/registrations_controller_spec.rb @@ -0,0 +1,114 @@ +require "rails_helper" + +RSpec.describe Api::RegistrationsController, type: :controller do + describe "POST #create" do + context "with valid parameters" do + let(:valid_params) do + { + user: { + nickname: "CaptainNemo", + password: "secretpassword" + } + } + end + + it "creates a new user and returns 201 with user data and JWT token" do + expect { + post :create, params: valid_params + }.to change(User, :count).by(1) + + expect(response).to have_http_status(:created) + + json = JSON.parse(response.body) + expect(json["token"]).to be_present + expect(json["user"]).to include( + "nickname" => "CaptainNemo", + "avatar_url" => nil + ) + expect(json["user"]["id"]).to be_present + expect(json["user"]["created_at"]).to be_present + + # Verify token is valid and decodes to created user + decoded = JwtService.decode(json["token"]) + expect(decoded[:user_id]).to eq(json["user"]["id"]) + end + + it "attaches avatar and returns avatar_url when avatar is provided" do + avatar = fixture_file_upload( + Rails.root.join("spec", "fixtures", "files", "avatar.png"), + "image/png" + ) rescue Rack::Test::UploadedFile.new( + StringIO.new("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15c4"), + "image/png", + original_filename: "avatar.png" + ) + + params_with_avatar = valid_params.deep_merge(user: { avatar: avatar }) + + post :create, params: params_with_avatar + expect(response).to have_http_status(:created) + + json = JSON.parse(response.body) + expect(json["user"]["avatar_url"]).to be_present + + user = User.find(json["user"]["id"]) + expect(user.avatar).to be_attached + end + end + + context "with invalid parameters" do + it "returns 422 unprocessable entity when nickname is missing" do + post :create, params: { user: { nickname: "", password: "password123" } } + + expect(response).to have_http_status(:unprocessable_content) + json = JSON.parse(response.body) + expect(json["errors"]).to be_an(Array) + expect(json["errors"]).to include("Nickname can't be blank") + end + + it "returns 422 unprocessable entity when password is too short" do + post :create, params: { user: { nickname: "CaptainNemo", password: "123" } } + + expect(response).to have_http_status(:unprocessable_content) + json = JSON.parse(response.body) + expect(json["errors"]).to include("Password is too short (minimum is 6 characters)") + end + + it "returns 422 unprocessable entity when nickname is already taken" do + User.create!(nickname: "CaptainNemo", password: "password123") + + post :create, params: { user: { nickname: "captainnemo", password: "password123" } } + + expect(response).to have_http_status(:unprocessable_content) + json = JSON.parse(response.body) + expect(json["errors"]).to include("Nickname has already been taken") + end + + it "returns 422 when avatar has an unsupported content type" do + invalid_avatar = Rack::Test::UploadedFile.new( + StringIO.new("plain text content"), + "text/plain", + original_filename: "test.txt" + ) + + post :create, params: { + user: { + nickname: "CaptainNemo", + password: "password123", + avatar: invalid_avatar + } + } + + expect(response).to have_http_status(:unprocessable_content) + json = JSON.parse(response.body) + expect(json["errors"]).to include("Avatar must be a PNG, JPEG, GIF or WebP image") + end + + it "raises ParameterMissing when user param is omitted" do + expect { + post :create, params: {} + }.to raise_error(ActionController::ParameterMissing) + end + end + end +end diff --git a/main-server/spec/controllers/api/sessions_controller_spec.rb b/main-server/spec/controllers/api/sessions_controller_spec.rb new file mode 100644 index 0000000..ef22d12 --- /dev/null +++ b/main-server/spec/controllers/api/sessions_controller_spec.rb @@ -0,0 +1,154 @@ +require "rails_helper" + +RSpec.describe Api::SessionsController, type: :controller do + let!(:user) { User.create!(nickname: "AdmiralNelson", password: "securepassword") } + let(:token) { JwtService.encode(user_id: user.id) } + + describe "POST #create" do + context "with valid credentials" do + it "returns 200 OK with user info and JWT token" do + post :create, params: { nickname: "AdmiralNelson", password: "securepassword" } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json["token"]).to be_present + expect(json["user"]).to include( + "id" => user.id, + "nickname" => "AdmiralNelson", + "avatar_url" => nil + ) + + decoded = JwtService.decode(json["token"]) + expect(decoded[:user_id]).to eq(user.id) + end + end + + context "with invalid credentials" do + it "returns 401 when password does not match" do + post :create, params: { nickname: "AdmiralNelson", password: "wrongpassword" } + + expect(response).to have_http_status(:unauthorized) + json = JSON.parse(response.body) + expect(json["error"]).to eq("Invalid nickname or password") + expect(json["token"]).to be_nil + end + + it "returns 401 when user does not exist" do + post :create, params: { nickname: "GhostCaptain", password: "securepassword" } + + expect(response).to have_http_status(:unauthorized) + json = JSON.parse(response.body) + expect(json["error"]).to eq("Invalid nickname or password") + end + + it "returns 401 when nickname is omitted" do + post :create, params: { password: "securepassword" } + + expect(response).to have_http_status(:unauthorized) + json = JSON.parse(response.body) + expect(json["error"]).to eq("Invalid nickname or password") + end + + it "returns 401 when password is omitted" do + post :create, params: { nickname: "AdmiralNelson" } + + expect(response).to have_http_status(:unauthorized) + json = JSON.parse(response.body) + expect(json["error"]).to eq("Invalid nickname or password") + end + + it "returns 401 when credentials are blank" do + post :create, params: { nickname: "", password: "" } + + expect(response).to have_http_status(:unauthorized) + json = JSON.parse(response.body) + expect(json["error"]).to eq("Invalid nickname or password") + end + end + end + + describe "DELETE #destroy" do + context "when authenticated" do + it "returns 200 with success message" do + request.headers["Authorization"] = "Bearer #{token}" + delete :destroy + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json["message"]).to eq("Signed out successfully") + end + end + + context "when unauthenticated" do + it "returns 401 unauthorized" do + delete :destroy + + expect(response).to have_http_status(:unauthorized) + json = JSON.parse(response.body) + expect(json["error"]).to eq("Unauthorized") + end + end + end + + describe "GET #me" do + context "when authenticated with Authorization header" do + it "returns 200 with user data" do + request.headers["Authorization"] = "Bearer #{token}" + get :me + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json["user"]).to include( + "id" => user.id, + "nickname" => "AdmiralNelson", + "avatar_url" => nil + ) + end + + it "includes avatar_url when user has an attached avatar" do + user.avatar.attach( + io: StringIO.new("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15c4"), + filename: "avatar.png", + content_type: "image/png" + ) + + request.headers["Authorization"] = "Bearer #{token}" + get :me + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json["user"]["avatar_url"]).to be_present + end + end + + context "when authenticated with token in query params" do + it "returns 200 with user data" do + get :me, params: { token: token } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json["user"]["id"]).to eq(user.id) + end + end + + context "when unauthenticated" do + it "returns 401 unauthorized" do + get :me + + expect(response).to have_http_status(:unauthorized) + json = JSON.parse(response.body) + expect(json["error"]).to eq("Unauthorized") + end + end + + context "when token is expired" do + it "returns 401 unauthorized" do + expired_token = JwtService.encode(user_id: user.id, exp: 1.hour.ago.to_i) + request.headers["Authorization"] = "Bearer #{expired_token}" + get :me + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/main-server/spec/controllers/application_controller_spec.rb b/main-server/spec/controllers/application_controller_spec.rb new file mode 100644 index 0000000..d170377 --- /dev/null +++ b/main-server/spec/controllers/application_controller_spec.rb @@ -0,0 +1,58 @@ +require "rails_helper" + +RSpec.describe ApplicationController, type: :controller do + controller do + def index + render json: { user_id: current_user.id } + end + end + + let(:user) { User.create!(nickname: "alice", password: "password123") } + let(:token) { JwtService.encode(user_id: user.id) } + + describe "#authenticate_request!" do + context "with valid Bearer token in Authorization header" do + it "sets current_user and succeeds" do + request.headers["Authorization"] = "Bearer #{token}" + get :index + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)).to eq({ "user_id" => user.id }) + end + end + + context "with valid token in params" do + it "sets current_user and succeeds" do + get :index, params: { token: token } + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)).to eq({ "user_id" => user.id }) + end + end + + context "without token" do + it "returns 401 unauthorized" do + get :index + expect(response).to have_http_status(:unauthorized) + expect(JSON.parse(response.body)).to eq({ "error" => "Unauthorized" }) + end + end + + context "with invalid token" do + it "returns 401 unauthorized" do + request.headers["Authorization"] = "Bearer invalid_token" + get :index + expect(response).to have_http_status(:unauthorized) + expect(JSON.parse(response.body)).to eq({ "error" => "Unauthorized" }) + end + end + + context "with token for nonexistent user" do + it "returns 401 unauthorized" do + orphan_token = JwtService.encode(user_id: 999_999) + request.headers["Authorization"] = "Bearer #{orphan_token}" + get :index + expect(response).to have_http_status(:unauthorized) + expect(JSON.parse(response.body)).to eq({ "error" => "Unauthorized" }) + end + end + end +end diff --git a/main-server/spec/jobs/matchmaking_sweep_job_spec.rb b/main-server/spec/jobs/matchmaking_sweep_job_spec.rb new file mode 100644 index 0000000..9a4f30c --- /dev/null +++ b/main-server/spec/jobs/matchmaking_sweep_job_spec.rb @@ -0,0 +1,10 @@ +require "rails_helper" + +RSpec.describe MatchmakingSweepJob, type: :job do + describe "#perform" do + it "calls MatchmakingService.sweep" do + expect(MatchmakingService).to receive(:sweep) + described_class.new.perform + end + end +end diff --git a/main-server/spec/models/user_spec.rb b/main-server/spec/models/user_spec.rb index bcbdd65..f293b18 100644 --- a/main-server/spec/models/user_spec.rb +++ b/main-server/spec/models/user_spec.rb @@ -38,6 +38,27 @@ RSpec.describe User, type: :model do expect(duplicate).not_to be_valid expect(duplicate.errors[:nickname]).to be_present end + + it "accepts valid image formats for avatar" do + user = User.new(valid_attributes) + user.avatar.attach( + io: StringIO.new("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15c4"), + filename: "avatar.png", + content_type: "image/png" + ) + expect(user).to be_valid + end + + it "rejects unsupported mime types for avatar" do + user = User.new(valid_attributes) + user.avatar.attach( + io: StringIO.new("plain text"), + filename: "test.txt", + content_type: "text/plain" + ) + expect(user).not_to be_valid + expect(user.errors[:avatar]).to include("must be a PNG, JPEG, GIF or WebP image") + end end describe "rating" do diff --git a/main-server/spec/rails_helper.rb b/main-server/spec/rails_helper.rb index ee4d56a..3123f26 100644 --- a/main-server/spec/rails_helper.rb +++ b/main-server/spec/rails_helper.rb @@ -2,7 +2,7 @@ if ENV["COVERAGE"] require "simplecov" SimpleCov.start "rails" do - add_filter "/spec/" + skip "/spec/" end end require 'spec_helper' @@ -69,7 +69,7 @@ RSpec.configure do |config| # behaviour is considered legacy and will be removed in a future version. # # To enable this behaviour uncomment the line below. - # config.infer_spec_type_from_file_location! + config.infer_spec_type_from_file_location! # Filter lines from Rails gems in backtraces. config.filter_rails_from_backtrace! diff --git a/main-server/spec/requests/api/registrations_spec.rb b/main-server/spec/requests/api/registrations_spec.rb new file mode 100644 index 0000000..d6ae38d --- /dev/null +++ b/main-server/spec/requests/api/registrations_spec.rb @@ -0,0 +1,52 @@ +require "rails_helper" + +RSpec.describe "Registrations API", type: :request do + describe "POST /api/register" do + let(:valid_attributes) do + { + user: { + nickname: "Sinbad", + password: "password123" + } + } + end + + context "with valid parameters" do + it "registers a new user and returns 201 Created with token" do + expect { + post "/api/register", params: valid_attributes, as: :json + }.to change(User, :count).by(1) + + expect(response).to have_http_status(:created) + json = JSON.parse(response.body) + + expect(json["token"]).to be_present + expect(json["user"]["nickname"]).to eq("Sinbad") + expect(json["user"]["id"]).to eq(User.last.id) + expect(json["user"]["avatar_url"]).to be_nil + expect(json["user"]["created_at"]).to be_present + end + end + + context "with invalid parameters" do + it "returns 422 Unprocessable Content when nickname is taken" do + User.create!(nickname: "Sinbad", password: "password123") + + post "/api/register", params: valid_attributes, as: :json + + expect(response).to have_http_status(422) + json = JSON.parse(response.body) + expect(json["errors"]).to include("Nickname has already been taken") + end + + it "returns 422 Unprocessable Content when password is too short" do + invalid_attrs = { user: { nickname: "Sinbad", password: "123" } } + post "/api/register", params: invalid_attrs, as: :json + + expect(response).to have_http_status(422) + json = JSON.parse(response.body) + expect(json["errors"]).to include("Password is too short (minimum is 6 characters)") + end + end + end +end diff --git a/main-server/spec/requests/api/sessions_spec.rb b/main-server/spec/requests/api/sessions_spec.rb new file mode 100644 index 0000000..4291194 --- /dev/null +++ b/main-server/spec/requests/api/sessions_spec.rb @@ -0,0 +1,61 @@ +require "rails_helper" + +RSpec.describe "Sessions API", type: :request do + let!(:user) { User.create!(nickname: "Columbus", password: "securepassword") } + let(:token) { JwtService.encode(user_id: user.id) } + + describe "POST /api/login" do + it "logs in with valid credentials and returns 200 OK with token" do + post "/api/login", params: { nickname: "Columbus", password: "securepassword" }, as: :json + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json["token"]).to be_present + expect(json["user"]["id"]).to eq(user.id) + expect(json["user"]["nickname"]).to eq("Columbus") + end + + it "returns 401 Unauthorized with invalid credentials" do + post "/api/login", params: { nickname: "Columbus", password: "wrongpassword" }, as: :json + + expect(response).to have_http_status(:unauthorized) + json = JSON.parse(response.body) + expect(json["error"]).to eq("Invalid nickname or password") + end + end + + describe "DELETE /api/logout" do + it "signs out successfully when bearer token is supplied" do + delete "/api/logout", headers: { "Authorization" => "Bearer #{token}" } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json["message"]).to eq("Signed out successfully") + end + + it "returns 401 Unauthorized when unauthenticated" do + delete "/api/logout" + + expect(response).to have_http_status(:unauthorized) + end + end + + describe "GET /api/me" do + it "returns user profile with Bearer authorization header" do + get "/api/me", headers: { "Authorization" => "Bearer #{token}" } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json["user"]["id"]).to eq(user.id) + expect(json["user"]["nickname"]).to eq("Columbus") + end + + it "returns 401 Unauthorized when Authorization header is missing" do + get "/api/me" + + expect(response).to have_http_status(:unauthorized) + json = JSON.parse(response.body) + expect(json["error"]).to eq("Unauthorized") + end + end +end