generated from VLADIMIR/template
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f648beaa33 | |||
| 69666ee264 | |||
| 4431a4d491 | |||
| 49fbda9e09 | |||
| ec2ea7d4a1 | |||
| c0b5ceb65f | |||
| 3b15fb3a2b | |||
| bbfca6d9a9 | |||
| 1a05799b2d | |||
| 5fcbf6f2c5 | |||
| 1acccd6c40 | |||
| bafe22a95d | |||
| a2cadf6eca | |||
| 7656100fe6 | |||
| 6d37610348 | |||
| 4ff43cc2dd | |||
| be15264764 | |||
| a797893a29 |
+1
-1
@@ -9,7 +9,7 @@ steps:
|
||||
- set GOOS=linux
|
||||
- set GOARCH=amd64
|
||||
- set CGO_ENABLED=0
|
||||
- go build -a -ldflags '-extldflags "-static"' -o evening_detective_server cmd/evening_detective_server/main.go
|
||||
- go build -a -ldflags '-extldflags "-static"' -o evening_detective_server ./cmd/evening_detective_server
|
||||
|
||||
- name: test
|
||||
image: golang
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Правила работы в репозитории evening_detective_server
|
||||
|
||||
Дополняют глобальный `~/.dsh/AGENTS.md`. Применяются в любой сессии в этом каталоге.
|
||||
|
||||
## Сборка и проверки
|
||||
|
||||
- **Кэш сборки**: системный `GOCACHE` (`/home/vladimir/.cache/go-build`) в этом окружении read-only. Всегда запускай Go-команды с локальным кэшем:
|
||||
`export GOCACHE=/home/vladimir/projects/evening_detective_server/.gocache`
|
||||
(иначе `go build/vet/test` падают с `read-only file system`).
|
||||
- Стандартный набор проверок после изменений: `gofmt -l cmd/ internal/ api/`, `go build ./...`, `go vet ./...`, `go test -count=1 ./...`; при конкурентности — `go test -race ./...`.
|
||||
- `go mod tidy` может не отработать (read-only module cache): если не хватает только перевода импортируемого пакета в прямые зависимости — перенеси его в первый require-блок вручную (эквивалентно tidy).
|
||||
|
||||
## Прото
|
||||
|
||||
- API описано в `api/main.proto`; сгенерированный код — `proto/` и `cmd/evening_detective_server/main.swagger.json`.
|
||||
- После правки proto обязателен `make generate` (protoc v35.1, плагины в `~/go/bin`), затем `go build ./...`.
|
||||
- **Конвенция REST — camelCase** (`accessToken`, `actionsCount`). Осознанное исключение: `file_type` в `Application`/`UploadFileRsp` (явный `json_name = "file_type"`, единый snake_case с внутренним JSON истории). Новые поля с явным json_name — только по согласованию.
|
||||
|
||||
## Известные факты (не переоткрывать в каждой задаче)
|
||||
|
||||
- Файлы хранятся в S3-совместимом RustFS (`file_storage`), URL префиксуются `FILE_PREFIX_DOMAIN` (см. `.env.example`). В БД и JSON истории — относительные имена; префикс добавляется только при чтении (идемпотентно, `prefixDomain`/`mapStory`).
|
||||
- `string_tools.Transliterate` удаляет ВСЁ кроме `[a-z0-9_]` — включая точку расширения: `Transliterate("photo.png")` → `"photopng"`. Работать с расширением файла — отдельно (split base/ext до транслитерации).
|
||||
- Улики = `Application { name, image, file_type }` (proto `api/main.proto`, `storytelling.Application`): живут в JSON истории сценария и в таблице `applications` (выданные команде). `file_type`: `image | pdf | audio`, деривируется из расширения через `file_storage.FileType`.
|
||||
- Загрузка файлов: `POST /api/files/upload` — только роль `Author`, allowlist расширений pdf/jpg/jpeg/png/gif/webp/mp3/ogg/wav, содержимое проверяется sniff'ом (текст/html отклоняется), имя уникализируется `translit(base)_<hex>.<ext>`. Лимит файла 64 МБ: HTTP `maxFileUploadBody`, gRPC `grpcMsgLimit = maxFileSize + 1MiB`, бизнес-проверка в `file_service`.
|
||||
- Ошибки размера на HTTP-слое мапятся в 413 через `customErrorHandler` (см. `cmd/evening_detective_server/main.go`): ветка `InvalidArgument` + «request body too large» — единственная реальная защита 413 (сгенерированный gateway-код строкифицирует `*http.MaxBytesError` через `%v`).
|
||||
- Скачивание `GET /api/files/{filename}` — публичное, с `X-Content-Type-Options: nosniff`.
|
||||
|
||||
## Процесс ревью (скилл task-execution)
|
||||
|
||||
Загружай скилл `task-execution` для нетривиальных задач. Внутри него действуют правила экономии контекста:
|
||||
- бюджет **3 раунда** на цикл (план/результат), «все замечания одним списком»;
|
||||
- план/состояние — во внешнем файле (`.dsh/task-plan.md`), промпт ревью — дельта + `git diff`;
|
||||
- саморевью фактическими проверками до отправки сеньёру (поведение функций на реальных данных, сгенерированный код, арифметика).
|
||||
|
||||
## Улучшения вне скоупа (скилл todo-collect)
|
||||
|
||||
Если в ходе задачи нашлось улучшение/долг, не мешающее текущей работе — **не чини попутно**: запиши его в локальную папку `todo/` по скиллу `todo-collect` (каждая задача — отдельный файл, шаблон в скилле) и продолжай текущую задачу. Папка в `.gitignore` — это локальный журнал, не коммитится.
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: create-go-module
|
||||
description: Создаёт Go-модуль в internal/modules/<snake_case>/ по конвенциям репозитория — обязательный interface.go с интерфейсом (имя начинается с I) и всеми публичными типами, в папке как минимум одна реализация интерфейса (неэкспортируемая структура + конструктор New*, возвращающий интерфейс). Загружай при создании нового модуля или заготовки модуля.
|
||||
whenToUse: Пользователь просит создать модуль/компонент/сервис/заготовку в internal/modules, либо «сделать по конвенциям проекта», либо явно упоминает interface.go или интерфейс с префиксом I.
|
||||
---
|
||||
|
||||
# Создание Go-модуля (internal/modules)
|
||||
|
||||
## Контекст
|
||||
|
||||
В этом проекте переиспользуемые компоненты живут в `internal/modules/<имя>/` — по одному модулю на папку. Публичный контракт модуля описывается в **обязательном** файле `interface.go`. Эталонные примеры действующих модулей: `internal/modules/password_generator`, `internal/modules/email_sender`, `internal/modules/cleaner`, `internal/modules/storytelling`, `internal/modules/processor_jwt`.
|
||||
|
||||
## Обязательные правила
|
||||
|
||||
1. **Папка модуля** — `internal/modules/<snake_case>/` (например, `email_sender`). Имя Go-пакета равно имени папки. Никаких вложенных подпакетов внутри модуля.
|
||||
2. **Файл `interface.go` обязателен.** В нём живёт ВЕСЬ публичный контракт модуля:
|
||||
- интерфейс(ы) модуля;
|
||||
- публичные типы: структуры-данные (DTO), перечисления, константы, именованные ошибки.
|
||||
Ничего другого (реализация, приватные хелперы) в этом файле нет.
|
||||
3. **Имя интерфейса начинается с `I`** — `I<CamelCase>`, например `IEmailSender`, `IPasswordGenerator`, `ICleaner`, `IStory`. Интерфейс без префикса `I` — недопустимо. В интерфейсе — только сигнатуры методов, без полей.
|
||||
4. **Реализация — в отдельном файле** (например, `<короткое_имя>.go` или `service.go`): неэкспортируемая структура (строчная, например `type emailSender struct`), методы которой реализуют интерфейс, и конструктор `func New<CamelCase>(...) I<CamelCase>`, возвращающий **интерфейс**, а не структуру.
|
||||
5. **В папке модуля — как минимум одна реализация интерфейса.** Модуль не может состоять из одного `interface.go`: рядом с ним всегда лежит неэкспортируемая структура, реализующая `I<CamelCase>` (см. правило 4), и конструктор, возвращающий интерфейс. Если реализаций несколько — каждая в своём файле.
|
||||
6. **Сигнатуры методов**: операции с внешним миром (сеть, файлы, БД, таймеры) принимают `context.Context` первым аргументом и возвращают `error`; чистые вычисления — без контекста (пример: `Generate() (string, error)` у `password_generator`).
|
||||
7. **Комментарии — на русском.** Каждый публичный идентификатор имеет doc-комментарий, начинающийся с имени символа. Пояснения сложных мест — тоже по-русски.
|
||||
8. **Ошибки** оборачиваются через `fmt.Errorf("<snake_case>: ...: %w", err)` — префикс с именем модуля.
|
||||
9. **JSON-теги** у сериализуемых структур — `json:"..."` (эталон: `storytelling/interface.go`).
|
||||
10. **Зависимости между модулями**: вместо импорта чужого модуля объявляется минимальный локальный интерфейс в файле `dependency.go` (эталон: `internal/modules/storytelling/dependency.go` объявляет собственный `ICleaner`). Это исключает циклические импорты и связывает модули только через их интерфейсы.
|
||||
11. **Тесты** — для модуля желателен файл `*_test.go` рядом с реализацией (примеры: `email_sender/sender_test.go`, `cleaner/service_test.go`).
|
||||
|
||||
## Шаги
|
||||
|
||||
1. **Определи имя модуля.** Если имя неоднозначно — уточни у пользователя. Приведи имя к двум формам:
|
||||
- snake_case — папка и пакет: «генератор паролей» → `password_generator`;
|
||||
- CamelCase — имена типов и интерфейса: `PasswordGenerator` → `IPasswordGenerator`.
|
||||
2. **Создай папку** `internal/modules/<snake_case>/`.
|
||||
3. **Напиши `interface.go`** (обязательно): сначала публичные типы/константы, затем интерфейс `I<CamelCase>`.
|
||||
4. **Напиши файл(ы) реализации** (обязательно, минимум один) — неэкспортируемая структура + конструктор `New<CamelCase>(...) I<CamelCase>`.
|
||||
5. **При необходимости** добавь `dependency.go` (локальные интерфейсы зависимостей) и `*_test.go`.
|
||||
6. **Проверь сборку** из корня проекта: `go build ./...` и `go vet ./...`. Ошибки — исправь.
|
||||
|
||||
## Шаблон interface.go
|
||||
|
||||
```go
|
||||
package <snake_case>
|
||||
|
||||
import "context"
|
||||
|
||||
// <TypeName> — описание публичного типа.
|
||||
type <TypeName> struct {
|
||||
Field string `json:"field"`
|
||||
}
|
||||
|
||||
// I<CamelCase> — контракт модуля <snake_case>.
|
||||
type I<CamelCase> interface {
|
||||
// <Method> — описание метода.
|
||||
<Method>(ctx context.Context, in <TypeName>) error
|
||||
}
|
||||
```
|
||||
|
||||
## Шаблон реализации
|
||||
|
||||
```go
|
||||
package <snake_case>
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// <lowerCamelCase> — реализация I<CamelCase>.
|
||||
type <lowerCamelCase> struct {
|
||||
// внутренние поля
|
||||
}
|
||||
|
||||
// New<CamelCase> создаёт реализацию I<CamelCase>.
|
||||
func New<CamelCase>(...) I<CamelCase> {
|
||||
return &<lowerCamelCase>{}
|
||||
}
|
||||
|
||||
func (s *<lowerCamelCase>) <Method>(ctx context.Context, in <TypeName>) error {
|
||||
// TODO: реализация
|
||||
return fmt.Errorf("<snake_case>: не реализовано")
|
||||
}
|
||||
```
|
||||
|
||||
## Критерии готовности
|
||||
|
||||
- [ ] Папка `internal/modules/<snake_case>/` создана, имя пакета равно имени папки.
|
||||
- [ ] `interface.go` существует; все публичные типы, константы и интерфейсы описаны именно в нём.
|
||||
- [ ] Имя интерфейса начинается с `I`.
|
||||
- [ ] В папке модуля есть как минимум одна реализация интерфейса (неэкспортируемая структура + конструктор).
|
||||
- [ ] Реализация неэкспортируемая; конструктор возвращает интерфейс, а не структуру.
|
||||
- [ ] doc-комментарии на русском у всех публичных идентификаторов.
|
||||
- [ ] `go build ./...` и `go vet ./...` проходят без ошибок.
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
name: task-execution
|
||||
description: Выполняет любую нетривиальную задачу по процессу «план → ревью сеньёром → правки → выполнение → ревью результата → правки». План и результат проверяет субагент в роли senior developer; каждый цикл повторяется, пока не устранены блокирующие замечания. Ниты и уточнения формулировок не блокируют одобрение — они фиксируются и могут остаться неисправленными. При неясностях агент задаёт вопросы пользователю. Загружай для реализации фичи, исправления бага, рефакторинга, написания кода или документации, когда задача не сводится к одному тривиальному действию.
|
||||
whenToUse: Пользователь просит выполнить задачу (написать код, починить баг, отрефакторить, задокументировать, что-то исследовать и внедрить) и задача не является тривиальным одношаговым действием. Для тривиальных правок скил можно не применять.
|
||||
---
|
||||
|
||||
# Выполнение любой задачи: план → сеньёр → выполнение → сеньёр
|
||||
|
||||
## Контекст
|
||||
|
||||
Любая нетривиальная задача выполняется строго по циклу: сначала **план**, затем **ревью плана сеньёром** с правками до одобрения, затем **выполнение** по одобренному плану, затем **ревью результата сеньёром** с правками, пока не устранены блокирующие замечания. Замечания делятся на **блокирующие** (исправляются обязательно) и **ниты** (фиксируются, но не блокируют одобрение и могут остаться неисправленными). На каждом этапе, если что-то неясно, задаются вопросы пользователю.
|
||||
|
||||
## Классификация замечаний
|
||||
|
||||
- **Блокирующее ([BLOCKER])** — влияет на функциональность, корректность, безопасность, целостность данных, совместимость, достижимость шага плана, ломает сборку/тесты, нарушает конвенции проекта, создаёт регрессию. Такие замечания исправляются все, без исключения.
|
||||
- **Нит ([NIT])** — уточнение формулировки, стиль, типографика, «на будущее», рекомендация без реального риска, альтернатива, которая не меняет поведение. Ниты фиксируются (в файле состояния или `todo/`), но НЕ обязательны к исправлению и не блокируют `APPROVED`.
|
||||
- Метку ставит сеньёр в каждом замечании. Если метка отсутствует или неоднозначна — трактуй замечание как блокирующее (осторожность в пользу качества); при сомнении можно спросить пользователя.
|
||||
|
||||
## Роли
|
||||
|
||||
- **Исполнитель** — ты (агент). Составляешь план, выполняешь, правишь по блокирующим замечаниям, фиксируешь ниты.
|
||||
- **Сеньёр (senior developer)** — отдельный субагент, запускаемый через `subagent` **один раз на задачу** и переиспользуемый между раундами через `send_message` (он помнит свои предыдущие замечания и дельты — не противоречит себе и не перечитывает весь файл состояния каждый раунд). Он **не видит наш диалог**, поэтому каждый промпт ревью должен быть самодостаточным — но самодостаточность достигается за счёт внешнего файла состояния, а не дублирования всего плана в промпте (см. правило 9). Новый сеньёр создаётся только при смене задачи или переполнении контекста субагента (тогда — со сводкой уже принятых решений).
|
||||
- **Пользователь** — источник требований и финальный судья. Ему задаются вопросы при неясностях.
|
||||
|
||||
## Обязательные правила
|
||||
|
||||
1. **Сначала план — потом выполнение.** Не начинай выполнение, пока сеньёр не одобрил план (ответил `APPROVED`).
|
||||
2. **Ревью плана и результата — только через субагента в роли senior developer** (см. шаблоны промптов ниже). **Создай сеньёра один раз** (первый раунд — `subagent` с `run_in_background: true`), далее каждый раунд — `send_message` тому же субагенту с дельтой. Ответ субагента приходит уведомлением (не синхронно): пока он работает, продолжай независимые шаги, по уведомлению забирай результат.
|
||||
3. **Блокирующие замечания исправляются все, без выбора.** Нельзя отбрасывать `[BLOCKER]` «потому что не согласен»: если не согласен — верни уточняющий вопрос сеньёру или спроси пользователя. **Ниты (`[NIT]`) исправлять не обязательно**: зафиксируй их в файле состояния или `todo/` и продолжай.
|
||||
4. **Цикл повторяется, пока сеньёр не вернёт `APPROVED`** — и для плана, и для результата. `APPROVED` достижим при наличии только нитов (сеньёр может вернуть `APPROVED (ниты: ...)`); блокирует цикл только наличие `[BLOCKER]`.
|
||||
5. **Неясно — спроси.** На любом этапе (задача, шаг плана, замечание сеньёра) при неоднозначности задай вопрос пользователю через `ask_user_question`. Догадки вместо вопросов — ошибка.
|
||||
6. **Отслеживай прогресс** через `todo_write`: план из шага 2 переносится в todo-список, пункты отмечаются по мере выполнения.
|
||||
7. **Профильные скилы** (например `create-go-module`) — источник правил «как делать» для конкретной задачи. Этот скил управляет процессом «как вести задачу»; оба применяются вместе.
|
||||
8. **Бюджет раундов ревью — 3 на цикл** (план и результат отдельно). В шаблонах промптов требуй от сеньёра перечислить **все замечания одним списком за один заход** с метками `[BLOCKER]`/`[NIT]`. После 3 раундов с неисправленными `[BLOCKER]` остановись и спроси пользователя: продолжать цикл или зафиксировать оставшиеся блокеры как известные ограничения (не растягивай полировку на десятки раундов).
|
||||
9. **Дифф-ревью и внешнее состояние.** План, чек-лист и учёт замечаний держи во внешнем файле состояния (например, `.dsh/task-plan.md`). Промпт ревью содержит: задачу, путь к файлу состояния, **короткую дельту** «что изменилось с прошлого раза» и `git diff` изменённых файлов. Не пересказывай ревьюеру весь план и всю историю замечаний — файл состояния он прочитает сам (это его контекст, а не наш).
|
||||
10. **Саморевью до отправки.** Перед первым и каждым повторным ревью прогоняй фактические проверки, которые сеньёр всё равно сделает: поведение функций на реальных данных (например, `Transliterate("photo.png")`, `http.DetectContentType`, `filepath.Ext("x.PNG?v=2")`), чтение сгенерированного кода (pb.gw.go и пр.), арифметику лимитов. «Ревью результата» отправляй как `git diff` + выводы проверок, а не как пересказ плана.
|
||||
|
||||
## Шаги
|
||||
|
||||
### 1. Уточни задачу
|
||||
|
||||
Разберись, что именно нужно сделать. Если неясны цель, объём, ограничения или критерии приёмки — задай вопросы пользователю до составления плана.
|
||||
|
||||
### 2. Составь план
|
||||
|
||||
План должен содержать:
|
||||
|
||||
- **Цель** и ожидаемый результат;
|
||||
- **Шаги** с конкретными файлами, командами и артефактами;
|
||||
- **Проверки** (сборка, тесты, линтеры, вёрстка и т.п.);
|
||||
- **Критерии готовности** — что считается «сделано»;
|
||||
- **Риски и открытые вопросы** (если есть).
|
||||
|
||||
Заведи todo-список через `todo_write` в соответствии с планом.
|
||||
|
||||
### 3. Ревью плана сеньёром
|
||||
|
||||
Сохрани план и критерии в файл состояния (например, `.dsh/task-plan.md`), прогони саморевью (правило 10), затем **создай сеньёра** (`subagent`, первый раунд) с промптом по шаблону **«Ревью плана»** (ниже) и дождись его ответа. Сеньёр возвращает либо `APPROVED`, либо список замечаний с метками.
|
||||
|
||||
### 4. Цикл правок плана
|
||||
|
||||
- Есть блокирующие замечания → исправь их в файле состояния (и todo-список), отправь на ревью **повторно ТОМУ ЖЕ сеньёру** через `send_message`: в промпт добавь только дельту «что изменилось с прошлого раза» (правило 9).
|
||||
- Ниты → зафиксируй в файле состояния (раздел «Ниты / известные ограничения») или `todo/`, не обязательно исправляй.
|
||||
- Повторяй, пока не получишь `APPROVED`; после 3 раундов с неисправленными `[BLOCKER]` — спроси пользователя (см. «Ограничение итераций»).
|
||||
|
||||
### 5. Выполнение
|
||||
|
||||
Выполняй строго по одобренному плану, отмечая прогресс в `todo_write`. Если в ходе выполнения стало ясно, что план нужно изменить — не отклоняйся молча: вернись к шагу 3 с обновлённым планом.
|
||||
|
||||
### 6. Ревью результата
|
||||
|
||||
Сначала сам проверь результат (прогони проверки из плана, правило 10), затем отдай его сеньёру по шаблону **«Ревью результата»** через `send_message` (тот же субагент): `git diff` изменённых файлов + выводы проверок. Сеньёр сверяет результат с планом (файл состояния) и критериями, проверяет корректность и соблюдение конвенций проекта.
|
||||
|
||||
### 7. Цикл правок результата
|
||||
|
||||
Исправляй блокирующие замечания, повторяй ревью **тому же сеньёру** через `send_message`, пока не вернёт `APPROVED`. Ниты — фиксируй в `todo/` или файле состояния, не обязательно исправляй. При каждом повторе сообщай сеньёру только дельту — что изменилось с прошлого раза (правило 9). После 3 раундов с неисправленными `[BLOCKER]` — спроси пользователя.
|
||||
|
||||
### 8. Финальный отчёт
|
||||
|
||||
Кратко сообщи пользователю: что сделано, какие файлы затронуты, как проверялось, что план и результат одобрены сеньёром.
|
||||
|
||||
## Шаблон: промпт «Ревью плана»
|
||||
|
||||
План и критерии — в файле состояния (путь ниже); в промпт включай только задачу, путь к файлу, дельту с прошлого раза (если есть) и diff, если правился код.
|
||||
|
||||
```
|
||||
Ты — senior developer. Оцени план выполнения задачи.
|
||||
|
||||
Задача: <текст задачи>
|
||||
План и критерии готовности: <путь к файлу состояния, например .dsh/task-plan.md> (прочитай сам)
|
||||
Изменения с прошлого раза: <дельту или «первое ревью»>
|
||||
|
||||
Проверь: полноту (нет ли пропущенных шагов), достижимость, корректность
|
||||
подхода, риски, соответствие конвенциям проекта, отсутствие лишних шагов.
|
||||
|
||||
Классифицируй каждое замечание:
|
||||
- [BLOCKER] — влияет на функциональность, корректность, безопасность,
|
||||
целостность данных, достижимость шага, ломает сборку/тесты, нарушает
|
||||
конвенции проекта, создаёт регрессию. Такие обязательны к исправлению.
|
||||
- [NIT] — уточнение формулировки, стиль, типографика, «на будущее»,
|
||||
рекомендация без реального риска. Ниты не блокируют APPROVED.
|
||||
|
||||
Перечисли ВСЕ замечания одним списком за один заход — включая мелочи и
|
||||
ниты; не растягивай на несколько раундов.
|
||||
Ответь СТРОГО одним из двух вариантов:
|
||||
- APPROVED (можно с пометкой «ниты: ...») — если блокирующих замечаний нет;
|
||||
- список замечаний, каждое в формате «[BLOCKER]|[NIT] <что не так> → <как исправить>».
|
||||
```
|
||||
|
||||
## Шаблон: промпт «Ревью результата»
|
||||
|
||||
```
|
||||
Ты — senior developer. Оцени результат выполнения задачи.
|
||||
|
||||
Задача: <текст задачи>
|
||||
План и критерии готовности: <путь к файлу состояния> (прочитай сам)
|
||||
Результат: git diff изменённых файлов + выводы проверок (build/vet/test/race)
|
||||
Изменения с прошлого раза: <дельту или «первое ревью»>
|
||||
|
||||
Проверь: результат соответствует плану и критериям готовности, код/текст
|
||||
корректен, соблюдены конвенции проекта, нет регрессий.
|
||||
|
||||
Классифицируй каждое замечание:
|
||||
- [BLOCKER] — влияет на функциональность, корректность, безопасность,
|
||||
целостность данных, ломает сборку/тесты, нарушает конвенции, регрессия.
|
||||
Такие обязательны к исправлению.
|
||||
- [NIT] — уточнение формулировки, стиль, типографика, «на будущее»,
|
||||
рекомендация без реального риска. Ниты не блокируют APPROVED.
|
||||
|
||||
Перечисли ВСЕ замечания одним списком за один заход — включая мелочи и ниты.
|
||||
Ответь СТРОГО одним из двух вариантов:
|
||||
- APPROVED (можно с пометкой «ниты: ...») — если блокирующих замечаний нет;
|
||||
- список замечаний, каждое в формате «[BLOCKER]|[NIT] <что не так> → <как исправить>».
|
||||
```
|
||||
|
||||
## Ограничение итераций
|
||||
|
||||
Если в одном цикле (план или результат) после **3 раундов** остаются неисправленные `[BLOCKER]` — остановись и спроси пользователя: продолжать цикл или зафиксировать оставшиеся блокеры как известные ограничения. Ниты не считаются причиной продолжения цикла: при одних нитах сеньёр возвращает `APPROVED (ниты: ...)`, а исполнитель фиксирует их в файле состояния или `todo/`.
|
||||
|
||||
## Критерии готовности
|
||||
|
||||
- [ ] План составлен и одобрен сеньёром (`APPROVED`) до начала выполнения.
|
||||
- [ ] Все блокирующие замечания сеньёра по плану устранены; ниты зафиксированы (в плане/`todo/`) и не блокируют одобрение.
|
||||
- [ ] Результат соответствует плану и критериям готовности.
|
||||
- [ ] Все блокирующие замечания сеньёра по результату устранены, получен `APPROVED`; ниты зафиксированы.
|
||||
- [ ] Пользователю дан финальный отчёт.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: todo-collect
|
||||
description: Записывает найденные улучшения «вне скоупа» текущей задачи в локальную папку todo/ — каждую задачу в отдельный файл, без выполнения и без расширения scope. Загружай при работе над любой задачей (реализация, ревью, рефакторинг, исследование), чтобы не терять замечания, не мешающие текущей работе.
|
||||
whenToUse: В процессе любой задачи обнаружено улучшение, оптимизация, потенциальный баг или долг, которые не блокируют и не относятся к текущей задаче. Триггеры: замечание в коде, «на будущее» из ревью, осознанное ограничение, отложенное решение.
|
||||
---
|
||||
|
||||
# Сбор улучшений «вне скоупа» в todo/
|
||||
|
||||
## Контекст
|
||||
|
||||
В ходе задачи постоянно встречаются места, которые можно улучшить, но чинить их сейчас — значит расширять scope и рисковать текущей работой (правило «не отклоняйся молча»). Такие находки не должны теряться: они записываются в локальную папку `todo/` репозитория (в `.gitignore`, в git не попадает) — **каждая задача в свой отдельный файл**.
|
||||
|
||||
## Правила
|
||||
|
||||
1. **Записывай, не чини.** Нашёл улучшение, не мешающее текущей задаче — запиши в `todo/` и продолжай текущую работу. Не выполняй записанные задачи в текущей сессии, если пользователь явно об этом не попросил.
|
||||
2. **Одна задача — один файл.** Имя файла — kebab-case, отражающее суть: `todo/<chto-i-gde>.md` (например, `todo/mapimage-idempotent-prefix.md`). Если файл с той же сутью уже существует — дополни его, не создавай дубль.
|
||||
3. **Записывай сразу.** Не откладывай «на потом»: в конце задачи контекст уже потерян. Короткая запись по шаблону ниже занимает минуту.
|
||||
4. **Фиксируй только факты и предложения.** Без воды: что не так, где, как исправить (с оценкой риска), кто/когда нашёл.
|
||||
5. **Не перемещай и не удаляй чужие записи.** Менять статус/содержимое своей записи можно; чужие — только по договорённости.
|
||||
6. **Папка локальная.** `todo/` в `.gitignore` — не коммить, не синхронизировать. Если задача заслуживает общего трекинга — предложи пользователю перенести её в issue-трекер.
|
||||
|
||||
## Шаблон записи
|
||||
|
||||
```markdown
|
||||
# <Краткое название задачи>
|
||||
|
||||
- **Где найдено**: <файл/пакет/место в коде; в какой задаче>
|
||||
- **Дата**: <YYYY-MM-DD>
|
||||
- **Статус**: todo
|
||||
|
||||
## Проблема
|
||||
|
||||
<что именно не так, почему это проблема, при каких условиях проявится>
|
||||
|
||||
## Предложение
|
||||
|
||||
<как исправить, с указанием конкретных файлов/функций; оценка риска и объёма>
|
||||
|
||||
## Связи
|
||||
|
||||
<опционально: связанные todo-файлы, ссылки на код, замечания ревью>
|
||||
```
|
||||
|
||||
## Статусы
|
||||
|
||||
- `todo` — записано, не начато;
|
||||
- `in_progress` — кто-то взялся (укажи имя/сессию);
|
||||
- `done` — выполнено (укажи, как и когда; можно удалить файл после подтверждения).
|
||||
|
||||
## Критерии готовности записи
|
||||
|
||||
- [ ] Проблема описана так, что понятна без контекста исходной задачи.
|
||||
- [ ] Предложено конкретное исправление (или явно сказано, что решение открыто).
|
||||
- [ ] Файл лежит в `todo/` с kebab-case именем, дублей нет.
|
||||
@@ -4,9 +4,20 @@ SMTP_HOST=mail.crabs-games.art
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=evening_detective@crabs-games.art
|
||||
SMTP_PASSWORD=your_password
|
||||
# Отображаемое имя отправителя: Gmail показывает его вместо голого адреса,
|
||||
# письма от «имени» выглядят человечнее и реже попадают в спам
|
||||
SMTP_FROM_NAME=Вечерний детектив
|
||||
# Адрес, на который пользователь сможет ответить на письмо
|
||||
SMTP_REPLY_TO=evening_detective@crabs-games.art
|
||||
# Общий таймаут SMTP-диалога (dial + auth + отправка), дефолт 10s
|
||||
SMTP_TIMEOUT=10s
|
||||
|
||||
JWT_SECRET=your_secret
|
||||
|
||||
# Публичный адрес фронтенда: используется для ссылки «Войти в игру» в
|
||||
# письмах. Если не задан или не http(s) — ссылка в письма не добавляется.
|
||||
APP_BASE_URL=https://evening-detective.crabs-games.art
|
||||
|
||||
S3_HOST=http://0.0.0.0:9000
|
||||
S3_USER=rustfs
|
||||
S3_PASSWORD=your_password
|
||||
|
||||
@@ -27,6 +27,14 @@ go.sum
|
||||
|
||||
.env
|
||||
|
||||
# Локальные Go-кэши (используются, когда системный кэш недоступен)
|
||||
.gocache/
|
||||
.gotmp/
|
||||
|
||||
# Локальный журнал улучшений «вне скоупа» текущей задачи
|
||||
# (каждая запись — отдельный файл, см. скилл todo-collect)
|
||||
todo/
|
||||
|
||||
.VSCodeCounter/
|
||||
|
||||
docker-compose-prod.yml
|
||||
@@ -10,7 +10,7 @@ generate:
|
||||
./api/main.proto
|
||||
|
||||
run:
|
||||
go run ./cmd/evening_detective_server/main.go
|
||||
go run ./cmd/evening_detective_server
|
||||
|
||||
build-builder:
|
||||
docker build -f Dockerfile.builder -t my-go-builder .
|
||||
@@ -20,7 +20,7 @@ build-linux:
|
||||
-v "$$PWD":/app \
|
||||
-w /app \
|
||||
my-go-builder sh -c \
|
||||
"GOOS=linux GOARCH=arm64 go build -o bin/evening_detective_server cmd/evening_detective_server/main.go"
|
||||
"GOOS=linux GOARCH=arm64 go build -o bin/evening_detective_server ./cmd/evening_detective_server"
|
||||
|
||||
test:
|
||||
go test -count=1 ./...
|
||||
|
||||
@@ -69,3 +69,39 @@ make test
|
||||
- `preset.yml` — отображаемое имя и описание;
|
||||
- `skills/software-law/` — база знаний: `SKILL.md` (правила консультаций) и `references/` (лицензии, авторские права, ПДн, договоры).
|
||||
|
||||
## MCP-сервер (встроен в основной сервис)
|
||||
|
||||
MCP-сервер (Model Context Protocol) встроен в основной бинарь и доступен как HTTP-эндпоинт `/api/mcp` (streamable HTTP) на REST-gateway — чтобы LLM-клиент (Claude Desktop, IDE с MCP-поддержкой и т.п.) мог играть в «Вечерний детектив». Инструменты вызывают игровые сервисы напрямую, отдельный процесс не нужен.
|
||||
|
||||
Инструменты:
|
||||
|
||||
| Инструмент | Назначение |
|
||||
|---|---|
|
||||
| `connect` | Подключиться к игре по ссылке `/team-story/{id}?password=...`; возвращает id команды, пароль и текущую историю |
|
||||
| `get_team_story` | История команды: точки, двери, улики, вопросы дела + инфо об игре (по паролю команды, без авторизации) |
|
||||
| `make_move` | Ход команды в точку по её коду; возвращает обновлённую историю (по паролю команды, без авторизации) |
|
||||
| `submit_answers` | Ответы команды на вопросы дела (массив `{question_code, answer}`); возвращает обновлённую историю (по паролю команды, без авторизации) |
|
||||
|
||||
Ссылку на игру (`/team-story/{id}?password=...`) выдаёт команде организатор личным каналом — например, вместе с паролем. Принимаются относительные и абсолютные ссылки (в т.ч. с любым хостом): HTTP-запросы по ним не выполняются, из ссылки берутся только id команды и пароль. Игроку авторизация не нужна: команда идентифицируется паролем из ссылки.
|
||||
|
||||
Запуск — обычный запуск основного сервиса:
|
||||
|
||||
```shell
|
||||
make run
|
||||
```
|
||||
|
||||
MCP-эндпоинт: `http://localhost:8090/api/mcp`. ВНИМАНИЕ: REST-gateway слушает `:8090` (gRPC — `:8080`), а `docker-compose.yml` публикует наружу только `8080` — для доступа к `/api/mcp` извне нужна публикация порта 8090 в compose либо проксирование пути `/api/mcp` через reverse-proxy (nginx/caddy). Эндпоинт публичный (как и REST `/api/teams/{id}/story`); секрет — пароль команды.
|
||||
|
||||
Подключение к MCP-клиенту (пример для Claude Desktop / аналогов):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"evening-detective": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:8090/api/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+250
@@ -365,6 +365,70 @@ service EveningDetectiveServer {
|
||||
};
|
||||
}
|
||||
|
||||
rpc AddScenarioQuestion(AddScenarioQuestionReq) returns (AddScenarioQuestionRsp) {
|
||||
option (google.api.http) = {
|
||||
post: "/api/scenarios/{id}/questions"
|
||||
body: "*"
|
||||
};
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags : "Вопросы сценария";
|
||||
summary: "Создать вопрос дела";
|
||||
};
|
||||
}
|
||||
|
||||
rpc UpdateScenarioQuestion(UpdateScenarioQuestionReq) returns (UpdateScenarioQuestionRsp) {
|
||||
option (google.api.http) = {
|
||||
put : "/api/scenarios/{id}/questions/{code}"
|
||||
body: "*"
|
||||
};
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags : "Вопросы сценария";
|
||||
summary: "Обновить вопрос дела";
|
||||
};
|
||||
}
|
||||
|
||||
rpc DeleteScenarioQuestion(DeleteScenarioQuestionReq) returns (DeleteScenarioQuestionRsp) {
|
||||
option (google.api.http) = {
|
||||
delete: "/api/scenarios/{id}/questions/{code}"
|
||||
};
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags : "Вопросы сценария";
|
||||
summary: "Удалить вопрос дела";
|
||||
};
|
||||
}
|
||||
|
||||
rpc UpdateScenarioIntro(UpdateScenarioIntroReq) returns (UpdateScenarioIntroRsp) {
|
||||
option (google.api.http) = {
|
||||
put : "/api/scenarios/{id}/introduction"
|
||||
body: "*"
|
||||
};
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags : "Сценарии";
|
||||
summary: "Обновить введение сценария";
|
||||
};
|
||||
}
|
||||
|
||||
rpc DownloadScenarioArchive(DownloadScenarioArchiveReq) returns (google.api.HttpBody) {
|
||||
option (google.api.http) = {
|
||||
get: "/api/scenarios/{id}/archive"
|
||||
};
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags : "Сценарии";
|
||||
summary: "Скачать сценарий архивом (со всеми материалами и картинками)";
|
||||
};
|
||||
}
|
||||
|
||||
rpc UploadScenarioArchive(google.api.HttpBody) returns (UploadScenarioArchiveRsp) {
|
||||
option (google.api.http) = {
|
||||
post: "/api/scenarios/archive"
|
||||
body: "*"
|
||||
};
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags : "Сценарии";
|
||||
summary: "Создать сценарий из архива";
|
||||
};
|
||||
}
|
||||
|
||||
rpc AddGame(AddGameReq) returns (AddGameRsp) {
|
||||
option (google.api.http) = {
|
||||
post: "/api/games"
|
||||
@@ -396,6 +460,16 @@ service EveningDetectiveServer {
|
||||
};
|
||||
}
|
||||
|
||||
rpc GetGameAnswers(GetGameAnswersReq) returns (GetGameAnswersRsp) {
|
||||
option (google.api.http) = {
|
||||
get: "/api/games/{gameId}/answers"
|
||||
};
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags : "Игры";
|
||||
summary: "Получить ответы команд на вопросы дела (для оценки организатором)";
|
||||
};
|
||||
}
|
||||
|
||||
rpc UpdateGame(UpdateGameReq) returns (UpdateGameRsp) {
|
||||
option (google.api.http) = {
|
||||
put : "/api/games/{id}"
|
||||
@@ -551,6 +625,44 @@ service EveningDetectiveServer {
|
||||
summary: "Удалить последний ход";
|
||||
};
|
||||
}
|
||||
|
||||
rpc GetTeamAnswers(GetTeamAnswersReq) returns (GetTeamAnswersRsp) {
|
||||
option (google.api.http) = {
|
||||
get: "/api/teams/{id}/answers"
|
||||
};
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
security: {
|
||||
security_requirement: {}
|
||||
}
|
||||
tags : "Ходы";
|
||||
summary: "Получить ответы команды на вопросы дела";
|
||||
};
|
||||
}
|
||||
|
||||
rpc SubmitTeamAnswers(SubmitTeamAnswersReq) returns (SubmitTeamAnswersRsp) {
|
||||
option (google.api.http) = {
|
||||
post: "/api/teams/{id}/answers"
|
||||
body: "*"
|
||||
};
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
security: {
|
||||
security_requirement: {}
|
||||
}
|
||||
tags : "Ходы";
|
||||
summary: "Отправить ответы команды на вопросы дела";
|
||||
};
|
||||
}
|
||||
|
||||
rpc SetTeamAnswerScore(SetTeamAnswerScoreReq) returns (SetTeamAnswerScoreRsp) {
|
||||
option (google.api.http) = {
|
||||
put : "/api/teams/{id}/answers/{questionCode}/score"
|
||||
body: "*"
|
||||
};
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags : "Игры";
|
||||
summary: "Выставить балл команде за ответ на вопрос дела";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
message PingReq {}
|
||||
@@ -703,6 +815,9 @@ message UploadFileReq {
|
||||
message UploadFileRsp {
|
||||
string error = 1;
|
||||
string filename = 2;
|
||||
// Тип загруженного файла: image | pdf | audio (определяется сервером по
|
||||
// содержимому). Удобен редактору для предзаполнения file_type улики.
|
||||
string file_type = 3 [json_name = "file_type"];
|
||||
}
|
||||
|
||||
message DownloadFileReq {
|
||||
@@ -755,7 +870,14 @@ message Scenario {
|
||||
}
|
||||
|
||||
message Story {
|
||||
Introduction introduction = 2;
|
||||
repeated Place places = 1;
|
||||
repeated Question questions = 3;
|
||||
}
|
||||
|
||||
message Introduction {
|
||||
string text = 1;
|
||||
string audio = 2;
|
||||
}
|
||||
|
||||
message Place {
|
||||
@@ -772,6 +894,10 @@ message Place {
|
||||
message Application {
|
||||
string name = 1;
|
||||
string image = 2;
|
||||
// Тип файла улики: image | pdf | audio. Значение выводится сервером из
|
||||
// расширения файла (см. file_storage.FileType); пустое значение возможно
|
||||
// только для legacy-строк без расширения.
|
||||
string file_type = 3 [json_name = "file_type"];
|
||||
}
|
||||
|
||||
message Door {
|
||||
@@ -784,6 +910,43 @@ message Key {
|
||||
string name = 1;
|
||||
}
|
||||
|
||||
// Вопрос дела - вопрос, на который команда должна ответить в конце игры.
|
||||
// Правильный ответ (answer) виден только автору сценария и организаторам
|
||||
// (при оценке ответов команд), команде не передаётся.
|
||||
message Question {
|
||||
// Код вопроса - используется при отправке ответов командой
|
||||
string code = 1;
|
||||
|
||||
// Текст вопроса
|
||||
string text = 2;
|
||||
|
||||
// Правильный ответ (справка для организатора при оценке)
|
||||
string answer = 3;
|
||||
}
|
||||
|
||||
// Ответ команды на вопрос дела
|
||||
message Answer {
|
||||
// Код вопроса
|
||||
string questionCode = 1;
|
||||
|
||||
// Текст ответа команды
|
||||
string answer = 2;
|
||||
}
|
||||
|
||||
// Ответ команды с баллом (для панели организатора)
|
||||
message AnswerScore {
|
||||
string questionCode = 1;
|
||||
string answer = 2;
|
||||
// Балл, выставленный организатором; отсутствует, если ещё не оценено
|
||||
optional int32 score = 3;
|
||||
}
|
||||
|
||||
// Ответы одной команды на вопросы дела
|
||||
message TeamAnswers {
|
||||
Team team = 1;
|
||||
repeated AnswerScore answers = 2;
|
||||
}
|
||||
|
||||
message UpdateScenarioReq {
|
||||
int32 id = 1;
|
||||
string name = 2;
|
||||
@@ -847,6 +1010,52 @@ message DeleteScenarioPlaceRsp {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
message UpdateScenarioIntroReq {
|
||||
int32 id = 1;
|
||||
Introduction introduction = 2;
|
||||
}
|
||||
|
||||
message UpdateScenarioIntroRsp {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
message AddScenarioQuestionReq {
|
||||
int32 id = 1;
|
||||
Question question = 2;
|
||||
}
|
||||
|
||||
message AddScenarioQuestionRsp {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
message UpdateScenarioQuestionReq {
|
||||
int32 id = 1;
|
||||
string code = 2;
|
||||
Question question = 3;
|
||||
}
|
||||
|
||||
message UpdateScenarioQuestionRsp {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
message DeleteScenarioQuestionReq {
|
||||
int32 id = 1;
|
||||
string code = 2;
|
||||
}
|
||||
|
||||
message DeleteScenarioQuestionRsp {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
message DownloadScenarioArchiveReq {
|
||||
int32 id = 1;
|
||||
}
|
||||
|
||||
message UploadScenarioArchiveRsp {
|
||||
string error = 1;
|
||||
int32 id = 2;
|
||||
}
|
||||
|
||||
message AddGameReq {
|
||||
string name = 1;
|
||||
string description = 2;
|
||||
@@ -896,6 +1105,16 @@ message GetGameRsp {
|
||||
Game game = 2;
|
||||
}
|
||||
|
||||
message GetGameAnswersReq {
|
||||
int32 gameId = 1;
|
||||
}
|
||||
|
||||
message GetGameAnswersRsp {
|
||||
string error = 1;
|
||||
repeated Question questions = 2;
|
||||
repeated TeamAnswers teams = 3;
|
||||
}
|
||||
|
||||
message UpdateGameReq {
|
||||
int32 id = 1;
|
||||
string name = 2;
|
||||
@@ -1021,3 +1240,34 @@ message DeleteLastTeamActionReq {
|
||||
message DeleteLastTeamActionRsp {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
message GetTeamAnswersReq {
|
||||
int64 id = 1;
|
||||
string password = 2;
|
||||
}
|
||||
|
||||
message GetTeamAnswersRsp {
|
||||
string error = 1;
|
||||
repeated Answer answers = 2;
|
||||
}
|
||||
|
||||
message SubmitTeamAnswersReq {
|
||||
int64 id = 1;
|
||||
string password = 2;
|
||||
repeated Answer answers = 3;
|
||||
}
|
||||
|
||||
message SubmitTeamAnswersRsp {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
message SetTeamAnswerScoreReq {
|
||||
int64 id = 1;
|
||||
string questionCode = 2;
|
||||
// Балл; отсутствует — сбросить выставленный балл
|
||||
optional int32 score = 3;
|
||||
}
|
||||
|
||||
message SetTeamAnswerScoreRsp {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,436 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/modules/scenario_archive"
|
||||
proto "evening_detective_server/proto"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"google.golang.org/genproto/googleapis/api/httpbody"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// testStub — минимальный gRPC-сервер, реализующий архивные и файловые RPC.
|
||||
type testStub struct {
|
||||
proto.UnimplementedEveningDetectiveServerServer
|
||||
|
||||
mu sync.Mutex
|
||||
uploaded []byte
|
||||
uploadErr error
|
||||
downloadErr error
|
||||
}
|
||||
|
||||
func (s *testStub) UploadScenarioArchive(_ context.Context, req *httpbody.HttpBody) (*proto.UploadScenarioArchiveRsp, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.uploaded = append([]byte(nil), req.Data...)
|
||||
return &proto.UploadScenarioArchiveRsp{Id: 42}, nil
|
||||
}
|
||||
|
||||
func (s *testStub) DownloadScenarioArchive(ctx context.Context, _ *proto.DownloadScenarioArchiveReq) (*httpbody.HttpBody, error) {
|
||||
_ = grpc.SetHeader(ctx, metadata.Pairs("Content-Disposition", `attachment; filename="test.zip"`))
|
||||
return &httpbody.HttpBody{
|
||||
Data: []byte("zip-data"),
|
||||
ContentType: "application/zip",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *testStub) UploadFile(_ context.Context, req *proto.UploadFileReq) (*proto.UploadFileRsp, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.uploadErr != nil {
|
||||
return nil, s.uploadErr
|
||||
}
|
||||
s.uploaded = append([]byte(nil), req.Data...)
|
||||
return &proto.UploadFileRsp{Filename: "stored.bin", FileType: "pdf"}, nil
|
||||
}
|
||||
|
||||
func (s *testStub) DownloadFile(ctx context.Context, _ *proto.DownloadFileReq) (*httpbody.HttpBody, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.downloadErr != nil {
|
||||
return nil, s.downloadErr
|
||||
}
|
||||
_ = grpc.SetHeader(ctx, metadata.Pairs("X-Content-Type-Options", "nosniff"))
|
||||
return &httpbody.HttpBody{
|
||||
Data: []byte("file-data"),
|
||||
ContentType: "application/pdf",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *testStub) getUploaded() []byte {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]byte(nil), s.uploaded...)
|
||||
}
|
||||
|
||||
// newTestGateway поднимает gRPC-сервер со stub и grpc-gateway с теми же
|
||||
// опциями, что и в main.go (outgoing matcher, error handler, лимиты,
|
||||
// rawBodyMarshaler).
|
||||
func newTestGateway(t *testing.T, stub proto.EveningDetectiveServerServer) *httptest.Server {
|
||||
return newTestGatewayWithLimit(t, stub, maxFileUploadBody)
|
||||
}
|
||||
|
||||
func newTestGatewayWithLimit(t *testing.T, stub proto.EveningDetectiveServerServer, uploadLimit int64) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
gs := grpc.NewServer(grpc.MaxRecvMsgSize(grpcMsgLimit))
|
||||
proto.RegisterEveningDetectiveServerServer(gs, stub)
|
||||
go func() { _ = gs.Serve(lis) }()
|
||||
t.Cleanup(gs.Stop)
|
||||
|
||||
conn, err := grpc.NewClient(
|
||||
lis.Addr().String(),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithDefaultCallOptions(
|
||||
grpc.MaxCallSendMsgSize(grpcMsgLimit),
|
||||
grpc.MaxCallRecvMsgSize(grpcMsgLimit),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
mux := runtime.NewServeMux(
|
||||
runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
|
||||
switch {
|
||||
case strings.EqualFold(key, "Content-Disposition"):
|
||||
return "Content-Disposition", true
|
||||
case strings.EqualFold(key, "X-Content-Type-Options"):
|
||||
return "X-Content-Type-Options", true
|
||||
}
|
||||
return runtime.DefaultHeaderMatcher(key)
|
||||
}),
|
||||
runtime.WithErrorHandler(customErrorHandler),
|
||||
runtime.WithMarshalerOption("application/zip", &rawBodyMarshaler{
|
||||
Marshaler: &runtime.HTTPBodyMarshaler{Marshaler: &runtime.JSONPb{}},
|
||||
}),
|
||||
)
|
||||
if err := proto.RegisterEveningDetectiveServerHandler(context.Background(), mux, conn); err != nil {
|
||||
t.Fatalf("register gateway: %v", err)
|
||||
}
|
||||
ts := httptest.NewServer(limitUploadBody(
|
||||
"/api/files/upload",
|
||||
uploadLimit,
|
||||
limitUploadBody("/api/scenarios/archive", int64(scenario_archive.MaxArchiveSize()), mux),
|
||||
))
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
func TestGatewayUploadRawZip(t *testing.T) {
|
||||
stub := &testStub{}
|
||||
ts := newTestGateway(t, stub)
|
||||
|
||||
raw := []byte("PK\x03\x04raw-zip-bytes")
|
||||
resp, err := http.Post(ts.URL+"/api/scenarios/archive", "application/zip", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(body), `"id":42`) {
|
||||
t.Errorf("ответ = %s, want id 42", body)
|
||||
}
|
||||
if got := stub.getUploaded(); !bytes.Equal(got, raw) {
|
||||
t.Errorf("сервер получил %q, want %q", got, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayDownloadZipWithContentDisposition(t *testing.T) {
|
||||
stub := &testStub{}
|
||||
ts := newTestGateway(t, stub)
|
||||
|
||||
resp, err := http.Get(ts.URL + "/api/scenarios/1/archive")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "application/zip" {
|
||||
t.Errorf("Content-Type = %q, want application/zip", ct)
|
||||
}
|
||||
if cd := resp.Header.Get("Content-Disposition"); cd != `attachment; filename="test.zip"` {
|
||||
t.Errorf("Content-Disposition = %q", cd)
|
||||
}
|
||||
if string(body) != "zip-data" {
|
||||
t.Errorf("body = %q, want zip-data", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayDownloadFileNosniff — скачивание файла отдаёт содержимое,
|
||||
// Content-Type и X-Content-Type-Options: nosniff.
|
||||
func TestGatewayDownloadFileNosniff(t *testing.T) {
|
||||
stub := &testStub{}
|
||||
ts := newTestGateway(t, stub)
|
||||
|
||||
resp, err := http.Get(ts.URL + "/api/files/clue.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "application/pdf" {
|
||||
t.Errorf("Content-Type = %q, want application/pdf", ct)
|
||||
}
|
||||
if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
|
||||
}
|
||||
if string(body) != "file-data" {
|
||||
t.Errorf("body = %q, want file-data", body)
|
||||
}
|
||||
}
|
||||
|
||||
// uploadJSONBody собирает JSON-тело загрузки файла (base64 data).
|
||||
func uploadJSONBody(filename string, data []byte) []byte {
|
||||
body, _ := json.Marshal(proto.UploadFileReq{
|
||||
Filename: filename,
|
||||
Data: data,
|
||||
})
|
||||
return body
|
||||
}
|
||||
|
||||
// TestGatewayUploadArchiveOverLimit — тело архива больше HTTP-лимита
|
||||
// (MaxBytesReader) → 413 с понятным текстом (связка middleware → декодер →
|
||||
// customErrorHandler).
|
||||
func TestGatewayUploadArchiveOverLimit(t *testing.T) {
|
||||
stub := &testStub{}
|
||||
ts := newTestGateway(t, stub)
|
||||
|
||||
big := bytes.Repeat([]byte("x"), scenario_archive.MaxArchiveSize()+1)
|
||||
resp, err := http.Post(ts.URL+"/api/scenarios/archive", "application/zip", bytes.NewReader(big))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, body = %s; want 413", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(body), "слишком большой") {
|
||||
t.Errorf("body = %s, want понятный текст об ограничении размера", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayUploadFileOverHTTPLimit — тело загрузки больше HTTP-лимита
|
||||
// (MaxBytesReader) → 413 с понятным текстом.
|
||||
func TestGatewayUploadFileOverHTTPLimit(t *testing.T) {
|
||||
stub := &testStub{}
|
||||
const limit = 1 << 20 // 1 MiB
|
||||
ts := newTestGatewayWithLimit(t, stub, limit)
|
||||
|
||||
// 1 MiB данных в base64 ~1.33 MiB > лимит 1 MiB.
|
||||
data := bytes.Repeat([]byte("x"), limit)
|
||||
resp, err := http.Post(ts.URL+"/api/files/upload", "application/json", bytes.NewReader(uploadJSONBody("clue.pdf", data)))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, body = %s; want 413", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(body), "слишком большой") {
|
||||
t.Errorf("body = %s, want понятный текст об ограничении размера", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayUploadFileExactLimit — тело ровно в пределах лимита проходит
|
||||
// через HTTP-слой и gRPC-транспорт → 200.
|
||||
func TestGatewayUploadFileExactLimit(t *testing.T) {
|
||||
stub := &testStub{}
|
||||
const limit = 2 << 20 // 2 MiB — лимит HTTP-тела
|
||||
ts := newTestGatewayWithLimit(t, stub, limit)
|
||||
|
||||
data := bytes.Repeat([]byte("x"), 1<<20) // 1 MiB данных → тело ~1.33 MiB < 2 MiB
|
||||
resp, err := http.Post(ts.URL+"/api/files/upload", "application/json", bytes.NewReader(uploadJSONBody("clue.pdf", data)))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s; want 200", resp.StatusCode, body)
|
||||
}
|
||||
if got := stub.getUploaded(); !bytes.Equal(got, data) {
|
||||
t.Errorf("сервер получил %d байт, want %d", len(got), len(data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayUploadFileResourceExhausted — бизнес-ветка: сервис отклоняет
|
||||
// файл больше лимита (ResourceExhausted) → 413.
|
||||
func TestGatewayUploadFileResourceExhausted(t *testing.T) {
|
||||
stub := &testStub{}
|
||||
stub.uploadErr = status.Error(codes.ResourceExhausted, "файл слишком большой (лимит 64 МБ)")
|
||||
ts := newTestGateway(t, stub)
|
||||
|
||||
resp, err := http.Post(ts.URL+"/api/files/upload", "application/json", bytes.NewReader(uploadJSONBody("clue.pdf", []byte("small"))))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, body = %s; want 413", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(body), "слишком большой") {
|
||||
t.Errorf("body = %s, want понятный текст об ограничении размера", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCustomErrorHandler — маппинг ошибок превышения размера в 413.
|
||||
func TestCustomErrorHandler(t *testing.T) {
|
||||
mux := runtime.NewServeMux()
|
||||
marshaler := &runtime.JSONPb{}
|
||||
|
||||
do := func(t *testing.T, req *http.Request, err error) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
customErrorHandler(context.Background(), mux, marshaler, rec, req, err)
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("MaxBytesError raw", func(t *testing.T) {
|
||||
rec := do(t, httptest.NewRequest(http.MethodPost, "/api/files/upload", nil), &http.MaxBytesError{Limit: 10})
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("status = %d, want 413", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ResourceExhausted on upload route", func(t *testing.T) {
|
||||
rec := do(t, httptest.NewRequest(http.MethodPost, "/api/files/upload", nil),
|
||||
status.Error(codes.ResourceExhausted, "файл слишком большой"))
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("status = %d, want 413", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidArgument with MaxBytesError text", func(t *testing.T) {
|
||||
rec := do(t, httptest.NewRequest(http.MethodPost, "/api/files/upload", nil),
|
||||
status.Error(codes.InvalidArgument, "http: request body too large"))
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("status = %d, want 413", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ResourceExhausted on other route delegates", func(t *testing.T) {
|
||||
rec := do(t, httptest.NewRequest(http.MethodPost, "/api/test/echo", nil),
|
||||
status.Error(codes.ResourceExhausted, "rate limit"))
|
||||
// DefaultHTTPErrorHandler мапит ResourceExhausted в 429.
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("status = %d, want 429 (дефолтный маппинг)", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestLimitUploadBody — тело загрузки ограничено на HTTP-слое
|
||||
// (защита от OOM), прочие маршруты не затронуты.
|
||||
func TestLimitUploadBody(t *testing.T) {
|
||||
limited := func(route string, limit int64) http.Handler {
|
||||
return limitUploadBody(route, limit, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
}
|
||||
|
||||
t.Run("files upload rejected when over limit", func(t *testing.T) {
|
||||
h := limited("/api/files/upload", 10)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/files/upload", bytes.NewReader(bytes.Repeat([]byte("x"), 11)))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("archive rejected when over limit", func(t *testing.T) {
|
||||
h := limited("/api/scenarios/archive", int64(scenario_archive.MaxArchiveSize()))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scenarios/archive", bytes.NewReader(bytes.Repeat([]byte("x"), scenario_archive.MaxArchiveSize()+1)))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("small body passes on limited routes", func(t *testing.T) {
|
||||
for _, route := range []string{"/api/files/upload", "/api/scenarios/archive"} {
|
||||
h := limited(route, 1024)
|
||||
req := httptest.NewRequest(http.MethodPost, route, bytes.NewReader([]byte("small")))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("%s: status = %d, want %d", route, rec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("other routes not limited", func(t *testing.T) {
|
||||
h := limited("/api/files/upload", 10)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/test/echo", bytes.NewReader(bytes.Repeat([]byte("x"), 100)))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCORSHeaders — фронт через fetch должен читать Content-Disposition
|
||||
// (имя файла архива) из ответа, а браузерный MCP-клиент — заголовки
|
||||
// streamable HTTP /mcp (Mcp-Session-Id, Mcp-Protocol-Version) в allow/expose.
|
||||
func TestCORSHeaders(t *testing.T) {
|
||||
h := cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/scenarios/1/archive", nil))
|
||||
if got := rec.Header().Get("Access-Control-Expose-Headers"); got != "Content-Disposition, Mcp-Session-Id, Mcp-Protocol-Version" {
|
||||
t.Errorf("Access-Control-Expose-Headers = %q, want Content-Disposition, Mcp-Session-Id, Mcp-Protocol-Version", got)
|
||||
}
|
||||
allow := rec.Header().Get("Access-Control-Allow-Headers")
|
||||
for _, want := range []string{"Mcp-Session-Id", "Mcp-Protocol-Version", "Last-Event-ID"} {
|
||||
if !strings.Contains(allow, want) {
|
||||
t.Errorf("Access-Control-Allow-Headers = %q, не содержит %q", allow, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,37 +3,48 @@ package main
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"evening_detective_server/internal/app"
|
||||
"evening_detective_server/internal/modules/cleaner"
|
||||
"evening_detective_server/internal/modules/email_sender"
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/password_generator"
|
||||
"evening_detective_server/internal/modules/processor_jwt"
|
||||
"evening_detective_server/internal/modules/scenario_archive"
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/modules/string_tools"
|
||||
"evening_detective_server/internal/repos/actions_repo"
|
||||
"evening_detective_server/internal/repos/applications_repo"
|
||||
"evening_detective_server/internal/repos/games_repo"
|
||||
"evening_detective_server/internal/repos/refresh_tokens_repo"
|
||||
"evening_detective_server/internal/repos/scenarios_repo"
|
||||
"evening_detective_server/internal/repos/team_answers_repo"
|
||||
"evening_detective_server/internal/repos/teams_repo"
|
||||
"evening_detective_server/internal/repos/users_repo"
|
||||
"evening_detective_server/internal/services/file_service"
|
||||
"evening_detective_server/internal/services/game_service"
|
||||
"evening_detective_server/internal/services/mcp_service"
|
||||
"evening_detective_server/internal/services/scenarios_service"
|
||||
"evening_detective_server/internal/services/ui_service"
|
||||
"evening_detective_server/internal/services/users_service"
|
||||
proto "evening_detective_server/proto"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
"github.com/swaggest/swgui/v5emb"
|
||||
)
|
||||
@@ -41,6 +52,19 @@ import (
|
||||
//go:embed main.swagger.json
|
||||
var swaggerJSON []byte
|
||||
|
||||
// maxFileSize — максимальный размер файла, передаваемого через gRPC
|
||||
const maxFileSize = 64 << 20 // 64 МБ
|
||||
|
||||
// maxFileUploadBody — лимит HTTP-тела POST /api/files/upload (JSON с base64):
|
||||
// ceil(maxFileSize·4/3) — точный размер base64, +2 КБ запаса на JSON-обёртку
|
||||
// {"filename":...,"data":...} с коротким именем файла.
|
||||
const maxFileUploadBody = (int64(maxFileSize)+2)/3*4 + 2*1024
|
||||
|
||||
// grpcMsgLimit — лимит размера gRPC-сообщения (сервер и клиент gateway):
|
||||
// maxFileSize + 1 MiB на прото-оверхед (tag+varint) поверх ровно 64 MiB
|
||||
// данных; авторитетная проверка размера — в file_service.
|
||||
const grpcMsgLimit = maxFileSize + (1 << 20)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
@@ -58,16 +82,25 @@ func main() {
|
||||
}
|
||||
|
||||
usersRepo := users_repo.NewUserRepo(dbpool)
|
||||
passwordGenerator := password_generator.NewGenerator(8)
|
||||
passwordGenerator := password_generator.NewGenerator(12)
|
||||
smtpTimeout := 10 * time.Second
|
||||
if v := os.Getenv("SMTP_TIMEOUT"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
smtpTimeout = d
|
||||
}
|
||||
}
|
||||
emailSender := email_sender.NewSender(
|
||||
os.Getenv("SMTP_HOST"),
|
||||
os.Getenv("SMTP_PORT"),
|
||||
os.Getenv("SMTP_USER"),
|
||||
os.Getenv("SMTP_PASSWORD"),
|
||||
os.Getenv("SMTP_FROM_NAME"),
|
||||
os.Getenv("SMTP_REPLY_TO"),
|
||||
smtpTimeout,
|
||||
)
|
||||
processorJWT := processor_jwt.NewProcessor(os.Getenv("JWT_SECRET"))
|
||||
refreshTokensRepo := refresh_tokens_repo.NewRefreshTokensRepo(dbpool)
|
||||
usersService := users_service.NewUsersService(usersRepo, passwordGenerator, emailSender, processorJWT, refreshTokensRepo)
|
||||
usersService := users_service.NewUsersService(usersRepo, passwordGenerator, emailSender, processorJWT, refreshTokensRepo, os.Getenv("APP_BASE_URL"))
|
||||
uiService := ui_service.NewUiService()
|
||||
fileStorage, err := file_storage.NewRustFSStorage(
|
||||
os.Getenv("S3_HOST"),
|
||||
@@ -78,19 +111,22 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to create connection rustfs: %v\n", err)
|
||||
}
|
||||
fileService := file_service.NewFileService(fileStorage)
|
||||
fileService := file_service.NewFileService(fileStorage, string_tools.NewStringTools(), maxFileSize)
|
||||
scenariosRepo := scenarios_repo.NewScenariosRepo(dbpool)
|
||||
cleaner := cleaner.NewCleaner()
|
||||
scenarioService := scenarios_service.NewScenarioService(
|
||||
scenariosRepo,
|
||||
cleaner,
|
||||
os.Getenv("FILE_PREFIX_DOMAIN"),
|
||||
fileStorage,
|
||||
scenario_archive.NewScenarioArchive(),
|
||||
)
|
||||
gameRepo := games_repo.NewGamesRepo(dbpool)
|
||||
teamRepo := teams_repo.NewTeamsRepo(dbpool)
|
||||
storyteller := storytelling.NewStory(cleaner)
|
||||
actionsRepo := actions_repo.NewActionsRepo(dbpool)
|
||||
applicationsRepo := applications_repo.NewApplicationsRepo(dbpool)
|
||||
answersRepo := team_answers_repo.NewTeamAnswersRepo(dbpool)
|
||||
gameService := game_service.NewGameService(
|
||||
gameRepo,
|
||||
teamRepo,
|
||||
@@ -101,6 +137,7 @@ func main() {
|
||||
storyteller,
|
||||
actionsRepo,
|
||||
applicationsRepo,
|
||||
answersRepo,
|
||||
cleaner,
|
||||
)
|
||||
|
||||
@@ -112,6 +149,7 @@ func main() {
|
||||
|
||||
// Create a gRPC server object
|
||||
s := grpc.NewServer(
|
||||
grpc.MaxRecvMsgSize(grpcMsgLimit),
|
||||
grpc.UnaryInterceptor(
|
||||
processor_jwt.NewAuthorizationInterceptor(
|
||||
map[string]bool{
|
||||
@@ -126,6 +164,8 @@ func main() {
|
||||
"/crabs.evening_detective_server.EveningDetectiveServer/GetScenariosCatalog": true,
|
||||
"/crabs.evening_detective_server.EveningDetectiveServer/GetTeamStory": true,
|
||||
"/crabs.evening_detective_server.EveningDetectiveServer/AddTeamAction": true,
|
||||
"/crabs.evening_detective_server.EveningDetectiveServer/GetTeamAnswers": true,
|
||||
"/crabs.evening_detective_server.EveningDetectiveServer/SubmitTeamAnswers": true,
|
||||
"/crabs.evening_detective_server.EveningDetectiveServer/GetTerms": true,
|
||||
"/crabs.evening_detective_server.EveningDetectiveServer/GetPrivacy": true,
|
||||
"/crabs.evening_detective_server.EveningDetectiveServer/GetConsent": true,
|
||||
@@ -157,6 +197,10 @@ func main() {
|
||||
conn, err := grpc.NewClient(
|
||||
"0.0.0.0:8080",
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithDefaultCallOptions(
|
||||
grpc.MaxCallSendMsgSize(grpcMsgLimit),
|
||||
grpc.MaxCallRecvMsgSize(grpcMsgLimit),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to dial server:", err)
|
||||
@@ -168,6 +212,15 @@ func main() {
|
||||
// акцепте соглашений (ст. 9 152-ФЗ). X-Password — подтверждение паролем
|
||||
// при удалении аккаунта (не в query, чтобы не светить креденшел в URL
|
||||
// и логах).
|
||||
// Сырая загрузка архива (без base64) для распространённых Content-Type;
|
||||
// JSON-вариант {"data": "<base64>"} работает через application/json.
|
||||
rawBody := &rawBodyMarshaler{
|
||||
Marshaler: &runtime.HTTPBodyMarshaler{
|
||||
Marshaler: &runtime.JSONPb{
|
||||
UnmarshalOptions: protojson.UnmarshalOptions{DiscardUnknown: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
gwmux := runtime.NewServeMux(
|
||||
runtime.WithIncomingHeaderMatcher(func(key string) (string, bool) {
|
||||
switch {
|
||||
@@ -178,6 +231,21 @@ func main() {
|
||||
}
|
||||
return runtime.DefaultHeaderMatcher(key)
|
||||
}),
|
||||
// Проброс Content-Disposition и X-Content-Type-Options (nosniff)
|
||||
// от сервиса в HTTP-ответ.
|
||||
runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
|
||||
switch {
|
||||
case strings.EqualFold(key, "Content-Disposition"):
|
||||
return "Content-Disposition", true
|
||||
case strings.EqualFold(key, "X-Content-Type-Options"):
|
||||
return "X-Content-Type-Options", true
|
||||
}
|
||||
return runtime.DefaultHeaderMatcher(key)
|
||||
}),
|
||||
runtime.WithErrorHandler(customErrorHandler),
|
||||
runtime.WithMarshalerOption("application/zip", rawBody),
|
||||
runtime.WithMarshalerOption("application/octet-stream", rawBody),
|
||||
runtime.WithMarshalerOption("application/x-zip-compressed", rawBody),
|
||||
)
|
||||
// Register Greeter
|
||||
err = proto.RegisterEveningDetectiveServerHandler(context.Background(), gwmux, conn)
|
||||
@@ -193,7 +261,32 @@ func main() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(swaggerJSON)
|
||||
})
|
||||
mainMux.Handle("/api/", gwmux)
|
||||
// MaxBytesReader на HTTP-слое: gateway буферизует тело целиком, без лимита
|
||||
// проверки в хендлере не спасут от OOM. Лимиты по маршрутам: файлы —
|
||||
// maxFileUploadBody, архивы — MaxArchiveSize().
|
||||
mainMux.Handle("/api/", limitUploadBody(
|
||||
"/api/files/upload",
|
||||
maxFileUploadBody,
|
||||
limitUploadBody(
|
||||
"/api/scenarios/archive",
|
||||
int64(scenario_archive.MaxArchiveSize()),
|
||||
gwmux,
|
||||
),
|
||||
))
|
||||
|
||||
// MCP-сервер (Model Context Protocol): эндпоинт /api/mcp (streamable HTTP).
|
||||
// Инструменты MCP вызывают игровые сервисы напрямую (см. mcp_service).
|
||||
// WithDisableLocalhostProtection: mcp-go отклоняет loopback-запросы с
|
||||
// не-localhost Host-заголовком (защита от DNS-rebinding), что ломает
|
||||
// работу за reverse-proxy (nginx/caddy) — контур деплоя проекта.
|
||||
// Эндпоинт публичный, секрет — пароль команды (аналогично публичному
|
||||
// REST /api/teams/{id}/story). Путь /api/mcp длиннее /api/, поэтому
|
||||
// http.ServeMux отдаёт MCP-запросы этому хендлеру, а не gateway.
|
||||
mcpHTTP := server.NewStreamableHTTPServer(
|
||||
mcp_service.NewMCPService(gameService).Server(),
|
||||
server.WithDisableLocalhostProtection(true),
|
||||
)
|
||||
mainMux.Handle("/api/mcp", mcpHTTP)
|
||||
|
||||
gwServer := &http.Server{
|
||||
Addr: ":8090",
|
||||
@@ -209,10 +302,77 @@ func cors(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, ResponseType, X-Id, X-Password")
|
||||
// Mcp-Session-Id / Mcp-Protocol-Version / Last-Event-ID — заголовки
|
||||
// streamable HTTP MCP-эндпоинта /api/mcp (сессия, версия протокола,
|
||||
// SSE-реконнект); без них браузерные MCP-клиенты заблокируются preflight.
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, ResponseType, X-Id, X-Password, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID")
|
||||
// Content-Disposition нужен фронту, чтобы прочитать имя файла архива;
|
||||
// Mcp-Session-Id / Mcp-Protocol-Version — браузерному MCP-клиенту,
|
||||
// чтобы читать сессионные заголовки ответов /api/mcp.
|
||||
w.Header().Set("Access-Control-Expose-Headers", "Content-Disposition, Mcp-Session-Id, Mcp-Protocol-Version")
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// limitUploadBody ограничивает тело POST-запроса на заданном маршруте
|
||||
// (gateway читает тело в память целиком).
|
||||
func limitUploadBody(route string, limit int64, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost && r.URL.Path == route {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// customErrorHandler мапит ошибки превышения размера тела в HTTP 413
|
||||
// с понятным текстом:
|
||||
// - *http.MaxBytesError (raw) — только для юнит-тестов: сгенерированный
|
||||
// gateway-код строкифицирует ошибку декодера через %v и тип теряется;
|
||||
// - gRPC ResourceExhausted на путях загрузки (файл больше лимита);
|
||||
// - InvalidArgument с текстом MaxBytesError ("http: request body too
|
||||
// large") — единственная реальная защита 413 на HTTP-слое, ветку нельзя
|
||||
// удалять при «упрощении» обработчика.
|
||||
//
|
||||
// Остальные ошибки обрабатываются как обычно (DefaultHTTPErrorHandler).
|
||||
func customErrorHandler(
|
||||
ctx context.Context,
|
||||
mux *runtime.ServeMux,
|
||||
marshaler runtime.Marshaler,
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
err error,
|
||||
) {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
writeTooLargeError(w, marshaler)
|
||||
return
|
||||
}
|
||||
if status.Code(err) == codes.ResourceExhausted {
|
||||
switch r.URL.Path {
|
||||
case "/api/files/upload", "/api/scenarios/archive":
|
||||
writeTooLargeError(w, marshaler)
|
||||
return
|
||||
}
|
||||
}
|
||||
if status.Code(err) == codes.InvalidArgument &&
|
||||
strings.Contains(status.Convert(err).Message(), "request body too large") {
|
||||
writeTooLargeError(w, marshaler)
|
||||
return
|
||||
}
|
||||
runtime.DefaultHTTPErrorHandler(ctx, mux, marshaler, w, r, err)
|
||||
}
|
||||
|
||||
// writeTooLargeError — ответ 413 в том же формате {"error": ...}, что и
|
||||
// DefaultHTTPErrorHandler. Текст лимита формируется из maxFileSize, чтобы
|
||||
// не расходиться с реальным лимитом при его изменении.
|
||||
func writeTooLargeError(w http.ResponseWriter, marshaler runtime.Marshaler) {
|
||||
w.Header().Set("Content-Type", marshaler.ContentType(nil))
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
_ = marshaler.NewEncoder(w).Encode(map[string]string{
|
||||
"error": fmt.Sprintf("файл слишком большой (лимит %d МБ)", maxFileSize>>20),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -356,6 +356,38 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/games/{gameId}/answers": {
|
||||
"get": {
|
||||
"summary": "Получить ответы команд на вопросы дела (для оценки организатором)",
|
||||
"operationId": "EveningDetectiveServer_GetGameAnswers",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/evening_detective_serverGetGameAnswersRsp"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "gameId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Игры"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/games/{id}": {
|
||||
"get": {
|
||||
"summary": "Получить игру",
|
||||
@@ -735,6 +767,40 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/scenarios/archive": {
|
||||
"post": {
|
||||
"summary": "Создать сценарий из архива",
|
||||
"operationId": "EveningDetectiveServer_UploadScenarioArchive",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/evening_detective_serverUploadScenarioArchiveRsp"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "body",
|
||||
"description": "Message that represents an arbitrary HTTP body. It should only be used for\npayload formats that can't be represented as JSON, such as raw binary or\nan HTML page.\n\n\nThis message can be used both in streaming and non-streaming API methods in\nthe request as well as the response.\n\nIt can be used as a top-level request field, which is convenient if one\nwants to extract parameters from either the URL or HTTP template into the\nrequest fields and also want access to the raw HTTP body.\n\nExample:\n\n message GetResourceRequest {\n // A unique request id.\n string request_id = 1;\n\n // The raw HTTP body is bound to this field.\n google.api.HttpBody http_body = 2;\n\n }\n\n service ResourceService {\n rpc GetResource(GetResourceRequest)\n returns (google.api.HttpBody);\n rpc UpdateResource(google.api.HttpBody)\n returns (google.protobuf.Empty);\n\n }\n\nExample with streaming methods:\n\n service CaldavService {\n rpc GetCalendar(stream google.api.HttpBody)\n returns (stream google.api.HttpBody);\n rpc UpdateCalendar(stream google.api.HttpBody)\n returns (stream google.api.HttpBody);\n\n }\n\nUse of this type only changes how the request and response bodies are\nhandled, all other features will continue to work unchanged.",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/apiHttpBody"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Сценарии"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/scenarios/{id}": {
|
||||
"get": {
|
||||
"summary": "Получить сценарий по id",
|
||||
@@ -835,6 +901,38 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/scenarios/{id}/archive": {
|
||||
"get": {
|
||||
"summary": "Скачать сценарий архивом (со всеми материалами и картинками)",
|
||||
"operationId": "EveningDetectiveServer_DownloadScenarioArchive",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/apiHttpBody"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Сценарии"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/scenarios/{id}/draft": {
|
||||
"put": {
|
||||
"summary": "Снять с публикации сценарий",
|
||||
@@ -907,6 +1005,46 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/scenarios/{id}/introduction": {
|
||||
"put": {
|
||||
"summary": "Обновить введение сценария",
|
||||
"operationId": "EveningDetectiveServer_UpdateScenarioIntro",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/evening_detective_serverUpdateScenarioIntroRsp"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/EveningDetectiveServerUpdateScenarioIntroBody"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Сценарии"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/scenarios/{id}/places": {
|
||||
"post": {
|
||||
"summary": "Создать точку сценария",
|
||||
@@ -1069,6 +1207,128 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/scenarios/{id}/questions": {
|
||||
"post": {
|
||||
"summary": "Создать вопрос дела",
|
||||
"operationId": "EveningDetectiveServer_AddScenarioQuestion",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/evening_detective_serverAddScenarioQuestionRsp"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/EveningDetectiveServerAddScenarioQuestionBody"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Вопросы сценария"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/scenarios/{id}/questions/{code}": {
|
||||
"delete": {
|
||||
"summary": "Удалить вопрос дела",
|
||||
"operationId": "EveningDetectiveServer_DeleteScenarioQuestion",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/evening_detective_serverDeleteScenarioQuestionRsp"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Вопросы сценария"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"summary": "Обновить вопрос дела",
|
||||
"operationId": "EveningDetectiveServer_UpdateScenarioQuestion",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/evening_detective_serverUpdateScenarioQuestionRsp"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/EveningDetectiveServerUpdateScenarioQuestionBody"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Вопросы сценария"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/teams": {
|
||||
"post": {
|
||||
"summary": "Создать команду",
|
||||
@@ -1217,6 +1477,138 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/teams/{id}/answers": {
|
||||
"get": {
|
||||
"summary": "Получить ответы команды на вопросы дела",
|
||||
"operationId": "EveningDetectiveServer_GetTeamAnswers",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/evening_detective_serverGetTeamAnswersRsp"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"format": "int64"
|
||||
},
|
||||
{
|
||||
"name": "password",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Ходы"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"summary": "Отправить ответы команды на вопросы дела",
|
||||
"operationId": "EveningDetectiveServer_SubmitTeamAnswers",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/evening_detective_serverSubmitTeamAnswersRsp"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"format": "int64"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/EveningDetectiveServerSubmitTeamAnswersBody"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Ходы"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/teams/{id}/answers/{questionCode}/score": {
|
||||
"put": {
|
||||
"summary": "Выставить балл команде за ответ на вопрос дела",
|
||||
"operationId": "EveningDetectiveServer_SetTeamAnswerScore",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/evening_detective_serverSetTeamAnswerScoreRsp"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"format": "int64"
|
||||
},
|
||||
{
|
||||
"name": "questionCode",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/EveningDetectiveServerSetTeamAnswerScoreBody"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Игры"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/teams/{id}/applications": {
|
||||
"post": {
|
||||
"summary": "Выдать улику",
|
||||
@@ -1610,6 +2002,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"EveningDetectiveServerAddScenarioQuestionBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {
|
||||
"$ref": "#/definitions/evening_detective_serverQuestion"
|
||||
}
|
||||
}
|
||||
},
|
||||
"EveningDetectiveServerAddTeamActionBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1663,9 +2063,34 @@
|
||||
"EveningDetectiveServerResetGameBody": {
|
||||
"type": "object"
|
||||
},
|
||||
"EveningDetectiveServerSetTeamAnswerScoreBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"score": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"title": "Балл; отсутствует — сбросить выставленный балл"
|
||||
}
|
||||
}
|
||||
},
|
||||
"EveningDetectiveServerStartGameBody": {
|
||||
"type": "object"
|
||||
},
|
||||
"EveningDetectiveServerSubmitTeamAnswersBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"answers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/evening_detective_serverAnswer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"EveningDetectiveServerUpdateGameBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1699,6 +2124,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"EveningDetectiveServerUpdateScenarioIntroBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"introduction": {
|
||||
"$ref": "#/definitions/evening_detective_serverIntroduction"
|
||||
}
|
||||
}
|
||||
},
|
||||
"EveningDetectiveServerUpdateScenarioPlaceBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1707,6 +2140,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"EveningDetectiveServerUpdateScenarioQuestionBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {
|
||||
"$ref": "#/definitions/evening_detective_serverQuestion"
|
||||
}
|
||||
}
|
||||
},
|
||||
"EveningDetectiveServerUpdateTeamBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1777,6 +2218,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverAddScenarioQuestionRsp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverAddScenarioReq": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1840,6 +2289,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverAnswer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"questionCode": {
|
||||
"type": "string",
|
||||
"title": "Код вопроса"
|
||||
},
|
||||
"answer": {
|
||||
"type": "string",
|
||||
"title": "Текст ответа команды"
|
||||
}
|
||||
},
|
||||
"title": "Ответ команды на вопрос дела"
|
||||
},
|
||||
"evening_detective_serverAnswerScore": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"questionCode": {
|
||||
"type": "string"
|
||||
},
|
||||
"answer": {
|
||||
"type": "string"
|
||||
},
|
||||
"score": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"title": "Балл, выставленный организатором; отсутствует, если ещё не оценено"
|
||||
}
|
||||
},
|
||||
"title": "Ответ команды с баллом (для панели организатора)"
|
||||
},
|
||||
"evening_detective_serverApplication": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1848,6 +2328,10 @@
|
||||
},
|
||||
"image": {
|
||||
"type": "string"
|
||||
},
|
||||
"file_type": {
|
||||
"type": "string",
|
||||
"description": "Тип файла улики: image | pdf | audio. Значение выводится сервером из\nрасширения файла (см. file_storage.FileType); пустое значение возможно\nтолько для legacy-строк без расширения."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1883,6 +2367,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverDeleteScenarioQuestionRsp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverDeleteScenarioRsp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2000,6 +2492,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverGetGameAnswersRsp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"questions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/evening_detective_serverQuestion"
|
||||
}
|
||||
},
|
||||
"teams": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/evening_detective_serverTeamAnswers"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverGetGameRsp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2103,6 +2617,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverGetTeamAnswersRsp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"answers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/evening_detective_serverAnswer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverGetTeamStoryRsp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2162,6 +2691,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverIntroduction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"audio": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverKey": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2263,6 +2803,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverQuestion": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"title": "Код вопроса - используется при отправке ответов командой"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"title": "Текст вопроса"
|
||||
},
|
||||
"answer": {
|
||||
"type": "string",
|
||||
"title": "Правильный ответ (справка для организатора при оценке)"
|
||||
}
|
||||
},
|
||||
"description": "Вопрос дела - вопрос, на который команда должна ответить в конце игры.\nПравильный ответ (answer) виден только автору сценария и организаторам\n(при оценке ответов команд), команде не передаётся."
|
||||
},
|
||||
"evening_detective_serverRefreshPasswordReq": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2348,6 +2906,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverSetTeamAnswerScoreRsp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverSignupReq": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2384,12 +2950,30 @@
|
||||
"evening_detective_serverStory": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"introduction": {
|
||||
"$ref": "#/definitions/evening_detective_serverIntroduction"
|
||||
},
|
||||
"places": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/evening_detective_serverPlace"
|
||||
}
|
||||
},
|
||||
"questions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/evening_detective_serverQuestion"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverSubmitTeamAnswersRsp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2422,6 +3006,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverTeamAnswers": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"team": {
|
||||
"$ref": "#/definitions/evening_detective_serverTeam"
|
||||
},
|
||||
"answers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/evening_detective_serverAnswerScore"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Ответы одной команды на вопросы дела"
|
||||
},
|
||||
"evening_detective_serverUpdateGameRsp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2430,6 +3030,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverUpdateScenarioIntroRsp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverUpdateScenarioPlaceRsp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2438,6 +3046,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverUpdateScenarioQuestionRsp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverUpdateScenarioRsp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2474,6 +3090,22 @@
|
||||
},
|
||||
"filename": {
|
||||
"type": "string"
|
||||
},
|
||||
"file_type": {
|
||||
"type": "string",
|
||||
"description": "Тип загруженного файла: image | pdf | audio (определяется сервером по\nсодержимому). Удобен редактору для предзаполнения file_type улики."
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverUploadScenarioArchiveRsp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"google.golang.org/genproto/googleapis/api/httpbody"
|
||||
)
|
||||
|
||||
// rawBodyMarshaler — маршалер для бинарного тела (application/zip и др.):
|
||||
// запрос попадает в HttpBody.Data без base64-JSON, для остального делегирует
|
||||
// JSON-маршалеру (работает и {"data": "<base64>"}).
|
||||
type rawBodyMarshaler struct {
|
||||
runtime.Marshaler
|
||||
}
|
||||
|
||||
func (m *rawBodyMarshaler) NewDecoder(r io.Reader) runtime.Decoder {
|
||||
return rawBodyDecoder{
|
||||
Decoder: m.Marshaler.NewDecoder(r),
|
||||
r: r,
|
||||
}
|
||||
}
|
||||
|
||||
// rawBodyDecoder — Decoder: для *httpbody.HttpBody читает тело целиком в Data,
|
||||
// для protobuf-сообщений декодирует JSON как обычно.
|
||||
type rawBodyDecoder struct {
|
||||
runtime.Decoder
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
func (d rawBodyDecoder) Decode(v interface{}) error {
|
||||
if body, ok := v.(*httpbody.HttpBody); ok {
|
||||
data, err := io.ReadAll(d.r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body.Data = data
|
||||
return nil
|
||||
}
|
||||
return d.Decoder.Decode(v)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
proto "evening_detective_server/proto"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"google.golang.org/genproto/googleapis/api/httpbody"
|
||||
)
|
||||
|
||||
func newTestRawBodyMarshaler() *rawBodyMarshaler {
|
||||
return &rawBodyMarshaler{
|
||||
Marshaler: &runtime.HTTPBodyMarshaler{
|
||||
Marshaler: &runtime.JSONPb{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawBodyDecoderDecodesHttpBody(t *testing.T) {
|
||||
m := newTestRawBodyMarshaler()
|
||||
raw := []byte("PK\x03\x04fake-zip-bytes")
|
||||
|
||||
protoReq := &httpbody.HttpBody{}
|
||||
if err := m.NewDecoder(bytes.NewReader(raw)).Decode(protoReq); err != nil {
|
||||
t.Fatalf("Decode: %v", err)
|
||||
}
|
||||
if !bytes.Equal(protoReq.Data, raw) {
|
||||
t.Errorf("Data = %q, want %q", protoReq.Data, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawBodyMarshalerDelegatesToJSONForProtos(t *testing.T) {
|
||||
m := newTestRawBodyMarshaler()
|
||||
|
||||
// Для protobuf-сообщения (не HttpBody) декодирование идёт через JSON.
|
||||
req := &proto.EchoReq{}
|
||||
if err := m.NewDecoder(strings.NewReader(`{"text":"hi"}`)).Decode(req); err != nil {
|
||||
t.Fatalf("Decode: %v", err)
|
||||
}
|
||||
if req.Text != "hi" {
|
||||
t.Errorf("Text = %q, want hi", req.Text)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ go 1.26
|
||||
|
||||
require (
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0
|
||||
github.com/mark3labs/mcp-go v0.58.0
|
||||
github.com/stretchr/testify v1.12.0
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754
|
||||
google.golang.org/grpc v1.83.0
|
||||
@@ -29,16 +30,17 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
|
||||
github.com/aws/smithy-go v1.27.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/vearutop/statigz v1.4.0 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/smithy-go v1.27.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"evening_detective_server/internal/services/game_service"
|
||||
proto "evening_detective_server/proto"
|
||||
)
|
||||
|
||||
func mapAnswers(o []game_service.Answer) []*proto.Answer {
|
||||
res := make([]*proto.Answer, 0, len(o))
|
||||
for _, item := range o {
|
||||
res = append(res, &proto.Answer{
|
||||
QuestionCode: item.QuestionCode,
|
||||
Answer: item.Text,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func convertAnswers(o []*proto.Answer) []game_service.Answer {
|
||||
res := make([]game_service.Answer, 0, len(o))
|
||||
for _, item := range o {
|
||||
res = append(res, game_service.Answer{
|
||||
QuestionCode: item.QuestionCode,
|
||||
Text: item.Answer,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func mapAnswerScores(o []game_service.AnswerScore) []*proto.AnswerScore {
|
||||
res := make([]*proto.AnswerScore, 0, len(o))
|
||||
for _, item := range o {
|
||||
score := &proto.AnswerScore{
|
||||
QuestionCode: item.QuestionCode,
|
||||
Answer: item.Text,
|
||||
}
|
||||
if item.Score != nil {
|
||||
v := int32(*item.Score)
|
||||
score.Score = &v
|
||||
}
|
||||
res = append(res, score)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func mapTeamAnswers(o []game_service.TeamAnswers) []*proto.TeamAnswers {
|
||||
res := make([]*proto.TeamAnswers, 0, len(o))
|
||||
for _, item := range o {
|
||||
res = append(res, &proto.TeamAnswers{
|
||||
Team: mapTeam(item.Team),
|
||||
Answers: mapAnswerScores(item.Answers),
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/processor_jwt"
|
||||
"evening_detective_server/internal/modules/string_tools"
|
||||
"evening_detective_server/internal/services/file_service"
|
||||
proto "evening_detective_server/proto"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// fakeStorage — in-memory реализация IFileStorage для app-слоя.
|
||||
type fakeStorage struct {
|
||||
files map[string]*file_storage.File
|
||||
putErr error
|
||||
getErr error
|
||||
}
|
||||
|
||||
func newFakeStorage() *fakeStorage {
|
||||
return &fakeStorage{files: map[string]*file_storage.File{}}
|
||||
}
|
||||
|
||||
func (s *fakeStorage) Put(_ context.Context, f *file_storage.File) error {
|
||||
if s.putErr != nil {
|
||||
return s.putErr
|
||||
}
|
||||
cp := *f
|
||||
cp.Data = append([]byte(nil), f.Data...)
|
||||
s.files[f.Name] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStorage) Get(_ context.Context, name string) (*file_storage.File, error) {
|
||||
if s.getErr != nil {
|
||||
return nil, s.getErr
|
||||
}
|
||||
f, ok := s.files[name]
|
||||
if !ok {
|
||||
return nil, file_storage.ErrFileNotFound
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *fakeStorage) Delete(_ context.Context, name string) error {
|
||||
delete(s.files, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStorage) MimeType(filename string) string {
|
||||
return file_storage.FileType(filename)
|
||||
}
|
||||
|
||||
func newTestFileServer(storage *fakeStorage, maxSize int) *server {
|
||||
if storage == nil {
|
||||
storage = newFakeStorage()
|
||||
}
|
||||
return &server{
|
||||
fileService: file_service.NewFileService(storage, string_tools.NewStringTools(), maxSize),
|
||||
}
|
||||
}
|
||||
|
||||
func ctxWithClaims(roles ...string) context.Context {
|
||||
return context.WithValue(context.Background(), "claims", &processor_jwt.JWTClaims{UserID: 1, Roles: roles})
|
||||
}
|
||||
|
||||
func pdfData(n int) []byte {
|
||||
b := make([]byte, n)
|
||||
copy(b, "%PDF-1.7 ")
|
||||
for i := 8; i < n; i++ {
|
||||
b[i] = 'x'
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestUploadFilePermissionDenied(t *testing.T) {
|
||||
s := newTestFileServer(nil, 1<<20)
|
||||
_, err := s.UploadFile(ctxWithClaims(), &proto.UploadFileReq{Filename: "clue.pdf", Data: pdfData(16)})
|
||||
if status.Code(err) != codes.PermissionDenied {
|
||||
t.Errorf("code = %v, want PermissionDenied", status.Code(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFileValidationInvalidArgument(t *testing.T) {
|
||||
s := newTestFileServer(nil, 1<<20)
|
||||
// Имя без расширения.
|
||||
_, err := s.UploadFile(ctxWithClaims("author"), &proto.UploadFileReq{Filename: "clue", Data: pdfData(16)})
|
||||
if status.Code(err) != codes.InvalidArgument {
|
||||
t.Errorf("code = %v, want InvalidArgument", status.Code(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFileTooLargeResourceExhausted(t *testing.T) {
|
||||
s := newTestFileServer(nil, 1024)
|
||||
_, err := s.UploadFile(ctxWithClaims("author"), &proto.UploadFileReq{Filename: "clue.pdf", Data: pdfData(1025)})
|
||||
if status.Code(err) != codes.ResourceExhausted {
|
||||
t.Errorf("code = %v, want ResourceExhausted", status.Code(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFilePutErrorInternal(t *testing.T) {
|
||||
storage := newFakeStorage()
|
||||
storage.putErr = errors.New("s3 недоступен")
|
||||
s := newTestFileServer(storage, 1<<20)
|
||||
|
||||
_, err := s.UploadFile(ctxWithClaims("author"), &proto.UploadFileReq{Filename: "clue.pdf", Data: pdfData(16)})
|
||||
if status.Code(err) != codes.Internal {
|
||||
t.Errorf("code = %v, want Internal", status.Code(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFileSuccess(t *testing.T) {
|
||||
s := newTestFileServer(nil, 1<<20)
|
||||
rsp, err := s.UploadFile(ctxWithClaims("author"), &proto.UploadFileReq{Filename: "clue.pdf", Data: pdfData(16)})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadFile: %v", err)
|
||||
}
|
||||
if rsp.Filename == "" {
|
||||
t.Errorf("Filename пустой")
|
||||
}
|
||||
if rsp.FileType != "pdf" {
|
||||
t.Errorf("FileType = %q, want pdf", rsp.FileType)
|
||||
}
|
||||
if rsp.Error != "" {
|
||||
t.Errorf("Error = %q, want пусто", rsp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// mockServerTransportStream — заглушка для grpc.SetHeader в юнит-тестах.
|
||||
type mockServerTransportStream struct {
|
||||
grpc.ServerTransportStream
|
||||
header metadata.MD
|
||||
}
|
||||
|
||||
func (m *mockServerTransportStream) SetHeader(md metadata.MD) error {
|
||||
m.header = md
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDownloadFileNotFound(t *testing.T) {
|
||||
s := newTestFileServer(nil, 1<<20)
|
||||
_, err := s.DownloadFile(context.Background(), &proto.DownloadFileReq{Filename: "missing.pdf"})
|
||||
if status.Code(err) != codes.NotFound {
|
||||
t.Errorf("code = %v, want NotFound", status.Code(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFileInternal(t *testing.T) {
|
||||
storage := newFakeStorage()
|
||||
storage.getErr = errors.New("s3 недоступен")
|
||||
s := newTestFileServer(storage, 1<<20)
|
||||
|
||||
_, err := s.DownloadFile(context.Background(), &proto.DownloadFileReq{Filename: "clue.pdf"})
|
||||
if status.Code(err) != codes.Internal {
|
||||
t.Errorf("code = %v, want Internal", status.Code(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFileSuccess(t *testing.T) {
|
||||
storage := newFakeStorage()
|
||||
storage.files["clue.pdf"] = &file_storage.File{
|
||||
Name: "clue.pdf",
|
||||
Data: []byte("%PDF-1.7 content"),
|
||||
Mime: "application/pdf",
|
||||
}
|
||||
s := newTestFileServer(storage, 1<<20)
|
||||
|
||||
stream := &mockServerTransportStream{}
|
||||
ctx := grpc.NewContextWithServerTransportStream(context.Background(), stream)
|
||||
|
||||
body, err := s.DownloadFile(ctx, &proto.DownloadFileReq{Filename: "clue.pdf"})
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadFile: %v", err)
|
||||
}
|
||||
if string(body.Data) != "%PDF-1.7 content" {
|
||||
t.Errorf("Data = %q", body.Data)
|
||||
}
|
||||
if body.ContentType != "application/pdf" {
|
||||
t.Errorf("ContentType = %q, want application/pdf", body.ContentType)
|
||||
}
|
||||
if got := stream.header.Get("X-Content-Type-Options"); len(got) != 1 || got[0] != "nosniff" {
|
||||
t.Errorf("X-Content-Type-Options = %v, want nosniff", got)
|
||||
}
|
||||
}
|
||||
+283
-11
@@ -2,6 +2,8 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
@@ -9,6 +11,9 @@ import (
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/processor_jwt"
|
||||
"evening_detective_server/internal/modules/roles"
|
||||
"evening_detective_server/internal/modules/scenario_archive"
|
||||
"evening_detective_server/internal/modules/string_tools"
|
||||
"evening_detective_server/internal/repos/scenarios_repo"
|
||||
"evening_detective_server/internal/services/file_service"
|
||||
"evening_detective_server/internal/services/game_service"
|
||||
"evening_detective_server/internal/services/scenarios_service"
|
||||
@@ -17,11 +22,15 @@ import (
|
||||
proto "evening_detective_server/proto"
|
||||
|
||||
"google.golang.org/genproto/googleapis/api/httpbody"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// stringTools — транслитерация имён файлов для HTTP-заголовков.
|
||||
var stringTools = string_tools.NewStringTools()
|
||||
|
||||
type server struct {
|
||||
proto.UnsafeEveningDetectiveServerServer
|
||||
|
||||
@@ -303,27 +312,46 @@ func (s *server) GetPermissions(ctx context.Context, req *proto.GetPermissionsRe
|
||||
}
|
||||
|
||||
func (s *server) UploadFile(ctx context.Context, req *proto.UploadFileReq) (*proto.UploadFileRsp, error) {
|
||||
filename, err := s.fileService.UploadFile(
|
||||
ctx,
|
||||
&file_storage.File{
|
||||
Name: req.Filename,
|
||||
Data: req.Data,
|
||||
},
|
||||
)
|
||||
// Файлы (в т.ч. улики) загружают авторы сценариев в редакторе.
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
filename, fileType, err := s.fileService.UploadFile(ctx, req.Filename, req.Data)
|
||||
if err != nil {
|
||||
return &proto.UploadFileRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
switch {
|
||||
case errors.Is(err, file_service.ErrFileTooLarge):
|
||||
return nil, status.Errorf(codes.ResourceExhausted, "%v", err)
|
||||
case errors.Is(err, file_service.ErrEmptyName),
|
||||
errors.Is(err, file_service.ErrEmptyData),
|
||||
errors.Is(err, file_service.ErrInvalidExtension),
|
||||
errors.Is(err, file_service.ErrInvalidContentType):
|
||||
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
default:
|
||||
return nil, status.Errorf(codes.Internal, "%v", err)
|
||||
}
|
||||
}
|
||||
return &proto.UploadFileRsp{
|
||||
Filename: filename,
|
||||
FileType: fileType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) DownloadFile(ctx context.Context, req *proto.DownloadFileReq) (*httpbody.HttpBody, error) {
|
||||
file, err := s.fileService.DownloadFile(ctx, req.Filename)
|
||||
if err != nil {
|
||||
return &httpbody.HttpBody{}, nil
|
||||
switch {
|
||||
case errors.Is(err, file_storage.ErrFileNotFound):
|
||||
return nil, status.Errorf(codes.NotFound, "%v", err)
|
||||
default:
|
||||
return nil, status.Errorf(codes.Internal, "%v", err)
|
||||
}
|
||||
}
|
||||
// nosniff — при публичном скачивании браузер не должен угадывать
|
||||
// Content-Type по содержимому (защита от stored-XSS при расхождении).
|
||||
if err := grpc.SetHeader(ctx, metadata.Pairs("X-Content-Type-Options", "nosniff")); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to set response header: %v", err)
|
||||
}
|
||||
return &httpbody.HttpBody{
|
||||
Data: file.Data,
|
||||
@@ -571,6 +599,171 @@ func (s *server) DeleteScenarioPlace(ctx context.Context, req *proto.DeleteScena
|
||||
return &proto.DeleteScenarioPlaceRsp{}, nil
|
||||
}
|
||||
|
||||
func (s *server) AddScenarioQuestion(ctx context.Context, req *proto.AddScenarioQuestionReq) (*proto.AddScenarioQuestionRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
err := s.scenarioService.AddScenarioQuestion(
|
||||
ctx,
|
||||
int(req.Id),
|
||||
convertQuestion(req.Question),
|
||||
claims.UserID,
|
||||
roles.HasRole(claims, roles.Admin),
|
||||
)
|
||||
if err != nil {
|
||||
return &proto.AddScenarioQuestionRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &proto.AddScenarioQuestionRsp{}, nil
|
||||
}
|
||||
|
||||
func (s *server) UpdateScenarioQuestion(ctx context.Context, req *proto.UpdateScenarioQuestionReq) (*proto.UpdateScenarioQuestionRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
err := s.scenarioService.UpdateScenarioQuestion(
|
||||
ctx,
|
||||
int(req.Id),
|
||||
req.Code,
|
||||
convertQuestion(req.Question),
|
||||
claims.UserID,
|
||||
roles.HasRole(claims, roles.Admin),
|
||||
)
|
||||
if err != nil {
|
||||
return &proto.UpdateScenarioQuestionRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &proto.UpdateScenarioQuestionRsp{}, nil
|
||||
}
|
||||
|
||||
func (s *server) DeleteScenarioQuestion(ctx context.Context, req *proto.DeleteScenarioQuestionReq) (*proto.DeleteScenarioQuestionRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
err := s.scenarioService.DeleteScenarioQuestion(
|
||||
ctx,
|
||||
int(req.Id),
|
||||
req.Code,
|
||||
claims.UserID,
|
||||
roles.HasRole(claims, roles.Admin),
|
||||
)
|
||||
if err != nil {
|
||||
return &proto.DeleteScenarioQuestionRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &proto.DeleteScenarioQuestionRsp{}, nil
|
||||
}
|
||||
|
||||
func (s *server) UpdateScenarioIntro(ctx context.Context, req *proto.UpdateScenarioIntroReq) (*proto.UpdateScenarioIntroRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
err := s.scenarioService.UpdateScenarioIntro(
|
||||
ctx,
|
||||
int(req.Id),
|
||||
convertIntroduction(req.Introduction),
|
||||
claims.UserID,
|
||||
roles.HasRole(claims, roles.Admin),
|
||||
)
|
||||
if err != nil {
|
||||
return &proto.UpdateScenarioIntroRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &proto.UpdateScenarioIntroRsp{}, nil
|
||||
}
|
||||
|
||||
// DownloadScenarioArchive отдаёт ZIP-архив сценария; ошибки — gRPC-статусами.
|
||||
func (s *server) DownloadScenarioArchive(ctx context.Context, req *proto.DownloadScenarioArchiveReq) (*httpbody.HttpBody, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
data, name, err := s.scenarioService.DownloadArchive(
|
||||
ctx,
|
||||
int(req.Id),
|
||||
claims.UserID,
|
||||
roles.HasRole(claims, roles.Admin),
|
||||
)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, scenarios_repo.ErrScenarioNotFound):
|
||||
return nil, status.Errorf(codes.NotFound, "%v", err)
|
||||
case errors.Is(err, scenarios_service.ErrScenarioNotOwner):
|
||||
return nil, status.Errorf(codes.PermissionDenied, "%v", err)
|
||||
default:
|
||||
return nil, status.Errorf(codes.Internal, "%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Имя файла транслитерируется (заголовки HTTP — только ASCII) и
|
||||
// санитайзится от header injection; заголовок пробрасывается gateway.
|
||||
transliterated := sanitizeFilename(stringTools.Transliterate(name))
|
||||
if transliterated == "" {
|
||||
transliterated = "scenario"
|
||||
}
|
||||
filename := transliterated + ".zip"
|
||||
if err := grpc.SetHeader(ctx, metadata.Pairs("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to set response header: %v", err)
|
||||
}
|
||||
|
||||
return &httpbody.HttpBody{
|
||||
Data: data,
|
||||
ContentType: "application/zip",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UploadScenarioArchive создаёт сценарий из ZIP-архива. Тело — сырые байты;
|
||||
// JSON base64 тоже поддерживается.
|
||||
func (s *server) UploadScenarioArchive(ctx context.Context, req *httpbody.HttpBody) (*proto.UploadScenarioArchiveRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
if req == nil || len(req.Data) == 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "пустое тело запроса")
|
||||
}
|
||||
if len(req.Data) > scenario_archive.MaxArchiveSize() {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "архив больше максимального размера (%d байт)", scenario_archive.MaxArchiveSize())
|
||||
}
|
||||
|
||||
id, err := s.scenarioService.UploadArchive(ctx, req.Data, claims.UserID)
|
||||
if err != nil {
|
||||
return &proto.UploadScenarioArchiveRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
return &proto.UploadScenarioArchiveRsp{
|
||||
Id: int32(id),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sanitizeFilename — защита от инъекции заголовков в Content-Disposition.
|
||||
func sanitizeFilename(name string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
`"`, "_",
|
||||
"\r", "_",
|
||||
"\n", "_",
|
||||
)
|
||||
return replacer.Replace(name)
|
||||
}
|
||||
|
||||
func (s *server) AddGame(ctx context.Context, req *proto.AddGameReq) (*proto.AddGameRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) {
|
||||
@@ -636,6 +829,25 @@ func (s *server) GetGame(ctx context.Context, req *proto.GetGameReq) (*proto.Get
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) GetGameAnswers(ctx context.Context, req *proto.GetGameAnswersReq) (*proto.GetGameAnswersRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
gameAnswers, err := s.gameService.GetGameAnswers(ctx, int(req.GameId))
|
||||
if err != nil {
|
||||
return &proto.GetGameAnswersRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &proto.GetGameAnswersRsp{
|
||||
Questions: mapQuestions(gameAnswers.Questions),
|
||||
Teams: mapTeamAnswers(gameAnswers.Teams),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) GetGames(ctx context.Context, req *proto.GetGamesReq) (*proto.GetGamesRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) {
|
||||
@@ -896,3 +1108,63 @@ func (s *server) DeleteLastTeamAction(ctx context.Context, req *proto.DeleteLast
|
||||
|
||||
return &proto.DeleteLastTeamActionRsp{}, nil
|
||||
}
|
||||
|
||||
func (s *server) GetTeamAnswers(ctx context.Context, req *proto.GetTeamAnswersReq) (*proto.GetTeamAnswersRsp, error) {
|
||||
answers, err := s.gameService.GetTeamAnswers(
|
||||
ctx,
|
||||
int(req.Id),
|
||||
req.Password,
|
||||
)
|
||||
if err != nil {
|
||||
return &proto.GetTeamAnswersRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &proto.GetTeamAnswersRsp{
|
||||
Answers: mapAnswers(answers),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) SubmitTeamAnswers(ctx context.Context, req *proto.SubmitTeamAnswersReq) (*proto.SubmitTeamAnswersRsp, error) {
|
||||
err := s.gameService.SubmitTeamAnswers(
|
||||
ctx,
|
||||
int(req.Id),
|
||||
req.Password,
|
||||
convertAnswers(req.Answers),
|
||||
)
|
||||
if err != nil {
|
||||
return &proto.SubmitTeamAnswersRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &proto.SubmitTeamAnswersRsp{}, nil
|
||||
}
|
||||
|
||||
func (s *server) SetTeamAnswerScore(ctx context.Context, req *proto.SetTeamAnswerScoreReq) (*proto.SetTeamAnswerScoreRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
var score *int
|
||||
if req.Score != nil {
|
||||
v := int(req.GetScore())
|
||||
score = &v
|
||||
}
|
||||
|
||||
err := s.gameService.SetTeamAnswerScore(
|
||||
ctx,
|
||||
int(req.Id),
|
||||
req.QuestionCode,
|
||||
score,
|
||||
)
|
||||
if err != nil {
|
||||
return &proto.SetTeamAnswerScoreRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &proto.SetTeamAnswerScoreRsp{}, nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// textFormatter — форматирование текста при конвертации входящих данных.
|
||||
var textFormatter = formatter_utils.NewFormatter()
|
||||
|
||||
func mapScenarios(o []*scenarios_service.Scenario) []*proto.Scenario {
|
||||
res := make([]*proto.Scenario, 0, len(o))
|
||||
for _, item := range o {
|
||||
@@ -44,7 +47,19 @@ func mapStory(o *storytelling.Story) *proto.Story {
|
||||
return nil
|
||||
}
|
||||
return &proto.Story{
|
||||
Introduction: mapIntroduction(o.Introduction),
|
||||
Places: mapPlaces(o.Places),
|
||||
Questions: mapQuestions(o.Questions),
|
||||
}
|
||||
}
|
||||
|
||||
func mapIntroduction(o *storytelling.Introduction) *proto.Introduction {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
return &proto.Introduction{
|
||||
Text: o.Text,
|
||||
Audio: o.Audio,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +112,7 @@ func mapApplication(o *storytelling.Application) *proto.Application {
|
||||
return &proto.Application{
|
||||
Name: o.Name,
|
||||
Image: o.Image,
|
||||
FileType: o.FileType,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,8 +133,8 @@ func mapKey(o *storytelling.Key) *proto.Key {
|
||||
func convertPlace(o *proto.Place) *storytelling.Place {
|
||||
return &storytelling.Place{
|
||||
Code: o.Code,
|
||||
Name: formatter_utils.FormatString(o.Name),
|
||||
Text: formatter_utils.FormatText(o.Text),
|
||||
Name: textFormatter.FormatString(o.Name),
|
||||
Text: textFormatter.FormatText(o.Text),
|
||||
Image: o.Image,
|
||||
Hidden: o.Hidden,
|
||||
Applications: convertApplications(o.Applications),
|
||||
@@ -153,21 +169,62 @@ func convertKeys(o []*proto.Key) []*storytelling.Key {
|
||||
|
||||
func convertApplication(o *proto.Application) *storytelling.Application {
|
||||
return &storytelling.Application{
|
||||
Name: formatter_utils.FormatString(o.Name),
|
||||
Name: textFormatter.FormatString(o.Name),
|
||||
Image: o.Image,
|
||||
FileType: o.FileType,
|
||||
}
|
||||
}
|
||||
|
||||
func convertDoor(o *proto.Door) *storytelling.Door {
|
||||
return &storytelling.Door{
|
||||
Code: o.Code,
|
||||
Name: formatter_utils.FormatString(o.Name),
|
||||
Name: textFormatter.FormatString(o.Name),
|
||||
Keys: convertKeys(o.Keys),
|
||||
}
|
||||
}
|
||||
|
||||
func convertKey(o *proto.Key) *storytelling.Key {
|
||||
return &storytelling.Key{
|
||||
Name: formatter_utils.FormatString(o.Name),
|
||||
Name: textFormatter.FormatString(o.Name),
|
||||
}
|
||||
}
|
||||
|
||||
func convertIntroduction(o *proto.Introduction) *storytelling.Introduction {
|
||||
if o == nil || (o.Text == "" && o.Audio == "") {
|
||||
return nil
|
||||
}
|
||||
return &storytelling.Introduction{
|
||||
Text: textFormatter.FormatText(o.Text),
|
||||
Audio: o.Audio,
|
||||
}
|
||||
}
|
||||
|
||||
func mapQuestions(o []*storytelling.Question) []*proto.Question {
|
||||
res := make([]*proto.Question, 0, len(o))
|
||||
for _, item := range o {
|
||||
res = append(res, mapQuestion(item))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func mapQuestion(o *storytelling.Question) *proto.Question {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
return &proto.Question{
|
||||
Code: o.Code,
|
||||
Text: o.Text,
|
||||
Answer: o.Answer,
|
||||
}
|
||||
}
|
||||
|
||||
func convertQuestion(o *proto.Question) *storytelling.Question {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
return &storytelling.Question{
|
||||
Code: o.Code,
|
||||
Text: textFormatter.FormatText(o.Text),
|
||||
Answer: textFormatter.FormatText(o.Answer),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ import "context"
|
||||
type Message struct {
|
||||
To string
|
||||
Subject string
|
||||
// Body — текстовая версия письма (text/plain).
|
||||
Body string
|
||||
// HTML — версия письма для почтовых клиентов (text/html).
|
||||
// Если пусто, письмо уходит только в text/plain.
|
||||
HTML string
|
||||
}
|
||||
|
||||
type IEmailSender interface {
|
||||
|
||||
@@ -2,9 +2,16 @@ package email_sender
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type sender struct {
|
||||
@@ -12,6 +19,11 @@ type sender struct {
|
||||
smtpPort string
|
||||
smtpUser string
|
||||
smtpPassword string
|
||||
from string
|
||||
fromName string
|
||||
replyTo string
|
||||
timeout time.Duration
|
||||
tlsConfig *tls.Config
|
||||
}
|
||||
|
||||
func NewSender(
|
||||
@@ -19,76 +31,201 @@ func NewSender(
|
||||
smtpPort string,
|
||||
smtpUser string,
|
||||
smtpPassword string,
|
||||
fromName string,
|
||||
replyTo string,
|
||||
timeout time.Duration,
|
||||
) IEmailSender {
|
||||
// From заголовка письма по умолчанию совпадает с учётной записью SMTP.
|
||||
from := smtpUser
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return &sender{
|
||||
smtpHost: smtpHost,
|
||||
smtpPort: smtpPort,
|
||||
smtpUser: smtpUser,
|
||||
smtpPassword: smtpPassword,
|
||||
from: from,
|
||||
fromName: sanitizeHeader(fromName),
|
||||
replyTo: sanitizeHeader(replyTo),
|
||||
timeout: timeout,
|
||||
// Проверка имени сервера включена всегда; поле переопределяется
|
||||
// только в тестах (свой RootCAs для самоподписанного сертификата).
|
||||
tlsConfig: &tls.Config{ServerName: smtpHost},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sender) Send(_ context.Context, message Message) error {
|
||||
emailMessage := []byte(fmt.Sprintf(
|
||||
"To: %s\r\n"+
|
||||
"Subject: %s\r\n"+
|
||||
"MIME-Version: 1.0\r\n"+
|
||||
"Content-Type: text/plain; charset=utf-8\r\n"+
|
||||
"\r\n"+
|
||||
"%s\r\n",
|
||||
message.To,
|
||||
message.Subject,
|
||||
message.Body,
|
||||
))
|
||||
|
||||
// Настраиваем TLS
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: s.smtpHost,
|
||||
}
|
||||
|
||||
// Подключаемся к серверу
|
||||
conn, err := tls.Dial("tcp", s.smtpHost+":"+s.smtpPort, tlsConfig)
|
||||
if err != nil {
|
||||
// Send доставляет письмо через SMTP (implicit TLS) с учётом контекста и
|
||||
// таймаута: соединение устанавливается через DialContext, а все фазы SMTP-
|
||||
// диалога ограничены общим deadline, производным от ctx.
|
||||
func (s *sender) Send(ctx context.Context, message Message) error {
|
||||
// Санитизация пользовательского ввода: CR/LF/NUL в заголовках ломают
|
||||
// формат письма и позволяют инъекцию произвольных заголовков (Bcc и т.п.).
|
||||
to := sanitizeHeader(message.To)
|
||||
subject := sanitizeHeader(message.Subject)
|
||||
if err := validateMessage(to, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
body := buildMessage(s.from, s.fromName, to, subject, s.replyTo, message.Body, message.HTML)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, s.timeout)
|
||||
defer cancel()
|
||||
|
||||
addr := net.JoinHostPort(s.smtpHost, s.smtpPort)
|
||||
rawConn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("email: smtp dial %s: %w", addr, err)
|
||||
}
|
||||
defer rawConn.Close()
|
||||
|
||||
conn := tls.Client(rawConn, s.tlsConfig)
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
// Единый бюджет времени на весь SMTP-диалог: зависший сервер не
|
||||
// должен держать запрос дольше таймаута.
|
||||
if err = conn.SetDeadline(deadline); err != nil {
|
||||
return fmt.Errorf("email: set deadline: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Создаем SMTP клиент
|
||||
client, err := smtp.NewClient(conn, s.smtpHost)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("email: smtp greeting: %w", err)
|
||||
}
|
||||
defer client.Quit()
|
||||
// Close гарантированно закрывает соединение при раннем выходе; в конце
|
||||
// штатного пути вместо него вызывается Quit.
|
||||
defer client.Close() //nolint:errcheck
|
||||
|
||||
// Аутентификация
|
||||
auth := smtp.PlainAuth("", s.smtpUser, s.smtpPassword, s.smtpHost)
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("email: smtp auth: %w", err)
|
||||
}
|
||||
if err = client.Mail(s.from); err != nil {
|
||||
return fmt.Errorf("email: smtp mail from: %w", err)
|
||||
}
|
||||
if err = client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("email: smtp rcpt to: %w", err)
|
||||
}
|
||||
|
||||
// Указываем отправителя
|
||||
if err = client.Mail(s.smtpUser); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Указываем получателя
|
||||
if err = client.Rcpt(message.To); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Отправляем данные письма
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("email: smtp data: %w", err)
|
||||
}
|
||||
_, err = w.Write(emailMessage)
|
||||
if err != nil {
|
||||
return err
|
||||
if _, err = w.Write(body); err != nil {
|
||||
return fmt.Errorf("email: smtp write: %w", err)
|
||||
}
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("email: smtp data close: %w", err)
|
||||
}
|
||||
if err = client.Quit(); err != nil {
|
||||
return fmt.Errorf("email: smtp quit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildMessage собирает тело письма. Заголовки обязаны быть ASCII, поэтому
|
||||
// не-ASCII значения (имя отправителя, тема) кодируются по RFC 2047, иначе
|
||||
// кириллица может быть испорчена промежуточными серверами. Заголовки
|
||||
// Date и Message-ID добавляются явно: их отсутствие — типичный признак
|
||||
// спама для почтовых фильтров (включая Gmail). Если задана HTML-версия,
|
||||
// письмо собирается как multipart/alternative (text/plain + text/html).
|
||||
func buildMessage(from, fromName, to, subject, replyTo, body, html string) []byte {
|
||||
var b strings.Builder
|
||||
b.Grow(len(from) + len(fromName) + len(to) + len(subject) + len(body) + len(html) + 512)
|
||||
b.WriteString("From: ")
|
||||
if fromName != "" {
|
||||
b.WriteString(mime.QEncoding.Encode("utf-8", fromName))
|
||||
b.WriteString(" <")
|
||||
b.WriteString(from)
|
||||
b.WriteString(">\r\n")
|
||||
} else {
|
||||
b.WriteString(from)
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
b.WriteString("To: ")
|
||||
b.WriteString(to)
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString("Subject: ")
|
||||
b.WriteString(mime.QEncoding.Encode("utf-8", subject))
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString("Date: ")
|
||||
b.WriteString(time.Now().UTC().Format(time.RFC1123Z))
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString("Message-ID: ")
|
||||
b.WriteString(messageID(from))
|
||||
b.WriteString("\r\n")
|
||||
if replyTo != "" {
|
||||
b.WriteString("Reply-To: ")
|
||||
b.WriteString(replyTo)
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
b.WriteString("MIME-Version: 1.0\r\n")
|
||||
if html == "" {
|
||||
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(body)
|
||||
b.WriteString("\r\n")
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
boundary := fmt.Sprintf("----=_evening_detective_%s", randomHex(10))
|
||||
b.WriteString("Content-Type: multipart/alternative; boundary=\"")
|
||||
b.WriteString(boundary)
|
||||
b.WriteString("\"\r\n\r\n")
|
||||
b.WriteString("--")
|
||||
b.WriteString(boundary)
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||
b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
|
||||
b.WriteString(body)
|
||||
b.WriteString("\r\n--")
|
||||
b.WriteString(boundary)
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString("Content-Type: text/html; charset=utf-8\r\n")
|
||||
b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
|
||||
b.WriteString(html)
|
||||
b.WriteString("\r\n--")
|
||||
b.WriteString(boundary)
|
||||
b.WriteString("--\r\n")
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// messageID генерирует уникальный Message-ID в домене отправителя.
|
||||
func messageID(from string) string {
|
||||
domain := ""
|
||||
if i := strings.LastIndex(from, "@"); i >= 0 && i < len(from)-1 {
|
||||
domain = from[i+1:]
|
||||
}
|
||||
if domain == "" {
|
||||
domain = "localhost"
|
||||
}
|
||||
return "<" + randomHex(12) + "@" + domain + ">"
|
||||
}
|
||||
|
||||
// randomHex возвращает случайную hex-строку (криптостойкий генератор;
|
||||
// при сбое — значение из таймера, чтобы отправка не падала).
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
}
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
// sanitizeHeader удаляет символы, ломающие структуру заголовков письма
|
||||
// (CRLF-инъекция заголовков, NUL).
|
||||
func sanitizeHeader(s string) string {
|
||||
r := strings.NewReplacer("\r", "", "\n", "", "\x00", "")
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// validateMessage проверяет адрес получателя и наличие темы до обращения к
|
||||
// SMTP-серверу.
|
||||
func validateMessage(to, subject string) error {
|
||||
if _, err := mail.ParseAddress(to); err != nil {
|
||||
return fmt.Errorf("email: некорректный адрес получателя: %w", err)
|
||||
}
|
||||
if subject == "" {
|
||||
return errors.New("email: пустая тема письма")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
package email_sender
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// startSMTPTestServer поднимает минимальный SMTP-сервер (implicit TLS) на
|
||||
// случайном порту 127.0.0.1 и возвращает его адрес и канал с принятыми
|
||||
// телами писем. При hang=true сервер отправляет приветствие и молчит —
|
||||
// для проверки таймаутов. Сертификат генерируется один на тест и
|
||||
// передаётся и серверу, и клиенту (testSender).
|
||||
func startSMTPTestServer(t *testing.T, hang bool, cert tls.Certificate) (addr string, messages chan []byte) {
|
||||
t.Helper()
|
||||
|
||||
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
messages = make(chan []byte, 8)
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if hang {
|
||||
go func() {
|
||||
// Приветствие отправляем, дальше молчим.
|
||||
_, _ = conn.Write([]byte("220 hang.example.com ESMTP\r\n"))
|
||||
}()
|
||||
continue
|
||||
}
|
||||
go handleSMTPConn(conn, messages)
|
||||
}
|
||||
}()
|
||||
|
||||
return ln.Addr().String(), messages
|
||||
}
|
||||
|
||||
// handleSMTPConn обслуживает один SMTP-диалог, достаточный для net/smtp:
|
||||
// EHLO, AUTH PLAIN, MAIL, RCPT, DATA, QUIT.
|
||||
func handleSMTPConn(conn net.Conn, messages chan<- []byte) {
|
||||
defer conn.Close()
|
||||
|
||||
r := bufio.NewReader(conn)
|
||||
write := func(s string) { _, _ = conn.Write([]byte(s)) }
|
||||
|
||||
write("220 test.example.com ESMTP\r\n")
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.ToUpper(strings.TrimSpace(line))
|
||||
switch {
|
||||
case strings.HasPrefix(cmd, "EHLO"):
|
||||
write("250-test.example.com\r\n250-AUTH PLAIN\r\n250 OK\r\n")
|
||||
case strings.HasPrefix(cmd, "AUTH"):
|
||||
write("235 2.7.0 Authentication successful\r\n")
|
||||
case strings.HasPrefix(cmd, "MAIL"):
|
||||
write("250 OK\r\n")
|
||||
case strings.HasPrefix(cmd, "RCPT"):
|
||||
write("250 OK\r\n")
|
||||
case strings.HasPrefix(cmd, "DATA"):
|
||||
write("354 End data with <CR><LF>.<CR><LF>\r\n")
|
||||
var body []byte
|
||||
for {
|
||||
b, err := r.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
body = append(body, b...)
|
||||
if string(b) == ".\r\n" {
|
||||
break
|
||||
}
|
||||
}
|
||||
select {
|
||||
case messages <- body:
|
||||
default:
|
||||
}
|
||||
write("250 OK\r\n")
|
||||
case strings.HasPrefix(cmd, "QUIT"):
|
||||
write("221 Bye\r\n")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testTLSCert генерирует самоподписанный сертификат для 127.0.0.1 и
|
||||
// возвращает его вместе с распарсенным x509-представлением (Leaf), чтобы
|
||||
// тест мог добавить его в корни доверия клиента.
|
||||
func testTLSCert(t *testing.T) tls.Certificate {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "127.0.0.1"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create certificate: %v", err)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatalf("parse certificate: %v", err)
|
||||
}
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}
|
||||
}
|
||||
|
||||
// testSender собирает sender, доверяющий самоподписанному сертификату
|
||||
// тестового SMTP-сервера (в проде NewSender использует системные корни).
|
||||
func testSender(t *testing.T, addr string, timeout time.Duration, cert tls.Certificate) *sender {
|
||||
t.Helper()
|
||||
host, port := splitHostPort(t, addr)
|
||||
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(cert.Leaf)
|
||||
|
||||
return &sender{
|
||||
smtpHost: host,
|
||||
smtpPort: port,
|
||||
smtpUser: "sender@example.com",
|
||||
smtpPassword: "secret",
|
||||
from: "sender@example.com",
|
||||
fromName: "Вечерний детектив",
|
||||
replyTo: "support@example.com",
|
||||
timeout: timeout,
|
||||
tlsConfig: &tls.Config{ServerName: host, RootCAs: pool},
|
||||
}
|
||||
}
|
||||
|
||||
func splitHostPort(t *testing.T, addr string) (host, port string) {
|
||||
t.Helper()
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("split host port: %v", err)
|
||||
}
|
||||
return host, port
|
||||
}
|
||||
|
||||
func TestSendSuccess(t *testing.T) {
|
||||
cert := testTLSCert(t)
|
||||
addr, messages := startSMTPTestServer(t, false, cert)
|
||||
s := testSender(t, addr, 5*time.Second, cert)
|
||||
|
||||
err := s.Send(context.Background(), Message{
|
||||
To: "user@example.com",
|
||||
Subject: "Привет, детектив!",
|
||||
Body: "Текст письма",
|
||||
HTML: "<html><body>Текст письма</body></html>",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case data := <-messages:
|
||||
raw := string(data)
|
||||
for _, want := range []string{
|
||||
// From с отображаемым именем (RFC 2047).
|
||||
"From: =?utf-8?q?",
|
||||
"<sender@example.com>",
|
||||
"To: user@example.com",
|
||||
"Subject: =?utf-8?q?",
|
||||
"Date: ",
|
||||
"Message-ID: <",
|
||||
"@example.com>",
|
||||
"Reply-To: support@example.com",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: multipart/alternative; boundary=",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"Текст письма",
|
||||
"Content-Type: text/html; charset=utf-8",
|
||||
"<html><body>Текст письма</body></html>",
|
||||
} {
|
||||
if !strings.Contains(raw, want) {
|
||||
t.Errorf("письмо не содержит %q:\n%s", want, raw)
|
||||
}
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("письмо не получено сервером")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendRejectsCRLFInjection(t *testing.T) {
|
||||
cert := testTLSCert(t)
|
||||
addr, messages := startSMTPTestServer(t, false, cert)
|
||||
s := testSender(t, addr, 5*time.Second, cert)
|
||||
|
||||
// CRLF в адресе — попытка инъекции заголовков (Bcc и т.п.): письмо
|
||||
// должно быть отклонено до обращения к SMTP-серверу.
|
||||
err := s.Send(context.Background(), Message{
|
||||
To: "user@example.com\r\nBcc: victim@example.com",
|
||||
Subject: "Test",
|
||||
Body: "body",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка для адреса с CRLF")
|
||||
}
|
||||
select {
|
||||
case <-messages:
|
||||
t.Fatal("письмо не должно было уйти на сервер")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendContextCancelled(t *testing.T) {
|
||||
cert := testTLSCert(t)
|
||||
addr, _ := startSMTPTestServer(t, false, cert)
|
||||
s := testSender(t, addr, 10*time.Second, cert)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
start := time.Now()
|
||||
err := s.Send(ctx, Message{To: "user@example.com", Subject: "Test", Body: "body"})
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка при отменённом контексте")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Fatalf("отмена контекста должна прерывать отправку быстро, заняло %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTimeout(t *testing.T) {
|
||||
cert := testTLSCert(t)
|
||||
addr, _ := startSMTPTestServer(t, true, cert) // сервер молчит после приветствия
|
||||
s := testSender(t, addr, 500*time.Millisecond, cert)
|
||||
|
||||
start := time.Now()
|
||||
err := s.Send(context.Background(), Message{To: "user@example.com", Subject: "Test", Body: "body"})
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка таймаута")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
||||
t.Fatalf("таймаут должен сработать быстро, заняло %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessage(t *testing.T) {
|
||||
// С HTML-версией — multipart/alternative.
|
||||
raw := string(buildMessage(
|
||||
"sender@example.com", "Вечерний детектив",
|
||||
"user@example.com", "Привет", "support@example.com",
|
||||
"текст", "<html><body>текст</body></html>",
|
||||
))
|
||||
for _, want := range []string{
|
||||
"From: =?utf-8?q?",
|
||||
"<sender@example.com>",
|
||||
"To: user@example.com",
|
||||
"Subject: =?utf-8?q?",
|
||||
"Date: ",
|
||||
"Message-ID: <",
|
||||
"Reply-To: support@example.com",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: multipart/alternative; boundary=",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"текст",
|
||||
"Content-Type: text/html; charset=utf-8",
|
||||
"<html><body>текст</body></html>",
|
||||
} {
|
||||
if !strings.Contains(raw, want) {
|
||||
t.Errorf("письмо не содержит %q:\n%s", want, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Без HTML-версии — только text/plain (обратная совместимость).
|
||||
raw = string(buildMessage(
|
||||
"sender@example.com", "Вечерний детектив",
|
||||
"user@example.com", "Привет", "",
|
||||
"текст", "",
|
||||
))
|
||||
for _, want := range []string{
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"текст",
|
||||
} {
|
||||
if !strings.Contains(raw, want) {
|
||||
t.Errorf("plain-письмо не содержит %q:\n%s", want, raw)
|
||||
}
|
||||
}
|
||||
if strings.Contains(raw, "multipart/alternative") {
|
||||
t.Errorf("plain-письмо не должно быть multipart:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageNoFromName(t *testing.T) {
|
||||
raw := string(buildMessage(
|
||||
"sender@example.com", "",
|
||||
"user@example.com", "Привет", "",
|
||||
"текст", "",
|
||||
))
|
||||
if !strings.Contains(raw, "From: sender@example.com\r\n") {
|
||||
t.Errorf("адрес без имени отправителя должен идти без угловых скобок:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageMessageIDDomain(t *testing.T) {
|
||||
raw := string(buildMessage(
|
||||
"evening_detective@crabs-games.art", "Вечерний детектив",
|
||||
"user@example.com", "Привет", "",
|
||||
"текст", "",
|
||||
))
|
||||
for _, want := range []string{
|
||||
"Message-ID: <",
|
||||
"@crabs-games.art>",
|
||||
} {
|
||||
if !strings.Contains(raw, want) {
|
||||
t.Errorf("письмо не содержит %q:\n%s", want, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeHeader(t *testing.T) {
|
||||
got := sanitizeHeader("a@b.c\r\nBcc: x@y.z\x00")
|
||||
want := "a@b.cBcc: x@y.z"
|
||||
if got != want {
|
||||
t.Errorf("sanitizeHeader = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMessage(t *testing.T) {
|
||||
if err := validateMessage("user@example.com", "тема"); err != nil {
|
||||
t.Errorf("валидное письмо отклонено: %v", err)
|
||||
}
|
||||
if err := validateMessage("not-an-email", "тема"); err == nil {
|
||||
t.Error("невалидный адрес принят")
|
||||
}
|
||||
if err := validateMessage("user@example.com", ""); err == nil {
|
||||
t.Error("пустая тема принята")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
package file_storage
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrFileNotFound — файл не найден в хранилище. Возвращается Get для
|
||||
// отсутствующих объектов (маппинг smithy-ошибок NoSuchKey/NotFound).
|
||||
var ErrFileNotFound = errors.New("файл не найден")
|
||||
|
||||
type File struct {
|
||||
Name string
|
||||
@@ -11,4 +21,35 @@ type File struct {
|
||||
type IFileStorage interface {
|
||||
Put(ctx context.Context, file *File) error
|
||||
Get(ctx context.Context, filename string) (*File, error)
|
||||
Delete(ctx context.Context, filename string) error
|
||||
MimeType(filename string) string
|
||||
}
|
||||
|
||||
// FileType возвращает категорию файла по расширению имени: pdf → "pdf",
|
||||
// jpg/jpeg/png/gif/webp → "image", mp3/ogg/wav → "audio", иначе "".
|
||||
// Регистр расширения и query-параметры игнорируются.
|
||||
// Используется для деривации file_type улик (см. storytelling.Application).
|
||||
func FileType(filename string) string {
|
||||
ext := strings.ToLower(extension(filename))
|
||||
switch ext {
|
||||
case ".pdf":
|
||||
return "pdf"
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
|
||||
return "image"
|
||||
case ".mp3", ".ogg", ".wav":
|
||||
return "audio"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// NewName возвращает случайное имя файла в хранилище (hex-токен 16 байт
|
||||
// без расширения): исключает коллизии в общем бакете. Расширение добавляет
|
||||
// вызывающий код.
|
||||
func NewName() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ package file_storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/smithy-go"
|
||||
"github.com/kzzan/s3kit"
|
||||
)
|
||||
|
||||
@@ -47,25 +51,70 @@ func NewRustFSStorage(
|
||||
}
|
||||
|
||||
func (s *storage) Put(ctx context.Context, file *File) error {
|
||||
mime := s.getMimeType(file.Name)
|
||||
return s.client.PutObjectBytes(ctx, s.bucket, file.Name, file.Data, mime)
|
||||
return s.client.PutObjectBytes(ctx, s.bucket, file.Name, file.Data, mimeFor(file))
|
||||
}
|
||||
|
||||
func (s *storage) Get(ctx context.Context, filename string) (*File, error) {
|
||||
data, err := s.client.GetObjectBytes(ctx, s.bucket, filename)
|
||||
if err != nil {
|
||||
// s3kit отдаёт сырые smithy-ошибки: маппим отсутствие объекта
|
||||
// (NoSuchKey/NotFound) в sentinel для прикладного слоя.
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
switch apiErr.ErrorCode() {
|
||||
case "NoSuchKey", "NotFound":
|
||||
return nil, ErrFileNotFound
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &File{
|
||||
Name: filename,
|
||||
Data: data,
|
||||
Mime: s.getMimeType(filename),
|
||||
Mime: mimeFromData(data, filename),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *storage) getMimeType(filename string) string {
|
||||
func (s *storage) Delete(ctx context.Context, filename string) error {
|
||||
return s.client.DeleteObject(ctx, s.bucket, filename)
|
||||
}
|
||||
|
||||
func (s *storage) MimeType(filename string) string {
|
||||
if mimeType := mime.TypeByExtension(filepath.Ext(filename)); mimeType != "" {
|
||||
return mimeType
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// mimeFor — Content-Type для Put: явный Mime файла приоритетнее вывода из
|
||||
// расширения, чтобы объект в S3 соответствовал фактическому содержимому.
|
||||
func mimeFor(f *File) string {
|
||||
if f.Mime != "" {
|
||||
return f.Mime
|
||||
}
|
||||
return mimeFromData(f.Data, f.Name)
|
||||
}
|
||||
|
||||
// mimeFromData — Content-Type по содержимому (первые 512 байт) с fallback
|
||||
// на расширение. text/* (например, HTML) трактуется как octet-stream и
|
||||
// уходит в fallback: при публичном скачивании не должно отдаваться как
|
||||
// html с доверенного домена.
|
||||
func mimeFromData(data []byte, filename string) string {
|
||||
detected := http.DetectContentType(data)
|
||||
if strings.HasPrefix(detected, "text/") || detected == "application/octet-stream" {
|
||||
if mimeType := mime.TypeByExtension(filepath.Ext(filename)); mimeType != "" {
|
||||
return mimeType
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return detected
|
||||
}
|
||||
|
||||
// extension — расширение имени файла в нижнем регистре, без query/fragment
|
||||
// (например, "photo.PNG?v=2" → ".png").
|
||||
func extension(filename string) string {
|
||||
if i := strings.IndexAny(filename, "?#"); i >= 0 {
|
||||
filename = filename[:i]
|
||||
}
|
||||
return filepath.Ext(strings.ToLower(filename))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package file_storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileType(t *testing.T) {
|
||||
cases := []struct {
|
||||
filename string
|
||||
want string
|
||||
}{
|
||||
{"clue.pdf", "pdf"},
|
||||
{"clue.PDF", "pdf"},
|
||||
{"photo.jpg", "image"},
|
||||
{"photo.jpeg", "image"},
|
||||
{"photo.PNG", "image"},
|
||||
{"photo.gif", "image"},
|
||||
{"photo.webp", "image"},
|
||||
{"audio.mp3", "audio"},
|
||||
{"audio.ogg", "audio"},
|
||||
{"audio.wav", "audio"},
|
||||
{"audio.m4a", ""},
|
||||
{"photo.PNG?v=2", "image"},
|
||||
{"http://domain/api/files/photo.pdf", "pdf"},
|
||||
{"noextension", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := FileType(c.filename); got != c.want {
|
||||
t.Errorf("FileType(%q) = %q, want %q", c.filename, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewName(t *testing.T) {
|
||||
a, err := NewName()
|
||||
if err != nil {
|
||||
t.Fatalf("NewName: %v", err)
|
||||
}
|
||||
b, err := NewName()
|
||||
if err != nil {
|
||||
t.Fatalf("NewName: %v", err)
|
||||
}
|
||||
if a == "" || b == "" {
|
||||
t.Fatalf("NewName вернул пустое имя")
|
||||
}
|
||||
if a == b {
|
||||
t.Errorf("NewName вернул одинаковые имена %q и %q", a, b)
|
||||
}
|
||||
// hex-токен 16 байт = 32 символа
|
||||
if len(a) != 32 {
|
||||
t.Errorf("длина имени = %d, want 32", len(a))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMimeFor(t *testing.T) {
|
||||
png := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("x", 16))
|
||||
|
||||
t.Run("explicit Mime wins", func(t *testing.T) {
|
||||
f := &File{Name: "clue.pdf", Data: png, Mime: "application/pdf"}
|
||||
if got := mimeFor(f); got != "application/pdf" {
|
||||
t.Errorf("mimeFor = %q, want application/pdf", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty Mime falls back to content sniff", func(t *testing.T) {
|
||||
f := &File{Name: "clue.bin", Data: png}
|
||||
if got := mimeFor(f); got != "image/png" {
|
||||
t.Errorf("mimeFor = %q, want image/png", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty Mime and unknown content falls back to extension", func(t *testing.T) {
|
||||
f := &File{Name: "clue.pdf", Data: []byte("not really a pdf")}
|
||||
if got := mimeFor(f); got != "application/pdf" {
|
||||
t.Errorf("mimeFor = %q, want application/pdf по расширению", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMimeFromData(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
filename string
|
||||
want string
|
||||
}{
|
||||
{"pdf", []byte("%PDF-1.7 fake"), "clue.pdf", "application/pdf"},
|
||||
{"png", []byte("\x89PNG\r\n\x1a\nxxx"), "photo.png", "image/png"},
|
||||
{"html falls back to extension", []byte("<html>hi</html>"), "photo.png", "image/png"},
|
||||
{"html unknown extension", []byte("<html>hi</html>"), "clue.xyz", "application/octet-stream"},
|
||||
{"unknown falls back to extension", []byte{0x00, 0x01, 0x02, 0x03}, "clue.pdf", "application/pdf"},
|
||||
{"empty data falls back", nil, "clue.pdf", "application/pdf"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := mimeFromData(c.data, c.filename); got != c.want {
|
||||
t.Errorf("mimeFromData(%q, %q) = %q, want %q", c.name, c.filename, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package formatter_utils
|
||||
|
||||
// IFormatter — форматирование текста сценария для отображения.
|
||||
type IFormatter interface {
|
||||
FormatText(text string) string
|
||||
FormatString(text string) string
|
||||
}
|
||||
@@ -5,7 +5,16 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func FormatText(text string) string {
|
||||
type formatter struct{}
|
||||
|
||||
// NewFormatter создаёт реализацию IFormatter.
|
||||
func NewFormatter() IFormatter {
|
||||
return &formatter{}
|
||||
}
|
||||
|
||||
// FormatText форматирует многострочный текст: обрезает пробелы по краям
|
||||
// строк, превращает "--" в тире и добавляет отступ абзаца.
|
||||
func (f *formatter) FormatText(text string) string {
|
||||
scanner := bufio.NewScanner(strings.NewReader(text))
|
||||
|
||||
scanner.Split(bufio.ScanLines)
|
||||
@@ -35,7 +44,9 @@ func FormatText(text string) string {
|
||||
return res.String()
|
||||
}
|
||||
|
||||
func FormatString(text string) string {
|
||||
// FormatString форматирует одиночную строку: обрезает пробелы и заменяет
|
||||
// "--" на тире.
|
||||
func (f *formatter) FormatString(text string) string {
|
||||
l := strings.TrimSpace(text)
|
||||
if strings.HasPrefix(l, "--") {
|
||||
l = strings.Replace(l, "--", "—", 1)
|
||||
|
||||
@@ -29,9 +29,10 @@ func Test_service_FormatText(t *testing.T) {
|
||||
want: " — Привет",
|
||||
},
|
||||
}
|
||||
formatter := NewFormatter()
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatText(tt.text)
|
||||
got := formatter.FormatText(tt.text)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatText() = %v, want %v", got, tt.want)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package roles
|
||||
|
||||
// Роли пользователей системы.
|
||||
const (
|
||||
Admin = "admin"
|
||||
Author = "author"
|
||||
Organizer = "organizer"
|
||||
User = "user"
|
||||
)
|
||||
|
||||
// AllRoles — список всех ролей.
|
||||
var AllRoles = []string{
|
||||
Admin,
|
||||
Author,
|
||||
Organizer,
|
||||
User,
|
||||
}
|
||||
|
||||
// IHasRole — контракт субъекта, у которого можно проверить наличие роли.
|
||||
type IHasRole interface {
|
||||
HasRole(role string) bool
|
||||
}
|
||||
@@ -1,23 +1,7 @@
|
||||
package roles
|
||||
|
||||
const (
|
||||
Admin = "admin"
|
||||
Author = "author"
|
||||
Organizer = "organizer"
|
||||
User = "user"
|
||||
)
|
||||
|
||||
var AllRoles = []string{
|
||||
Admin,
|
||||
Author,
|
||||
Organizer,
|
||||
User,
|
||||
}
|
||||
|
||||
type IHasRole interface {
|
||||
HasRole(role string) bool
|
||||
}
|
||||
|
||||
// HasRole возвращает true, если у субъекта есть роль; администратор
|
||||
// проходит проверку любой роли.
|
||||
func HasRole(claims IHasRole, role string) bool {
|
||||
if claims.HasRole(Admin) {
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package roles
|
||||
|
||||
import "slices"
|
||||
|
||||
// subject — реализация IHasRole: субъект с фиксированным набором ролей.
|
||||
type subject struct {
|
||||
roles []string
|
||||
}
|
||||
|
||||
// NewSubject создаёт субъект с заданными ролями (например, для тестов
|
||||
// авторизации или in-memory проверок прав).
|
||||
func NewSubject(roles ...string) IHasRole {
|
||||
return &subject{roles: roles}
|
||||
}
|
||||
|
||||
// HasRole возвращает true, если роль есть в наборе ролей субъекта.
|
||||
func (s *subject) HasRole(role string) bool {
|
||||
return slices.Contains(s.roles, role)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package roles
|
||||
|
||||
import "testing"
|
||||
|
||||
func Test_subject_HasRole(t *testing.T) {
|
||||
subject := NewSubject(Author, Organizer)
|
||||
|
||||
if !subject.HasRole(Author) {
|
||||
t.Errorf("HasRole(%q) = false, want true", Author)
|
||||
}
|
||||
if !subject.HasRole(Organizer) {
|
||||
t.Errorf("HasRole(%q) = false, want true", Organizer)
|
||||
}
|
||||
if subject.HasRole(Admin) {
|
||||
t.Errorf("HasRole(%q) = true, want false", Admin)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_subject_HasRoleEmpty(t *testing.T) {
|
||||
subject := NewSubject()
|
||||
|
||||
if subject.HasRole(User) {
|
||||
t.Errorf("HasRole(%q) = true, want false для субъекта без ролей", User)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// Пакет scenario_archive — сборка и разбор ZIP-архива сценария.
|
||||
// Архив: scenario.json (описание и история) + images/<имя> (файлы изображений).
|
||||
// Все пути внутри архива относительные.
|
||||
package scenario_archive
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
|
||||
// Лимиты на входящие архивы (защита от zip-bomb). Суммарный объём считается
|
||||
// по фактически распакованным байтам — размеры в заголовках ZIP подделываются.
|
||||
var (
|
||||
maxArchiveSize = 32 << 20 // 32 МБ — сырой размер архива
|
||||
maxUnpackedSize = 128 << 20 // 128 МБ — суммарный объём распакованного
|
||||
maxEntries = 500 // число записей
|
||||
maxEntrySize = 32 << 20 // 32 МБ — размер одной записи
|
||||
maxPackSize = 256 << 20 // 256 МБ — суммарный объём изображений при сборке
|
||||
)
|
||||
|
||||
type scenarioArchive struct{}
|
||||
|
||||
// NewScenarioArchive создаёт реализацию IScenarioArchive.
|
||||
func NewScenarioArchive() IScenarioArchive {
|
||||
return &scenarioArchive{}
|
||||
}
|
||||
|
||||
// MaxArchiveSize — максимальный размер входящего архива (для проверок
|
||||
// в хендлере и на HTTP-слое).
|
||||
func MaxArchiveSize() int {
|
||||
return maxArchiveSize
|
||||
}
|
||||
|
||||
// ImagePath возвращает путь изображения внутри архива (images/<имя>) и true
|
||||
// для ссылок нашего хранилища: относительное имя или URL с доменом (legacy).
|
||||
// Внешние URL и пустые ссылки — ("", false): в архив не кладутся.
|
||||
func ImagePath(ref, domain string) (string, bool) {
|
||||
ref = strings.TrimPrefix(ref, domain)
|
||||
if ref == "" {
|
||||
return "", false
|
||||
}
|
||||
low := strings.ToLower(ref)
|
||||
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") {
|
||||
return "", false
|
||||
}
|
||||
return "images/" + path.Base(ref), true
|
||||
}
|
||||
|
||||
// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
|
||||
// переписываются в images/<имя>, файлы запрашиваются через getFile; внешние
|
||||
// URL остаются без изменений. Архив детерминирован (порядок записей и метки).
|
||||
func (a *scenarioArchive) Pack(
|
||||
ctx context.Context,
|
||||
scenario *repos.Scenario,
|
||||
domain string,
|
||||
getFile func(ctx context.Context, name string) ([]byte, error),
|
||||
) ([]byte, error) {
|
||||
if scenario == nil {
|
||||
return nil, errors.New("сценарий не задан")
|
||||
}
|
||||
|
||||
story := &storytelling.Story{}
|
||||
if scenario.Scenario != "" {
|
||||
if err := json.Unmarshal([]byte(scenario.Scenario), story); err != nil {
|
||||
return nil, fmt.Errorf("не удалось разобрать историю сценария: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Имя в хранилище → путь в архиве; конфликт базовых имён — ошибка.
|
||||
archivePathOf := map[string]string{}
|
||||
addRef := func(ref string) error {
|
||||
if ref == "" {
|
||||
return nil
|
||||
}
|
||||
archivePath, ok := ImagePath(ref, domain)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if _, exists := archivePathOf[ref]; exists {
|
||||
return nil
|
||||
}
|
||||
for storageName, existingPath := range archivePathOf {
|
||||
if existingPath == archivePath {
|
||||
return fmt.Errorf("конфликт имён изображений в архиве: %q и %q", storageName, ref)
|
||||
}
|
||||
}
|
||||
archivePathOf[ref] = archivePath
|
||||
return nil
|
||||
}
|
||||
if scenario.Image != nil {
|
||||
if err := addRef(*scenario.Image); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if story.Introduction != nil {
|
||||
if err := addRef(story.Introduction.Audio); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, place := range story.Places {
|
||||
if err := addRef(place.Image); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, application := range place.Applications {
|
||||
if err := addRef(application.Image); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем файлы из хранилища (имя = ссылка без домена), с суммарным лимитом.
|
||||
images := make(map[string][]byte, len(archivePathOf))
|
||||
var totalPacked int64
|
||||
for ref, archivePath := range archivePathOf {
|
||||
storageName := strings.TrimPrefix(ref, domain)
|
||||
data, err := getFile(ctx, storageName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось получить изображение %q: %w", storageName, err)
|
||||
}
|
||||
totalPacked += int64(len(data))
|
||||
if totalPacked > int64(maxPackSize) {
|
||||
return nil, fmt.Errorf("суммарный объём изображений больше %d байт", maxPackSize)
|
||||
}
|
||||
images[archivePath] = data
|
||||
}
|
||||
|
||||
for _, place := range story.Places {
|
||||
place.Image = rewriteRef(place.Image, archivePathOf)
|
||||
for _, application := range place.Applications {
|
||||
application.Image = rewriteRef(application.Image, archivePathOf)
|
||||
}
|
||||
}
|
||||
if story.Introduction != nil {
|
||||
story.Introduction.Audio = rewriteRef(story.Introduction.Audio, archivePathOf)
|
||||
}
|
||||
|
||||
doc := ScenarioJSON{
|
||||
Version: Version,
|
||||
Name: scenario.Name,
|
||||
Description: strPtrValue(scenario.Description),
|
||||
Story: story,
|
||||
}
|
||||
if scenario.Image != nil {
|
||||
doc.Image = rewriteRef(*scenario.Image, archivePathOf)
|
||||
}
|
||||
docJSON, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось собрать %s: %w", FileName, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
if err := writeZipEntry(zw, FileName, docJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(images))
|
||||
for name := range images {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
if err := writeZipEntry(zw, name, images[name]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("не удалось закрыть архив: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeZipEntry(zw *zip.Writer, name string, data []byte) error {
|
||||
header := &zip.FileHeader{
|
||||
Name: name,
|
||||
Method: zip.Deflate,
|
||||
}
|
||||
// Нулевое время — детерминизм архива между запусками.
|
||||
header.Modified = time.Time{}
|
||||
w, err := zw.CreateHeader(header)
|
||||
if err != nil {
|
||||
return fmt.Errorf("не удалось создать запись %q: %w", name, err)
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return fmt.Errorf("не удалось записать %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rewriteRef(ref string, archivePathOf map[string]string) string {
|
||||
if ref == "" {
|
||||
return ""
|
||||
}
|
||||
if archivePath, ok := archivePathOf[ref]; ok {
|
||||
return archivePath
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
func strPtrValue(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
|
||||
func (a *scenarioArchive) Unpack(data []byte) (*Bundle, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("архив пуст")
|
||||
}
|
||||
if len(data) > maxArchiveSize {
|
||||
return nil, fmt.Errorf("архив больше максимального размера (%d байт)", maxArchiveSize)
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось открыть архив: %w", err)
|
||||
}
|
||||
if len(zr.File) > maxEntries {
|
||||
return nil, fmt.Errorf("в архиве больше %d записей", maxEntries)
|
||||
}
|
||||
|
||||
bundle := &Bundle{Files: make(map[string][]byte, len(zr.File))}
|
||||
var totalUnpacked int64
|
||||
var scenarioRaw []byte
|
||||
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
if err := validateEntryPath(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось открыть запись %q: %w", name, err)
|
||||
}
|
||||
content, err := readEntry(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("запись %q: %w", name, err)
|
||||
}
|
||||
|
||||
totalUnpacked += int64(len(content))
|
||||
if totalUnpacked > int64(maxUnpackedSize) {
|
||||
return nil, fmt.Errorf("суммарный размер распакованного архива больше %d байт", maxUnpackedSize)
|
||||
}
|
||||
|
||||
// Дубли записей отвергаем — «последний победил» портит данные.
|
||||
if name == FileName {
|
||||
if scenarioRaw != nil {
|
||||
return nil, fmt.Errorf("в архиве несколько записей %s", FileName)
|
||||
}
|
||||
scenarioRaw = content
|
||||
continue
|
||||
}
|
||||
if _, exists := bundle.Files[name]; exists {
|
||||
return nil, fmt.Errorf("в архиве несколько записей %q", name)
|
||||
}
|
||||
bundle.Files[name] = content
|
||||
}
|
||||
|
||||
if scenarioRaw == nil {
|
||||
return nil, fmt.Errorf("в архиве нет файла %s", FileName)
|
||||
}
|
||||
|
||||
scenario := &ScenarioJSON{}
|
||||
if err := json.Unmarshal(scenarioRaw, scenario); err != nil {
|
||||
return nil, fmt.Errorf("не удалось разобрать %s: %w", FileName, err)
|
||||
}
|
||||
if scenario.Version != Version {
|
||||
return nil, fmt.Errorf("неподдерживаемая версия архива: %d", scenario.Version)
|
||||
}
|
||||
if scenario.Name == "" {
|
||||
return nil, errors.New("в сценарии не указано название")
|
||||
}
|
||||
if scenario.Story == nil {
|
||||
return nil, errors.New("в сценарии не указана история")
|
||||
}
|
||||
|
||||
bundle.Scenario = scenario
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
// readEntry читает запись с лимитом по фактическим байтам (заголовки ZIP
|
||||
// подделываются тривиально).
|
||||
func readEntry(r io.Reader) ([]byte, error) {
|
||||
lr := io.LimitReader(r, int64(maxEntrySize)+1)
|
||||
content, err := io.ReadAll(lr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось прочитать: %w", err)
|
||||
}
|
||||
if int64(len(content)) > int64(maxEntrySize) {
|
||||
return nil, fmt.Errorf("размер больше максимального (%d байт)", maxEntrySize)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// validateEntryPath отклоняет небезопасные пути записей.
|
||||
func validateEntryPath(name string) error {
|
||||
if name == "" {
|
||||
return errors.New("пустое имя записи в архиве")
|
||||
}
|
||||
if strings.Contains(name, "\\") {
|
||||
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
|
||||
}
|
||||
if strings.HasPrefix(name, "/") {
|
||||
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
|
||||
}
|
||||
for _, part := range strings.Split(name, "/") {
|
||||
if part == ".." {
|
||||
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
|
||||
}
|
||||
}
|
||||
if clean := path.Clean(name); clean != name {
|
||||
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
package scenario_archive
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
|
||||
const testDomain = "http://storage.test/api/files/"
|
||||
|
||||
// testArchive — экземпляр модуля для тестов.
|
||||
var testArchive = NewScenarioArchive()
|
||||
|
||||
// buildScenario собирает сценарий с историей и ссылками на изображения.
|
||||
func buildScenario() *repos.Scenario {
|
||||
description := "Детективная история"
|
||||
image := "cover.png"
|
||||
return &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Ночной клуб",
|
||||
Description: &description,
|
||||
Image: &image,
|
||||
Scenario: `{"places":[
|
||||
{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png","applications":[{"name":"Билет","image":"ticket.jpg"}]},
|
||||
{"code":"parking","name":"Парковка","text":"Пусто","image":"club.png"}
|
||||
]}`,
|
||||
}
|
||||
}
|
||||
|
||||
// memoryFiles — замена хранилища для Pack: имя → содержимое.
|
||||
type memoryFiles map[string][]byte
|
||||
|
||||
func (m memoryFiles) get(_ context.Context, name string) ([]byte, error) {
|
||||
data, ok := m[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file not found: %s", name)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func newMemoryFiles() memoryFiles {
|
||||
return memoryFiles{
|
||||
"cover.png": []byte("cover-bytes"),
|
||||
"club.png": []byte("club-bytes"),
|
||||
"ticket.jpg": []byte("ticket-bytes"),
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackUnpackRoundTrip(t *testing.T) {
|
||||
files := newMemoryFiles()
|
||||
data, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
|
||||
bundle, err := testArchive.Unpack(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Unpack: %v", err)
|
||||
}
|
||||
|
||||
if bundle.Scenario.Version != Version {
|
||||
t.Errorf("Version = %d, want %d", bundle.Scenario.Version, Version)
|
||||
}
|
||||
if bundle.Scenario.Name != "Ночной клуб" {
|
||||
t.Errorf("Name = %q, want %q", bundle.Scenario.Name, "Ночной клуб")
|
||||
}
|
||||
if bundle.Scenario.Description != "Детективная история" {
|
||||
t.Errorf("Description = %q", bundle.Scenario.Description)
|
||||
}
|
||||
if bundle.Scenario.Image != "images/cover.png" {
|
||||
t.Errorf("Image = %q, want %q", bundle.Scenario.Image, "images/cover.png")
|
||||
}
|
||||
if bundle.Scenario.Story == nil || len(bundle.Scenario.Story.Places) != 2 {
|
||||
t.Fatalf("Story.Places = %+v, want 2 places", bundle.Scenario.Story)
|
||||
}
|
||||
|
||||
club := bundle.Scenario.Story.Places[0]
|
||||
if club.Image != "images/club.png" {
|
||||
t.Errorf("place image = %q, want %q", club.Image, "images/club.png")
|
||||
}
|
||||
if len(club.Applications) != 1 || club.Applications[0].Image != "images/ticket.jpg" {
|
||||
t.Errorf("application image = %+v, want images/ticket.jpg", club.Applications)
|
||||
}
|
||||
|
||||
for _, want := range []string{"images/cover.png", "images/club.png", "images/ticket.jpg"} {
|
||||
if _, ok := bundle.Files[want]; !ok {
|
||||
t.Errorf("в архиве нет файла %q", want)
|
||||
}
|
||||
}
|
||||
// Дублирующаяся ссылка (club.png в двух точках) кладётся в архив один раз.
|
||||
if len(bundle.Files) != 3 {
|
||||
t.Errorf("Files = %v, want 3 files", bundle.Files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackUnpackIntroAudioRoundTrip(t *testing.T) {
|
||||
description := "desc"
|
||||
image := "cover.png"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "С введением",
|
||||
Description: &description,
|
||||
Image: &image,
|
||||
Scenario: `{"introduction":{"text":"Введение","audio":"intro.mp3"},"places":[
|
||||
{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png"}
|
||||
]}`,
|
||||
}
|
||||
files := memoryFiles{
|
||||
"cover.png": []byte("cover-bytes"),
|
||||
"club.png": []byte("club-bytes"),
|
||||
"intro.mp3": []byte("intro-audio-bytes"),
|
||||
}
|
||||
|
||||
data, err := testArchive.Pack(context.Background(), scenario, testDomain, files.get)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
|
||||
bundle, err := testArchive.Unpack(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Unpack: %v", err)
|
||||
}
|
||||
|
||||
if bundle.Scenario.Story.Introduction == nil {
|
||||
t.Fatal("Story.Introduction = nil, want введение")
|
||||
}
|
||||
if bundle.Scenario.Story.Introduction.Text != "Введение" {
|
||||
t.Errorf("Introduction.Text = %q, want %q", bundle.Scenario.Story.Introduction.Text, "Введение")
|
||||
}
|
||||
if bundle.Scenario.Story.Introduction.Audio != "images/intro.mp3" {
|
||||
t.Errorf("Introduction.Audio = %q, want %q", bundle.Scenario.Story.Introduction.Audio, "images/intro.mp3")
|
||||
}
|
||||
if _, ok := bundle.Files["images/intro.mp3"]; !ok {
|
||||
t.Errorf("в архиве нет аудиофайла %q", "images/intro.mp3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackExternalURLsKeptAndNotFetched(t *testing.T) {
|
||||
description := "desc"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Внешний",
|
||||
Description: &description,
|
||||
Image: ptr("https://example.com/cover.png"),
|
||||
Scenario: `{"places":[
|
||||
{"code":"p","name":"P","text":"t","image":"http://other.example/x.png"}
|
||||
]}`,
|
||||
}
|
||||
|
||||
files := newMemoryFiles()
|
||||
var fetched []string
|
||||
getFile := func(ctx context.Context, name string) ([]byte, error) {
|
||||
fetched = append(fetched, name)
|
||||
return files.get(ctx, name)
|
||||
}
|
||||
|
||||
data, err := testArchive.Pack(context.Background(), scenario, testDomain, getFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
if len(fetched) != 0 {
|
||||
t.Errorf("getFile вызван для внешних URL: %v", fetched)
|
||||
}
|
||||
|
||||
bundle, err := testArchive.Unpack(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Unpack: %v", err)
|
||||
}
|
||||
if bundle.Scenario.Image != "https://example.com/cover.png" {
|
||||
t.Errorf("Image = %q, внешний URL должен остаться без изменений", bundle.Scenario.Image)
|
||||
}
|
||||
if bundle.Scenario.Story.Places[0].Image != "http://other.example/x.png" {
|
||||
t.Errorf("place image = %q, внешний URL должен остаться без изменений", bundle.Scenario.Story.Places[0].Image)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackLegacyFullURLImage(t *testing.T) {
|
||||
// Legacy-данные: полный URL хранилища срезается до базового имени файла.
|
||||
description := "desc"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Legacy",
|
||||
Description: &description,
|
||||
Image: ptr(testDomain + "cover.png"),
|
||||
Scenario: `{"places":[]}`,
|
||||
}
|
||||
data, err := testArchive.Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
bundle, err := testArchive.Unpack(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Unpack: %v", err)
|
||||
}
|
||||
if bundle.Scenario.Image != "images/cover.png" {
|
||||
t.Errorf("Image = %q, want %q", bundle.Scenario.Image, "images/cover.png")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackMissingImage(t *testing.T) {
|
||||
files := newMemoryFiles()
|
||||
delete(files, "ticket.jpg")
|
||||
|
||||
_, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
if err == nil {
|
||||
t.Fatal("Pack должен вернуть ошибку при недоступном изображении")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ticket.jpg") {
|
||||
t.Errorf("ошибка должна содержать имя файла: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackDeterministic(t *testing.T) {
|
||||
files := newMemoryFiles()
|
||||
first, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
second, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
if !bytes.Equal(first, second) {
|
||||
t.Error("архив недетерминирован: повторный Pack дал другие байты")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackNilScenario(t *testing.T) {
|
||||
_, err := testArchive.Pack(context.Background(), nil, testDomain, nil)
|
||||
if err == nil {
|
||||
t.Fatal("Pack(nil) должен вернуть ошибку")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackImageNameCollision(t *testing.T) {
|
||||
description := "desc"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Конфликт",
|
||||
Description: &description,
|
||||
Scenario: `{"places":[
|
||||
{"code":"a","name":"A","text":"t","image":"a.png"},
|
||||
{"code":"b","name":"B","text":"t","image":"dir/a.png"}
|
||||
]}`,
|
||||
}
|
||||
_, err := testArchive.Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
|
||||
if err == nil {
|
||||
t.Fatal("Pack должен вернуть ошибку при конфликте имён изображений")
|
||||
}
|
||||
}
|
||||
|
||||
// writeZip собирает архив с заданными записями (для негативных тестов Unpack).
|
||||
func writeZip(t *testing.T, entries map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for name, content := range entries {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create(%q): %v", name, err)
|
||||
}
|
||||
if _, err := w.Write(content); err != nil {
|
||||
t.Fatalf("Write(%q): %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// validScenarioJSON — минимальный корректный scenario.json.
|
||||
func validScenarioJSON() []byte {
|
||||
b, _ := json.Marshal(ScenarioJSON{
|
||||
Version: Version,
|
||||
Name: "Тест",
|
||||
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
func TestUnpackRejectsBadPaths(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"../evil",
|
||||
"a/../../evil",
|
||||
"/abs",
|
||||
"dir\\evil",
|
||||
"a/../b",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
data := writeZip(t, map[string][]byte{
|
||||
FileName: validScenarioJSON(),
|
||||
"images/x.png": []byte("x"),
|
||||
name: []byte("evil"),
|
||||
})
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatalf("Unpack должен отклонить путь %q", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackEmpty(t *testing.T) {
|
||||
if _, err := testArchive.Unpack(nil); err == nil {
|
||||
t.Fatal("Unpack(nil) должен вернуть ошибку")
|
||||
}
|
||||
if _, err := testArchive.Unpack([]byte{}); err == nil {
|
||||
t.Fatal("testArchive.Unpack(пусто) должен вернуть ошибку")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackNotAZip(t *testing.T) {
|
||||
if _, err := testArchive.Unpack([]byte("this is not a zip")); err == nil {
|
||||
t.Fatal("Unpack должен отклонить не-zip данные")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackMissingScenarioJSON(t *testing.T) {
|
||||
data := writeZip(t, map[string][]byte{"images/x.png": []byte("x")})
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен требовать scenario.json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackInvalidScenarioJSON(t *testing.T) {
|
||||
data := writeZip(t, map[string][]byte{FileName: []byte("{not json")})
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить битый scenario.json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackBadVersion(t *testing.T) {
|
||||
b, _ := json.Marshal(ScenarioJSON{Version: 99, Name: "X", Story: &storytelling.Story{}})
|
||||
data := writeZip(t, map[string][]byte{FileName: b})
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить неизвестную версию")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackEmptyName(t *testing.T) {
|
||||
b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "", Story: &storytelling.Story{}})
|
||||
data := writeZip(t, map[string][]byte{FileName: b})
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен требовать название")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackNilStory(t *testing.T) {
|
||||
b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "X"})
|
||||
data := writeZip(t, map[string][]byte{FileName: b})
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен требовать story")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackEntrySizeLimit(t *testing.T) {
|
||||
prev := maxEntrySize
|
||||
maxEntrySize = 4
|
||||
defer func() { maxEntrySize = prev }()
|
||||
|
||||
data := writeZip(t, map[string][]byte{
|
||||
FileName: validScenarioJSON(),
|
||||
"images/big.png": bytes.Repeat([]byte("x"), 8),
|
||||
})
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить запись больше maxEntrySize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackTotalSizeLimit(t *testing.T) {
|
||||
prev := maxUnpackedSize
|
||||
maxUnpackedSize = 6
|
||||
defer func() { maxUnpackedSize = prev }()
|
||||
|
||||
data := writeZip(t, map[string][]byte{
|
||||
FileName: validScenarioJSON(),
|
||||
"images/a.png": []byte("aaa"),
|
||||
"images/b.png": []byte("bbb"),
|
||||
})
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить суммарный объём больше maxUnpackedSize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackArchiveSizeLimit(t *testing.T) {
|
||||
prev := maxArchiveSize
|
||||
maxArchiveSize = 4
|
||||
defer func() { maxArchiveSize = prev }()
|
||||
|
||||
data := writeZip(t, map[string][]byte{FileName: validScenarioJSON()})
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить архив больше maxArchiveSize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackEntriesLimit(t *testing.T) {
|
||||
prev := maxEntries
|
||||
maxEntries = 2
|
||||
defer func() { maxEntries = prev }()
|
||||
|
||||
data := writeZip(t, map[string][]byte{
|
||||
FileName: validScenarioJSON(),
|
||||
"images/a.png": []byte("a"),
|
||||
"images/b.png": []byte("b"),
|
||||
})
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить архив с числом записей больше maxEntries")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackRejectsDuplicateScenarioJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for i := 0; i < 2; i++ {
|
||||
w, err := zw.Create(FileName)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := w.Write(validScenarioJSON()); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
if _, err := testArchive.Unpack(buf.Bytes()); err == nil {
|
||||
t.Fatal("Unpack должен отклонить дубли scenario.json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackRejectsDuplicateFiles(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for i := 0; i < 3; i++ {
|
||||
name := FileName
|
||||
content := validScenarioJSON()
|
||||
switch i {
|
||||
case 1:
|
||||
name = "images/x.png"
|
||||
content = []byte("x")
|
||||
case 2:
|
||||
// Дубль записи images/x.png.
|
||||
name = "images/x.png"
|
||||
content = []byte("y")
|
||||
}
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := w.Write(content); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
if _, err := testArchive.Unpack(buf.Bytes()); err == nil {
|
||||
t.Fatal("Unpack должен отклонить дубли файлов")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackTotalSizeLimit(t *testing.T) {
|
||||
prev := maxPackSize
|
||||
maxPackSize = 8
|
||||
defer func() { maxPackSize = prev }()
|
||||
|
||||
description := "desc"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Большой",
|
||||
Description: &description,
|
||||
Scenario: `{"places":[
|
||||
{"code":"a","name":"A","text":"t","image":"a.png"},
|
||||
{"code":"b","name":"B","text":"t","image":"b.png"}
|
||||
]}`,
|
||||
}
|
||||
files := memoryFiles{
|
||||
"a.png": bytes.Repeat([]byte("x"), 6),
|
||||
"b.png": bytes.Repeat([]byte("y"), 6),
|
||||
}
|
||||
if _, err := testArchive.Pack(context.Background(), scenario, testDomain, files.get); err == nil {
|
||||
t.Fatal("Pack должен отклонить суммарный объём больше maxPackSize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImagePath(t *testing.T) {
|
||||
cases := []struct {
|
||||
ref string
|
||||
domain string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"cover.png", "", "images/cover.png", true},
|
||||
{"dir/cover.png", "", "images/cover.png", true},
|
||||
{testDomain + "cover.png", testDomain, "images/cover.png", true},
|
||||
{"", "", "", false},
|
||||
{"http://example.com/x.png", "", "", false},
|
||||
{"https://example.com/x.png", testDomain, "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := ImagePath(tc.ref, tc.domain)
|
||||
if got != tc.want || ok != tc.ok {
|
||||
t.Errorf("ImagePath(%q, %q) = (%q, %v), want (%q, %v)",
|
||||
tc.ref, tc.domain, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
@@ -0,0 +1,47 @@
|
||||
package scenario_archive
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
|
||||
// Имя файла с описанием сценария внутри архива.
|
||||
const FileName = "scenario.json"
|
||||
|
||||
// Текущая версия формата scenario.json.
|
||||
const Version = 1
|
||||
|
||||
// ScenarioJSON — описание сценария в архиве; Story — в формате колонки
|
||||
// scenarios.scenario в БД.
|
||||
type ScenarioJSON struct {
|
||||
Version int `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Story *storytelling.Story `json:"story"`
|
||||
}
|
||||
|
||||
// Bundle — результат разбора входящего архива.
|
||||
type Bundle struct {
|
||||
Scenario *ScenarioJSON
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
// IScenarioArchive — контракт модуля scenario_archive: сборка и разбор
|
||||
// ZIP-архива сценария.
|
||||
type IScenarioArchive interface {
|
||||
// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
|
||||
// переписываются в images/<имя>, файлы запрашиваются через getFile;
|
||||
// внешние URL остаются без изменений.
|
||||
Pack(
|
||||
ctx context.Context,
|
||||
scenario *repos.Scenario,
|
||||
domain string,
|
||||
getFile func(ctx context.Context, name string) ([]byte, error),
|
||||
) ([]byte, error)
|
||||
|
||||
// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
|
||||
Unpack(data []byte) (*Bundle, error)
|
||||
}
|
||||
@@ -4,11 +4,42 @@ type IStory interface {
|
||||
GetStory(scenario *Story, codes []string) *Story
|
||||
}
|
||||
|
||||
// История - это набор точек
|
||||
// История - это введение, набор точек и вопросы дела
|
||||
type Story struct {
|
||||
|
||||
// Введение - то, что игрок получает в начале игры
|
||||
Introduction *Introduction `json:"introduction,omitempty"`
|
||||
|
||||
// Список точек
|
||||
Places []*Place `json:"places"`
|
||||
|
||||
// Вопросы дела - список вопросов, на которые команда должна ответить
|
||||
Questions []*Question `json:"questions,omitempty"`
|
||||
}
|
||||
|
||||
// Вопрос дела - вопрос, который автор сценария добавляет вместе с
|
||||
// правильным ответом; команда отвечает на вопросы в конце игры
|
||||
type Question struct {
|
||||
|
||||
// Код - идентификатор вопроса, используется при отправке ответов
|
||||
Code string `json:"code"`
|
||||
|
||||
// Текст вопроса
|
||||
Text string `json:"text"`
|
||||
|
||||
// Правильный ответ - виден только автору и организаторам,
|
||||
// команде не передаётся
|
||||
Answer string `json:"answer"`
|
||||
}
|
||||
|
||||
// Введение - текст и аудио, которые игрок получает в начале игры
|
||||
type Introduction struct {
|
||||
|
||||
// Текст введения
|
||||
Text string `json:"text"`
|
||||
|
||||
// Аудио введения
|
||||
Audio string `json:"audio,omitempty"`
|
||||
}
|
||||
|
||||
// Точка - что-то куда можно сходить
|
||||
@@ -51,6 +82,9 @@ type Application struct {
|
||||
|
||||
// Картинка
|
||||
Image string `json:"image"`
|
||||
|
||||
// Тип файла улики: image | pdf | audio.
|
||||
FileType string `json:"file_type,omitempty"`
|
||||
}
|
||||
|
||||
// Дверь - действие или диалог
|
||||
|
||||
@@ -21,6 +21,23 @@ func (s *story) GetStory(
|
||||
codesWithUseActions := map[string]struct{}{}
|
||||
codesHidden := map[string]struct{}{}
|
||||
givenApplications := map[string]struct{}{}
|
||||
// Введение передаётся игроку всегда — и до первого хода (начало игры),
|
||||
// и в дальнейшем (как заголовок истории).
|
||||
if scenario.Introduction != nil {
|
||||
story.Introduction = &Introduction{
|
||||
Text: s.cleaner.ClearText(scenario.Introduction.Text),
|
||||
Audio: scenario.Introduction.Audio,
|
||||
}
|
||||
}
|
||||
// Вопросы дела передаются команде всегда — с начала игры; правильные
|
||||
// ответы (Answer) команде не передаются: они нужны только автору и
|
||||
// организаторам при оценке ответов.
|
||||
for _, question := range scenario.Questions {
|
||||
story.Questions = append(story.Questions, &Question{
|
||||
Code: question.Code,
|
||||
Text: s.cleaner.ClearText(question.Text),
|
||||
})
|
||||
}
|
||||
for i, code := range codes {
|
||||
var prevPlace *Place
|
||||
if i > 0 {
|
||||
@@ -138,6 +155,7 @@ func (s *story) mapPlace(
|
||||
&Application{
|
||||
Name: s.cleaner.ClearText(application.Name),
|
||||
Image: application.Image,
|
||||
FileType: application.FileType,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,52 @@ func Test_story_GetStory(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Введение передаётся в начале игры (без действий)",
|
||||
scenario: &Story{
|
||||
Introduction: &Introduction{
|
||||
Text: "Текст введения.([Ы])",
|
||||
Audio: "intro.mp3",
|
||||
},
|
||||
},
|
||||
codes: []string{},
|
||||
want: &Story{
|
||||
Introduction: &Introduction{
|
||||
Text: "Текст введения.",
|
||||
Audio: "intro.mp3",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Введение передаётся вместе с точками",
|
||||
scenario: &Story{
|
||||
Introduction: &Introduction{
|
||||
Text: "Текст введения.",
|
||||
Audio: "intro.mp3",
|
||||
},
|
||||
Places: []*Place{
|
||||
{
|
||||
Code: "Ы",
|
||||
Name: "Название точки",
|
||||
Text: "Текст точки.",
|
||||
},
|
||||
},
|
||||
},
|
||||
codes: []string{"Ы"},
|
||||
want: &Story{
|
||||
Introduction: &Introduction{
|
||||
Text: "Текст введения.",
|
||||
Audio: "intro.mp3",
|
||||
},
|
||||
Places: []*Place{
|
||||
{
|
||||
Code: "Ы",
|
||||
Name: "Название точки",
|
||||
Text: "Текст точки.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Получение точки",
|
||||
scenario: &Story{
|
||||
@@ -185,6 +231,7 @@ func Test_story_GetStory(t *testing.T) {
|
||||
{
|
||||
Name: "Название улики",
|
||||
Image: "image.png",
|
||||
FileType: "image",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -201,6 +248,7 @@ func Test_story_GetStory(t *testing.T) {
|
||||
{
|
||||
Name: "Название улики",
|
||||
Image: "image.png",
|
||||
FileType: "image",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -759,3 +807,113 @@ func Test_story_GetStory(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_story_GetStoryQuestions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scenario *Story
|
||||
codes []string
|
||||
want *Story
|
||||
}{
|
||||
{
|
||||
name: "Вопросы передаются команде с начала игры (без действий)",
|
||||
scenario: &Story{
|
||||
Questions: []*Question{
|
||||
{
|
||||
Code: "q1",
|
||||
Text: "Кто убийца?",
|
||||
Answer: "Дворецкий",
|
||||
},
|
||||
},
|
||||
},
|
||||
codes: []string{},
|
||||
want: &Story{
|
||||
Questions: []*Question{
|
||||
{
|
||||
Code: "q1",
|
||||
Text: "Кто убийца?",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Правильные ответы не передаются команде",
|
||||
scenario: &Story{
|
||||
Questions: []*Question{
|
||||
{
|
||||
Code: "q1",
|
||||
Text: "Вопрос 1.([секрет])",
|
||||
Answer: "Ответ 1",
|
||||
},
|
||||
{
|
||||
Code: "q2",
|
||||
Text: "Вопрос 2",
|
||||
Answer: "Ответ 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
codes: []string{},
|
||||
want: &Story{
|
||||
Questions: []*Question{
|
||||
{
|
||||
Code: "q1",
|
||||
Text: "Вопрос 1.",
|
||||
},
|
||||
{
|
||||
Code: "q2",
|
||||
Text: "Вопрос 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Вопросы передаются вместе с точками и введением",
|
||||
scenario: &Story{
|
||||
Introduction: &Introduction{
|
||||
Text: "Введение.",
|
||||
Audio: "intro.mp3",
|
||||
},
|
||||
Places: []*Place{
|
||||
{
|
||||
Code: "Ы",
|
||||
Name: "Название точки",
|
||||
Text: "Текст точки.",
|
||||
},
|
||||
},
|
||||
Questions: []*Question{
|
||||
{
|
||||
Code: "q1",
|
||||
Text: "Вопрос 1",
|
||||
Answer: "Ответ 1",
|
||||
},
|
||||
},
|
||||
},
|
||||
codes: []string{"Ы"},
|
||||
want: &Story{
|
||||
Introduction: &Introduction{
|
||||
Text: "Введение.",
|
||||
Audio: "intro.mp3",
|
||||
},
|
||||
Places: []*Place{
|
||||
{
|
||||
Code: "Ы",
|
||||
Name: "Название точки",
|
||||
Text: "Текст точки.",
|
||||
},
|
||||
},
|
||||
Questions: []*Question{
|
||||
{
|
||||
Code: "q1",
|
||||
Text: "Вопрос 1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := NewStory(cleaner.NewCleaner()).GetStory(tt.scenario, tt.codes)
|
||||
assert.Equal(t, got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package string_tools
|
||||
|
||||
// IStringTools — утилиты работы со строками.
|
||||
type IStringTools interface {
|
||||
// Transliterate выполняет транслитерацию русского текста в латиницу.
|
||||
Transliterate(text string) string
|
||||
}
|
||||
@@ -44,9 +44,16 @@ var (
|
||||
)
|
||||
)
|
||||
|
||||
type stringTools struct{}
|
||||
|
||||
// NewStringTools создаёт реализацию IStringTools.
|
||||
func NewStringTools() IStringTools {
|
||||
return &stringTools{}
|
||||
}
|
||||
|
||||
// Transliterate выполняет транслитерацию русского текста в латиницу,
|
||||
// заменяет пробелы на _, удаляет все остальные символы, кроме цифр (знаки препинания и т.д.)
|
||||
func Transliterate(text string) string {
|
||||
func (s *stringTools) Transliterate(text string) string {
|
||||
|
||||
// Приводим к нижнему регистру и транслитерируем
|
||||
result := replacer.Replace(strings.ToLower(text))
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package repos
|
||||
|
||||
// TeamAnswer — ответ команды на вопрос дела (вопросы живут в сценарии,
|
||||
// ответы — в таблице team_answers).
|
||||
type TeamAnswer struct {
|
||||
TeamId int
|
||||
QuestionCode string
|
||||
Text string
|
||||
// Score — балл, выставленный организатором; nil — ещё не оценено.
|
||||
Score *int
|
||||
}
|
||||
@@ -4,4 +4,5 @@ type Application struct {
|
||||
Name string
|
||||
Image string
|
||||
TeamId int
|
||||
FileType string
|
||||
}
|
||||
|
||||
@@ -27,15 +27,17 @@ func (r *ApplicationsRepo) AddApplication(
|
||||
teamId int,
|
||||
name string,
|
||||
image string,
|
||||
fileType string,
|
||||
) (int, error) {
|
||||
id := 0
|
||||
err := r.pool.QueryRow(
|
||||
ctx,
|
||||
`INSERT INTO applications (name, image, team_id)
|
||||
VALUES ($1, $2, $3)
|
||||
`INSERT INTO applications (name, image, file_type, team_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id`,
|
||||
name,
|
||||
image,
|
||||
fileType,
|
||||
teamId,
|
||||
).Scan(&id)
|
||||
|
||||
@@ -55,6 +57,7 @@ func (r *ApplicationsRepo) GetApplicationsByTeamIDsAndState(
|
||||
`SELECT
|
||||
name,
|
||||
image,
|
||||
file_type,
|
||||
team_id
|
||||
FROM applications
|
||||
WHERE team_id = ANY($1) and status = $2`,
|
||||
@@ -72,6 +75,7 @@ func (r *ApplicationsRepo) GetApplicationsByTeamIDsAndState(
|
||||
err := rows.Scan(
|
||||
&application.Name,
|
||||
&application.Image,
|
||||
&application.FileType,
|
||||
&application.TeamId,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package team_answers_repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"evening_detective_server/internal/repos"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type TeamAnswersRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewTeamAnswersRepo(pool *pgxpool.Pool) *TeamAnswersRepo {
|
||||
return &TeamAnswersRepo{
|
||||
pool: pool,
|
||||
}
|
||||
}
|
||||
|
||||
// UpsertAnswers сохраняет ответы команды: существующие перезаписываются,
|
||||
// отсутствующие — вставляются (UNIQUE (team_id, question_code)).
|
||||
func (r *TeamAnswersRepo) UpsertAnswers(
|
||||
ctx context.Context,
|
||||
teamId int,
|
||||
answers []repos.TeamAnswer,
|
||||
) error {
|
||||
for _, answer := range answers {
|
||||
_, err := r.pool.Exec(
|
||||
ctx,
|
||||
`INSERT INTO team_answers (team_id, question_code, answer)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (team_id, question_code)
|
||||
DO UPDATE SET
|
||||
answer = EXCLUDED.answer,
|
||||
updated_at = NOW()`,
|
||||
teamId,
|
||||
answer.QuestionCode,
|
||||
answer.Text,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TeamAnswersRepo) GetAnswersByTeamID(
|
||||
ctx context.Context,
|
||||
teamId int,
|
||||
) ([]*repos.TeamAnswer, error) {
|
||||
rows, err := r.pool.Query(
|
||||
ctx,
|
||||
`SELECT
|
||||
team_id,
|
||||
question_code,
|
||||
answer,
|
||||
score
|
||||
FROM team_answers
|
||||
WHERE team_id = $1
|
||||
ORDER BY question_code ASC`,
|
||||
teamId,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var answers []*repos.TeamAnswer
|
||||
for rows.Next() {
|
||||
answer := &repos.TeamAnswer{}
|
||||
err := rows.Scan(
|
||||
&answer.TeamId,
|
||||
&answer.QuestionCode,
|
||||
&answer.Text,
|
||||
&answer.Score,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answers = append(answers, answer)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return answers, nil
|
||||
}
|
||||
|
||||
// GetAnswersByGameID возвращает ответы всех команд игры (вместе с баллами).
|
||||
func (r *TeamAnswersRepo) GetAnswersByGameID(
|
||||
ctx context.Context,
|
||||
gameId int,
|
||||
) ([]*repos.TeamAnswer, error) {
|
||||
rows, err := r.pool.Query(
|
||||
ctx,
|
||||
`SELECT
|
||||
teams.id,
|
||||
team_answers.question_code,
|
||||
team_answers.answer,
|
||||
team_answers.score
|
||||
FROM team_answers
|
||||
JOIN teams ON teams.id = team_answers.team_id
|
||||
WHERE teams.game_id = $1
|
||||
ORDER BY teams.id ASC, team_answers.question_code ASC`,
|
||||
gameId,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var answers []*repos.TeamAnswer
|
||||
for rows.Next() {
|
||||
answer := &repos.TeamAnswer{}
|
||||
err := rows.Scan(
|
||||
&answer.TeamId,
|
||||
&answer.QuestionCode,
|
||||
&answer.Text,
|
||||
&answer.Score,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answers = append(answers, answer)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return answers, nil
|
||||
}
|
||||
|
||||
// SetAnswerScore выставляет балл за ответ; score == nil сбрасывает балл.
|
||||
// Балл можно выставить и за неотвеченный вопрос (команда может не дать
|
||||
// ответ): если строки (team_id, question_code) ещё нет, она создаётся с
|
||||
// пустым ответом и выставленным баллом.
|
||||
func (r *TeamAnswersRepo) SetAnswerScore(
|
||||
ctx context.Context,
|
||||
teamId int,
|
||||
questionCode string,
|
||||
score *int,
|
||||
) error {
|
||||
_, err := r.pool.Exec(
|
||||
ctx,
|
||||
`INSERT INTO team_answers (team_id, question_code, answer, score)
|
||||
VALUES ($1, $2, '', $3)
|
||||
ON CONFLICT (team_id, question_code)
|
||||
DO UPDATE SET
|
||||
score = EXCLUDED.score,
|
||||
updated_at = NOW()`,
|
||||
teamId,
|
||||
questionCode,
|
||||
score,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -2,31 +2,137 @@ package file_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/string_tools"
|
||||
)
|
||||
|
||||
// Ошибки валидации загружаемого файла. Маппятся в gRPC-статусы в app-слое:
|
||||
// ErrFileTooLarge → ResourceExhausted, остальные → InvalidArgument.
|
||||
var (
|
||||
ErrEmptyName = errors.New("пустое имя файла")
|
||||
ErrEmptyData = errors.New("пустые данные файла")
|
||||
ErrInvalidExtension = errors.New("недопустимое расширение файла (разрешены pdf, jpg, jpeg, png, gif, webp, mp3, ogg, wav)")
|
||||
ErrInvalidContentType = errors.New("содержимое файла не соответствует разрешённым типам (pdf, изображения или аудио)")
|
||||
ErrFileTooLarge = errors.New("файл слишком большой")
|
||||
)
|
||||
|
||||
// allowedExtensions — расширения, разрешённые для загрузки: PDF, изображения
|
||||
// и аудио (аудио-введение сценария загружается тем же эндпоинтом).
|
||||
var allowedExtensions = map[string]struct{}{
|
||||
".pdf": {},
|
||||
".jpg": {},
|
||||
".jpeg": {},
|
||||
".png": {},
|
||||
".gif": {},
|
||||
".webp": {},
|
||||
".mp3": {},
|
||||
".ogg": {},
|
||||
".wav": {},
|
||||
}
|
||||
|
||||
type FileService struct {
|
||||
fileStorage file_storage.IFileStorage
|
||||
stringTools string_tools.IStringTools
|
||||
maxFileSize int
|
||||
}
|
||||
|
||||
func NewFileService(
|
||||
fileStorage file_storage.IFileStorage,
|
||||
stringTools string_tools.IStringTools,
|
||||
maxFileSize int,
|
||||
) *FileService {
|
||||
return &FileService{
|
||||
fileStorage: fileStorage,
|
||||
stringTools: stringTools,
|
||||
maxFileSize: maxFileSize,
|
||||
}
|
||||
}
|
||||
|
||||
// UploadFile сохраняет файл в хранилище под уникальным именем и возвращает
|
||||
// сохранённое имя и категорию файла (image|pdf|audio).
|
||||
//
|
||||
// Порядок проверок: имя/данные → расширение → base → размер → содержимое.
|
||||
// Проверка размера — defense-in-depth: фактический лимит запроса
|
||||
// устанавливается на HTTP-слое (MaxBytesReader) и gRPC-лимитами.
|
||||
func (s *FileService) UploadFile(
|
||||
ctx context.Context,
|
||||
file *file_storage.File,
|
||||
) (string, error) {
|
||||
file.Name = string_tools.Transliterate(file.Name)
|
||||
if err := s.fileStorage.Put(ctx, file); err != nil {
|
||||
return "", err
|
||||
filename string,
|
||||
data []byte,
|
||||
) (string, string, error) {
|
||||
if filename == "" {
|
||||
return "", "", ErrEmptyName
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return "", "", ErrEmptyData
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
if _, ok := allowedExtensions[ext]; !ok {
|
||||
return "", "", ErrInvalidExtension
|
||||
}
|
||||
|
||||
base := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||
if base == "" {
|
||||
return "", "", ErrEmptyName
|
||||
}
|
||||
|
||||
// Транслитерация может обнулить base (например, «!!!.pdf»): хранимое
|
||||
// имя без base недопустимо — проверяем до размера и содержимого.
|
||||
transliterated := s.stringTools.Transliterate(base)
|
||||
if transliterated == "" {
|
||||
return "", "", ErrEmptyName
|
||||
}
|
||||
|
||||
if len(data) > s.maxFileSize {
|
||||
return "", "", fmt.Errorf("%w (лимит %d МБ)", ErrFileTooLarge, s.maxFileSize>>20)
|
||||
}
|
||||
|
||||
// Категория по содержимому (сырой sniff): text/* и octet-stream не
|
||||
// проходят вообще, расширение должно соответствовать категории.
|
||||
sniffed := http.DetectContentType(data)
|
||||
fileType := contentTypeCategory(sniffed)
|
||||
if fileType == "" || fileType != file_storage.FileType(filename) {
|
||||
return "", "", ErrInvalidContentType
|
||||
}
|
||||
|
||||
randomName, err := file_storage.NewName()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("не удалось сгенерировать имя файла: %w", err)
|
||||
}
|
||||
storedName := transliterated + "_" + randomName + ext
|
||||
|
||||
if err := s.fileStorage.Put(ctx, &file_storage.File{
|
||||
Name: storedName,
|
||||
Data: data,
|
||||
Mime: sniffed,
|
||||
}); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return storedName, fileType, nil
|
||||
}
|
||||
|
||||
// contentTypeCategory возвращает категорию по sniffed-типу:
|
||||
// application/pdf → pdf; image/* → image; audio/* и application/ogg → audio;
|
||||
// всё остальное (text/*, application/octet-stream и пр.) → "".
|
||||
func contentTypeCategory(sniffed string) string {
|
||||
switch {
|
||||
case sniffed == "application/pdf":
|
||||
return "pdf"
|
||||
case strings.HasPrefix(sniffed, "image/"):
|
||||
return "image"
|
||||
case strings.HasPrefix(sniffed, "audio/"):
|
||||
return "audio"
|
||||
case sniffed == "application/ogg":
|
||||
return "audio"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
return file.Name, nil
|
||||
}
|
||||
|
||||
func (s *FileService) DownloadFile(
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package file_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/string_tools"
|
||||
)
|
||||
|
||||
// fakeStorage — in-memory реализация IFileStorage для тестов.
|
||||
type fakeStorage struct {
|
||||
files map[string]*file_storage.File
|
||||
}
|
||||
|
||||
func newFakeStorage() *fakeStorage {
|
||||
return &fakeStorage{files: map[string]*file_storage.File{}}
|
||||
}
|
||||
|
||||
func (s *fakeStorage) Put(_ context.Context, f *file_storage.File) error {
|
||||
cp := *f
|
||||
cp.Data = append([]byte(nil), f.Data...)
|
||||
s.files[f.Name] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStorage) Get(_ context.Context, name string) (*file_storage.File, error) {
|
||||
f, ok := s.files[name]
|
||||
if !ok {
|
||||
return nil, file_storage.ErrFileNotFound
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *fakeStorage) Delete(_ context.Context, name string) error {
|
||||
delete(s.files, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStorage) MimeType(filename string) string {
|
||||
return file_storage.FileType(filename)
|
||||
}
|
||||
|
||||
func newService(maxFileSize int) *FileService {
|
||||
return NewFileService(newFakeStorage(), string_tools.NewStringTools(), maxFileSize)
|
||||
}
|
||||
|
||||
func mustUpload(t *testing.T, svc *FileService, filename string, data []byte) (string, string) {
|
||||
t.Helper()
|
||||
name, fileType, err := svc.UploadFile(context.Background(), filename, data)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadFile(%q): %v", filename, err)
|
||||
}
|
||||
return name, fileType
|
||||
}
|
||||
|
||||
func TestUploadFileValid(t *testing.T) {
|
||||
svc := newService(1 << 20)
|
||||
|
||||
cases := []struct {
|
||||
filename string
|
||||
data []byte
|
||||
wantType string
|
||||
wantExt string
|
||||
wantMime string
|
||||
}{
|
||||
{"улика.pdf", []byte("%PDF-1.7 content"), "pdf", ".pdf", "application/pdf"},
|
||||
{"photo.png", []byte("\x89PNG\r\n\x1a\ncontent"), "image", ".png", "image/png"},
|
||||
{"photo.PNG", []byte("\x89PNG\r\n\x1a\ncontent"), "image", ".png", "image/png"},
|
||||
{"photo.jpg", []byte("\xff\xd8\xff\xe0jpeg-content"), "image", ".jpg", "image/jpeg"},
|
||||
{"audio.mp3", []byte("ID3\x04\x00\x00\x00\x00\x00\x00audio"), "audio", ".mp3", "audio/mpeg"},
|
||||
{"audio.ogg", []byte("OggS\x00\x02audio"), "audio", ".ogg", "application/ogg"},
|
||||
{"audio.wav", []byte("RIFF\x24\x00\x00\x00WAVEfmt "), "audio", ".wav", "audio/wave"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
name, fileType := mustUpload(t, svc, c.filename, c.data)
|
||||
if fileType != c.wantType {
|
||||
t.Errorf("%s: fileType = %q, want %q", c.filename, fileType, c.wantType)
|
||||
}
|
||||
if !strings.HasSuffix(name, c.wantExt) {
|
||||
t.Errorf("%s: имя %q не оканчивается на %q", c.filename, name, c.wantExt)
|
||||
}
|
||||
if !strings.Contains(name, "_") {
|
||||
t.Errorf("%s: имя %q не содержит уникальный hex-суффикс", c.filename, name)
|
||||
}
|
||||
// Сохранённый MIME соответствует содержимому (sniffed-тип).
|
||||
stored, err := svc.fileStorage.Get(context.Background(), name)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%q): %v", name, err)
|
||||
}
|
||||
if stored.Mime != c.wantMime {
|
||||
t.Errorf("%s: MIME = %q, want %q", c.filename, stored.Mime, c.wantMime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFileUniqueNames(t *testing.T) {
|
||||
svc := newService(1 << 20)
|
||||
data := []byte("%PDF-1.7 same content")
|
||||
|
||||
first, _ := mustUpload(t, svc, "clue.pdf", data)
|
||||
second, _ := mustUpload(t, svc, "clue.pdf", data)
|
||||
|
||||
if first == second {
|
||||
t.Errorf("одинаковые имена %q — коллизия в бакете", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFileValidation(t *testing.T) {
|
||||
svc := newService(1 << 20)
|
||||
pdf := []byte("%PDF-1.7 content")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
filename string
|
||||
data []byte
|
||||
wantErr error
|
||||
}{
|
||||
{"пустое имя", "", pdf, ErrEmptyName},
|
||||
{"пустые данные", "clue.pdf", nil, ErrEmptyData},
|
||||
{"нет расширения", "clue", pdf, ErrInvalidExtension},
|
||||
{"недопустимое расширение", "clue.exe", []byte("MZ\x90\x00"), ErrInvalidExtension},
|
||||
{"пустой base (.pdf)", ".pdf", pdf, ErrEmptyName},
|
||||
{"base из не-латиницы (!!!.pdf)", "!!!.pdf", pdf, ErrEmptyName},
|
||||
{"html под видом png", "photo.png", []byte("<html>hi</html>"), ErrInvalidContentType},
|
||||
{"octet-stream", "clue.pdf", []byte{0x00, 0x01, 0x02, 0x03}, ErrInvalidContentType},
|
||||
{"mismatch pdf+png", "clue.pdf", []byte("\x89PNG\r\n\x1a\ncontent"), ErrInvalidContentType},
|
||||
}
|
||||
for _, c := range cases {
|
||||
_, _, err := svc.UploadFile(context.Background(), c.filename, c.data)
|
||||
if !errors.Is(err, c.wantErr) {
|
||||
t.Errorf("%s: err = %v, want %v", c.name, err, c.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFileTooLarge(t *testing.T) {
|
||||
svc := newService(1024)
|
||||
|
||||
// ровно лимит — проходит
|
||||
if _, _, err := svc.UploadFile(context.Background(), "clue.pdf", bytes1024()); err != nil {
|
||||
t.Errorf("ровно лимит: err = %v, want nil", err)
|
||||
}
|
||||
// больше лимита — ErrFileTooLarge
|
||||
_, _, err := svc.UploadFile(context.Background(), "clue.pdf", bytes1025())
|
||||
if !errors.Is(err, ErrFileTooLarge) {
|
||||
t.Errorf("больше лимита: err = %v, want ErrFileTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
func bytes1024() []byte {
|
||||
b := make([]byte, 1024)
|
||||
copy(b, "%PDF-1.7 ")
|
||||
for i := 8; i < len(b); i++ {
|
||||
b[i] = 'x'
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func bytes1025() []byte {
|
||||
return append(bytes1024(), 'y')
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package game_service
|
||||
|
||||
import "evening_detective_server/internal/modules/storytelling"
|
||||
|
||||
// Answer — ответ команды на вопрос дела (для публичного API команды).
|
||||
type Answer struct {
|
||||
QuestionCode string
|
||||
Text string
|
||||
}
|
||||
|
||||
// AnswerScore — ответ команды с баллом организатора (для панели оценки).
|
||||
type AnswerScore struct {
|
||||
QuestionCode string
|
||||
Text string
|
||||
// Score — балл; nil — ещё не оценено.
|
||||
Score *int
|
||||
}
|
||||
|
||||
// TeamAnswers — ответы одной команды игры.
|
||||
type TeamAnswers struct {
|
||||
Team *Team
|
||||
Answers []AnswerScore
|
||||
}
|
||||
|
||||
// GameAnswers — полная картина для оценки организатором: вопросы сценария
|
||||
// (с правильными ответами) и ответы всех команд с баллами.
|
||||
type GameAnswers struct {
|
||||
Questions []*storytelling.Question
|
||||
Teams []TeamAnswers
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package game_service
|
||||
|
||||
type Application struct {
|
||||
Name string
|
||||
Image string
|
||||
}
|
||||
@@ -1,21 +1,64 @@
|
||||
package game_service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
|
||||
func mapApplications(o []*repos.Application) []*storytelling.Application {
|
||||
// mapApplications — улики команды для ответа организатору (GetFullGame):
|
||||
// имена файлов префиксуются доменом идемпотентно, file_type деривируется
|
||||
// из расширения при пустом значении (легаси-строки таблицы).
|
||||
func mapApplications(o []*repos.Application, domain string) []*storytelling.Application {
|
||||
res := make([]*storytelling.Application, 0, len(o))
|
||||
for _, item := range o {
|
||||
res = append(res, mapApplication(item))
|
||||
res = append(res, mapApplication(item, domain))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func mapApplication(o *repos.Application) *storytelling.Application {
|
||||
return &storytelling.Application{
|
||||
func mapApplication(o *repos.Application, domain string) *storytelling.Application {
|
||||
image := prefixDomain(o.Image, domain)
|
||||
app := &storytelling.Application{
|
||||
Name: o.Name,
|
||||
Image: o.Image,
|
||||
Image: image,
|
||||
}
|
||||
app.FileType = o.FileType
|
||||
if app.FileType == "" {
|
||||
app.FileType = file_storage.FileType(image)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// newApplication — улика для сохранения команде (AddTeamAction): имена
|
||||
// приходят из истории уже с доменным префиксом (mapStory), в БД хранятся
|
||||
// относительные пути — префикс снимается идемпотентно; file_type
|
||||
// деривируется при пустом значении. Name не меняется (очистка — в
|
||||
// вызывающем коде, cleaner'ом).
|
||||
func newApplication(app *storytelling.Application, domain string) *repos.Application {
|
||||
image := strings.TrimPrefix(app.Image, domain)
|
||||
fileType := app.FileType
|
||||
if fileType == "" {
|
||||
fileType = file_storage.FileType(image)
|
||||
}
|
||||
return &repos.Application{
|
||||
Name: app.Name,
|
||||
Image: image,
|
||||
FileType: fileType,
|
||||
}
|
||||
}
|
||||
|
||||
// prefixDomain — идемпотентная префиксация ссылки доменом хранилища
|
||||
// (пустые ссылки и внешние http/https URL не трогаются).
|
||||
func prefixDomain(ref, domain string) string {
|
||||
if ref == "" || strings.HasPrefix(ref, domain) {
|
||||
return ref
|
||||
}
|
||||
low := strings.ToLower(ref)
|
||||
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") {
|
||||
return ref
|
||||
}
|
||||
return domain + ref
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package game_service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
|
||||
const testDomain = "http://storage.test/api/files/"
|
||||
|
||||
func TestNewApplication(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
app *storytelling.Application
|
||||
image string // ожидаемое хранимое имя (без домена)
|
||||
ftype string // ожидаемый file_type
|
||||
}{
|
||||
{
|
||||
name: "доменный префикс снимается, file_type сохраняется",
|
||||
app: &storytelling.Application{
|
||||
Name: "Улика",
|
||||
Image: testDomain + "clue.pdf",
|
||||
FileType: "pdf",
|
||||
},
|
||||
image: "clue.pdf",
|
||||
ftype: "pdf",
|
||||
},
|
||||
{
|
||||
name: "относительное имя не трогается",
|
||||
app: &storytelling.Application{
|
||||
Name: "Улика",
|
||||
Image: "clue.png",
|
||||
FileType: "image",
|
||||
},
|
||||
image: "clue.png",
|
||||
ftype: "image",
|
||||
},
|
||||
{
|
||||
name: "пустой file_type деривируется из расширения",
|
||||
app: &storytelling.Application{
|
||||
Name: "Улика",
|
||||
Image: testDomain + "clue.ogg",
|
||||
},
|
||||
image: "clue.ogg",
|
||||
ftype: "audio",
|
||||
},
|
||||
{
|
||||
name: "внешний URL снимается как префикс только при совпадении домена",
|
||||
app: &storytelling.Application{
|
||||
Name: "Улика",
|
||||
Image: "https://example.com/clue.pdf",
|
||||
},
|
||||
image: "https://example.com/clue.pdf",
|
||||
ftype: "pdf",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := newApplication(c.app, testDomain)
|
||||
if got.Name != c.app.Name {
|
||||
t.Errorf("%s: Name = %q, want %q", c.name, got.Name, c.app.Name)
|
||||
}
|
||||
if got.Image != c.image {
|
||||
t.Errorf("%s: Image = %q, want %q", c.name, got.Image, c.image)
|
||||
}
|
||||
if got.FileType != c.ftype {
|
||||
t.Errorf("%s: FileType = %q, want %q", c.name, got.FileType, c.ftype)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapApplications(t *testing.T) {
|
||||
o := []*repos.Application{
|
||||
{Name: "Улика", Image: "clue.pdf", FileType: ""},
|
||||
{Name: "Фото", Image: "https://external.example.com/pic.jpg", FileType: "image"},
|
||||
}
|
||||
|
||||
got := mapApplications(o, testDomain)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
// Относительное имя префиксуется доменом, file_type деривируется.
|
||||
if got[0].Image != testDomain+"clue.pdf" {
|
||||
t.Errorf("Image[0] = %q, want %q", got[0].Image, testDomain+"clue.pdf")
|
||||
}
|
||||
if got[0].FileType != "pdf" {
|
||||
t.Errorf("FileType[0] = %q, want pdf", got[0].FileType)
|
||||
}
|
||||
|
||||
// Внешний URL не префиксуется повторно.
|
||||
if got[1].Image != "https://external.example.com/pic.jpg" {
|
||||
t.Errorf("Image[1] = %q, want внешний URL без изменений", got[1].Image)
|
||||
}
|
||||
if got[1].FileType != "image" {
|
||||
t.Errorf("FileType[1] = %q, want image", got[1].FileType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefixDomain(t *testing.T) {
|
||||
cases := []struct {
|
||||
ref string
|
||||
domain string
|
||||
want string
|
||||
}{
|
||||
{"clue.pdf", testDomain, testDomain + "clue.pdf"},
|
||||
{testDomain + "clue.pdf", testDomain, testDomain + "clue.pdf"},
|
||||
{"https://example.com/clue.pdf", testDomain, "https://example.com/clue.pdf"},
|
||||
{"http://example.com/clue.pdf", testDomain, "http://example.com/clue.pdf"},
|
||||
{"", testDomain, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := prefixDomain(c.ref, c.domain); got != c.want {
|
||||
t.Errorf("prefixDomain(%q) = %q, want %q", c.ref, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,16 @@ package game_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"evening_detective_server/internal/modules/cleaner"
|
||||
"evening_detective_server/internal/modules/password_generator"
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
"evening_detective_server/internal/repos/actions_repo"
|
||||
"evening_detective_server/internal/repos/applications_repo"
|
||||
"evening_detective_server/internal/repos/games_repo"
|
||||
"evening_detective_server/internal/repos/scenarios_repo"
|
||||
"evening_detective_server/internal/repos/team_answers_repo"
|
||||
"evening_detective_server/internal/repos/teams_repo"
|
||||
"evening_detective_server/internal/services/scenarios_service"
|
||||
"time"
|
||||
@@ -24,6 +27,7 @@ type GameService struct {
|
||||
storyteller storytelling.IStory
|
||||
actionsRepo *actions_repo.ActionsRepo
|
||||
applicationsRepo *applications_repo.ApplicationsRepo
|
||||
answersRepo *team_answers_repo.TeamAnswersRepo
|
||||
cleaner cleaner.ICleaner
|
||||
}
|
||||
|
||||
@@ -37,6 +41,7 @@ func NewGameService(
|
||||
storyteller storytelling.IStory,
|
||||
actionsRepo *actions_repo.ActionsRepo,
|
||||
applicationsRepo *applications_repo.ApplicationsRepo,
|
||||
answersRepo *team_answers_repo.TeamAnswersRepo,
|
||||
cleaner cleaner.ICleaner,
|
||||
) *GameService {
|
||||
return &GameService{
|
||||
@@ -49,6 +54,7 @@ func NewGameService(
|
||||
storyteller: storyteller,
|
||||
actionsRepo: actionsRepo,
|
||||
applicationsRepo: applicationsRepo,
|
||||
answersRepo: answersRepo,
|
||||
cleaner: cleaner,
|
||||
}
|
||||
}
|
||||
@@ -114,7 +120,7 @@ func (s *GameService) GetFullGame(
|
||||
|
||||
for _, team := range res.Teams {
|
||||
team.ActionsCount = actionsCountByTeamIDs[team.ID]
|
||||
team.Applications = mapApplications(needApplicationsByTeamIDs[team.ID])
|
||||
team.Applications = mapApplications(needApplicationsByTeamIDs[team.ID], s.domain)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
@@ -263,8 +269,9 @@ func (s *GameService) AddTeamAction(ctx context.Context, teamId int, password st
|
||||
lastPlace := teamStory.Places[len(teamStory.Places)-1]
|
||||
|
||||
for _, application := range lastPlace.Applications {
|
||||
_, err := s.applicationsRepo.AddApplication(ctx, teamId, s.cleaner.ClearText(application.Name), application.Image)
|
||||
if err != nil {
|
||||
app := newApplication(application, s.domain)
|
||||
app.Name = s.cleaner.ClearText(app.Name)
|
||||
if _, err := s.applicationsRepo.AddApplication(ctx, teamId, app.Name, app.Image, app.FileType); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -305,3 +312,191 @@ func (s *GameService) DeleteLastTeamAction(ctx context.Context, teamId int) erro
|
||||
|
||||
return s.actionsRepo.DeleteLastActionByTeamId(ctx, teamId)
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrGameFinished — после завершения игры ответы команд больше нельзя
|
||||
// изменять: организатор оценивает их в панели игры.
|
||||
ErrGameFinished = errors.New("Игра завершена, ответы больше нельзя изменять")
|
||||
// ErrGameNotStarted — игра ещё не начата, отвечать на вопросы нельзя.
|
||||
ErrGameNotStarted = errors.New("Игра не начата, отвечать на вопросы нельзя")
|
||||
// ErrQuestionNotFound — вопрос с таким кодом отсутствует в сценарии игры.
|
||||
ErrQuestionNotFound = errors.New("Вопрос не найден")
|
||||
)
|
||||
|
||||
// getTeamAndGame проверяет команду по паролю и возвращает её вместе с игрой.
|
||||
func (s *GameService) getTeamAndGame(ctx context.Context, teamId int, password string) (*repos.Team, *repos.Game, error) {
|
||||
team, err := s.teamsRepo.GetTeamByID(ctx, teamId)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if team.Password != password {
|
||||
return nil, nil, teams_repo.ErrTeamNotFound
|
||||
}
|
||||
game, err := s.gamesRepo.GetGameByID(ctx, team.GameId)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return team, game, nil
|
||||
}
|
||||
|
||||
// getScenarioQuestionsByCode возвращает вопросы сценария игры с индексацией по коду.
|
||||
func (s *GameService) getScenarioQuestionsByCode(ctx context.Context, scenarioId int) ([]*storytelling.Question, map[string]*storytelling.Question, error) {
|
||||
scenario, err := s.scenarioService.GetFullScenarioByID(ctx, scenarioId)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
byCode := make(map[string]*storytelling.Question, len(scenario.Story.Questions))
|
||||
for _, question := range scenario.Story.Questions {
|
||||
byCode[question.Code] = question
|
||||
}
|
||||
return scenario.Story.Questions, byCode, nil
|
||||
}
|
||||
|
||||
// GetTeamAnswers возвращает ответы команды на вопросы дела (без баллов:
|
||||
// баллы видны только организаторам). Ответы на удалённые из сценария
|
||||
// вопросы не возвращаются.
|
||||
func (s *GameService) GetTeamAnswers(ctx context.Context, teamId int, password string) ([]Answer, error) {
|
||||
_, game, err := s.getTeamAndGame(ctx, teamId, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, byCode, err := s.getScenarioQuestionsByCode(ctx, game.ScenarioId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
teamAnswers, err := s.answersRepo.GetAnswersByTeamID(ctx, teamId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answers := make([]Answer, 0, len(teamAnswers))
|
||||
for _, teamAnswer := range teamAnswers {
|
||||
if _, ok := byCode[teamAnswer.QuestionCode]; !ok {
|
||||
continue
|
||||
}
|
||||
answers = append(answers, Answer{
|
||||
QuestionCode: teamAnswer.QuestionCode,
|
||||
Text: teamAnswer.Text,
|
||||
})
|
||||
}
|
||||
return answers, nil
|
||||
}
|
||||
|
||||
// SubmitTeamAnswers сохраняет ответы команды на вопросы дела (upsert).
|
||||
// Доступно только пока игра идёт (статусы run/pause): после завершения
|
||||
// ответы фиксируются для оценки организатором. Переданные коды должны
|
||||
// существовать в сценарии игры; пустая строка очищает ответ.
|
||||
func (s *GameService) SubmitTeamAnswers(ctx context.Context, teamId int, password string, answers []Answer) error {
|
||||
_, game, err := s.getTeamAndGame(ctx, teamId, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch game.Status {
|
||||
case "run", "pause":
|
||||
// игра идёт — можно отвечать
|
||||
default:
|
||||
if game.Status == "finish" {
|
||||
return ErrGameFinished
|
||||
}
|
||||
return ErrGameNotStarted
|
||||
}
|
||||
_, byCode, err := s.getScenarioQuestionsByCode(ctx, game.ScenarioId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]repos.TeamAnswer, 0, len(answers))
|
||||
for _, answer := range answers {
|
||||
if _, ok := byCode[answer.QuestionCode]; !ok {
|
||||
return ErrQuestionNotFound
|
||||
}
|
||||
rows = append(rows, repos.TeamAnswer{
|
||||
QuestionCode: answer.QuestionCode,
|
||||
Text: answer.Text,
|
||||
})
|
||||
}
|
||||
return s.answersRepo.UpsertAnswers(ctx, teamId, rows)
|
||||
}
|
||||
|
||||
// GetGameAnswers возвращает полную картину для оценки организатором:
|
||||
// вопросы сценария с правильными ответами и ответы всех команд с баллами.
|
||||
// Команда может не дать ответ на вопрос — такой вопрос попадает в список
|
||||
// ответов команды с пустым текстом и без балла (организатор видит его и
|
||||
// может выставить балл, в т.ч. штрафной). Ответы на удалённые из сценария
|
||||
// вопросы не возвращаются (как в GetTeamAnswers).
|
||||
func (s *GameService) GetGameAnswers(ctx context.Context, gameId int) (*GameAnswers, error) {
|
||||
game, err := s.gamesRepo.GetGameByID(ctx, gameId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
questions, byCode, err := s.getScenarioQuestionsByCode(ctx, game.ScenarioId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
teams, err := s.teamsRepo.GetTeamsByGameID(ctx, gameId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.answersRepo.GetAnswersByGameID(ctx, gameId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answerByTeamAndCode := make(map[int]map[string]AnswerScore, len(teams))
|
||||
for _, row := range rows {
|
||||
if _, ok := byCode[row.QuestionCode]; !ok {
|
||||
continue
|
||||
}
|
||||
score := row.Score
|
||||
if answerByTeamAndCode[row.TeamId] == nil {
|
||||
answerByTeamAndCode[row.TeamId] = make(map[string]AnswerScore, len(questions))
|
||||
}
|
||||
answerByTeamAndCode[row.TeamId][row.QuestionCode] = AnswerScore{
|
||||
QuestionCode: row.QuestionCode,
|
||||
Text: row.Text,
|
||||
Score: score,
|
||||
}
|
||||
}
|
||||
teamAnswers := make([]TeamAnswers, 0, len(teams))
|
||||
for _, team := range teams {
|
||||
byCode := answerByTeamAndCode[team.ID]
|
||||
// Все вопросы сценария в порядке сценария; неотвеченные — с пустым
|
||||
// текстом и без балла, чтобы организатор видел пропуски.
|
||||
answers := make([]AnswerScore, 0, len(questions))
|
||||
for _, question := range questions {
|
||||
if answer, ok := byCode[question.Code]; ok {
|
||||
answers = append(answers, answer)
|
||||
continue
|
||||
}
|
||||
answers = append(answers, AnswerScore{QuestionCode: question.Code})
|
||||
}
|
||||
teamAnswers = append(teamAnswers, TeamAnswers{
|
||||
Team: mapTeam(team),
|
||||
Answers: answers,
|
||||
})
|
||||
}
|
||||
return &GameAnswers{
|
||||
Questions: questions,
|
||||
Teams: teamAnswers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetTeamAnswerScore выставляет балл команде за ответ на вопрос дела;
|
||||
// score == nil сбрасывает балл. Балл может быть любым целым числом
|
||||
// (в т.ч. отрицательным — штрафные баллы). Вопрос должен существовать
|
||||
// в сценарии игры.
|
||||
func (s *GameService) SetTeamAnswerScore(ctx context.Context, teamId int, questionCode string, score *int) error {
|
||||
team, err := s.teamsRepo.GetTeamByID(ctx, teamId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
game, err := s.gamesRepo.GetGameByID(ctx, team.GameId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, byCode, err := s.getScenarioQuestionsByCode(ctx, game.ScenarioId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := byCode[questionCode]; !ok {
|
||||
return ErrQuestionNotFound
|
||||
}
|
||||
return s.answersRepo.SetAnswerScore(ctx, teamId, questionCode, score)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package mcp_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/services/game_service"
|
||||
|
||||
"github.com/mark3labs/mcp-go/client"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
// Интеграционный тест HTTP-«клея»: MCP-сервер монтируется как
|
||||
// streamable HTTP (эндпоинт /api/mcp) и вызывается через HTTP-клиент mcp-go.
|
||||
// Проверяются initialize, tools/list (точное множество) и tools/call
|
||||
// connect — как это будет делать внешний MCP-клиент.
|
||||
func TestStreamableHTTPEndToEnd(t *testing.T) {
|
||||
svc := NewMCPService(&fakeGamePlayer{story: defaultStory(), game: defaultGame()})
|
||||
|
||||
mcpHTTP := server.NewStreamableHTTPServer(svc.Server())
|
||||
srv := httptest.NewServer(mcpHTTP)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
// ВАЖНО: клиент POST-ит строго на переданный baseURL без добавления
|
||||
// пути — эндпоинт /api/mcp передаём явно.
|
||||
mcpClient, err := client.NewStreamableHttpClient(srv.URL + "/api/mcp")
|
||||
if err != nil {
|
||||
t.Fatalf("NewStreamableHttpClient() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = mcpClient.Close() })
|
||||
|
||||
initRequest := mcp.InitializeRequest{}
|
||||
initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
|
||||
initRequest.Params.ClientInfo = mcp.Implementation{Name: "http_it", Version: "1.0.0"}
|
||||
if _, err := mcpClient.Initialize(ctx, initRequest); err != nil {
|
||||
t.Fatalf("Initialize() error = %v", err)
|
||||
}
|
||||
|
||||
toolsResult, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListTools() error = %v", err)
|
||||
}
|
||||
names := make([]string, 0, len(toolsResult.Tools))
|
||||
for _, tool := range toolsResult.Tools {
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
assertExactTools(t, names)
|
||||
|
||||
callRequest := mcp.CallToolRequest{}
|
||||
callRequest.Params.Name = "connect"
|
||||
callRequest.Params.Arguments = map[string]any{
|
||||
"url": "/team-story/10?password=team-pass-1",
|
||||
}
|
||||
result, err := mcpClient.CallTool(ctx, callRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("CallTool(connect) error = %v", err)
|
||||
}
|
||||
var text strings.Builder
|
||||
for _, content := range result.Content {
|
||||
if tc, ok := content.(mcp.TextContent); ok {
|
||||
text.WriteString(tc.Text)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(text.String(), "Подключено к команде 10") {
|
||||
t.Fatalf("CallTool(connect) = %q; want success message", text.String())
|
||||
}
|
||||
if !strings.Contains(text.String(), "entrance") {
|
||||
t.Fatalf("CallTool(connect) = %q; want story JSON", text.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка, что структуры моков сериализуемы (защита от изменения типов
|
||||
// storytelling/game_service без обновления моков).
|
||||
func TestMockTypesUsable(t *testing.T) {
|
||||
_ = defaultStory()
|
||||
_ = defaultGame()
|
||||
_ = (*game_service.Game)(nil)
|
||||
_ = (*storytelling.Story)(nil)
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// Package mcp_service собирает MCP-сервер (Model Context Protocol) для
|
||||
// игры «Вечерний детектив»: инструменты MCP вызывают игровые сервисы
|
||||
// напрямую (через интерфейс GamePlayer), без HTTP-прослойки. MCP-сервер
|
||||
// монтируется в основной процесс как HTTP-эндпоинт /mcp (streamable HTTP)
|
||||
// на REST-gateway.
|
||||
package mcp_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/services/game_service"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
// defaultCallTimeout — таймаут на выполнение одного инструмента:
|
||||
// защита от зависших запросов к БД (10s — как у прежнего HTTP-прокси).
|
||||
const defaultCallTimeout = 10 * time.Second
|
||||
|
||||
// GamePlayer — граница доступа к игровым сервисам, реализуемая
|
||||
// *game_service.GameService; интерфейс позволяет тестировать сервис
|
||||
// без БД.
|
||||
type GamePlayer interface {
|
||||
GetTeamActions(ctx context.Context, teamId int, password string) (*storytelling.Story, *game_service.Game, error)
|
||||
AddTeamAction(ctx context.Context, teamId int, password, actionCode string) error
|
||||
SubmitTeamAnswers(ctx context.Context, teamId int, password string, answers []game_service.Answer) error
|
||||
}
|
||||
|
||||
// MCPService собирает MCP-сервер с игровыми инструментами. Сервис не
|
||||
// хранит состояние между вызовами: каждый вызов получает id команды и
|
||||
// пароль явно — либо из ссылки на игру через инструмент connect, либо
|
||||
// напрямую.
|
||||
type MCPService struct {
|
||||
game GamePlayer
|
||||
}
|
||||
|
||||
// NewMCPService создаёт сервис поверх игровых сервисов.
|
||||
func NewMCPService(game GamePlayer) *MCPService {
|
||||
return &MCPService{game: game}
|
||||
}
|
||||
|
||||
// Server собирает и возвращает MCP-сервер с зарегистрированными
|
||||
// инструментами.
|
||||
func (s *MCPService) Server() *server.MCPServer {
|
||||
srv := server.NewMCPServer(
|
||||
"evening-detective-mcp",
|
||||
"0.1.0",
|
||||
server.WithInstructions("Инструменты для игры «Вечерний детектив»: подключитесь к игре по ссылке (connect) — организатор выдаёт ссылку вида /team-story/{id}?password=..., — затем смотрите историю команды (get_team_story), делайте ходы (make_move) по кодам точек сценария и отвечайте на вопросы дела (submit_answers)."),
|
||||
)
|
||||
|
||||
srv.AddTool(
|
||||
mcp.NewTool(
|
||||
"connect",
|
||||
mcp.WithDescription("Подключиться к игре по ссылке вида /team-story/{id}?password=... (принимаются относительные и абсолютные ссылки, в т.ч. с любым хостом — запросы по ним не выполняются, из ссылки берутся только id команды и пароль). Извлекает id команды и пароль и сразу возвращает текущую историю команды вместе с этими параметрами для последующих вызовов get_team_story/make_move."),
|
||||
mcp.WithString("url", mcp.Required(), mcp.Description("Ссылка на игру, например /team-story/10?password=team-pass-1")),
|
||||
),
|
||||
s.handleConnect,
|
||||
)
|
||||
|
||||
srv.AddTool(
|
||||
mcp.NewTool(
|
||||
"get_team_story",
|
||||
mcp.WithDescription("Получить текущую историю команды: введение сценария (текст и аудио), видимые точки сценария (текст, двери, улики) и информацию об игре. Команда идентифицируется паролем."),
|
||||
mcp.WithNumber("team_id", mcp.Required(), mcp.Description("ID команды")),
|
||||
mcp.WithString("password", mcp.Required(), mcp.Description("Пароль команды")),
|
||||
),
|
||||
s.handleGetTeamStory,
|
||||
)
|
||||
|
||||
srv.AddTool(
|
||||
mcp.NewTool(
|
||||
"make_move",
|
||||
mcp.WithDescription("Сделать ход команды — перейти в точку сценария по её коду. После успешного хода возвращает обновлённую историю команды. Команда идентифицируется паролем."),
|
||||
mcp.WithNumber("team_id", mcp.Required(), mcp.Description("ID команды")),
|
||||
mcp.WithString("password", mcp.Required(), mcp.Description("Пароль команды")),
|
||||
mcp.WithString("code", mcp.Required(), mcp.Description("Код точки сценария, в которую идёт команда")),
|
||||
),
|
||||
s.handleMakeMove,
|
||||
)
|
||||
|
||||
srv.AddTool(
|
||||
mcp.NewToolWithRawSchema(
|
||||
"submit_answers",
|
||||
"Отправить ответы команды на вопросы дела (список вопросов — в истории команды через get_team_story). Работает, пока игра идёт; после завершения игры ответы изменить нельзя. После успешной отправки возвращает обновлённую историю команды. Команда идентифицируется паролем.",
|
||||
json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"team_id": {"type": "number", "description": "ID команды"},
|
||||
"password": {"type": "string", "description": "Пароль команды"},
|
||||
"answers": {
|
||||
"type": "array",
|
||||
"description": "Ответы команды на вопросы дела; переданные коды перезаписываются, пустая строка очищает ответ",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"questionCode": {"type": "string", "description": "Код вопроса из истории команды"},
|
||||
"answer": {"type": "string", "description": "Текст ответа команды"}
|
||||
},
|
||||
"required": ["questionCode", "answer"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["team_id", "password", "answers"]
|
||||
}`),
|
||||
),
|
||||
s.handleSubmitAnswers,
|
||||
)
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
// connectArgs — результат разбора ссылки на игру.
|
||||
type connectArgs struct {
|
||||
teamID int64
|
||||
password string
|
||||
}
|
||||
|
||||
// parseConnectURL разбирает ссылку на игру вида /team-story/{id}?password=...
|
||||
// и возвращает id команды и пароль.
|
||||
//
|
||||
// Ссылка используется ТОЛЬКО как носитель id и пароля: MCP-сервер не
|
||||
// выполняет HTTP-запросов по ней, поэтому схема и хост не проверяются
|
||||
// (протокол-относительная ссылка //host/team-story/{id}?password=...
|
||||
// принимается, «mailto:...» отклоняется на проверке пути).
|
||||
func parseConnectURL(rawURL string) (connectArgs, error) {
|
||||
if strings.TrimSpace(rawURL) == "" {
|
||||
return connectArgs{}, fmt.Errorf("требуется url")
|
||||
}
|
||||
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return connectArgs{}, fmt.Errorf("невалидная ссылка: %w", err)
|
||||
}
|
||||
|
||||
const prefix = "/team-story/"
|
||||
if !strings.HasPrefix(u.Path, prefix) {
|
||||
return connectArgs{}, fmt.Errorf("невалидная ссылка: путь должен быть %s{id}", prefix)
|
||||
}
|
||||
idPart := strings.TrimPrefix(u.Path, prefix)
|
||||
if idPart == "" {
|
||||
return connectArgs{}, fmt.Errorf("невалидная ссылка: отсутствует id команды в пути %q", u.Path)
|
||||
}
|
||||
teamID, err := strconv.ParseInt(idPart, 10, 64)
|
||||
if err != nil || teamID < 0 {
|
||||
return connectArgs{}, fmt.Errorf("невалидная ссылка: id команды %q — не целое неотрицательное число", idPart)
|
||||
}
|
||||
|
||||
password := u.Query().Get("password")
|
||||
if password == "" {
|
||||
return connectArgs{}, fmt.Errorf("невалидная ссылка: отсутствует query-параметр password")
|
||||
}
|
||||
|
||||
return connectArgs{teamID: teamID, password: password}, nil
|
||||
}
|
||||
|
||||
// gameResponse — компактное описание игры для ответа инструментов
|
||||
// (game_service.Game JSON-тегов не имеет).
|
||||
type gameResponse struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// storyResponse — JSON-ответ инструментов: история команды + инфо об игре.
|
||||
type storyResponse struct {
|
||||
Story *storytelling.Story `json:"story"`
|
||||
Game *gameResponse `json:"game"`
|
||||
}
|
||||
|
||||
// storyJSON сериализует историю команды и игру в JSON.
|
||||
func storyJSON(story *storytelling.Story, game *game_service.Game) (string, error) {
|
||||
resp := storyResponse{
|
||||
Story: story,
|
||||
Game: &gameResponse{
|
||||
ID: game.ID,
|
||||
Name: game.Name,
|
||||
Status: game.Status,
|
||||
},
|
||||
}
|
||||
encoded, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func (s *MCPService) handleConnect(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
rawURL, _ := req.GetArguments()["url"].(string)
|
||||
|
||||
args, err := parseConnectURL(rawURL)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("connect: %v", err)), nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout)
|
||||
defer cancel()
|
||||
|
||||
story, game, err := s.game.GetTeamActions(ctx, int(args.teamID), args.password)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("connect: %v", err)), nil
|
||||
}
|
||||
|
||||
jsonBody, err := storyJSON(story, game)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("connect: %v", err)), nil
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(fmt.Sprintf(
|
||||
"Подключено к команде %d. Пароль: %s. История команды:\n%s",
|
||||
args.teamID,
|
||||
args.password,
|
||||
jsonBody,
|
||||
)), nil
|
||||
}
|
||||
|
||||
func (s *MCPService) handleGetTeamStory(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
teamID, ok := argInt64(req.GetArguments()["team_id"])
|
||||
if !ok {
|
||||
return mcp.NewToolResultError("get_team_story: требуется team_id (число)"), nil
|
||||
}
|
||||
password, _ := req.GetArguments()["password"].(string)
|
||||
if password == "" {
|
||||
return mcp.NewToolResultError("get_team_story: требуется password"), nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout)
|
||||
defer cancel()
|
||||
|
||||
story, game, err := s.game.GetTeamActions(ctx, int(teamID), password)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("get_team_story: %v", err)), nil
|
||||
}
|
||||
|
||||
jsonBody, err := storyJSON(story, game)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("get_team_story: %v", err)), nil
|
||||
}
|
||||
return mcp.NewToolResultText(jsonBody), nil
|
||||
}
|
||||
|
||||
func (s *MCPService) handleMakeMove(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := req.GetArguments()
|
||||
teamID, ok := argInt64(args["team_id"])
|
||||
if !ok {
|
||||
return mcp.NewToolResultError("make_move: требуется team_id (число)"), nil
|
||||
}
|
||||
password, _ := args["password"].(string)
|
||||
code, _ := args["code"].(string)
|
||||
if password == "" || code == "" {
|
||||
return mcp.NewToolResultError("make_move: требуется password и code"), nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := s.game.AddTeamAction(ctx, int(teamID), password, code); err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("make_move: %v", err)), nil
|
||||
}
|
||||
|
||||
story, game, err := s.game.GetTeamActions(ctx, int(teamID), password)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("make_move: %v", err)), nil
|
||||
}
|
||||
|
||||
jsonBody, err := storyJSON(story, game)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("make_move: %v", err)), nil
|
||||
}
|
||||
return mcp.NewToolResultText(jsonBody), nil
|
||||
}
|
||||
|
||||
// submitAnswersArgs — разобранные аргументы инструмента submit_answers.
|
||||
type submitAnswersArgs struct {
|
||||
teamID int64
|
||||
password string
|
||||
answers []game_service.Answer
|
||||
}
|
||||
|
||||
// parseSubmitAnswersArgs разбирает аргументы инструмента submit_answers:
|
||||
// answers — массив объектов {questionCode, answer} (JSON-массив приходит
|
||||
// как []any с map[string]any элементами). Ключ questionCode совпадает с
|
||||
// REST JSON (camelCase) — единый формат для клиентов.
|
||||
func parseSubmitAnswersArgs(args map[string]any) (submitAnswersArgs, error) {
|
||||
teamID, ok := argInt64(args["team_id"])
|
||||
if !ok {
|
||||
return submitAnswersArgs{}, fmt.Errorf("требуется team_id (число)")
|
||||
}
|
||||
password, _ := args["password"].(string)
|
||||
if password == "" {
|
||||
return submitAnswersArgs{}, fmt.Errorf("требуется password")
|
||||
}
|
||||
rawAnswers, ok := args["answers"].([]any)
|
||||
if !ok {
|
||||
return submitAnswersArgs{}, fmt.Errorf("требуется answers (массив ответов)")
|
||||
}
|
||||
answers := make([]game_service.Answer, 0, len(rawAnswers))
|
||||
for i, raw := range rawAnswers {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return submitAnswersArgs{}, fmt.Errorf("answers[%d]: ожидается объект {questionCode, answer}", i)
|
||||
}
|
||||
questionCode, _ := item["questionCode"].(string)
|
||||
answerText, _ := item["answer"].(string)
|
||||
if questionCode == "" {
|
||||
return submitAnswersArgs{}, fmt.Errorf("answers[%d]: требуется questionCode", i)
|
||||
}
|
||||
answers = append(answers, game_service.Answer{
|
||||
QuestionCode: questionCode,
|
||||
Text: answerText,
|
||||
})
|
||||
}
|
||||
return submitAnswersArgs{
|
||||
teamID: teamID,
|
||||
password: password,
|
||||
answers: answers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *MCPService) handleSubmitAnswers(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args, err := parseSubmitAnswersArgs(req.GetArguments())
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := s.game.SubmitTeamAnswers(ctx, int(args.teamID), args.password, args.answers); err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil
|
||||
}
|
||||
|
||||
story, game, err := s.game.GetTeamActions(ctx, int(args.teamID), args.password)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil
|
||||
}
|
||||
|
||||
jsonBody, err := storyJSON(story, game)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil
|
||||
}
|
||||
return mcp.NewToolResultText(jsonBody), nil
|
||||
}
|
||||
|
||||
// argInt64 достаёт int64 из аргумента инструмента: числа приходят как
|
||||
// float64 (JSON), строки — как string.
|
||||
func argInt64(v any) (int64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n), true
|
||||
case string:
|
||||
id, err := strconv.ParseInt(n, 10, 64)
|
||||
return id, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
package mcp_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/services/game_service"
|
||||
|
||||
"github.com/mark3labs/mcp-go/client"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
// fakeGamePlayer — мок GamePlayer с предзаданными историей, игрой и
|
||||
// ошибками (реализует интерфейс GamePlayer, БД не нужна).
|
||||
type fakeGamePlayer struct {
|
||||
story *storytelling.Story
|
||||
game *game_service.Game
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeGamePlayer) GetTeamActions(_ context.Context, teamID int, password string) (*storytelling.Story, *game_service.Game, error) {
|
||||
if f.err != nil {
|
||||
return nil, nil, f.err
|
||||
}
|
||||
return f.story, f.game, nil
|
||||
}
|
||||
|
||||
func (f *fakeGamePlayer) AddTeamAction(_ context.Context, teamID int, password, code string) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeGamePlayer) SubmitTeamAnswers(_ context.Context, teamID int, password string, answers []game_service.Answer) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultStory() *storytelling.Story {
|
||||
return &storytelling.Story{
|
||||
Introduction: &storytelling.Introduction{
|
||||
Text: "Добро пожаловать в особняк.",
|
||||
Audio: "http://storage.test/api/files/intro.mp3",
|
||||
},
|
||||
Places: []*storytelling.Place{
|
||||
{
|
||||
Code: "entrance",
|
||||
Name: "Вход",
|
||||
Text: "Вы у входа в особняк.",
|
||||
Doors: []*storytelling.Door{
|
||||
{Code: "hall", Name: "Зал"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultGame() *game_service.Game {
|
||||
return &game_service.Game{
|
||||
ID: 1,
|
||||
Name: "Игра 1",
|
||||
Status: "started",
|
||||
}
|
||||
}
|
||||
|
||||
// mcpFixture поднимает in-process MCP-клиент над сервисом с моком
|
||||
// GamePlayer. Каждый кейс создаёт СВЕЖИЙ сервис.
|
||||
type mcpFixture struct {
|
||||
client *client.Client
|
||||
}
|
||||
|
||||
func newMCPFixture(t *testing.T, player GamePlayer) *mcpFixture {
|
||||
t.Helper()
|
||||
|
||||
svc := NewMCPService(player)
|
||||
|
||||
mcpClient, err := client.NewInProcessClient(svc.Server())
|
||||
if err != nil {
|
||||
t.Fatalf("NewInProcessClient() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = mcpClient.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
if err := mcpClient.Start(ctx); err != nil {
|
||||
t.Fatalf("client.Start() error = %v", err)
|
||||
}
|
||||
|
||||
initRequest := mcp.InitializeRequest{}
|
||||
initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
|
||||
initRequest.Params.ClientInfo = mcp.Implementation{Name: "mcp_service_test", Version: "1.0.0"}
|
||||
if _, err := mcpClient.Initialize(ctx, initRequest); err != nil {
|
||||
t.Fatalf("client.Initialize() error = %v", err)
|
||||
}
|
||||
|
||||
return &mcpFixture{client: mcpClient}
|
||||
}
|
||||
|
||||
// call вызывает инструмент с аргументами и возвращает текст результата.
|
||||
func (f *mcpFixture) call(t *testing.T, name string, args map[string]any) string {
|
||||
t.Helper()
|
||||
request := mcp.CallToolRequest{}
|
||||
request.Params.Name = name
|
||||
request.Params.Arguments = args
|
||||
|
||||
result, err := f.client.CallTool(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("CallTool(%s) error = %v", name, err)
|
||||
}
|
||||
var text strings.Builder
|
||||
for _, content := range result.Content {
|
||||
switch c := content.(type) {
|
||||
case mcp.TextContent:
|
||||
text.WriteString(c.Text)
|
||||
default:
|
||||
t.Fatalf("CallTool(%s) unexpected content type %T", name, content)
|
||||
}
|
||||
}
|
||||
return text.String()
|
||||
}
|
||||
|
||||
// toolNames возвращает имена инструментов сервера.
|
||||
func (f *mcpFixture) toolNames(t *testing.T) []string {
|
||||
t.Helper()
|
||||
toolsResult, err := f.client.ListTools(context.Background(), mcp.ListToolsRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListTools() error = %v", err)
|
||||
}
|
||||
names := make([]string, 0, len(toolsResult.Tools))
|
||||
for _, tool := range toolsResult.Tools {
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func assertExactTools(t *testing.T, names []string) {
|
||||
t.Helper()
|
||||
if len(names) != 4 {
|
||||
t.Fatalf("tools = %v; want ровно 4 инструмента", names)
|
||||
}
|
||||
for _, want := range []string{"connect", "get_team_story", "make_move", "submit_answers"} {
|
||||
found := false
|
||||
for _, name := range names {
|
||||
if name == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("tools = %v; не содержит %q", names, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerListsTools(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()})
|
||||
assertExactTools(t, f.toolNames(t))
|
||||
}
|
||||
|
||||
func TestConnectRelativeURL(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()})
|
||||
|
||||
got := f.call(t, "connect", map[string]any{
|
||||
"url": "/team-story/10?password=team-pass-1",
|
||||
})
|
||||
if !strings.Contains(got, "Подключено к команде 10") {
|
||||
t.Fatalf("connect: %q; want team id in message", got)
|
||||
}
|
||||
if !strings.Contains(got, "team-pass-1") {
|
||||
t.Fatalf("connect: %q; want password echoed", got)
|
||||
}
|
||||
if !strings.Contains(got, "entrance") {
|
||||
t.Fatalf("connect: %q; want story JSON", got)
|
||||
}
|
||||
if !strings.Contains(got, `"game":{"id":1,"name":"Игра 1","status":"started"}`) {
|
||||
t.Fatalf("connect: %q; want game DTO", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectProtocolRelativeURL: протокол-относительная ссылка принимается
|
||||
// (запросы по ней не выполняются — из ссылки берутся только id и пароль).
|
||||
func TestConnectProtocolRelativeURL(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()})
|
||||
|
||||
got := f.call(t, "connect", map[string]any{
|
||||
"url": "//host/team-story/10?password=team-pass-1",
|
||||
})
|
||||
if !strings.Contains(got, "Подключено к команде 10") {
|
||||
t.Fatalf("connect: %q; want success", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectFullURL(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()})
|
||||
|
||||
got := f.call(t, "connect", map[string]any{
|
||||
"url": "http://any.example:9999/team-story/10?password=team-pass-1",
|
||||
})
|
||||
if !strings.Contains(got, "Подключено к команде 10") {
|
||||
t.Fatalf("connect: %q; want success (хост игнорируется)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectMissingURL(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{})
|
||||
|
||||
got := f.call(t, "connect", map[string]any{})
|
||||
if !strings.Contains(got, "требуется url") {
|
||||
t.Fatalf("connect без url: %q; want args hint", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectInvalidURLs(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"пустой password", "/team-story/10", "password"},
|
||||
{"не тот путь", "/game/10?password=x", "путь должен быть"},
|
||||
{"mailto-схема", "mailto:team@example.com", "путь должен быть"},
|
||||
{"id не число", "/team-story/abc?password=x", "id команды"},
|
||||
{"отрицательный id", "/team-story/-5?password=x", "id команды"},
|
||||
{"ошибка url.Parse в пути", "/team-story/%zz?password=x", "невалидная ссылка"},
|
||||
{"нет id в пути", "/team-story/?password=x", "отсутствует id команды"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := f.call(t, "connect", map[string]any{"url": tc.url})
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Fatalf("connect(%q): %q; want подстроку %q", tc.url, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectBusinessError(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{err: errors.New("team not found")})
|
||||
|
||||
got := f.call(t, "connect", map[string]any{
|
||||
"url": "/team-story/10?password=wrong",
|
||||
})
|
||||
if !strings.Contains(got, "team not found") {
|
||||
t.Fatalf("connect с ошибкой сервиса: %q; want error text", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTeamStory(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()})
|
||||
|
||||
got := f.call(t, "get_team_story", map[string]any{
|
||||
"team_id": float64(10),
|
||||
"password": "team-pass-1",
|
||||
})
|
||||
if !strings.Contains(got, "entrance") {
|
||||
t.Fatalf("get_team_story: %q; want story JSON", got)
|
||||
}
|
||||
if !strings.Contains(got, `"introduction"`) || !strings.Contains(got, "Добро пожаловать в особняк.") {
|
||||
t.Fatalf("get_team_story: %q; want introduction in story JSON", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTeamStoryBusinessError(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{err: errors.New("team not found")})
|
||||
|
||||
got := f.call(t, "get_team_story", map[string]any{
|
||||
"team_id": float64(10),
|
||||
"password": "wrong",
|
||||
})
|
||||
if !strings.Contains(got, "team not found") {
|
||||
t.Fatalf("get_team_story с ошибкой сервиса: %q; want error text", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTeamStoryInvalidTeamID(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{})
|
||||
|
||||
got := f.call(t, "get_team_story", map[string]any{
|
||||
"team_id": "not-a-number",
|
||||
"password": "x",
|
||||
})
|
||||
if !strings.Contains(got, "team_id") {
|
||||
t.Fatalf("get_team_story с нечисловым team_id: %q; want args hint", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeMove(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()})
|
||||
|
||||
got := f.call(t, "make_move", map[string]any{
|
||||
"team_id": "10", // строка тоже принимается
|
||||
"password": "team-pass-1",
|
||||
"code": "hall",
|
||||
})
|
||||
if !strings.Contains(got, "entrance") {
|
||||
t.Fatalf("make_move: %q; want updated story JSON", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeMoveActionError(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{err: errors.New("code is forbidden")})
|
||||
|
||||
got := f.call(t, "make_move", map[string]any{
|
||||
"team_id": float64(10),
|
||||
"password": "team-pass-1",
|
||||
"code": "forbidden",
|
||||
})
|
||||
if !strings.Contains(got, "code is forbidden") {
|
||||
t.Fatalf("make_move: %q; want error text", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeMoveMissingCode(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{})
|
||||
|
||||
got := f.call(t, "make_move", map[string]any{
|
||||
"team_id": float64(10),
|
||||
"password": "team-pass-1",
|
||||
"code": "",
|
||||
})
|
||||
if !strings.Contains(got, "password и code") {
|
||||
t.Fatalf("make_move без code: %q; want args hint", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeMoveWithoutTeamID(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{})
|
||||
|
||||
got := f.call(t, "make_move", map[string]any{
|
||||
"password": "x",
|
||||
"code": "hall",
|
||||
})
|
||||
if !strings.Contains(got, "team_id") {
|
||||
t.Fatalf("make_move без team_id: %q; want args hint", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConnectURL(t *testing.T) {
|
||||
okCases := []struct {
|
||||
name string
|
||||
url string
|
||||
teamID int64
|
||||
password string
|
||||
}{
|
||||
{"относительная", "/team-story/10?password=p1", 10, "p1"},
|
||||
{"полный URL", "http://host:8090/team-story/7?password=p2", 7, "p2"},
|
||||
{"протокол-относительная", "//host/team-story/3?password=p3", 3, "p3"},
|
||||
{"лишний query", "/team-story/1?password=p4&foo=bar", 1, "p4"},
|
||||
}
|
||||
for _, tc := range okCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args, err := parseConnectURL(tc.url)
|
||||
if err != nil {
|
||||
t.Fatalf("parseConnectURL(%q) error = %v", tc.url, err)
|
||||
}
|
||||
if args.teamID != tc.teamID || args.password != tc.password {
|
||||
t.Fatalf("parseConnectURL(%q) = %+v; want teamID=%d password=%q", tc.url, args, tc.teamID, tc.password)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
errCases := []struct {
|
||||
name string
|
||||
url string
|
||||
}{
|
||||
{"пустая", ""},
|
||||
{"пробелы", " "},
|
||||
{"нет password", "/team-story/10"},
|
||||
{"не тот путь", "/game/10?password=x"},
|
||||
{"id не число", "/team-story/abc?password=x"},
|
||||
{"отрицательный id", "/team-story/-5?password=x"},
|
||||
{"ошибка парсинга", "/team-story/%zz?password=x"},
|
||||
}
|
||||
for _, tc := range errCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := parseConnectURL(tc.url); err == nil {
|
||||
t.Fatalf("parseConnectURL(%q) error = nil; want error", tc.url)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewMCPService — конструктор не требует БД и возвращает сервис.
|
||||
func TestNewMCPService(t *testing.T) {
|
||||
svc := NewMCPService(&fakeGamePlayer{})
|
||||
if svc == nil {
|
||||
t.Fatal("NewMCPService() = nil")
|
||||
}
|
||||
if svc.game == nil {
|
||||
t.Fatal("NewMCPService(): game == nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultCallTimeout — защита от случайного изменения таймаута.
|
||||
func TestDefaultCallTimeout(t *testing.T) {
|
||||
if defaultCallTimeout != 10*time.Second {
|
||||
t.Fatalf("defaultCallTimeout = %v; want 10s", defaultCallTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitAnswers(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()})
|
||||
|
||||
got := f.call(t, "submit_answers", map[string]any{
|
||||
"team_id": float64(10),
|
||||
"password": "team-pass-1",
|
||||
"answers": []any{
|
||||
map[string]any{"questionCode": "q1", "answer": "Дворецкий"},
|
||||
map[string]any{"questionCode": "q2", "answer": ""},
|
||||
},
|
||||
})
|
||||
if !strings.Contains(got, "entrance") {
|
||||
t.Fatalf("submit_answers: %q; want story JSON в ответе", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitAnswersValidation(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
want string
|
||||
}{
|
||||
{"нет team_id", map[string]any{"password": "x", "answers": []any{}}, "team_id"},
|
||||
{"нет password", map[string]any{"team_id": float64(1), "answers": []any{}}, "password"},
|
||||
{"нет answers", map[string]any{"team_id": float64(1), "password": "x"}, "answers"},
|
||||
{"элемент не объект", map[string]any{"team_id": float64(1), "password": "x", "answers": []any{"str"}}, "answers[0]"},
|
||||
{"нет questionCode", map[string]any{"team_id": float64(1), "password": "x", "answers": []any{map[string]any{"answer": "x"}}}, "questionCode"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := f.call(t, "submit_answers", tc.args)
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Fatalf("submit_answers: %q; want подстроку %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitAnswersBusinessError(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{err: errors.New("Игра завершена, ответы больше нельзя изменять")})
|
||||
|
||||
got := f.call(t, "submit_answers", map[string]any{
|
||||
"team_id": float64(10),
|
||||
"password": "wrong",
|
||||
"answers": []any{map[string]any{"questionCode": "q1", "answer": "x"}},
|
||||
})
|
||||
if !strings.Contains(got, "Игра завершена") {
|
||||
t.Fatalf("submit_answers с ошибкой сервиса: %q; want error text", got)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package scenarios_service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
@@ -73,13 +76,34 @@ func mapStory(o string, domain string) (*storytelling.Story, error) {
|
||||
return nil, err
|
||||
}
|
||||
for _, place := range res.Places {
|
||||
if place.Image != "" {
|
||||
place.Image = domain + place.Image
|
||||
place.Image = prefixDomain(place.Image, domain)
|
||||
for _, application := range place.Applications {
|
||||
application.Image = prefixDomain(application.Image, domain)
|
||||
if application.FileType == "" {
|
||||
application.FileType = file_storage.FileType(application.Image)
|
||||
}
|
||||
}
|
||||
}
|
||||
if res.Introduction != nil {
|
||||
res.Introduction.Audio = prefixDomain(res.Introduction.Audio, domain)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// prefixDomain — идемпотентная префиксация ссылки доменом хранилища:
|
||||
// пустые ссылки и внешние http/https URL не трогаются, повторный префикс
|
||||
// исключён. Единое правило для картинок точек, аудио введения и улик.
|
||||
func prefixDomain(ref, domain string) string {
|
||||
if ref == "" || strings.HasPrefix(ref, domain) {
|
||||
return ref
|
||||
}
|
||||
low := strings.ToLower(ref)
|
||||
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") {
|
||||
return ref
|
||||
}
|
||||
return domain + ref
|
||||
}
|
||||
|
||||
func convertStory(o *storytelling.Story) (string, error) {
|
||||
b, err := json.Marshal(o)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package scenarios_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
)
|
||||
|
||||
// TestMapStoryPrefixesEvidence — улики получают полный URL (домен + имя),
|
||||
// как картинки точек и аудио введения; префиксация идемпотентна, внешние
|
||||
// URL не трогаются; file_type деривируется при пустом значении.
|
||||
func TestMapStoryPrefixesEvidence(t *testing.T) {
|
||||
storyJSON := `{
|
||||
"introduction": {"text": "Вступление", "audio": "intro.mp3"},
|
||||
"places": [{
|
||||
"code": "p1",
|
||||
"name": "Место",
|
||||
"text": "Текст",
|
||||
"image": "place.png",
|
||||
"applications": [
|
||||
{"name": "Договор", "image": "contract.pdf"},
|
||||
{"name": "Фото", "image": "https://external.example.com/pic.jpg", "file_type": "image"}
|
||||
]
|
||||
}]
|
||||
}`
|
||||
|
||||
story, err := mapStory(storyJSON, testDomain)
|
||||
if err != nil {
|
||||
t.Fatalf("mapStory: %v", err)
|
||||
}
|
||||
|
||||
if story.Introduction.Audio != testDomain+"intro.mp3" {
|
||||
t.Errorf("Audio = %q, want %q", story.Introduction.Audio, testDomain+"intro.mp3")
|
||||
}
|
||||
place := story.Places[0]
|
||||
if place.Image != testDomain+"place.png" {
|
||||
t.Errorf("place.Image = %q, want %q", place.Image, testDomain+"place.png")
|
||||
}
|
||||
|
||||
// Улика из хранилища: полный URL + деривированный file_type.
|
||||
app := place.Applications[0]
|
||||
if app.Image != testDomain+"contract.pdf" {
|
||||
t.Errorf("application.Image = %q, want %q", app.Image, testDomain+"contract.pdf")
|
||||
}
|
||||
if app.FileType != "pdf" {
|
||||
t.Errorf("application.FileType = %q, want pdf", app.FileType)
|
||||
}
|
||||
|
||||
// Внешний URL не префиксуется, file_type сохраняется.
|
||||
external := place.Applications[1]
|
||||
if external.Image != "https://external.example.com/pic.jpg" {
|
||||
t.Errorf("external.Image = %q, want без изменений", external.Image)
|
||||
}
|
||||
if external.FileType != "image" {
|
||||
t.Errorf("external.FileType = %q, want image", external.FileType)
|
||||
}
|
||||
|
||||
// Идемпотентность: повторный прогон не даёт двойного префикса.
|
||||
jsonOut, err := convertStory(story)
|
||||
if err != nil {
|
||||
t.Fatalf("convertStory: %v", err)
|
||||
}
|
||||
again, err := mapStory(jsonOut, testDomain)
|
||||
if err != nil {
|
||||
t.Fatalf("mapStory повторно: %v", err)
|
||||
}
|
||||
if again.Places[0].Applications[0].Image != testDomain+"contract.pdf" {
|
||||
t.Errorf("повторный mapStory: Image = %q, want %q", again.Places[0].Applications[0].Image, testDomain+"contract.pdf")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeFileType — невалидный file_type заменяется деривацией из
|
||||
// расширения файла, известные значения сохраняются.
|
||||
func TestNormalizeFileType(t *testing.T) {
|
||||
cases := []struct {
|
||||
fileType string
|
||||
image string
|
||||
want string
|
||||
}{
|
||||
{"banana", "http://domain/api/files/clue.pdf", "pdf"},
|
||||
{"banana", "clue", ""},
|
||||
{"", "clue.png", "image"},
|
||||
{"pdf", "clue.pdf", "pdf"},
|
||||
{"image", "clue.gif", "image"},
|
||||
{"audio", "clue.ogg", "audio"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := normalizeFileType(c.fileType, c.image); got != c.want {
|
||||
t.Errorf("normalizeFileType(%q, %q) = %q, want %q", c.fileType, c.image, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeStoryBanana — файл-улика с невалидным file_type проходит
|
||||
// normalizeStory и получает деривированный тип.
|
||||
func TestNormalizeStoryBanana(t *testing.T) {
|
||||
story := &storytelling.Story{
|
||||
Places: []*storytelling.Place{
|
||||
{
|
||||
Code: "p1",
|
||||
Name: "Место",
|
||||
Applications: []*storytelling.Application{
|
||||
{Name: "Договор", Image: "clue.pdf", FileType: "banana"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonOut, err := normalizeStory(story)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeStory: %v", err)
|
||||
}
|
||||
|
||||
parsed, err := mapStory(jsonOut, "")
|
||||
if err != nil {
|
||||
t.Fatalf("mapStory: %v", err)
|
||||
}
|
||||
if got := parsed.Places[0].Applications[0].FileType; got != "pdf" {
|
||||
t.Errorf("FileType = %q, want pdf", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateStoryTrimsEvidenceDomain — сохранение истории снимает доменный
|
||||
// префикс с улик (в БД хранятся относительные имена).
|
||||
func TestUpdateStoryTrimsEvidenceDomain(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Сценарий с историей, где улика уже с доменным префиксом.
|
||||
scenario := seedScenario(t, repo, storage)
|
||||
scenario.Scenario = `{"places":[{"code":"p1","name":"Место","text":"","image":"place.png","applications":[{"name":"Договор","image":"http://storage.test/api/files/contract.pdf","file_type":"pdf"}]}]}`
|
||||
|
||||
story := &storytelling.Story{
|
||||
Places: []*storytelling.Place{
|
||||
{
|
||||
Code: "p1",
|
||||
Name: "Место",
|
||||
Image: testDomain + "place.png",
|
||||
Text: "Текст",
|
||||
Applications: []*storytelling.Application{
|
||||
{Name: "Договор", Image: testDomain + "contract.pdf", FileType: "pdf"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := svc.updateStory(context.Background(), scenario.ID, story); err != nil {
|
||||
t.Fatalf("updateStory: %v", err)
|
||||
}
|
||||
|
||||
parsed, err := mapStory(scenario.Scenario, testDomain)
|
||||
if err != nil {
|
||||
t.Fatalf("mapStory: %v", err)
|
||||
}
|
||||
app := parsed.Places[0].Applications[0]
|
||||
if app.Image != testDomain+"contract.pdf" {
|
||||
t.Errorf("после trim+prefix Image = %q, want %q (без двойного префикса)", app.Image, testDomain+"contract.pdf")
|
||||
}
|
||||
if app.FileType != "pdf" {
|
||||
t.Errorf("FileType = %q, want pdf", app.FileType)
|
||||
}
|
||||
}
|
||||
@@ -4,26 +4,50 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"evening_detective_server/internal/modules/cleaner"
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/scenario_archive"
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
"evening_detective_server/internal/repos/scenarios_repo"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// scenariosRepository — граница доступа к данным сценариев, реализуемая
|
||||
// *scenarios_repo.ScenariosRepo (интерфейс — для тестов без БД).
|
||||
type scenariosRepository interface {
|
||||
AddScenario(ctx context.Context, name string, authorId int) (int, error)
|
||||
GetScenariosByAuthorID(ctx context.Context, authorId int) ([]*repos.Scenario, error)
|
||||
GetScenariosByStatus(ctx context.Context, status string) ([]*repos.Scenario, error)
|
||||
GetScenarioByID(ctx context.Context, id int) (*repos.Scenario, error)
|
||||
UpdateScenarioByID(ctx context.Context, id int, name string, description string, image string) error
|
||||
UpdateScenarioStatusByID(ctx context.Context, id int, status string, allowPublished bool) (bool, error)
|
||||
DeleteScenarioByID(ctx context.Context, id int, allowPublished bool) (bool, error)
|
||||
GetStoryByScenarioID(ctx context.Context, id int) (string, error)
|
||||
UpdateStoryByScenarioID(ctx context.Context, id int, story string) error
|
||||
}
|
||||
|
||||
type ScenarioService struct {
|
||||
scenariosRepo *scenarios_repo.ScenariosRepo
|
||||
scenariosRepo scenariosRepository
|
||||
cleaner cleaner.ICleaner
|
||||
fileStorage file_storage.IFileStorage
|
||||
scenarioArchive scenario_archive.IScenarioArchive
|
||||
domain string
|
||||
}
|
||||
|
||||
func NewScenarioService(
|
||||
scenariosRepo *scenarios_repo.ScenariosRepo,
|
||||
scenariosRepo scenariosRepository,
|
||||
cleaner cleaner.ICleaner,
|
||||
domain string,
|
||||
fileStorage file_storage.IFileStorage,
|
||||
scenarioArchive scenario_archive.IScenarioArchive,
|
||||
) *ScenarioService {
|
||||
return &ScenarioService{
|
||||
scenariosRepo: scenariosRepo,
|
||||
cleaner: cleaner,
|
||||
fileStorage: fileStorage,
|
||||
scenarioArchive: scenarioArchive,
|
||||
domain: domain,
|
||||
}
|
||||
}
|
||||
@@ -252,6 +276,94 @@ func (s *ScenarioService) DeleteScenarioPlace(
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// UpdateScenarioIntro сохраняет введение сценария (текст и аудио).
|
||||
func (s *ScenarioService) UpdateScenarioIntro(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
introduction *storytelling.Introduction,
|
||||
actorId int,
|
||||
isAdmin bool,
|
||||
) error {
|
||||
if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
story, err := s.getStory(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
story.Introduction = introduction
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// AddScenarioQuestion добавляет вопрос дела в конец списка вопросов сценария.
|
||||
func (s *ScenarioService) AddScenarioQuestion(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
question *storytelling.Question,
|
||||
actorId int,
|
||||
isAdmin bool,
|
||||
) error {
|
||||
if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
story, err := s.getStory(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
story.Questions = append(story.Questions, question)
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// UpdateScenarioQuestion обновляет вопрос дела по его коду. Смена кода
|
||||
// оставляет строки team_answers старого кода в БД
|
||||
func (s *ScenarioService) UpdateScenarioQuestion(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
code string,
|
||||
question *storytelling.Question,
|
||||
actorId int,
|
||||
isAdmin bool,
|
||||
) error {
|
||||
if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
story, err := s.getStory(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range story.Questions {
|
||||
if story.Questions[i].Code == code {
|
||||
story.Questions[i] = question
|
||||
break
|
||||
}
|
||||
}
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// DeleteScenarioQuestion удаляет вопрос дела по его коду.
|
||||
func (s *ScenarioService) DeleteScenarioQuestion(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
code string,
|
||||
actorId int,
|
||||
isAdmin bool,
|
||||
) error {
|
||||
if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
story, err := s.getStory(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range story.Questions {
|
||||
if story.Questions[i].Code == code {
|
||||
story.Questions = append(story.Questions[:i], story.Questions[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
func (s *ScenarioService) getStory(ctx context.Context, id int) (*storytelling.Story, error) {
|
||||
storyString, err := s.scenariosRepo.GetStoryByScenarioID(ctx, id)
|
||||
if err != nil {
|
||||
@@ -260,14 +372,163 @@ func (s *ScenarioService) getStory(ctx context.Context, id int) (*storytelling.S
|
||||
return mapStory(storyString, s.domain)
|
||||
}
|
||||
|
||||
func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storytelling.Story) error {
|
||||
mapCodes := map[string]struct{}{}
|
||||
for _, place := range story.Places {
|
||||
mapCodes[place.Code] = struct{}{}
|
||||
place.Image = strings.TrimPrefix(place.Image, s.domain)
|
||||
// DownloadArchive собирает ZIP-архив сценария
|
||||
func (s *ScenarioService) DownloadArchive(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
actorId int,
|
||||
isAdmin bool,
|
||||
) ([]byte, string, error) {
|
||||
scenario, err := s.getScenarioForChange(ctx, id, actorId, isAdmin)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(mapCodes) != len(story.Places) {
|
||||
return errors.New("Такой код точки уже существует")
|
||||
data, err := s.scenarioArchive.Pack(
|
||||
ctx,
|
||||
scenario,
|
||||
s.domain,
|
||||
func(ctx context.Context, name string) ([]byte, error) {
|
||||
file, err := s.fileStorage.Get(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file.Data, nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return data, scenario.Name, nil
|
||||
}
|
||||
|
||||
// UploadArchive импортирует сценарий из ZIP-архива: изображения сохраняются
|
||||
// под новыми случайными именами (не перетирают чужие файлы), ссылки
|
||||
// переписываются. Новый сценарий всегда draft — статус из архива игнорируется.
|
||||
func (s *ScenarioService) UploadArchive(
|
||||
ctx context.Context,
|
||||
data []byte,
|
||||
authorId int,
|
||||
) (int, error) {
|
||||
bundle, err := s.scenarioArchive.Unpack(data)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
scenario := bundle.Scenario
|
||||
|
||||
// При ошибке удаляем загруженные файлы (best-effort) — без orphan-объектов.
|
||||
uploaded := map[string]string{}
|
||||
ok := false
|
||||
defer func() {
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
for _, name := range uploaded {
|
||||
_ = s.fileStorage.Delete(ctx, name)
|
||||
}
|
||||
}()
|
||||
|
||||
rewrite := func(ref string) (string, error) {
|
||||
// Берём только ссылки images/... из архива; остальное — как есть.
|
||||
archiveRef := strings.TrimPrefix(ref, s.domain)
|
||||
if !strings.HasPrefix(archiveRef, "images/") {
|
||||
return ref, nil
|
||||
}
|
||||
if name, exists := uploaded[archiveRef]; exists {
|
||||
return name, nil
|
||||
}
|
||||
content, exists := bundle.Files[archiveRef]
|
||||
if !exists {
|
||||
return "", fmt.Errorf("изображение %q не найдено в архиве", archiveRef)
|
||||
}
|
||||
name, err := file_storage.NewName()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name += strings.ToLower(filepath.Ext(archiveRef))
|
||||
if err := s.fileStorage.Put(ctx, &file_storage.File{
|
||||
Name: name,
|
||||
Data: content,
|
||||
Mime: s.fileStorage.MimeType(name),
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("не удалось сохранить изображение %q: %w", archiveRef, err)
|
||||
}
|
||||
uploaded[archiveRef] = name
|
||||
return name, nil
|
||||
}
|
||||
|
||||
image, err := rewrite(scenario.Image)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, place := range scenario.Story.Places {
|
||||
place.Image, err = rewrite(place.Image)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, application := range place.Applications {
|
||||
application.Image, err = rewrite(application.Image)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if scenario.Story.Introduction != nil {
|
||||
scenario.Story.Introduction.Audio, err = rewrite(scenario.Story.Introduction.Audio)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
storyJSON, err := normalizeStory(scenario.Story)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
id, err := s.scenariosRepo.AddScenario(ctx, scenario.Name, authorId)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := s.scenariosRepo.UpdateScenarioByID(ctx, id, scenario.Name, scenario.Description, image); err != nil {
|
||||
// Зачищаем строку сценария (best-effort), как и файлы.
|
||||
_, _ = s.scenariosRepo.DeleteScenarioByID(ctx, id, true)
|
||||
return 0, err
|
||||
}
|
||||
if err := s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyJSON); err != nil {
|
||||
_, _ = s.scenariosRepo.DeleteScenarioByID(ctx, id, true)
|
||||
return 0, err
|
||||
}
|
||||
ok = true
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storytelling.Story) error {
|
||||
for _, place := range story.Places {
|
||||
place.Image = strings.TrimPrefix(place.Image, s.domain)
|
||||
for _, application := range place.Applications {
|
||||
application.Image = strings.TrimPrefix(application.Image, s.domain)
|
||||
}
|
||||
}
|
||||
if story.Introduction != nil {
|
||||
story.Introduction.Audio = strings.TrimPrefix(story.Introduction.Audio, s.domain)
|
||||
}
|
||||
storyString, err := normalizeStory(story)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString)
|
||||
}
|
||||
|
||||
// normalizeStory проверяет уникальность кодов точек, отбрасывает пустые коды,
|
||||
// нормализует file_type улик и возвращает JSON истории.
|
||||
// Общая для редактирования и импорта.
|
||||
func normalizeStory(story *storytelling.Story) (string, error) {
|
||||
codes := map[string]struct{}{}
|
||||
for _, place := range story.Places {
|
||||
codes[place.Code] = struct{}{}
|
||||
}
|
||||
if len(codes) != len(story.Places) {
|
||||
return "", errors.New("Такой код точки уже существует")
|
||||
}
|
||||
|
||||
cleanPlaces := make([]*storytelling.Place, 0, len(story.Places))
|
||||
@@ -275,14 +536,45 @@ func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storyt
|
||||
if place.Code == "" {
|
||||
continue
|
||||
}
|
||||
for _, application := range place.Applications {
|
||||
// file_type принимает только известные значения; невалидные
|
||||
// (включая значения из импортированных архивов) заменяются
|
||||
// деривацией из расширения файла.
|
||||
application.FileType = normalizeFileType(application.FileType, application.Image)
|
||||
}
|
||||
cleanPlaces = append(cleanPlaces, place)
|
||||
}
|
||||
story.Places = cleanPlaces
|
||||
|
||||
storyString, err := convertStory(story)
|
||||
if err != nil {
|
||||
return err
|
||||
// Коды вопросов уникальны, пустые коды отбрасываются (как у точек).
|
||||
questionCodes := map[string]struct{}{}
|
||||
for _, question := range story.Questions {
|
||||
questionCodes[question.Code] = struct{}{}
|
||||
}
|
||||
if len(questionCodes) != len(story.Questions) {
|
||||
return "", errors.New("Такой код вопроса уже существует")
|
||||
}
|
||||
cleanQuestions := make([]*storytelling.Question, 0, len(story.Questions))
|
||||
for _, question := range story.Questions {
|
||||
if question.Code == "" {
|
||||
continue
|
||||
}
|
||||
cleanQuestions = append(cleanQuestions, question)
|
||||
}
|
||||
story.Questions = cleanQuestions
|
||||
|
||||
return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString)
|
||||
return convertStory(story)
|
||||
}
|
||||
|
||||
// normalizeFileType приводит file_type к одному из {pdf, image, audio, ""}.
|
||||
// Пустое/невалидное значение при сохранении заменяется деривацией из
|
||||
// расширения файла; пустое значение в legacy-строках деривируется при
|
||||
// чтении (mapStory).
|
||||
func normalizeFileType(fileType, image string) string {
|
||||
switch fileType {
|
||||
case "pdf", "image", "audio":
|
||||
return fileType
|
||||
default:
|
||||
return file_storage.FileType(image)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,817 @@
|
||||
package scenarios_service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/scenario_archive"
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
"evening_detective_server/internal/repos/scenarios_repo"
|
||||
)
|
||||
|
||||
const testDomain = "http://storage.test/api/files/"
|
||||
|
||||
// fakeStorage — in-memory реализация IFileStorage.
|
||||
type fakeStorage struct {
|
||||
mu sync.Mutex
|
||||
files map[string]*file_storage.File
|
||||
getErr error
|
||||
}
|
||||
|
||||
func newFakeStorage() *fakeStorage {
|
||||
return &fakeStorage{files: map[string]*file_storage.File{}}
|
||||
}
|
||||
|
||||
func (s *fakeStorage) Put(_ context.Context, f *file_storage.File) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
cp := *f
|
||||
cp.Data = append([]byte(nil), f.Data...)
|
||||
s.files[f.Name] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStorage) Get(_ context.Context, name string) (*file_storage.File, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.getErr != nil {
|
||||
return nil, s.getErr
|
||||
}
|
||||
f, ok := s.files[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file not found: %s", name)
|
||||
}
|
||||
cp := *f
|
||||
cp.Data = append([]byte(nil), f.Data...)
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (s *fakeStorage) Delete(_ context.Context, name string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.files, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStorage) MimeType(filename string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *fakeStorage) names() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
res := make([]string, 0, len(s.files))
|
||||
for name := range s.files {
|
||||
res = append(res, name)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// fakeScenariosRepo — минимальная in-memory реализация scenariosRepository.
|
||||
type fakeScenariosRepo struct {
|
||||
mu sync.Mutex
|
||||
byID map[int]*repos.Scenario
|
||||
nextID int
|
||||
addErr error
|
||||
updateErr error
|
||||
storyErr error
|
||||
}
|
||||
|
||||
func newFakeScenariosRepo() *fakeScenariosRepo {
|
||||
return &fakeScenariosRepo{
|
||||
byID: map[int]*repos.Scenario{},
|
||||
nextID: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) AddScenario(_ context.Context, name string, authorId int) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.addErr != nil {
|
||||
return 0, r.addErr
|
||||
}
|
||||
r.nextID++
|
||||
description := ""
|
||||
r.byID[r.nextID] = &repos.Scenario{
|
||||
ID: r.nextID,
|
||||
Name: name,
|
||||
Description: &description,
|
||||
Author: &repos.User{ID: authorId},
|
||||
Status: "draft",
|
||||
}
|
||||
return r.nextID, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) GetScenariosByAuthorID(_ context.Context, authorId int) ([]*repos.Scenario, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var res []*repos.Scenario
|
||||
for _, s := range r.byID {
|
||||
if s.Author != nil && s.Author.ID == authorId {
|
||||
res = append(res, s)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) GetScenariosByStatus(_ context.Context, status string) ([]*repos.Scenario, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var res []*repos.Scenario
|
||||
for _, s := range r.byID {
|
||||
if s.Status == status {
|
||||
res = append(res, s)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) GetScenarioByID(_ context.Context, id int) (*repos.Scenario, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return nil, scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) UpdateScenarioByID(_ context.Context, id int, name string, description string, image string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.updateErr != nil {
|
||||
return r.updateErr
|
||||
}
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
s.Name = name
|
||||
s.Description = &description
|
||||
s.Image = &image
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) UpdateScenarioStatusByID(_ context.Context, id int, status string, allowPublished bool) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return false, scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
if !allowPublished && s.Status == "public" {
|
||||
return false, nil
|
||||
}
|
||||
s.Status = status
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) DeleteScenarioByID(_ context.Context, id int, allowPublished bool) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return false, scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
if !allowPublished && s.Status == "public" {
|
||||
return false, nil
|
||||
}
|
||||
s.IsDeleted = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) GetStoryByScenarioID(_ context.Context, id int) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.storyErr != nil {
|
||||
return "", r.storyErr
|
||||
}
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return "", scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
return s.Scenario, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) UpdateStoryByScenarioID(_ context.Context, id int, story string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
s.Scenario = story
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) get(id int) *repos.Scenario {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.byID[id]
|
||||
}
|
||||
|
||||
func newTestService(repo *fakeScenariosRepo, storage *fakeStorage) *ScenarioService {
|
||||
if repo == nil {
|
||||
repo = newFakeScenariosRepo()
|
||||
}
|
||||
if storage == nil {
|
||||
storage = newFakeStorage()
|
||||
}
|
||||
return NewScenarioService(repo, nil, testDomain, storage, scenario_archive.NewScenarioArchive())
|
||||
}
|
||||
|
||||
// seedScenario кладёт в repo сценарий с историей и изображениями в storage.
|
||||
func seedScenario(t *testing.T, repo *fakeScenariosRepo, storage *fakeStorage) *repos.Scenario {
|
||||
t.Helper()
|
||||
description := "Детективная история"
|
||||
image := "cover.png"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Ночной клуб",
|
||||
Description: &description,
|
||||
Image: &image,
|
||||
Author: &repos.User{ID: 7},
|
||||
Status: "draft",
|
||||
Scenario: `{"places":[
|
||||
{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png","applications":[{"name":"Билет","image":"ticket.jpg"}]},
|
||||
{"code":"parking","name":"Парковка","text":"Пусто","image":"club.png"}
|
||||
]}`,
|
||||
}
|
||||
repo.byID[scenario.ID] = scenario
|
||||
_ = storage.Put(context.Background(), &file_storage.File{Name: "cover.png", Data: []byte("cover-bytes")})
|
||||
_ = storage.Put(context.Background(), &file_storage.File{Name: "club.png", Data: []byte("club-bytes")})
|
||||
_ = storage.Put(context.Background(), &file_storage.File{Name: "ticket.jpg", Data: []byte("ticket-bytes")})
|
||||
return scenario
|
||||
}
|
||||
|
||||
func TestUploadArchive(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
srcStorage := newFakeStorage()
|
||||
seedScenario(t, repo, srcStorage)
|
||||
|
||||
// Собираем архив как при скачивании (Pack через storage).
|
||||
svc := newTestService(repo, srcStorage)
|
||||
archive, _, err := svc.DownloadArchive(context.Background(), 1, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadArchive: %v", err)
|
||||
}
|
||||
|
||||
// Импортируем в «чистые» repo и storage.
|
||||
importRepo := newFakeScenariosRepo()
|
||||
importStorage := newFakeStorage()
|
||||
importSvc := newTestService(importRepo, importStorage)
|
||||
|
||||
id, err := importSvc.UploadArchive(context.Background(), archive, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArchive: %v", err)
|
||||
}
|
||||
if id != 1 {
|
||||
t.Errorf("id = %d, want 1", id)
|
||||
}
|
||||
|
||||
saved := importRepo.get(id)
|
||||
if saved == nil {
|
||||
t.Fatal("сценарий не создан")
|
||||
}
|
||||
if saved.Name != "Ночной клуб" {
|
||||
t.Errorf("Name = %q", saved.Name)
|
||||
}
|
||||
if saved.Description == nil || *saved.Description != "Детективная история" {
|
||||
t.Errorf("Description = %v", saved.Description)
|
||||
}
|
||||
if saved.Status != "draft" {
|
||||
t.Errorf("Status = %q, want draft", saved.Status)
|
||||
}
|
||||
if saved.Author == nil || saved.Author.ID != 42 {
|
||||
t.Errorf("Author = %+v, want id 42", saved.Author)
|
||||
}
|
||||
|
||||
// Ссылки переписаны на новые имена без префикса images/.
|
||||
if saved.Image == nil || strings.HasPrefix(*saved.Image, "images/") || *saved.Image == "cover.png" {
|
||||
t.Errorf("Image = %v, want новое случайное имя без images/", saved.Image)
|
||||
}
|
||||
story := &storytelling.Story{}
|
||||
if err := json.Unmarshal([]byte(saved.Scenario), story); err != nil {
|
||||
t.Fatalf("story не разобрался: %v", err)
|
||||
}
|
||||
if len(story.Places) != 2 {
|
||||
t.Fatalf("places = %d, want 2", len(story.Places))
|
||||
}
|
||||
club := story.Places[0]
|
||||
if club.Image == "" || strings.HasPrefix(club.Image, "images/") || club.Image == "club.png" {
|
||||
t.Errorf("place image = %q, want новое имя", club.Image)
|
||||
}
|
||||
if len(club.Applications) != 1 || strings.HasPrefix(club.Applications[0].Image, "images/") {
|
||||
t.Errorf("application image = %+v, want новое имя", club.Applications)
|
||||
}
|
||||
// Одна и та же картинка в двух точках — одно новое имя.
|
||||
if story.Places[1].Image != club.Image {
|
||||
t.Errorf("дублирующаяся картинка переписана по-разному: %q и %q", story.Places[1].Image, club.Image)
|
||||
}
|
||||
|
||||
// Файлы загружены в storage под новыми именами с правильным содержимым.
|
||||
files := importStorage.names()
|
||||
if len(files) != 3 {
|
||||
t.Fatalf("storage files = %v, want 3", files)
|
||||
}
|
||||
contents := map[string]bool{}
|
||||
for _, name := range files {
|
||||
file, err := importStorage.Get(context.Background(), name)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%q): %v", name, err)
|
||||
}
|
||||
contents[string(file.Data)] = true
|
||||
}
|
||||
for _, want := range []string{"cover-bytes", "club-bytes", "ticket-bytes"} {
|
||||
if !contents[want] {
|
||||
t.Errorf("в storage нет содержимого %q, есть: %v", want, contents)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadArchiveWithIntro(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
srcStorage := newFakeStorage()
|
||||
description := "Детективная история"
|
||||
image := "cover.png"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Ночной клуб",
|
||||
Description: &description,
|
||||
Image: &image,
|
||||
Author: &repos.User{ID: 7},
|
||||
Status: "draft",
|
||||
Scenario: `{"introduction":{"text":"Введение","audio":"intro.mp3"},"places":[
|
||||
{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png"}
|
||||
]}`,
|
||||
}
|
||||
repo.byID[scenario.ID] = scenario
|
||||
_ = srcStorage.Put(context.Background(), &file_storage.File{Name: "cover.png", Data: []byte("cover-bytes")})
|
||||
_ = srcStorage.Put(context.Background(), &file_storage.File{Name: "club.png", Data: []byte("club-bytes")})
|
||||
_ = srcStorage.Put(context.Background(), &file_storage.File{Name: "intro.mp3", Data: []byte("intro-audio-bytes")})
|
||||
|
||||
// Экспорт (Pack кладёт аудио введения в архив), затем импорт в «чистые»
|
||||
// repo и storage: ссылки должны быть переписаны на новые имена.
|
||||
svc := newTestService(repo, srcStorage)
|
||||
archive, _, err := svc.DownloadArchive(context.Background(), 1, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadArchive: %v", err)
|
||||
}
|
||||
|
||||
importRepo := newFakeScenariosRepo()
|
||||
importStorage := newFakeStorage()
|
||||
importSvc := newTestService(importRepo, importStorage)
|
||||
|
||||
id, err := importSvc.UploadArchive(context.Background(), archive, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArchive: %v", err)
|
||||
}
|
||||
|
||||
saved := importRepo.get(id)
|
||||
story := &storytelling.Story{}
|
||||
if err := json.Unmarshal([]byte(saved.Scenario), story); err != nil {
|
||||
t.Fatalf("story не разобрался: %v", err)
|
||||
}
|
||||
if story.Introduction == nil {
|
||||
t.Fatal("Introduction = nil, want введение после импорта")
|
||||
}
|
||||
if story.Introduction.Text != "Введение" {
|
||||
t.Errorf("Introduction.Text = %q, want %q", story.Introduction.Text, "Введение")
|
||||
}
|
||||
if story.Introduction.Audio == "" || strings.HasPrefix(story.Introduction.Audio, "images/") || story.Introduction.Audio == "intro.mp3" {
|
||||
t.Errorf("Introduction.Audio = %q, want новое случайное имя без images/", story.Introduction.Audio)
|
||||
}
|
||||
|
||||
// Аудио введения загружено в storage под новым именем.
|
||||
files := importStorage.names()
|
||||
found := false
|
||||
for _, name := range files {
|
||||
file, err := importStorage.Get(context.Background(), name)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%q): %v", name, err)
|
||||
}
|
||||
if string(file.Data) == "intro-audio-bytes" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("аудио введения не загружено в storage; файлы: %v", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadArchiveMissingImage(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Архив ссылается на images/missing.png, файла в архиве нет.
|
||||
archive := buildRawArchive(t, map[string][]byte{
|
||||
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
|
||||
Version: scenario_archive.Version,
|
||||
Name: "Тест",
|
||||
Image: "images/missing.png",
|
||||
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
|
||||
}),
|
||||
})
|
||||
|
||||
if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
|
||||
t.Fatal("UploadArchive должен вернуть ошибку при отсутствующем файле")
|
||||
}
|
||||
if len(storage.names()) != 0 {
|
||||
t.Errorf("storage не должен содержать файлов: %v", storage.names())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadArchiveExternalURLKept(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
archive := buildRawArchive(t, map[string][]byte{
|
||||
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
|
||||
Version: scenario_archive.Version,
|
||||
Name: "Тест",
|
||||
Image: "https://example.com/cover.png",
|
||||
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P", Image: "http://other.example/x.png"}}},
|
||||
}),
|
||||
})
|
||||
|
||||
id, err := svc.UploadArchive(context.Background(), archive, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArchive: %v", err)
|
||||
}
|
||||
saved := repo.get(id)
|
||||
if saved.Image == nil || *saved.Image != "https://example.com/cover.png" {
|
||||
t.Errorf("Image = %v, внешний URL должен остаться без изменений", saved.Image)
|
||||
}
|
||||
if len(storage.names()) != 0 {
|
||||
t.Errorf("storage не должен содержать файлов: %v", storage.names())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadArchiveCleanupOnRepoError(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
repo.addErr = errors.New("db is down")
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
archive := buildRawArchive(t, map[string][]byte{
|
||||
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
|
||||
Version: scenario_archive.Version,
|
||||
Name: "Тест",
|
||||
Image: "images/x.png",
|
||||
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
|
||||
}),
|
||||
"images/x.png": []byte("x"),
|
||||
})
|
||||
|
||||
if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
|
||||
t.Fatal("UploadArchive должен вернуть ошибку репозитория")
|
||||
}
|
||||
// Загруженные файлы удалены (best-effort), orphan-объектов не осталось.
|
||||
if len(storage.names()) != 0 {
|
||||
t.Errorf("после ошибки БД storage должен быть пуст: %v", storage.names())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadArchiveDeletesRowOnPartialFailure(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
repo.updateErr = errors.New("db is down")
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
archive := buildRawArchive(t, map[string][]byte{
|
||||
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
|
||||
Version: scenario_archive.Version,
|
||||
Name: "Тест",
|
||||
Image: "images/x.png",
|
||||
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
|
||||
}),
|
||||
"images/x.png": []byte("x"),
|
||||
})
|
||||
|
||||
if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
|
||||
t.Fatal("UploadArchive должен вернуть ошибку репозитория")
|
||||
}
|
||||
// Строка помечена удалённой (best-effort, soft delete как в проде), файлы зачищены.
|
||||
if got := repo.get(1); got == nil || !got.IsDeleted {
|
||||
t.Errorf("строка сценария должна быть удалена после частичного сбоя, осталась: %+v", got)
|
||||
}
|
||||
if len(storage.names()) != 0 {
|
||||
t.Errorf("после частичного сбоя storage должен быть пуст: %v", storage.names())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadArchive(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
data, name, err := svc.DownloadArchive(context.Background(), 1, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadArchive: %v", err)
|
||||
}
|
||||
if name != "Ночной клуб" {
|
||||
t.Errorf("name = %q", name)
|
||||
}
|
||||
|
||||
bundle, err := scenario_archive.NewScenarioArchive().Unpack(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Unpack: %v", err)
|
||||
}
|
||||
if bundle.Scenario.Name != "Ночной клуб" {
|
||||
t.Errorf("scenario name = %q", bundle.Scenario.Name)
|
||||
}
|
||||
if bundle.Scenario.Image != "images/cover.png" {
|
||||
t.Errorf("scenario image = %q", bundle.Scenario.Image)
|
||||
}
|
||||
for _, want := range []string{"images/cover.png", "images/club.png", "images/ticket.jpg"} {
|
||||
if _, ok := bundle.Files[want]; !ok {
|
||||
t.Errorf("в архиве нет %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadArchiveNotOwner(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
if _, _, err := svc.DownloadArchive(context.Background(), 1, 99, false); !errors.Is(err, ErrScenarioNotOwner) {
|
||||
t.Errorf("err = %v, want ErrScenarioNotOwner", err)
|
||||
}
|
||||
// Админ может скачать чужой сценарий.
|
||||
if _, _, err := svc.DownloadArchive(context.Background(), 1, 99, true); err != nil {
|
||||
t.Errorf("админ: err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadArchiveNotFound(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
if _, _, err := svc.DownloadArchive(context.Background(), 404, 1, true); !errors.Is(err, scenarios_repo.ErrScenarioNotFound) {
|
||||
t.Errorf("err = %v, want ErrScenarioNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadArchiveMissingImage(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
seedScenario(t, repo, storage)
|
||||
_ = storage.Delete(context.Background(), "ticket.jpg")
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
if _, _, err := svc.DownloadArchive(context.Background(), 1, 7, false); err == nil {
|
||||
t.Fatal("DownloadArchive должен вернуть ошибку при недоступном изображении")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateScenarioIntro(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Установка введения автором.
|
||||
err := svc.UpdateScenarioIntro(context.Background(), 1, &storytelling.Introduction{
|
||||
Text: "Введение",
|
||||
Audio: "intro.mp3",
|
||||
}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateScenarioIntro: %v", err)
|
||||
}
|
||||
|
||||
// В хранилище — голое имя аудио, без домена.
|
||||
parsed := &storytelling.Story{}
|
||||
if err := json.Unmarshal([]byte(repo.get(1).Scenario), parsed); err != nil {
|
||||
t.Fatalf("json.Unmarshal: %v", err)
|
||||
}
|
||||
if parsed.Introduction == nil {
|
||||
t.Fatal("Introduction = nil, want введение в сохранённой истории")
|
||||
}
|
||||
if parsed.Introduction.Text != "Введение" {
|
||||
t.Errorf("Introduction.Text = %q, want %q", parsed.Introduction.Text, "Введение")
|
||||
}
|
||||
if parsed.Introduction.Audio != "intro.mp3" {
|
||||
t.Errorf("Introduction.Audio = %q, want %q (без домена)", parsed.Introduction.Audio, "intro.mp3")
|
||||
}
|
||||
|
||||
// Наружу — аудио с доменом.
|
||||
full, err := svc.GetFullScenarioByID(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFullScenarioByID: %v", err)
|
||||
}
|
||||
if full.Story.Introduction == nil {
|
||||
t.Fatal("Story.Introduction = nil, want введение в полном сценарии")
|
||||
}
|
||||
if full.Story.Introduction.Audio != testDomain+"intro.mp3" {
|
||||
t.Errorf("Introduction.Audio = %q, want %q", full.Story.Introduction.Audio, testDomain+"intro.mp3")
|
||||
}
|
||||
|
||||
// Редактирование точки не должно затрагивать введение и голое имя аудио.
|
||||
err = svc.UpdateScenarioPlace(context.Background(), 1, "club", &storytelling.Place{
|
||||
Code: "club",
|
||||
Name: "Клуб",
|
||||
Text: "Обновлённый текст.",
|
||||
}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateScenarioPlace: %v", err)
|
||||
}
|
||||
parsed = &storytelling.Story{}
|
||||
if err := json.Unmarshal([]byte(repo.get(1).Scenario), parsed); err != nil {
|
||||
t.Fatalf("json.Unmarshal: %v", err)
|
||||
}
|
||||
if parsed.Introduction == nil || parsed.Introduction.Text != "Введение" || parsed.Introduction.Audio != "intro.mp3" {
|
||||
t.Errorf("введение повреждено после UpdateScenarioPlace: %+v", parsed.Introduction)
|
||||
}
|
||||
|
||||
// Очистка введения (nil) — поле исчезает из истории.
|
||||
err = svc.UpdateScenarioIntro(context.Background(), 1, nil, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateScenarioIntro(nil): %v", err)
|
||||
}
|
||||
parsed = &storytelling.Story{}
|
||||
if err := json.Unmarshal([]byte(repo.get(1).Scenario), parsed); err != nil {
|
||||
t.Fatalf("json.Unmarshal: %v", err)
|
||||
}
|
||||
if parsed.Introduction != nil {
|
||||
t.Errorf("Introduction = %+v, want nil после очистки", parsed.Introduction)
|
||||
}
|
||||
|
||||
// Чужой автор не может менять введение.
|
||||
err = svc.UpdateScenarioIntro(context.Background(), 1, &storytelling.Introduction{Text: "x"}, 99, false)
|
||||
if !errors.Is(err, ErrScenarioNotOwner) {
|
||||
t.Errorf("err = %v, want ErrScenarioNotOwner", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildRawArchive собирает zip из произвольных записей (для тестов импорта).
|
||||
func buildRawArchive(t *testing.T, entries map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for name, content := range entries {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create(%q): %v", name, err)
|
||||
}
|
||||
if _, err := w.Write(content); err != nil {
|
||||
t.Fatalf("Write(%q): %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v scenario_archive.ScenarioJSON) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestScenarioQuestionsCRUD(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Добавление вопроса автором.
|
||||
err := svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{
|
||||
Code: "q1",
|
||||
Text: "Кто убийца?",
|
||||
Answer: "Дворецкий",
|
||||
}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("AddScenarioQuestion: %v", err)
|
||||
}
|
||||
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{
|
||||
Code: "q2",
|
||||
Text: "Орудие?",
|
||||
Answer: "Канделябр",
|
||||
}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("AddScenarioQuestion: %v", err)
|
||||
}
|
||||
|
||||
full, err := svc.GetFullScenarioByID(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFullScenarioByID: %v", err)
|
||||
}
|
||||
if len(full.Story.Questions) != 2 {
|
||||
t.Fatalf("len(Questions) = %d, want 2", len(full.Story.Questions))
|
||||
}
|
||||
// Вопросы добавляются в конец списка: q1 добавлен первым.
|
||||
if full.Story.Questions[0].Code != "q1" || full.Story.Questions[0].Answer != "Дворецкий" {
|
||||
t.Errorf("Questions[0] = %+v, want q1 с правильным ответом", full.Story.Questions[0])
|
||||
}
|
||||
if full.Story.Questions[1].Code != "q2" || full.Story.Questions[1].Answer != "Канделябр" {
|
||||
t.Errorf("Questions[1] = %+v, want q2 с правильным ответом", full.Story.Questions[1])
|
||||
}
|
||||
|
||||
// Обновление вопроса по коду.
|
||||
err = svc.UpdateScenarioQuestion(context.Background(), 1, "q1", &storytelling.Question{
|
||||
Code: "q1",
|
||||
Text: "Кто убийца на самом деле?",
|
||||
Answer: "Экономка",
|
||||
}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateScenarioQuestion: %v", err)
|
||||
}
|
||||
full, err = svc.GetFullScenarioByID(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFullScenarioByID: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, q := range full.Story.Questions {
|
||||
if q.Code == "q1" {
|
||||
found = true
|
||||
if q.Text != "Кто убийца на самом деле?" || q.Answer != "Экономка" {
|
||||
t.Errorf("q1 = %+v, want обновлённый вопрос", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("вопрос q1 не найден после обновления")
|
||||
}
|
||||
|
||||
// Удаление вопроса.
|
||||
err = svc.DeleteScenarioQuestion(context.Background(), 1, "q2", 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteScenarioQuestion: %v", err)
|
||||
}
|
||||
full, err = svc.GetFullScenarioByID(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFullScenarioByID: %v", err)
|
||||
}
|
||||
if len(full.Story.Questions) != 1 || full.Story.Questions[0].Code != "q1" {
|
||||
t.Errorf("Questions = %+v, want только q1", full.Story.Questions)
|
||||
}
|
||||
|
||||
// Чужой автор не может менять вопросы.
|
||||
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "q3"}, 99, false)
|
||||
if !errors.Is(err, ErrScenarioNotOwner) {
|
||||
t.Errorf("AddScenarioQuestion чужой автор: err = %v, want ErrScenarioNotOwner", err)
|
||||
}
|
||||
err = svc.DeleteScenarioQuestion(context.Background(), 1, "q1", 99, false)
|
||||
if !errors.Is(err, ErrScenarioNotOwner) {
|
||||
t.Errorf("DeleteScenarioQuestion чужой автор: err = %v, want ErrScenarioNotOwner", err)
|
||||
}
|
||||
|
||||
// Админ может менять вопросы чужого сценария.
|
||||
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{
|
||||
Code: "q3", Text: "Вопрос админа",
|
||||
}, 1, true)
|
||||
if err != nil {
|
||||
t.Fatalf("AddScenarioQuestion админ: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStoryQuestionCodes(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Дубликат кода вопроса — ошибка.
|
||||
err := svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "q1"}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("AddScenarioQuestion q1: %v", err)
|
||||
}
|
||||
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "q1"}, 7, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "Такой код вопроса уже существует") {
|
||||
t.Errorf("AddScenarioQuestion с дублем кода: err = %v, want ошибку о дубле", err)
|
||||
}
|
||||
|
||||
// Пустой код — вопрос отбрасывается при сохранении.
|
||||
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "", Text: "без кода"}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("AddScenarioQuestion с пустым кодом: %v", err)
|
||||
}
|
||||
full, err := svc.GetFullScenarioByID(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFullScenarioByID: %v", err)
|
||||
}
|
||||
for _, q := range full.Story.Questions {
|
||||
if q.Code == "" {
|
||||
t.Error("вопрос с пустым кодом сохранился")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package users_service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Шаблоны писем сервиса. Структура по базе знаний копирайтера (email.md):
|
||||
// приветствие → контекст («почему это письмо») → ценность (данные для входа)
|
||||
// → CTA → подпись → P.S. Письма транзакционные, но по форме — «человек
|
||||
// человеку»: Gmail и другие почтовики агрессивнее фильтруют короткие
|
||||
// «роботные» письма без структуры.
|
||||
//
|
||||
// Каждое письмо формируется в двух версиях: text/plain (для старых клиентов
|
||||
// и фильтров) и text/html (для отображения); sender собирает их в
|
||||
// multipart/alternative.
|
||||
|
||||
const (
|
||||
// welcomeSubject — тема письма о регистрации: конкретика без КАПСА и
|
||||
// кликбейта, до 50 символов (видно на мобильных целиком).
|
||||
welcomeSubject = "Твой доступ к «Вечернему детективу»"
|
||||
// resetSubject — тема письма о сбросе пароля.
|
||||
resetSubject = "Новый пароль для «Вечернего детектива»"
|
||||
)
|
||||
|
||||
// welcomePlain возвращает текстовую версию письма о регистрации.
|
||||
func welcomePlain(username, email, password, loginURL string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(greeting(username))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("Твоя регистрация в «Вечернем детективе» прошла. Данные для входа:\n\n")
|
||||
b.WriteString(" Логин: ")
|
||||
b.WriteString(email)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(" Пароль: ")
|
||||
b.WriteString(password)
|
||||
b.WriteString("\n\n")
|
||||
if loginURL != "" {
|
||||
b.WriteString("Войти в игру: ")
|
||||
b.WriteString(loginURL)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString("Если ты не регистрировался — просто проигнорируй это письмо.\n\n")
|
||||
b.WriteString("— Команда «Вечернего детектива»\n")
|
||||
b.WriteString("P.S. Сохрани это письмо: пароль понадобится для входа.")
|
||||
return sanitizeLine(b.String())
|
||||
}
|
||||
|
||||
// welcomeHTML возвращает HTML-версию письма о регистрации.
|
||||
func welcomeHTML(username, email, password, loginURL string) string {
|
||||
var content strings.Builder
|
||||
content.WriteString("<p>Привет, <strong>")
|
||||
content.WriteString(html.EscapeString(username))
|
||||
content.WriteString("</strong>!</p>\n")
|
||||
content.WriteString("<p>Твоя регистрация в «Вечернем детективе» прошла. Данные для входа:</p>\n")
|
||||
content.WriteString(credentialsBlockHTML(email, password))
|
||||
if loginURL != "" {
|
||||
content.WriteString(buttonHTML(loginURL, "Войти в игру"))
|
||||
}
|
||||
content.WriteString("<p style=\"color:#777777; font-size:13px;\">Если ты не регистрировался — просто проигнорируй это письмо.</p>")
|
||||
return wrapHTML(content.String())
|
||||
}
|
||||
|
||||
// resetPlain возвращает текстовую версию письма о сбросе пароля.
|
||||
func resetPlain(username, email, password, loginURL string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(greeting(username))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("Ты запросил сброс пароля в «Вечернем детективе». Новый пароль для входа:\n\n")
|
||||
b.WriteString(" Логин: ")
|
||||
b.WriteString(email)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(" Пароль: ")
|
||||
b.WriteString(password)
|
||||
b.WriteString("\n\n")
|
||||
if loginURL != "" {
|
||||
b.WriteString("Войти в игру: ")
|
||||
b.WriteString(loginURL)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString("Никому не сообщай пароль и не пересылай это письмо.\n")
|
||||
b.WriteString("Если это был не ты — ответь на это письмо: мы поможем защитить аккаунт.\n\n")
|
||||
b.WriteString("— Команда «Вечернего детектива»\n")
|
||||
return sanitizeLine(b.String())
|
||||
}
|
||||
|
||||
// resetHTML возвращает HTML-версию письма о сбросе пароля.
|
||||
func resetHTML(username, email, password, loginURL string) string {
|
||||
var content strings.Builder
|
||||
content.WriteString("<p>Привет, <strong>")
|
||||
content.WriteString(html.EscapeString(username))
|
||||
content.WriteString("</strong>!</p>\n")
|
||||
content.WriteString("<p>Ты запросил сброс пароля в «Вечернем детективе». Новый пароль для входа:</p>\n")
|
||||
content.WriteString(credentialsBlockHTML(email, password))
|
||||
if loginURL != "" {
|
||||
content.WriteString(buttonHTML(loginURL, "Войти в игру"))
|
||||
}
|
||||
content.WriteString("<p>Никому не сообщай пароль и не пересылай это письмо.</p>\n")
|
||||
content.WriteString("<p style=\"color:#777777; font-size:13px;\">Если это был не ты — ответь на это письмо: мы поможем защитить аккаунт.</p>")
|
||||
return wrapHTML(content.String())
|
||||
}
|
||||
|
||||
// greeting возвращает приветствие с именем пользователя; при пустом имени —
|
||||
// без обращения, чтобы не было «Привет, !».
|
||||
func greeting(username string) string {
|
||||
username = sanitizeLine(strings.TrimSpace(username))
|
||||
if username == "" {
|
||||
return "Привет!"
|
||||
}
|
||||
return fmt.Sprintf("Привет, %s!", username)
|
||||
}
|
||||
|
||||
// credentialsBlockHTML — блок «Логин / Пароль» в табличной вёрстке,
|
||||
// совместимой с Gmail.
|
||||
func credentialsBlockHTML(email, password string) string {
|
||||
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:16px 0; border:1px solid #e3ddd2; border-radius:6px; background-color:#faf8f5; width:100%;">
|
||||
<tr>
|
||||
<td style="padding:12px 16px; font-size:14px; color:#555555; width:120px;">Логин</td>
|
||||
<td style="padding:12px 16px; font-size:14px; color:#222222;"><strong>` + html.EscapeString(email) + `</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 16px; font-size:14px; color:#555555; width:120px; border-top:1px solid #e3ddd2;">Пароль</td>
|
||||
<td style="padding:12px 16px; font-size:14px; color:#1a1a2e; border-top:1px solid #e3ddd2;"><strong>` + html.EscapeString(password) + `</strong></td>
|
||||
</tr>
|
||||
</table>`
|
||||
}
|
||||
|
||||
// buttonHTML — CTA-кнопка табличной вёрсткой (Gmail не поддерживает
|
||||
// margin/padding на <a>, поэтому отступы — на ячейке <td>).
|
||||
func buttonHTML(link, label string) string {
|
||||
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px 0;">
|
||||
<tr>
|
||||
<td style="border-radius:6px; background-color:#c9a227;">
|
||||
<a href="` + html.EscapeString(link) + `" style="display:inline-block; padding:12px 28px; color:#1a1a2e; font-weight:bold; text-decoration:none; font-size:15px; border-radius:6px;">` + html.EscapeString(label) + `</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`
|
||||
}
|
||||
|
||||
// wrapHTML — каркас письма: тёмная шапка с названием сервиса, контент,
|
||||
// подвал с дисклеймером. Только табличная вёрстка и inline-стили: внешние
|
||||
// таблицы стилей Gmail вырезает.
|
||||
func wrapHTML(content string) string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f4f1ec; font-family:Arial, Helvetica, sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f1ec; padding:24px 0;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px; width:100%; background-color:#ffffff; border-radius:8px; overflow:hidden;">
|
||||
<tr>
|
||||
<td style="background-color:#1a1a2e; padding:20px 28px;">
|
||||
<span style="color:#ffffff; font-size:20px; font-weight:bold; letter-spacing:1px;">ВЕЧЕРНИЙ ДЕТЕКТИВ</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:28px; color:#222222; font-size:15px; line-height:1.6;">
|
||||
` + content + `
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:20px 28px; background-color:#faf8f5; color:#777777; font-size:12px; line-height:1.5;">
|
||||
Это автоматическое письмо сервиса «Вечерний детектив».<br>
|
||||
Если ты получил его по ошибке — просто ответь на это письмо.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
// sanitizeLine убирает переносы строк из пользовательского ввода,
|
||||
// вставляемого в тело письма (защита от инъекции строк в DATA-фазу SMTP).
|
||||
func sanitizeLine(s string) string {
|
||||
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
|
||||
}
|
||||
|
||||
// loginURL нормализует адрес фронтенда для ссылки в письме: срезает
|
||||
// завершающий слэш и допускает только http(s). Пустое или некорректное
|
||||
// значение — ссылка в письма не добавляется.
|
||||
func loginURL(baseURL string) string {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
if baseURL == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
||||
return ""
|
||||
}
|
||||
return baseURL
|
||||
}
|
||||
@@ -12,7 +12,10 @@ import (
|
||||
"evening_detective_server/internal/repos/user_agreements_repo"
|
||||
"evening_detective_server/internal/repos/users_repo"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/mail"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -29,20 +32,44 @@ const (
|
||||
privacyVersion = "1.0"
|
||||
)
|
||||
|
||||
// usersRepository — граница доступа к данным пользователей, реализуемая
|
||||
// *users_repo.UsersRepo; интерфейс позволяет тестировать сервис без БД.
|
||||
type usersRepository interface {
|
||||
AddUserWithAgreements(
|
||||
ctx context.Context,
|
||||
username string,
|
||||
email string,
|
||||
passwordHash string,
|
||||
roles []string,
|
||||
agreements []user_agreements_repo.Agreement,
|
||||
) (int, error)
|
||||
UpdateUserPassword(ctx context.Context, email string, passwordHash string) error
|
||||
GetUserByEmail(ctx context.Context, email string) (*repos.User, error)
|
||||
GetUserByID(ctx context.Context, id int) (*repos.User, error)
|
||||
GetUsers(ctx context.Context) ([]*repos.User, error)
|
||||
AddUserRole(ctx context.Context, userId int, role string) error
|
||||
DeleteUserRole(ctx context.Context, userId int, role string) error
|
||||
DeleteUser(ctx context.Context, userId int) error
|
||||
}
|
||||
|
||||
type UsersService struct {
|
||||
usersRepo *users_repo.UsersRepo
|
||||
usersRepo usersRepository
|
||||
passwordGenerator password_generator.IPasswordGenerator
|
||||
emailSender email_sender.IEmailSender
|
||||
processorJWT processor_jwt.IProcessorJWT
|
||||
refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo
|
||||
// appBaseURL — публичный адрес фронтенда для ссылки «Войти в игру» в
|
||||
// письмах; пустое значение — ссылка не добавляется.
|
||||
appBaseURL string
|
||||
}
|
||||
|
||||
func NewUsersService(
|
||||
usersRepo *users_repo.UsersRepo,
|
||||
usersRepo usersRepository,
|
||||
passwordGenerator password_generator.IPasswordGenerator,
|
||||
emailSender email_sender.IEmailSender,
|
||||
processorJWT processor_jwt.IProcessorJWT,
|
||||
refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo,
|
||||
appBaseURL string,
|
||||
) *UsersService {
|
||||
return &UsersService{
|
||||
usersRepo: usersRepo,
|
||||
@@ -50,6 +77,7 @@ func NewUsersService(
|
||||
emailSender: emailSender,
|
||||
processorJWT: processorJWT,
|
||||
refreshTokensRepo: refreshTokensRepo,
|
||||
appBaseURL: loginURL(appBaseURL),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +97,11 @@ func (s *UsersService) AddUser(
|
||||
return ErrTermsNotAccepted
|
||||
}
|
||||
|
||||
email = normalizeEmail(email)
|
||||
if err := validateEmail(email); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
password, err := s.passwordGenerator.Generate()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -79,7 +112,7 @@ func (s *UsersService) AddUser(
|
||||
}
|
||||
// Создание пользователя и фиксация акцептов — в одной транзакции:
|
||||
// без записей о согласиях пользователь не существует.
|
||||
_, err = s.usersRepo.AddUserWithAgreements(
|
||||
id, err := s.usersRepo.AddUserWithAgreements(
|
||||
ctx,
|
||||
username,
|
||||
email,
|
||||
@@ -104,13 +137,26 @@ func (s *UsersService) AddUser(
|
||||
return err
|
||||
}
|
||||
|
||||
// Консистентность ответа и состояния БД: если письмо с паролем не
|
||||
// ушло, пользователь не должен оставаться в системе «полусозданным»
|
||||
// (клиент получит ошибку и повторит регистрацию). Компенсация —
|
||||
// удаление созданного пользователя. Выполняется вне отменённого
|
||||
// контекста: письмо могло упасть именно из-за истечения таймаута ctx,
|
||||
// и тогда тот же ctx не дал бы выполнить удаление.
|
||||
err = s.emailSender.Send(ctx, email_sender.Message{
|
||||
To: email,
|
||||
Subject: "Приветствую тебя, детектив!",
|
||||
Body: fmt.Sprintf("Вот твой пароль для входа в систему: %s", password),
|
||||
Subject: welcomeSubject,
|
||||
Body: welcomePlain(username, email, password, s.appBaseURL),
|
||||
HTML: welcomeHTML(username, email, password, s.appBaseURL),
|
||||
})
|
||||
if err != nil {
|
||||
if delErr := s.usersRepo.DeleteUser(context.WithoutCancel(ctx), id); delErr != nil {
|
||||
log.Printf("email: отправка не удалась (%v) и компенсация не выполнена: %v", err, delErr)
|
||||
}
|
||||
return fmt.Errorf("email send: %w", err)
|
||||
}
|
||||
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAccount удаляет учётную запись и все персональные данные пользователя
|
||||
@@ -135,6 +181,21 @@ func (s *UsersService) RefreshPassword(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
) error {
|
||||
email = normalizeEmail(email)
|
||||
if err := validateEmail(email); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Анти-enumeration: ответ одинаков для существующего и несуществующего
|
||||
// адреса, письмо отправляется только реальному пользователю.
|
||||
user, err := s.usersRepo.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, users_repo.ErrUserNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
password, err := s.passwordGenerator.Generate()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -144,22 +205,42 @@ func (s *UsersService) RefreshPassword(
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.usersRepo.UpdateUserPassword(
|
||||
ctx,
|
||||
email,
|
||||
string(hashedPassword),
|
||||
)
|
||||
// Письмо отправляется ДО смены пароля: при сбое отправки пароль
|
||||
// остаётся прежним и аккаунт не блокируется (иначе старый пароль уже
|
||||
// не работает, а новый пользователь так и не узнал).
|
||||
err = s.emailSender.Send(ctx, email_sender.Message{
|
||||
To: user.Email,
|
||||
Subject: resetSubject,
|
||||
Body: resetPlain(user.Username, user.Email, password, s.appBaseURL),
|
||||
HTML: resetHTML(user.Username, user.Email, password, s.appBaseURL),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("email send: %w", err)
|
||||
}
|
||||
|
||||
err = s.emailSender.Send(ctx, email_sender.Message{
|
||||
To: email,
|
||||
Subject: "Приветствую тебя, детектив!",
|
||||
Body: fmt.Sprintf("Вот твой новый пароль для входа в систему, не теряй: %s", password),
|
||||
})
|
||||
return s.usersRepo.UpdateUserPassword(ctx, user.Email, string(hashedPassword))
|
||||
}
|
||||
|
||||
return err
|
||||
// normalizeEmail приводит адрес к каноническому виду (без пробелов, нижний
|
||||
// регистр): "User@Example.COM " и "user@example.com" — один аккаунт.
|
||||
func normalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
// validateEmail проверяет, что строка является одиночным email-адресом.
|
||||
func validateEmail(email string) error {
|
||||
if email == "" {
|
||||
return errors.New("Email не указан")
|
||||
}
|
||||
addr, err := mail.ParseAddress(email)
|
||||
if err != nil {
|
||||
return errors.New("Некорректный email")
|
||||
}
|
||||
// Отклоняем форму "Имя <a@b.c>" и всё, что не является чистым адресом.
|
||||
if !strings.EqualFold(addr.Address, email) {
|
||||
return errors.New("Некорректный email")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UsersService) Login(
|
||||
@@ -167,9 +248,11 @@ func (s *UsersService) Login(
|
||||
email string,
|
||||
password string,
|
||||
) (string, string, error) {
|
||||
// Email нормализуется так же, как при регистрации: иначе пользователь,
|
||||
// зарегистрировавшийся как user@example.com, не войдёт с User@Example.COM.
|
||||
user, err := s.usersRepo.GetUserByEmail(
|
||||
ctx,
|
||||
email,
|
||||
normalizeEmail(email),
|
||||
)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
package users_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"evening_detective_server/internal/modules/email_sender"
|
||||
"evening_detective_server/internal/modules/password_generator"
|
||||
"evening_detective_server/internal/modules/processor_jwt"
|
||||
"evening_detective_server/internal/repos"
|
||||
"evening_detective_server/internal/repos/refresh_tokens_repo"
|
||||
"evening_detective_server/internal/repos/user_agreements_repo"
|
||||
"evening_detective_server/internal/repos/users_repo"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type fakeSender struct {
|
||||
mu sync.Mutex
|
||||
messages []email_sender.Message
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeSender) Send(_ context.Context, m email_sender.Message) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.messages = append(f.messages, m)
|
||||
return f.err
|
||||
}
|
||||
|
||||
func (f *fakeSender) sent() []email_sender.Message {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]email_sender.Message(nil), f.messages...)
|
||||
}
|
||||
|
||||
// fakeRepo — минимальная in-memory реализация usersRepository для тестов
|
||||
// сервиса без БД.
|
||||
type fakeRepo struct {
|
||||
mu sync.Mutex
|
||||
users map[string]*repos.User
|
||||
byID map[int]*repos.User
|
||||
nextID int
|
||||
}
|
||||
|
||||
func newFakeRepo() *fakeRepo {
|
||||
return &fakeRepo{
|
||||
users: map[string]*repos.User{},
|
||||
byID: map[int]*repos.User{},
|
||||
nextID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fakeRepo) AddUserWithAgreements(
|
||||
_ context.Context,
|
||||
username, email, passwordHash string,
|
||||
roles []string,
|
||||
_ []user_agreements_repo.Agreement,
|
||||
) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, ok := r.users[email]; ok {
|
||||
return 0, errors.New("Username или email уже используется")
|
||||
}
|
||||
id := r.nextID
|
||||
r.nextID++
|
||||
u := &repos.User{ID: id, Username: username, Email: email, PasswordHash: passwordHash, Roles: roles}
|
||||
r.users[email] = u
|
||||
r.byID[id] = u
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) UpdateUserPassword(_ context.Context, email, passwordHash string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
u, ok := r.users[email]
|
||||
if !ok {
|
||||
return users_repo.ErrUserNotFound
|
||||
}
|
||||
u.PasswordHash = passwordHash
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetUserByEmail(_ context.Context, email string) (*repos.User, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
u, ok := r.users[email]
|
||||
if !ok {
|
||||
return nil, users_repo.ErrUserNotFound
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetUserByID(_ context.Context, id int) (*repos.User, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
u, ok := r.byID[id]
|
||||
if !ok {
|
||||
return nil, users_repo.ErrUserNotFound
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetUsers(_ context.Context) ([]*repos.User, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]*repos.User, 0, len(r.users))
|
||||
for _, u := range r.users {
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) AddUserRole(_ context.Context, _ int, _ string) error { return nil }
|
||||
|
||||
func (r *fakeRepo) DeleteUserRole(_ context.Context, _ int, _ string) error { return nil }
|
||||
|
||||
func (r *fakeRepo) DeleteUser(_ context.Context, userId int) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
u, ok := r.byID[userId]
|
||||
if !ok {
|
||||
return users_repo.ErrUserNotFound
|
||||
}
|
||||
delete(r.byID, userId)
|
||||
delete(r.users, u.Email)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) userCount() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.users)
|
||||
}
|
||||
|
||||
func (r *fakeRepo) seed(email, passwordHash string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
u := &repos.User{ID: r.nextID, Email: email, PasswordHash: passwordHash}
|
||||
r.nextID++
|
||||
r.users[email] = u
|
||||
r.byID[u.ID] = u
|
||||
}
|
||||
|
||||
func newTestService(repo usersRepository, sender email_sender.IEmailSender) *UsersService {
|
||||
return newTestServiceWithBaseURL(repo, sender, "")
|
||||
}
|
||||
|
||||
func newTestServiceWithBaseURL(repo usersRepository, sender email_sender.IEmailSender, baseURL string) *UsersService {
|
||||
// Пул создаётся лениво (pgxpool.New не контактирует с БД) и в этих
|
||||
// тестах не используется.
|
||||
pool, err := pgxpool.New(context.Background(), "postgres://postgres:postgres@localhost:5432/none")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return NewUsersService(
|
||||
repo,
|
||||
password_generator.NewGenerator(12),
|
||||
sender,
|
||||
processor_jwt.NewProcessor("test-secret"),
|
||||
refresh_tokens_repo.NewRefreshTokensRepo(pool),
|
||||
baseURL,
|
||||
)
|
||||
}
|
||||
|
||||
func TestAddUserSuccess(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
sender := &fakeSender{}
|
||||
svc := newTestService(repo, sender)
|
||||
|
||||
err := svc.AddUser(context.Background(), "detective", " User@Example.COM ", true, true, "1.2.3.4", "ua")
|
||||
if err != nil {
|
||||
t.Fatalf("AddUser: %v", err)
|
||||
}
|
||||
|
||||
// Email нормализован: пробелы убраны, регистр приведён.
|
||||
if _, ok := repo.users["user@example.com"]; !ok {
|
||||
t.Errorf("пользователь не создан с нормализованным email: %+v", repo.users)
|
||||
}
|
||||
|
||||
msgs := sender.sent()
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("ожидалось 1 письмо, отправлено %d", len(msgs))
|
||||
}
|
||||
if msgs[0].To != "user@example.com" {
|
||||
t.Errorf("письмо ушло на %q, want user@example.com", msgs[0].To)
|
||||
}
|
||||
if !strings.Contains(msgs[0].Subject, "Вечернему детективу") {
|
||||
t.Errorf("неожиданная тема письма: %q", msgs[0].Subject)
|
||||
}
|
||||
if !strings.Contains(msgs[0].Body, "пароль") {
|
||||
t.Error("в текстовой версии письма не упоминается пароль")
|
||||
}
|
||||
if msgs[0].HTML == "" {
|
||||
t.Error("у письма нет HTML-версии — Gmail хуже рендерит такие письма")
|
||||
}
|
||||
// APP_BASE_URL в тесте пуст — кнопка «Войти в игру» не добавляется,
|
||||
// но каркас письма (шапка сервиса) обязан присутствовать.
|
||||
if !strings.Contains(msgs[0].HTML, "ВЕЧЕРНИЙ ДЕТЕКТИВ") {
|
||||
t.Error("HTML-версия письма не содержит шапку сервиса")
|
||||
}
|
||||
if strings.Contains(msgs[0].HTML, "Войти в игру") {
|
||||
t.Error("кнопка входа не должна добавляться без APP_BASE_URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddUserWithBaseURLAddsLoginLink(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
sender := &fakeSender{}
|
||||
svc := newTestServiceWithBaseURL(repo, sender, "https://evening-detective.crabs-games.art/")
|
||||
|
||||
err := svc.AddUser(context.Background(), "detective", "user@example.com", true, true, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("AddUser: %v", err)
|
||||
}
|
||||
|
||||
msgs := sender.sent()
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("ожидалось 1 письмо, отправлено %d", len(msgs))
|
||||
}
|
||||
// Завершающий слэш срезается, ссылка есть и в plain, и в HTML-версии.
|
||||
if !strings.Contains(msgs[0].Body, "https://evening-detective.crabs-games.art") {
|
||||
t.Errorf("plain-версия не содержит ссылку на вход:\n%s", msgs[0].Body)
|
||||
}
|
||||
if !strings.Contains(msgs[0].HTML, "https://evening-detective.crabs-games.art") {
|
||||
t.Error("HTML-версия не содержит ссылку на вход")
|
||||
}
|
||||
if !strings.Contains(msgs[0].HTML, "Войти в игру") {
|
||||
t.Error("HTML-версия не содержит кнопку «Войти в игру»")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginURLInvalid(t *testing.T) {
|
||||
for _, in := range []string{"", " ", "ftp://example.com", "not-a-url", "javascript:alert(1)"} {
|
||||
if got := loginURL(in); got != "" {
|
||||
t.Errorf("loginURL(%q) = %q, want пусто", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddUserSendFailureCompensates(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
sender := &fakeSender{err: errors.New("smtp down")}
|
||||
svc := newTestService(repo, sender)
|
||||
|
||||
err := svc.AddUser(context.Background(), "detective", "user@example.com", true, true, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка при сбое отправки письма")
|
||||
}
|
||||
|
||||
// Компенсация: клиенту ошибка, пользователя в БД нет.
|
||||
if repo.userCount() != 0 {
|
||||
t.Errorf("пользователь остался после ошибки отправки: %+v", repo.users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddUserInvalidEmail(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
svc := newTestService(repo, &fakeSender{})
|
||||
|
||||
err := svc.AddUser(context.Background(), "detective", "not-an-email", true, true, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка валидации email")
|
||||
}
|
||||
if repo.userCount() != 0 {
|
||||
t.Error("пользователь не должен создаваться при невалидном email")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddUserRequiresTerms(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
svc := newTestService(repo, &fakeSender{})
|
||||
|
||||
err := svc.AddUser(context.Background(), "detective", "user@example.com", false, true, "", "")
|
||||
if !errors.Is(err, ErrTermsNotAccepted) {
|
||||
t.Fatalf("ожидался ErrTermsNotAccepted, получено %v", err)
|
||||
}
|
||||
if repo.userCount() != 0 {
|
||||
t.Error("пользователь не должен создаваться без акцепта")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshPasswordSuccess(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.seed("user@example.com", "old-hash")
|
||||
sender := &fakeSender{}
|
||||
svc := newTestService(repo, sender)
|
||||
|
||||
// Ввод с пробелами и другим регистром — нормализуется.
|
||||
err := svc.RefreshPassword(context.Background(), " User@Example.COM ")
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshPassword: %v", err)
|
||||
}
|
||||
|
||||
if len(sender.sent()) != 1 {
|
||||
t.Fatalf("ожидалось 1 письмо, отправлено %d", len(sender.sent()))
|
||||
}
|
||||
u, _ := repo.GetUserByEmail(context.Background(), "user@example.com")
|
||||
if u.PasswordHash == "old-hash" {
|
||||
t.Error("пароль не изменён")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshPasswordSendFailureKeepsPassword(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.seed("user@example.com", "old-hash")
|
||||
sender := &fakeSender{err: errors.New("smtp down")}
|
||||
svc := newTestService(repo, sender)
|
||||
|
||||
err := svc.RefreshPassword(context.Background(), "user@example.com")
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка при сбое отправки")
|
||||
}
|
||||
|
||||
u, _ := repo.GetUserByEmail(context.Background(), "user@example.com")
|
||||
if u.PasswordHash != "old-hash" {
|
||||
t.Error("пароль изменён, хотя письмо не ушло — аккаунт заблокирован")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshPasswordUnknownEmail(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
sender := &fakeSender{}
|
||||
svc := newTestService(repo, sender)
|
||||
|
||||
// Анти-enumeration: для несуществующего адреса — успех и без письма.
|
||||
if err := svc.RefreshPassword(context.Background(), "nobody@example.com"); err != nil {
|
||||
t.Fatalf("для несуществующего email ожидался успех, получено %v", err)
|
||||
}
|
||||
if len(sender.sent()) != 0 {
|
||||
t.Error("письмо не должно уходить несуществующему пользователю")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshPasswordInvalidEmail(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
sender := &fakeSender{}
|
||||
svc := newTestService(repo, sender)
|
||||
|
||||
if err := svc.RefreshPassword(context.Background(), "not-an-email"); err == nil {
|
||||
t.Fatal("ожидалась ошибка валидации email")
|
||||
}
|
||||
if len(sender.sent()) != 0 {
|
||||
t.Error("письмо не должно уходить при невалидном email")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE applications
|
||||
ADD COLUMN IF NOT EXISTS file_type TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE applications
|
||||
DROP COLUMN IF EXISTS file_type;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE
|
||||
IF NOT EXISTS team_answers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
team_id INTEGER NOT NULL,
|
||||
question_code TEXT NOT NULL,
|
||||
answer TEXT NOT NULL DEFAULT '',
|
||||
score INTEGER,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW (),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW (),
|
||||
|
||||
CONSTRAINT fk_team_answers_team FOREIGN KEY (team_id) REFERENCES teams (id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_team_answer UNIQUE (team_id, question_code)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS team_answers;
|
||||
+1740
-357
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/utilities"
|
||||
"google.golang.org/genproto/googleapis/api/httpbody"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/grpclog"
|
||||
@@ -1023,6 +1024,278 @@ func local_request_EveningDetectiveServer_DeleteScenarioPlace_0(ctx context.Cont
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_EveningDetectiveServer_AddScenarioQuestion_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq AddScenarioQuestionReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
if req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
}
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int32(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
msg, err := client.AddScenarioQuestion(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_EveningDetectiveServer_AddScenarioQuestion_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq AddScenarioQuestionReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int32(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
msg, err := server.AddScenarioQuestion(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_EveningDetectiveServer_UpdateScenarioQuestion_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq UpdateScenarioQuestionReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
if req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
}
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int32(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
val, ok = pathParams["code"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "code")
|
||||
}
|
||||
protoReq.Code, err = runtime.String(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "code", err)
|
||||
}
|
||||
msg, err := client.UpdateScenarioQuestion(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_EveningDetectiveServer_UpdateScenarioQuestion_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq UpdateScenarioQuestionReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int32(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
val, ok = pathParams["code"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "code")
|
||||
}
|
||||
protoReq.Code, err = runtime.String(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "code", err)
|
||||
}
|
||||
msg, err := server.UpdateScenarioQuestion(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_EveningDetectiveServer_DeleteScenarioQuestion_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq DeleteScenarioQuestionReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
if req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
}
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int32(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
val, ok = pathParams["code"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "code")
|
||||
}
|
||||
protoReq.Code, err = runtime.String(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "code", err)
|
||||
}
|
||||
msg, err := client.DeleteScenarioQuestion(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_EveningDetectiveServer_DeleteScenarioQuestion_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq DeleteScenarioQuestionReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int32(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
val, ok = pathParams["code"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "code")
|
||||
}
|
||||
protoReq.Code, err = runtime.String(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "code", err)
|
||||
}
|
||||
msg, err := server.DeleteScenarioQuestion(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_EveningDetectiveServer_UpdateScenarioIntro_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq UpdateScenarioIntroReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
if req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
}
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int32(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
msg, err := client.UpdateScenarioIntro(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_EveningDetectiveServer_UpdateScenarioIntro_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq UpdateScenarioIntroReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int32(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
msg, err := server.UpdateScenarioIntro(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_EveningDetectiveServer_DownloadScenarioArchive_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq DownloadScenarioArchiveReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
if req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
}
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int32(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
msg, err := client.DownloadScenarioArchive(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_EveningDetectiveServer_DownloadScenarioArchive_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq DownloadScenarioArchiveReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int32(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
msg, err := server.DownloadScenarioArchive(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_EveningDetectiveServer_UploadScenarioArchive_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq httpbody.HttpBody
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
if req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
}
|
||||
msg, err := client.UploadScenarioArchive(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_EveningDetectiveServer_UploadScenarioArchive_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq httpbody.HttpBody
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.UploadScenarioArchive(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_EveningDetectiveServer_AddGame_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq AddGameReq
|
||||
@@ -1110,6 +1383,45 @@ func local_request_EveningDetectiveServer_GetGame_0(ctx context.Context, marshal
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_EveningDetectiveServer_GetGameAnswers_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq GetGameAnswersReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
if req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
}
|
||||
val, ok := pathParams["gameId"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "gameId")
|
||||
}
|
||||
protoReq.GameId, err = runtime.Int32(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "gameId", err)
|
||||
}
|
||||
msg, err := client.GetGameAnswers(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_EveningDetectiveServer_GetGameAnswers_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq GetGameAnswersReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
val, ok := pathParams["gameId"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "gameId")
|
||||
}
|
||||
protoReq.GameId, err = runtime.Int32(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "gameId", err)
|
||||
}
|
||||
msg, err := server.GetGameAnswers(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_EveningDetectiveServer_UpdateGame_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq UpdateGameReq
|
||||
@@ -1712,6 +2024,165 @@ func local_request_EveningDetectiveServer_DeleteLastTeamAction_0(ctx context.Con
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
var filter_EveningDetectiveServer_GetTeamAnswers_0 = &utilities.DoubleArray{Encoding: map[string]int{"id": 0}, Base: []int{1, 1, 0}, Check: []int{0, 1, 2}}
|
||||
|
||||
func request_EveningDetectiveServer_GetTeamAnswers_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq GetTeamAnswersReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
if req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
}
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int64(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
if err := req.ParseForm(); err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_EveningDetectiveServer_GetTeamAnswers_0); err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := client.GetTeamAnswers(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_EveningDetectiveServer_GetTeamAnswers_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq GetTeamAnswersReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int64(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
if err := req.ParseForm(); err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_EveningDetectiveServer_GetTeamAnswers_0); err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.GetTeamAnswers(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_EveningDetectiveServer_SubmitTeamAnswers_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq SubmitTeamAnswersReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
if req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
}
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int64(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
msg, err := client.SubmitTeamAnswers(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_EveningDetectiveServer_SubmitTeamAnswers_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq SubmitTeamAnswersReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int64(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
msg, err := server.SubmitTeamAnswers(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_EveningDetectiveServer_SetTeamAnswerScore_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq SetTeamAnswerScoreReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
if req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
}
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int64(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
val, ok = pathParams["questionCode"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "questionCode")
|
||||
}
|
||||
protoReq.QuestionCode, err = runtime.String(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "questionCode", err)
|
||||
}
|
||||
msg, err := client.SetTeamAnswerScore(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_EveningDetectiveServer_SetTeamAnswerScore_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq SetTeamAnswerScoreReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int64(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
val, ok = pathParams["questionCode"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "questionCode")
|
||||
}
|
||||
protoReq.QuestionCode, err = runtime.String(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "questionCode", err)
|
||||
}
|
||||
msg, err := server.SetTeamAnswerScore(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
// RegisterEveningDetectiveServerHandlerServer registers the http handlers for service EveningDetectiveServer to "mux".
|
||||
// UnaryRPC :call EveningDetectiveServerServer directly.
|
||||
// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906.
|
||||
@@ -2318,6 +2789,126 @@ func RegisterEveningDetectiveServerHandlerServer(ctx context.Context, mux *runti
|
||||
}
|
||||
forward_EveningDetectiveServer_DeleteScenarioPlace_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_AddScenarioQuestion_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/AddScenarioQuestion", runtime.WithHTTPPathPattern("/api/scenarios/{id}/questions"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_EveningDetectiveServer_AddScenarioQuestion_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_AddScenarioQuestion_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPut, pattern_EveningDetectiveServer_UpdateScenarioQuestion_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/UpdateScenarioQuestion", runtime.WithHTTPPathPattern("/api/scenarios/{id}/questions/{code}"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_EveningDetectiveServer_UpdateScenarioQuestion_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_UpdateScenarioQuestion_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodDelete, pattern_EveningDetectiveServer_DeleteScenarioQuestion_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/DeleteScenarioQuestion", runtime.WithHTTPPathPattern("/api/scenarios/{id}/questions/{code}"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_EveningDetectiveServer_DeleteScenarioQuestion_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_DeleteScenarioQuestion_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPut, pattern_EveningDetectiveServer_UpdateScenarioIntro_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/UpdateScenarioIntro", runtime.WithHTTPPathPattern("/api/scenarios/{id}/introduction"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_EveningDetectiveServer_UpdateScenarioIntro_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_UpdateScenarioIntro_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodGet, pattern_EveningDetectiveServer_DownloadScenarioArchive_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/DownloadScenarioArchive", runtime.WithHTTPPathPattern("/api/scenarios/{id}/archive"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_EveningDetectiveServer_DownloadScenarioArchive_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_DownloadScenarioArchive_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_UploadScenarioArchive_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/UploadScenarioArchive", runtime.WithHTTPPathPattern("/api/scenarios/archive"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_EveningDetectiveServer_UploadScenarioArchive_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_UploadScenarioArchive_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_AddGame_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -2378,6 +2969,26 @@ func RegisterEveningDetectiveServerHandlerServer(ctx context.Context, mux *runti
|
||||
}
|
||||
forward_EveningDetectiveServer_GetGame_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodGet, pattern_EveningDetectiveServer_GetGameAnswers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/GetGameAnswers", runtime.WithHTTPPathPattern("/api/games/{gameId}/answers"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_EveningDetectiveServer_GetGameAnswers_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_GetGameAnswers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPut, pattern_EveningDetectiveServer_UpdateGame_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -2658,6 +3269,66 @@ func RegisterEveningDetectiveServerHandlerServer(ctx context.Context, mux *runti
|
||||
}
|
||||
forward_EveningDetectiveServer_DeleteLastTeamAction_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodGet, pattern_EveningDetectiveServer_GetTeamAnswers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/GetTeamAnswers", runtime.WithHTTPPathPattern("/api/teams/{id}/answers"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_EveningDetectiveServer_GetTeamAnswers_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_GetTeamAnswers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_SubmitTeamAnswers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/SubmitTeamAnswers", runtime.WithHTTPPathPattern("/api/teams/{id}/answers"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_EveningDetectiveServer_SubmitTeamAnswers_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_SubmitTeamAnswers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPut, pattern_EveningDetectiveServer_SetTeamAnswerScore_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/SetTeamAnswerScore", runtime.WithHTTPPathPattern("/api/teams/{id}/answers/{questionCode}/score"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_EveningDetectiveServer_SetTeamAnswerScore_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_SetTeamAnswerScore_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -3208,6 +3879,108 @@ func RegisterEveningDetectiveServerHandlerClient(ctx context.Context, mux *runti
|
||||
}
|
||||
forward_EveningDetectiveServer_DeleteScenarioPlace_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_AddScenarioQuestion_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/AddScenarioQuestion", runtime.WithHTTPPathPattern("/api/scenarios/{id}/questions"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_EveningDetectiveServer_AddScenarioQuestion_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_AddScenarioQuestion_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPut, pattern_EveningDetectiveServer_UpdateScenarioQuestion_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/UpdateScenarioQuestion", runtime.WithHTTPPathPattern("/api/scenarios/{id}/questions/{code}"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_EveningDetectiveServer_UpdateScenarioQuestion_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_UpdateScenarioQuestion_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodDelete, pattern_EveningDetectiveServer_DeleteScenarioQuestion_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/DeleteScenarioQuestion", runtime.WithHTTPPathPattern("/api/scenarios/{id}/questions/{code}"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_EveningDetectiveServer_DeleteScenarioQuestion_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_DeleteScenarioQuestion_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPut, pattern_EveningDetectiveServer_UpdateScenarioIntro_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/UpdateScenarioIntro", runtime.WithHTTPPathPattern("/api/scenarios/{id}/introduction"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_EveningDetectiveServer_UpdateScenarioIntro_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_UpdateScenarioIntro_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodGet, pattern_EveningDetectiveServer_DownloadScenarioArchive_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/DownloadScenarioArchive", runtime.WithHTTPPathPattern("/api/scenarios/{id}/archive"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_EveningDetectiveServer_DownloadScenarioArchive_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_DownloadScenarioArchive_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_UploadScenarioArchive_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/UploadScenarioArchive", runtime.WithHTTPPathPattern("/api/scenarios/archive"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_EveningDetectiveServer_UploadScenarioArchive_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_UploadScenarioArchive_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_AddGame_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -3259,6 +4032,23 @@ func RegisterEveningDetectiveServerHandlerClient(ctx context.Context, mux *runti
|
||||
}
|
||||
forward_EveningDetectiveServer_GetGame_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodGet, pattern_EveningDetectiveServer_GetGameAnswers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/GetGameAnswers", runtime.WithHTTPPathPattern("/api/games/{gameId}/answers"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_EveningDetectiveServer_GetGameAnswers_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_GetGameAnswers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPut, pattern_EveningDetectiveServer_UpdateGame_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -3497,6 +4287,57 @@ func RegisterEveningDetectiveServerHandlerClient(ctx context.Context, mux *runti
|
||||
}
|
||||
forward_EveningDetectiveServer_DeleteLastTeamAction_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodGet, pattern_EveningDetectiveServer_GetTeamAnswers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/GetTeamAnswers", runtime.WithHTTPPathPattern("/api/teams/{id}/answers"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_EveningDetectiveServer_GetTeamAnswers_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_GetTeamAnswers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_SubmitTeamAnswers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/SubmitTeamAnswers", runtime.WithHTTPPathPattern("/api/teams/{id}/answers"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_EveningDetectiveServer_SubmitTeamAnswers_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_SubmitTeamAnswers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPut, pattern_EveningDetectiveServer_SetTeamAnswerScore_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/SetTeamAnswerScore", runtime.WithHTTPPathPattern("/api/teams/{id}/answers/{questionCode}/score"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_EveningDetectiveServer_SetTeamAnswerScore_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_SetTeamAnswerScore_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3531,9 +4372,16 @@ var (
|
||||
pattern_EveningDetectiveServer_AddScenarioPlace_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "scenarios", "id", "places"}, ""))
|
||||
pattern_EveningDetectiveServer_UpdateScenarioPlace_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"api", "scenarios", "id", "places", "code"}, ""))
|
||||
pattern_EveningDetectiveServer_DeleteScenarioPlace_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"api", "scenarios", "id", "places", "code"}, ""))
|
||||
pattern_EveningDetectiveServer_AddScenarioQuestion_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "scenarios", "id", "questions"}, ""))
|
||||
pattern_EveningDetectiveServer_UpdateScenarioQuestion_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"api", "scenarios", "id", "questions", "code"}, ""))
|
||||
pattern_EveningDetectiveServer_DeleteScenarioQuestion_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"api", "scenarios", "id", "questions", "code"}, ""))
|
||||
pattern_EveningDetectiveServer_UpdateScenarioIntro_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "scenarios", "id", "introduction"}, ""))
|
||||
pattern_EveningDetectiveServer_DownloadScenarioArchive_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "scenarios", "id", "archive"}, ""))
|
||||
pattern_EveningDetectiveServer_UploadScenarioArchive_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "scenarios", "archive"}, ""))
|
||||
pattern_EveningDetectiveServer_AddGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "games"}, ""))
|
||||
pattern_EveningDetectiveServer_GetGames_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "games"}, ""))
|
||||
pattern_EveningDetectiveServer_GetGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "games", "id"}, ""))
|
||||
pattern_EveningDetectiveServer_GetGameAnswers_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "games", "gameId", "answers"}, ""))
|
||||
pattern_EveningDetectiveServer_UpdateGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "games", "id"}, ""))
|
||||
pattern_EveningDetectiveServer_StartGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "games", "id", "start"}, ""))
|
||||
pattern_EveningDetectiveServer_PauseGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "games", "id", "pause"}, ""))
|
||||
@@ -3548,6 +4396,9 @@ var (
|
||||
pattern_EveningDetectiveServer_GetTeamStory_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "teams", "id", "story"}, ""))
|
||||
pattern_EveningDetectiveServer_AddTeamAction_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "teams", "id", "actions"}, ""))
|
||||
pattern_EveningDetectiveServer_DeleteLastTeamAction_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "teams", "id", "last-actions"}, ""))
|
||||
pattern_EveningDetectiveServer_GetTeamAnswers_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "teams", "id", "answers"}, ""))
|
||||
pattern_EveningDetectiveServer_SubmitTeamAnswers_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "teams", "id", "answers"}, ""))
|
||||
pattern_EveningDetectiveServer_SetTeamAnswerScore_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 1, 0, 4, 1, 5, 4, 2, 5}, []string{"api", "teams", "id", "answers", "questionCode", "score"}, ""))
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -3581,9 +4432,16 @@ var (
|
||||
forward_EveningDetectiveServer_AddScenarioPlace_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_UpdateScenarioPlace_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_DeleteScenarioPlace_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_AddScenarioQuestion_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_UpdateScenarioQuestion_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_DeleteScenarioQuestion_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_UpdateScenarioIntro_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_DownloadScenarioArchive_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_UploadScenarioArchive_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_AddGame_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_GetGames_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_GetGame_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_GetGameAnswers_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_UpdateGame_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_StartGame_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_PauseGame_0 = runtime.ForwardResponseMessage
|
||||
@@ -3598,4 +4456,7 @@ var (
|
||||
forward_EveningDetectiveServer_GetTeamStory_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_AddTeamAction_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_DeleteLastTeamAction_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_GetTeamAnswers_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_SubmitTeamAnswers_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_SetTeamAnswerScore_0 = runtime.ForwardResponseMessage
|
||||
)
|
||||
|
||||
@@ -50,9 +50,16 @@ const (
|
||||
EveningDetectiveServer_AddScenarioPlace_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddScenarioPlace"
|
||||
EveningDetectiveServer_UpdateScenarioPlace_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UpdateScenarioPlace"
|
||||
EveningDetectiveServer_DeleteScenarioPlace_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteScenarioPlace"
|
||||
EveningDetectiveServer_AddScenarioQuestion_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddScenarioQuestion"
|
||||
EveningDetectiveServer_UpdateScenarioQuestion_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UpdateScenarioQuestion"
|
||||
EveningDetectiveServer_DeleteScenarioQuestion_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteScenarioQuestion"
|
||||
EveningDetectiveServer_UpdateScenarioIntro_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UpdateScenarioIntro"
|
||||
EveningDetectiveServer_DownloadScenarioArchive_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DownloadScenarioArchive"
|
||||
EveningDetectiveServer_UploadScenarioArchive_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UploadScenarioArchive"
|
||||
EveningDetectiveServer_AddGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddGame"
|
||||
EveningDetectiveServer_GetGames_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetGames"
|
||||
EveningDetectiveServer_GetGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetGame"
|
||||
EveningDetectiveServer_GetGameAnswers_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetGameAnswers"
|
||||
EveningDetectiveServer_UpdateGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UpdateGame"
|
||||
EveningDetectiveServer_StartGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/StartGame"
|
||||
EveningDetectiveServer_PauseGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/PauseGame"
|
||||
@@ -67,6 +74,9 @@ const (
|
||||
EveningDetectiveServer_GetTeamStory_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetTeamStory"
|
||||
EveningDetectiveServer_AddTeamAction_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddTeamAction"
|
||||
EveningDetectiveServer_DeleteLastTeamAction_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteLastTeamAction"
|
||||
EveningDetectiveServer_GetTeamAnswers_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetTeamAnswers"
|
||||
EveningDetectiveServer_SubmitTeamAnswers_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/SubmitTeamAnswers"
|
||||
EveningDetectiveServer_SetTeamAnswerScore_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/SetTeamAnswerScore"
|
||||
)
|
||||
|
||||
// EveningDetectiveServerClient is the client API for EveningDetectiveServer service.
|
||||
@@ -103,9 +113,16 @@ type EveningDetectiveServerClient interface {
|
||||
AddScenarioPlace(ctx context.Context, in *AddScenarioPlaceReq, opts ...grpc.CallOption) (*AddScenarioPlaceRsp, error)
|
||||
UpdateScenarioPlace(ctx context.Context, in *UpdateScenarioPlaceReq, opts ...grpc.CallOption) (*UpdateScenarioPlaceRsp, error)
|
||||
DeleteScenarioPlace(ctx context.Context, in *DeleteScenarioPlaceReq, opts ...grpc.CallOption) (*DeleteScenarioPlaceRsp, error)
|
||||
AddScenarioQuestion(ctx context.Context, in *AddScenarioQuestionReq, opts ...grpc.CallOption) (*AddScenarioQuestionRsp, error)
|
||||
UpdateScenarioQuestion(ctx context.Context, in *UpdateScenarioQuestionReq, opts ...grpc.CallOption) (*UpdateScenarioQuestionRsp, error)
|
||||
DeleteScenarioQuestion(ctx context.Context, in *DeleteScenarioQuestionReq, opts ...grpc.CallOption) (*DeleteScenarioQuestionRsp, error)
|
||||
UpdateScenarioIntro(ctx context.Context, in *UpdateScenarioIntroReq, opts ...grpc.CallOption) (*UpdateScenarioIntroRsp, error)
|
||||
DownloadScenarioArchive(ctx context.Context, in *DownloadScenarioArchiveReq, opts ...grpc.CallOption) (*httpbody.HttpBody, error)
|
||||
UploadScenarioArchive(ctx context.Context, in *httpbody.HttpBody, opts ...grpc.CallOption) (*UploadScenarioArchiveRsp, error)
|
||||
AddGame(ctx context.Context, in *AddGameReq, opts ...grpc.CallOption) (*AddGameRsp, error)
|
||||
GetGames(ctx context.Context, in *GetGamesReq, opts ...grpc.CallOption) (*GetGamesRsp, error)
|
||||
GetGame(ctx context.Context, in *GetGameReq, opts ...grpc.CallOption) (*GetGameRsp, error)
|
||||
GetGameAnswers(ctx context.Context, in *GetGameAnswersReq, opts ...grpc.CallOption) (*GetGameAnswersRsp, error)
|
||||
UpdateGame(ctx context.Context, in *UpdateGameReq, opts ...grpc.CallOption) (*UpdateGameRsp, error)
|
||||
StartGame(ctx context.Context, in *StartGameReq, opts ...grpc.CallOption) (*StartGameRsp, error)
|
||||
PauseGame(ctx context.Context, in *PauseGameReq, opts ...grpc.CallOption) (*PauseGameRsp, error)
|
||||
@@ -120,6 +137,9 @@ type EveningDetectiveServerClient interface {
|
||||
GetTeamStory(ctx context.Context, in *GetTeamStoryReq, opts ...grpc.CallOption) (*GetTeamStoryRsp, error)
|
||||
AddTeamAction(ctx context.Context, in *AddTeamActionReq, opts ...grpc.CallOption) (*AddTeamActionRsp, error)
|
||||
DeleteLastTeamAction(ctx context.Context, in *DeleteLastTeamActionReq, opts ...grpc.CallOption) (*DeleteLastTeamActionRsp, error)
|
||||
GetTeamAnswers(ctx context.Context, in *GetTeamAnswersReq, opts ...grpc.CallOption) (*GetTeamAnswersRsp, error)
|
||||
SubmitTeamAnswers(ctx context.Context, in *SubmitTeamAnswersReq, opts ...grpc.CallOption) (*SubmitTeamAnswersRsp, error)
|
||||
SetTeamAnswerScore(ctx context.Context, in *SetTeamAnswerScoreReq, opts ...grpc.CallOption) (*SetTeamAnswerScoreRsp, error)
|
||||
}
|
||||
|
||||
type eveningDetectiveServerClient struct {
|
||||
@@ -430,6 +450,66 @@ func (c *eveningDetectiveServerClient) DeleteScenarioPlace(ctx context.Context,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *eveningDetectiveServerClient) AddScenarioQuestion(ctx context.Context, in *AddScenarioQuestionReq, opts ...grpc.CallOption) (*AddScenarioQuestionRsp, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AddScenarioQuestionRsp)
|
||||
err := c.cc.Invoke(ctx, EveningDetectiveServer_AddScenarioQuestion_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *eveningDetectiveServerClient) UpdateScenarioQuestion(ctx context.Context, in *UpdateScenarioQuestionReq, opts ...grpc.CallOption) (*UpdateScenarioQuestionRsp, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UpdateScenarioQuestionRsp)
|
||||
err := c.cc.Invoke(ctx, EveningDetectiveServer_UpdateScenarioQuestion_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *eveningDetectiveServerClient) DeleteScenarioQuestion(ctx context.Context, in *DeleteScenarioQuestionReq, opts ...grpc.CallOption) (*DeleteScenarioQuestionRsp, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(DeleteScenarioQuestionRsp)
|
||||
err := c.cc.Invoke(ctx, EveningDetectiveServer_DeleteScenarioQuestion_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *eveningDetectiveServerClient) UpdateScenarioIntro(ctx context.Context, in *UpdateScenarioIntroReq, opts ...grpc.CallOption) (*UpdateScenarioIntroRsp, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UpdateScenarioIntroRsp)
|
||||
err := c.cc.Invoke(ctx, EveningDetectiveServer_UpdateScenarioIntro_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *eveningDetectiveServerClient) DownloadScenarioArchive(ctx context.Context, in *DownloadScenarioArchiveReq, opts ...grpc.CallOption) (*httpbody.HttpBody, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(httpbody.HttpBody)
|
||||
err := c.cc.Invoke(ctx, EveningDetectiveServer_DownloadScenarioArchive_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *eveningDetectiveServerClient) UploadScenarioArchive(ctx context.Context, in *httpbody.HttpBody, opts ...grpc.CallOption) (*UploadScenarioArchiveRsp, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UploadScenarioArchiveRsp)
|
||||
err := c.cc.Invoke(ctx, EveningDetectiveServer_UploadScenarioArchive_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *eveningDetectiveServerClient) AddGame(ctx context.Context, in *AddGameReq, opts ...grpc.CallOption) (*AddGameRsp, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AddGameRsp)
|
||||
@@ -460,6 +540,16 @@ func (c *eveningDetectiveServerClient) GetGame(ctx context.Context, in *GetGameR
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *eveningDetectiveServerClient) GetGameAnswers(ctx context.Context, in *GetGameAnswersReq, opts ...grpc.CallOption) (*GetGameAnswersRsp, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetGameAnswersRsp)
|
||||
err := c.cc.Invoke(ctx, EveningDetectiveServer_GetGameAnswers_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *eveningDetectiveServerClient) UpdateGame(ctx context.Context, in *UpdateGameReq, opts ...grpc.CallOption) (*UpdateGameRsp, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UpdateGameRsp)
|
||||
@@ -600,6 +690,36 @@ func (c *eveningDetectiveServerClient) DeleteLastTeamAction(ctx context.Context,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *eveningDetectiveServerClient) GetTeamAnswers(ctx context.Context, in *GetTeamAnswersReq, opts ...grpc.CallOption) (*GetTeamAnswersRsp, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetTeamAnswersRsp)
|
||||
err := c.cc.Invoke(ctx, EveningDetectiveServer_GetTeamAnswers_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *eveningDetectiveServerClient) SubmitTeamAnswers(ctx context.Context, in *SubmitTeamAnswersReq, opts ...grpc.CallOption) (*SubmitTeamAnswersRsp, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(SubmitTeamAnswersRsp)
|
||||
err := c.cc.Invoke(ctx, EveningDetectiveServer_SubmitTeamAnswers_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *eveningDetectiveServerClient) SetTeamAnswerScore(ctx context.Context, in *SetTeamAnswerScoreReq, opts ...grpc.CallOption) (*SetTeamAnswerScoreRsp, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(SetTeamAnswerScoreRsp)
|
||||
err := c.cc.Invoke(ctx, EveningDetectiveServer_SetTeamAnswerScore_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EveningDetectiveServerServer is the server API for EveningDetectiveServer service.
|
||||
// All implementations must embed UnimplementedEveningDetectiveServerServer
|
||||
// for forward compatibility.
|
||||
@@ -634,9 +754,16 @@ type EveningDetectiveServerServer interface {
|
||||
AddScenarioPlace(context.Context, *AddScenarioPlaceReq) (*AddScenarioPlaceRsp, error)
|
||||
UpdateScenarioPlace(context.Context, *UpdateScenarioPlaceReq) (*UpdateScenarioPlaceRsp, error)
|
||||
DeleteScenarioPlace(context.Context, *DeleteScenarioPlaceReq) (*DeleteScenarioPlaceRsp, error)
|
||||
AddScenarioQuestion(context.Context, *AddScenarioQuestionReq) (*AddScenarioQuestionRsp, error)
|
||||
UpdateScenarioQuestion(context.Context, *UpdateScenarioQuestionReq) (*UpdateScenarioQuestionRsp, error)
|
||||
DeleteScenarioQuestion(context.Context, *DeleteScenarioQuestionReq) (*DeleteScenarioQuestionRsp, error)
|
||||
UpdateScenarioIntro(context.Context, *UpdateScenarioIntroReq) (*UpdateScenarioIntroRsp, error)
|
||||
DownloadScenarioArchive(context.Context, *DownloadScenarioArchiveReq) (*httpbody.HttpBody, error)
|
||||
UploadScenarioArchive(context.Context, *httpbody.HttpBody) (*UploadScenarioArchiveRsp, error)
|
||||
AddGame(context.Context, *AddGameReq) (*AddGameRsp, error)
|
||||
GetGames(context.Context, *GetGamesReq) (*GetGamesRsp, error)
|
||||
GetGame(context.Context, *GetGameReq) (*GetGameRsp, error)
|
||||
GetGameAnswers(context.Context, *GetGameAnswersReq) (*GetGameAnswersRsp, error)
|
||||
UpdateGame(context.Context, *UpdateGameReq) (*UpdateGameRsp, error)
|
||||
StartGame(context.Context, *StartGameReq) (*StartGameRsp, error)
|
||||
PauseGame(context.Context, *PauseGameReq) (*PauseGameRsp, error)
|
||||
@@ -651,6 +778,9 @@ type EveningDetectiveServerServer interface {
|
||||
GetTeamStory(context.Context, *GetTeamStoryReq) (*GetTeamStoryRsp, error)
|
||||
AddTeamAction(context.Context, *AddTeamActionReq) (*AddTeamActionRsp, error)
|
||||
DeleteLastTeamAction(context.Context, *DeleteLastTeamActionReq) (*DeleteLastTeamActionRsp, error)
|
||||
GetTeamAnswers(context.Context, *GetTeamAnswersReq) (*GetTeamAnswersRsp, error)
|
||||
SubmitTeamAnswers(context.Context, *SubmitTeamAnswersReq) (*SubmitTeamAnswersRsp, error)
|
||||
SetTeamAnswerScore(context.Context, *SetTeamAnswerScoreReq) (*SetTeamAnswerScoreRsp, error)
|
||||
mustEmbedUnimplementedEveningDetectiveServerServer()
|
||||
}
|
||||
|
||||
@@ -751,6 +881,24 @@ func (UnimplementedEveningDetectiveServerServer) UpdateScenarioPlace(context.Con
|
||||
func (UnimplementedEveningDetectiveServerServer) DeleteScenarioPlace(context.Context, *DeleteScenarioPlaceReq) (*DeleteScenarioPlaceRsp, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method DeleteScenarioPlace not implemented")
|
||||
}
|
||||
func (UnimplementedEveningDetectiveServerServer) AddScenarioQuestion(context.Context, *AddScenarioQuestionReq) (*AddScenarioQuestionRsp, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AddScenarioQuestion not implemented")
|
||||
}
|
||||
func (UnimplementedEveningDetectiveServerServer) UpdateScenarioQuestion(context.Context, *UpdateScenarioQuestionReq) (*UpdateScenarioQuestionRsp, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UpdateScenarioQuestion not implemented")
|
||||
}
|
||||
func (UnimplementedEveningDetectiveServerServer) DeleteScenarioQuestion(context.Context, *DeleteScenarioQuestionReq) (*DeleteScenarioQuestionRsp, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method DeleteScenarioQuestion not implemented")
|
||||
}
|
||||
func (UnimplementedEveningDetectiveServerServer) UpdateScenarioIntro(context.Context, *UpdateScenarioIntroReq) (*UpdateScenarioIntroRsp, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UpdateScenarioIntro not implemented")
|
||||
}
|
||||
func (UnimplementedEveningDetectiveServerServer) DownloadScenarioArchive(context.Context, *DownloadScenarioArchiveReq) (*httpbody.HttpBody, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method DownloadScenarioArchive not implemented")
|
||||
}
|
||||
func (UnimplementedEveningDetectiveServerServer) UploadScenarioArchive(context.Context, *httpbody.HttpBody) (*UploadScenarioArchiveRsp, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UploadScenarioArchive not implemented")
|
||||
}
|
||||
func (UnimplementedEveningDetectiveServerServer) AddGame(context.Context, *AddGameReq) (*AddGameRsp, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AddGame not implemented")
|
||||
}
|
||||
@@ -760,6 +908,9 @@ func (UnimplementedEveningDetectiveServerServer) GetGames(context.Context, *GetG
|
||||
func (UnimplementedEveningDetectiveServerServer) GetGame(context.Context, *GetGameReq) (*GetGameRsp, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetGame not implemented")
|
||||
}
|
||||
func (UnimplementedEveningDetectiveServerServer) GetGameAnswers(context.Context, *GetGameAnswersReq) (*GetGameAnswersRsp, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetGameAnswers not implemented")
|
||||
}
|
||||
func (UnimplementedEveningDetectiveServerServer) UpdateGame(context.Context, *UpdateGameReq) (*UpdateGameRsp, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UpdateGame not implemented")
|
||||
}
|
||||
@@ -802,6 +953,15 @@ func (UnimplementedEveningDetectiveServerServer) AddTeamAction(context.Context,
|
||||
func (UnimplementedEveningDetectiveServerServer) DeleteLastTeamAction(context.Context, *DeleteLastTeamActionReq) (*DeleteLastTeamActionRsp, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method DeleteLastTeamAction not implemented")
|
||||
}
|
||||
func (UnimplementedEveningDetectiveServerServer) GetTeamAnswers(context.Context, *GetTeamAnswersReq) (*GetTeamAnswersRsp, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetTeamAnswers not implemented")
|
||||
}
|
||||
func (UnimplementedEveningDetectiveServerServer) SubmitTeamAnswers(context.Context, *SubmitTeamAnswersReq) (*SubmitTeamAnswersRsp, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SubmitTeamAnswers not implemented")
|
||||
}
|
||||
func (UnimplementedEveningDetectiveServerServer) SetTeamAnswerScore(context.Context, *SetTeamAnswerScoreReq) (*SetTeamAnswerScoreRsp, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SetTeamAnswerScore not implemented")
|
||||
}
|
||||
func (UnimplementedEveningDetectiveServerServer) mustEmbedUnimplementedEveningDetectiveServerServer() {
|
||||
}
|
||||
func (UnimplementedEveningDetectiveServerServer) testEmbeddedByValue() {}
|
||||
@@ -1364,6 +1524,114 @@ func _EveningDetectiveServer_DeleteScenarioPlace_Handler(srv interface{}, ctx co
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _EveningDetectiveServer_AddScenarioQuestion_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AddScenarioQuestionReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(EveningDetectiveServerServer).AddScenarioQuestion(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: EveningDetectiveServer_AddScenarioQuestion_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(EveningDetectiveServerServer).AddScenarioQuestion(ctx, req.(*AddScenarioQuestionReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _EveningDetectiveServer_UpdateScenarioQuestion_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdateScenarioQuestionReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(EveningDetectiveServerServer).UpdateScenarioQuestion(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: EveningDetectiveServer_UpdateScenarioQuestion_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(EveningDetectiveServerServer).UpdateScenarioQuestion(ctx, req.(*UpdateScenarioQuestionReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _EveningDetectiveServer_DeleteScenarioQuestion_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(DeleteScenarioQuestionReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(EveningDetectiveServerServer).DeleteScenarioQuestion(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: EveningDetectiveServer_DeleteScenarioQuestion_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(EveningDetectiveServerServer).DeleteScenarioQuestion(ctx, req.(*DeleteScenarioQuestionReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _EveningDetectiveServer_UpdateScenarioIntro_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdateScenarioIntroReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(EveningDetectiveServerServer).UpdateScenarioIntro(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: EveningDetectiveServer_UpdateScenarioIntro_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(EveningDetectiveServerServer).UpdateScenarioIntro(ctx, req.(*UpdateScenarioIntroReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _EveningDetectiveServer_DownloadScenarioArchive_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(DownloadScenarioArchiveReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(EveningDetectiveServerServer).DownloadScenarioArchive(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: EveningDetectiveServer_DownloadScenarioArchive_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(EveningDetectiveServerServer).DownloadScenarioArchive(ctx, req.(*DownloadScenarioArchiveReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _EveningDetectiveServer_UploadScenarioArchive_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(httpbody.HttpBody)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(EveningDetectiveServerServer).UploadScenarioArchive(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: EveningDetectiveServer_UploadScenarioArchive_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(EveningDetectiveServerServer).UploadScenarioArchive(ctx, req.(*httpbody.HttpBody))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _EveningDetectiveServer_AddGame_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AddGameReq)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -1418,6 +1686,24 @@ func _EveningDetectiveServer_GetGame_Handler(srv interface{}, ctx context.Contex
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _EveningDetectiveServer_GetGameAnswers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetGameAnswersReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(EveningDetectiveServerServer).GetGameAnswers(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: EveningDetectiveServer_GetGameAnswers_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(EveningDetectiveServerServer).GetGameAnswers(ctx, req.(*GetGameAnswersReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _EveningDetectiveServer_UpdateGame_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdateGameReq)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -1670,6 +1956,60 @@ func _EveningDetectiveServer_DeleteLastTeamAction_Handler(srv interface{}, ctx c
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _EveningDetectiveServer_GetTeamAnswers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetTeamAnswersReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(EveningDetectiveServerServer).GetTeamAnswers(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: EveningDetectiveServer_GetTeamAnswers_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(EveningDetectiveServerServer).GetTeamAnswers(ctx, req.(*GetTeamAnswersReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _EveningDetectiveServer_SubmitTeamAnswers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SubmitTeamAnswersReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(EveningDetectiveServerServer).SubmitTeamAnswers(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: EveningDetectiveServer_SubmitTeamAnswers_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(EveningDetectiveServerServer).SubmitTeamAnswers(ctx, req.(*SubmitTeamAnswersReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _EveningDetectiveServer_SetTeamAnswerScore_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SetTeamAnswerScoreReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(EveningDetectiveServerServer).SetTeamAnswerScore(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: EveningDetectiveServer_SetTeamAnswerScore_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(EveningDetectiveServerServer).SetTeamAnswerScore(ctx, req.(*SetTeamAnswerScoreReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// EveningDetectiveServer_ServiceDesc is the grpc.ServiceDesc for EveningDetectiveServer service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -1797,6 +2137,30 @@ var EveningDetectiveServer_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "DeleteScenarioPlace",
|
||||
Handler: _EveningDetectiveServer_DeleteScenarioPlace_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AddScenarioQuestion",
|
||||
Handler: _EveningDetectiveServer_AddScenarioQuestion_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdateScenarioQuestion",
|
||||
Handler: _EveningDetectiveServer_UpdateScenarioQuestion_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "DeleteScenarioQuestion",
|
||||
Handler: _EveningDetectiveServer_DeleteScenarioQuestion_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdateScenarioIntro",
|
||||
Handler: _EveningDetectiveServer_UpdateScenarioIntro_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "DownloadScenarioArchive",
|
||||
Handler: _EveningDetectiveServer_DownloadScenarioArchive_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UploadScenarioArchive",
|
||||
Handler: _EveningDetectiveServer_UploadScenarioArchive_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AddGame",
|
||||
Handler: _EveningDetectiveServer_AddGame_Handler,
|
||||
@@ -1809,6 +2173,10 @@ var EveningDetectiveServer_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GetGame",
|
||||
Handler: _EveningDetectiveServer_GetGame_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetGameAnswers",
|
||||
Handler: _EveningDetectiveServer_GetGameAnswers_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdateGame",
|
||||
Handler: _EveningDetectiveServer_UpdateGame_Handler,
|
||||
@@ -1865,6 +2233,18 @@ var EveningDetectiveServer_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "DeleteLastTeamAction",
|
||||
Handler: _EveningDetectiveServer_DeleteLastTeamAction_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetTeamAnswers",
|
||||
Handler: _EveningDetectiveServer_GetTeamAnswers_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SubmitTeamAnswers",
|
||||
Handler: _EveningDetectiveServer_SubmitTeamAnswers_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SetTeamAnswerScore",
|
||||
Handler: _EveningDetectiveServer_SetTeamAnswerScore_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "main.proto",
|
||||
|
||||
Reference in New Issue
Block a user