up
This commit is contained in:
+10
-3
@@ -27,13 +27,13 @@
|
||||
|
||||
### ED-029 — Автономная площадка и панель организатора
|
||||
|
||||
- Статус: `IN_PROGRESS` — этап 3 принят пользователем без WAN; этапы 4–9 завершены; аппаратная приёмка этапа 8 подтверждена пользователем.
|
||||
- Статус: `IN_PROGRESS` — этап 3 принят пользователем без WAN; этапы 4–10 завершены; аппаратная приёмка этапа 8 подтверждена пользователем.
|
||||
- Репозитории: корневой, `evening_detective_server`, `evening_detective_frontend`.
|
||||
- Цель: реализовать принятый пользователем план из 15 последовательных этапов: автономное окружение; локальные организаторы; прежние адреса через Cudy; пакет игры; экспорт со статусом офлайн-режима; локальный импорт; интерфейс передачи; полный билетный и игровой flow; пакет результатов; возврат онлайн; интерфейс возврата; устройства; панель состояния; сбор данных Cudy; Wi-Fi и испытание площадки.
|
||||
- Критерии: проверяемый результат каждого этапа; цикл «онлайн → ноутбук → проведение → онлайн» через интерфейс с сохранением QR, билетов и связей; повторные импорты без дублей; мониторинг устройств и Wi-Fi; полный прогон без WAN, 100 клиентов и проверка покрытия.
|
||||
- Ограничения: только тестовые игры до завершения возврата; существующие правила игры и формат `offline` сохраняются; без несогласованных новых зависимостей и отклонений от стандартов; без персональных списков игроков, CPU/памяти Cudy и автоматического переключения серверов; настройки роутера менять только по явному запросу пользователя; секреты и пакеты вне Git; commit/push выполняет пользователь.
|
||||
- Проверка: Go-стандарт и `go test ./...`; PostgreSQL-интеграционные тесты для атомарности; frontend-стандарт, type-check, lint, lint:css и build; штатная генерация protobuf; ручные контрольные точки после этапов 3, 8, 11, 15.
|
||||
- Прогресс: этапы 1–2 реализованы и проверены в локальном Docker-окружении. Добавлены отдельный Compose, подготовка образов заранее, постоянные тома, миграции перед стартом, healthchecks и прокси. Явный `APP_MODE=venue` отключает SMTP, фоновые письма и внешний webhook; почтовые операции аккаунта возвращают безопасную ошибку до изменения данных. Локальные аккаунты создаются штатным `dev-user.sql`, роли и правила автоматического подтверждения команд сохраняются. Этап 3 принят пользователем после проверки без WAN; формат и код пакета игры (этап 4) реализованы и проверены. Экспорт со статусом офлайн-режима (этап 5) и локальный импорт (этап 6) реализованы и проверены. Этап 7 реализован и проверен; этап 8 проверен на изолированном стенде и принят пользователем на аппаратной контрольной точке; этап 9 реализован и проверен; этапы 10–15 ещё не завершены.
|
||||
- Прогресс: этапы 1–2 реализованы и проверены в локальном Docker-окружении. Добавлены отдельный Compose, подготовка образов заранее, постоянные тома, миграции перед стартом, healthchecks и прокси. Явный `APP_MODE=venue` отключает SMTP, фоновые письма и внешний webhook; почтовые операции аккаунта возвращают безопасную ошибку до изменения данных. Локальные аккаунты создаются штатным `dev-user.sql`, роли и правила автоматического подтверждения команд сохраняются. Этап 3 принят пользователем после проверки без WAN; формат и код пакета игры (этап 4) реализованы и проверены. Экспорт со статусом офлайн-режима (этап 5) и локальный импорт (этап 6) реализованы и проверены. Этап 7 реализован и проверен; этап 8 проверен на изолированном стенде и принят пользователем на аппаратной контрольной точке; этапы 9–11 реализованы и проверены; этапы 12–15 ещё не завершены.
|
||||
- Дополнение по запросу пользователя: структура `venue` приведена к `prod` (`backend/`, `frontend/dist/`, `migrations/`, `Dockerfile.backend`, `docker-compose.yml`, `deploy.sh`); имена основных Compose-сервисов совпадают. Общая генерация `make build` / `make prod-build` готовит артефакты обеих папок; frontend собирается отдельно с настройками своего окружения. Текущие файлы `prod` при этой работе не изменяются; сборка проверяется с production-выводом в `/tmp`.
|
||||
- Упрощение запуска: отдельные `init.sh` и `prepare.sh` удалены, всё собрано в `deploy.sh`. Единственная команда запуска — `make venue`: создаёт `.env` только при его отсутствии, при необходимости готовит зависимости/образы и автоматически создаёт организатора. Повторный готовый запуск не собирает и не скачивает образы. `make venue-update` явно пересобирает окружение с интернетом. Аккаунт организатора синхронизируется со значениями `.env` при каждом запуске. Первый запуск, повторный запуск без интернета и обновление проверяются `python3 tests/venue/deploy.py` с подставными командами; реальный готовый запуск проверен через Docker и API-smoke.
|
||||
- Настройки площадки: по запросу пользователя необязательный `venue/.env.example` удалён; настройки создаются автоматически через `make venue`. Убраны ссылка и проверка примера; `python3 tests/venue/deploy.py` прошёл. Существующая `.env` не изменялась.
|
||||
@@ -71,7 +71,14 @@
|
||||
- Приёмка этапа 8: пользователь подтвердил «Этап 8 принят» при согласовании следующего этапа. Отдельный повтор аппаратной проверки агентом не выполнялся.
|
||||
- Результат этапа 9: добавлен серверный сервис подготовки и проверки ZIP результатов версии 1, без новых API/UI, protobuf, миграций и зависимостей. Read-only PostgreSQL-снимок `REPEATABLE READ` связывает завершённую импортированную venue-игру с ID/SHA-256 исходного пакета. Сохраняются итоговые команды/участие, исходные и новые билеты, явные удаления, пароли QR/хеши билетов, порядок и время ходов/MCP, материалы и их выдача, ответы и nullable баллы. Исходные ссылки пользователей восстанавливаются из реестра; локальные исполнители вынесены в отдельное пространство. Материалы ссылаются на исходный ZIP, без локальных адресов RustFS. Изменённый игровой сценарий/формат и неизвестные материалы отклоняются. Повторная подготовка создаёт новый снимок с актуальными оценками и не меняет игру; кодек проверяет состав, версию, размеры/CRC/SHA-256, лимиты JSON/ZIP, уникальность и связи. Контракт описан в backend `docs/GAME_PACKAGE.md`.
|
||||
- Проверки этапа 9: полный `go test ./...`, unit-тесты кодека/снимка и `python3 tests/venue/flow.py --results` прошли. Изолированный PostgreSQL/RustFS-стенд проверил обе игры, связь с исходным ZIP, новые локальные записи, прогресс/выдачу/ответы/баллы, свежий снимок после оценивания, конкурентное оценивание, сохранность предыдущего пакета, повтор после перезапуска и отсутствие изменений исходных игр. Unit-тесты проверяют удаления, пустые наборы, повторные/неизвестные ходы/MCP, исходных и локальных исполнителей, повреждённые ZIP и связи, лимиты, отмену и ошибки записи. `gofmt`, Python-синтаксис, diff-проверки, ручное ревью по Go-стандарту и независимое read-only ревью выполнены. Frontend-код не менялся; его штатная сборка в `/tmp` прошла в составе стенда. Тестовые пакеты/контейнеры удалены; работающие окружения и Cudy не менялись. Commit/push и развёртывание не выполнялись.
|
||||
- Следующий шаг: этап 10 — безопасное применение результатов онлайн с проверкой исходного экспорта, конфликтами, соответствиями локальных ID и идемпотентностью возврата. API/UI передачи результатов остаются следующим этапам. ZIP на 1 ГиБ, нагрузка на 100 клиентов и покрытие Wi-Fi этим прогоном не проверялись. До завершения возврата использовать только тестовые игры. Общая задача остаётся `IN_PROGRESS` и не переносится в архив.
|
||||
- Результат этапа 10: серверный `GameResultsApplyService` принимает ZIP только в `prod`, проверяет источник/игру/ID/SHA-256 экспорта и сохранённый исходный ZIP из закрытого хранилища. Источник команд/билетов/сценария/материалов/вопросов сверяется с ZIP, формат неизменяем. По уточнению пользователя ZIP считается источником истины: возвращаемые поля, включая онлайн-прогресс/баллы/формат и игровой сценарий, перезаписываются без сравнения с исходным или последним применённым состоянием. Изменённый сценарий восстанавливается отдельной копией для игры, не меняя сценарии других игр; исходные онлайн-ссылки материалов сохраняются. Одна `SERIALIZABLE` транзакция с блокировками и максимум тремя попытками при serialization failure/deadlock записывает итоговые команды/участие, билеты, удаления и связи карты, ходы/MCP, материалы/выдачу, ответы, nullable баллы, `finish` и фактические даты. Баллы не пересчитываются; оформление, плановая дата, музыка, карта и экраны сохраняют онлайн-изменения. Исходные ID сохраняются; новые команды сохраняют venue-ID/пароль, новым билетам/ходам/уликам выделяются онлайн-ID с реестром соответствий. Материалы возвращаются в исходные онлайн-ссылки без новых загрузок и скачивания URL. Онлайн-ссылки пользователей и технические почтовые поля сохраняются, локальные исполнители остаются в закрытом реестре; новые почтовые задания/токены не создаются, возвращённые игры исключены из очереди новых напоминаний об отзыве.
|
||||
- Реестр этапа 10: миграция `game_returns` сохраняет ID/хеш ZIP, экспорт, исходный ID игры, точное UTC-время подготовки с наносекундами, время применения, соответствия ID (включая сценарий), локальных исполнителей и инициатора. Реестр остаётся после удаления игры. Повтор тех же байтов возвращает сохранённую запись, включая старый пакет после нового и повтор после удаления игры; тот же ID с другими байтами отклоняется. Новый пакет требует строго более позднего `created_at`, без поправок часов. `game.export` скрывается в игре/списке после успешного возврата соответствующего экспорта; исходный экспорт остаётся `ready`, ZIP сохраняется. Входной ZIP проходит приватный staging, чтобы проверенные данные и SHA-256 относились к одним байтам; временные файлы 0600/0700 удаляются при успехе, отказе и отмене.
|
||||
- Проверки этапа 10: полный `go test ./...` с отдельным PostgreSQL, релевантный `go test -race`, unit-тесты источника/материалов/пользователей/безопасных ошибок/private staging, PostgreSQL-регрессии rollback/удалений/ID и хеш-коллизий/последовательностей/точных повторов/конкурентности/порядка пакетов (включая шаг 1 нс)/исправленных баллов/онлайн-редактирования/почтовых заданий прошли. `python3 tests/venue/flow.py --apply` прошёл для обоих форматов с настоящими venue-ZIP, prod PostgreSQL/private RustFS, исправлением баллов, прежними и новыми ссылками, сохранением оформления, исчезновением офлайн-отметки и повтором старого пакета после нового/перезапуска prod. `gofmt`, Python-синтаксис, diff-проверки, ручное ревью по Go-стандарту и независимое read-only ревью выполнены. Контракт обновлён в backend `docs/GAME_PACKAGE.md`; API/UI, protobuf и зависимости не менялись. Тестовые контейнеры/ZIP удалены; рабочие окружения и Cudy не менялись. Commit/push и развёртывание не выполнялись.
|
||||
- Уточнение этапа 10 (2026-10-11): по запросу пользователя возвращаемый ZIP — источник истины, включая игровой сценарий. Удалены сравнения с исходным/последним онлайн-состоянием и поле `snapshot` реестра. При отличии сценария создаётся копия для игры с исходными вопросами и онлайн-ссылками материалов; другие игры сохраняют свой сценарий, ID копии записывается в соответствиях. Проверки после изменения: полный `go test ./...` с отдельным PostgreSQL, race для return repo/results service/export service, `flow.py --apply` обоих форматов с перезапуском prod, `gofmt`, diff-проверка и ручное ревью по Go-стандарту прошли. PostgreSQL-регрессии проверяют перезапись онлайн-команд/ответов/баллов/формата, удаление добавленных онлайн-команд, смену/правки общего сценария и отсутствие повторного копирования неизменённого сценария. Онлайн-правки возвращаемых данных теряются намеренно; проверки источника, порядка пакетов, коллизий и атомарность сохранены.
|
||||
- Ограничение этапа 10: подлинность ZIP криптографически не подтверждается. `nextval` PostgreSQL не откатывается: большие разрывы venue-ID расходуют sequence даже при отказе. Продвижение выполняется пакетами с пределом пять секунд, затем данные откатываются и выдаётся безопасная повторяемая ошибка; INT32-потолок отклоняется. Перед поддержкой таких площадок требуется расширение типов ID; лимит отмечен `shortcut:` в коде и контракте.
|
||||
- Результат этапа 11 (2026-10-11): подключены сервисы подготовки/применения, protobuf/OpenAPI и отдельные HTTP handlers `results-package/download` и `results-package/apply`. Venue выдаёт свежий ZIP завершённой импортированной игры после полной подготовки в файле 0600; prod принимает ограниченный поток до 1 ГиБ без обязательного Content-Length. Положительный ID и совпадение с manifest проверяются до повтора и изменений; временные файлы очищаются. Ответ содержит только gameID/packageID/appliedAt; безопасные ошибки 400/413/409/503, права организатора/администратора/разработчика. В настройках игры добавлены скачивание и применение ZIP, явное предупреждение о перезаписи данных/сценария и сохранении оформления/музыки, клиентская валидация, обновление авторизации, загрузка и блокировка повторного запроса. После успеха страница пересоздаётся и перечитывает данные без старых ответов/черновиков; повтор сообщает «Результаты приняты». Возврат остаётся доступен после снятия офлайн-отметки. Обновлены репозиторные proxy configs (1 ГиБ/30 минут), контракт и изолированный стенд; работающие серверы не менялись.
|
||||
- Проверки этапа 11: штатная генерация обоих репозиториев, gofmt, `go test ./...`, PostgreSQL/race-регрессии return repo/results/export service и HTTP поверх реального сервиса, frontend type-check/lint/lint:css/build, diff-проверки и ручное ревью по обоим стандартам прошли. Независимое ревью не выявило замечаний. `python3 tests/venue/flow.py --apply --browser` прошёл полный цикл реальных API/PostgreSQL/RustFS/Firefox для online (1440 px) и offline (390 px): кнопка свежего ZIP → страница prod → результаты, прежние ссылки/билеты, сохранённые название/описание, снятая офлайн-отметка, исправленный балл, перезаписанный онлайн-ответ, повтор старого ZIP после нового и перезапуска. Проверены чужой ID до/после применения, права, MIME, заявленный размер, поток без Content-Length, безопасные ошибки и очистка файлов. Аппаратный прогон на телефонах не повторялся.
|
||||
- Следующий шаг: этап 12. Этап 11 завершён после контрольного прогона; общая задача остаётся `IN_PROGRESS` и не переносится в архив. ZIP остаётся неподписанным. Фактический ZIP 1 ГиБ, нагрузка на 100 клиентов и покрытие Wi-Fi не проверялись; развёртывание, commit/push не выполнялись.
|
||||
|
||||
### ED-024 — Общая дизайн-система фронтенда
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ python3 tests/venue/dns.py --server 192.168.10.155
|
||||
python3 tests/venue/flow.py
|
||||
python3 tests/venue/flow.py --browser
|
||||
python3 tests/venue/flow.py --results
|
||||
python3 tests/venue/flow.py --apply
|
||||
python3 tests/venue/flow.py --apply --browser
|
||||
```
|
||||
|
||||
`flow.py` собирает актуальный Go/Vue-код во временном каталоге, поднимает
|
||||
@@ -94,3 +96,24 @@ backend, имитируя смену DNS; API-ответы не подменяю
|
||||
Нагрузка на 100 клиентов, покрытие Wi-Fi, ZIP на 1 ГиБ и возврат результатов
|
||||
относятся к отдельным проверкам/этапам. До готовности возврата использовать
|
||||
только тестовые игры.
|
||||
|
||||
|
||||
`--apply` включает проверки этапа 9 и дополнительно проверяет этап 10:
|
||||
настоящий ZIP площадки применяется серверным сервисом к изолированному prod,
|
||||
с проверкой исходного ZIP в private RustFS. Проверяются перезапись онлайн-правок данными ZIP,
|
||||
сохранение онлайн-оформления, исправленный балл,
|
||||
повтор старого ZIP после нового и после перезапуска prod, прежние QR/билеты и
|
||||
ссылки новых venue-команд/билетов, исчезновение `game.export`. Новых endpoint-ов
|
||||
нет: service-only Go harness вызывается из стенда. Все ZIP и ресурсы остаются
|
||||
в приватном временном каталоге и удаляются после прогона.
|
||||
|
||||
|
||||
Этап 11: `--browser` также проходит возврат обоих форматов через реальные
|
||||
интерфейсы: свежий ZIP кнопкой venue, загрузка на странице prod, предупреждение
|
||||
о перезаписи, блокировка повторного запроса, результаты, старые ссылки/билеты,
|
||||
сохранение названия/описания, исчезновение офлайн-отметки, исправленная оценка,
|
||||
перезапись онлайн-ответа и повтор старого пакета после перезапуска. Прокси стенда
|
||||
проверяет лимит ZIP 1 ГиБ и использует таймаут 30 минут; небольшая задержка
|
||||
ответа применяется только для проверки блокировки кнопки. До пользовательских
|
||||
flow запускаются PostgreSQL/race-регрессии возврата и реального HTTP-применения.
|
||||
Работающие серверы не меняются; фактическая загрузка ZIP 1 ГиБ не проверяется.
|
||||
|
||||
+55
-11
@@ -30,7 +30,7 @@ MATERIAL = b'%PDF-1.4\n% ED-029 stage 8 test clue\n%%EOF\n'
|
||||
def run(*args, input=None, cwd=None):
|
||||
result = subprocess.run(args, input=input, cwd=cwd, text=True, capture_output=True, timeout=180)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f'{args[0]} {args[1]} failed: {result.stderr[-2000:]}')
|
||||
raise RuntimeError(f'{args[0]} {args[1]} failed: {result.stdout[-2000:]}\n{result.stderr[-2000:]}')
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class API:
|
||||
data = None if body is None else json.dumps(body).encode()
|
||||
request = urllib.request.Request(self.base + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
response = OPENER.open(request, timeout=30)
|
||||
response = OPENER.open(request, timeout=1800 if "/results-package/" in request.full_url or request.full_url.endswith("/api/games/import") else 30)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
@@ -95,6 +95,7 @@ class Stand:
|
||||
self.network_created = False
|
||||
self.current = 'prod'
|
||||
self.delay_actions = 0
|
||||
self.delay_results = 0
|
||||
self.apis = {}
|
||||
stand = self
|
||||
|
||||
@@ -116,14 +117,21 @@ class Stand:
|
||||
|
||||
def handle_request(self):
|
||||
if self.path.startswith('/api/'):
|
||||
if self.command == 'POST' and self.path.endswith('/results-package/apply'):
|
||||
time.sleep(stand.delay_results)
|
||||
if self.command == 'POST' and self.path.endswith('/actions'):
|
||||
time.sleep(stand.delay_actions)
|
||||
headers = {key: value for key, value in self.headers.items() if key.lower() not in ('host', 'connection', 'content-length')}
|
||||
length = int(self.headers.get('Content-Length', 0))
|
||||
limit = 1024 ** 3 if '/results-package/' in self.path or self.path == '/api/games/import' else 257 * 1024 ** 2
|
||||
if length < 0 or length > limit:
|
||||
self.send_error(413)
|
||||
return
|
||||
self.connection.settimeout(1800)
|
||||
request = urllib.request.Request(stand.apis[stand.current].base + self.path,
|
||||
data=self.rfile.read(length) if length else None, headers=headers, method=self.command)
|
||||
try:
|
||||
response = OPENER.open(request, timeout=30)
|
||||
response = OPENER.open(request, timeout=1800 if "/results-package/" in request.full_url or request.full_url.endswith("/api/games/import") else 30)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
@@ -201,16 +209,16 @@ class Stand:
|
||||
self.server_thread.start()
|
||||
print('PASS: separate prod/venue DB and RustFS in internal Docker network', flush=True)
|
||||
|
||||
def restart(self):
|
||||
for suffix in ('venue-app', 'venue-pg', 'venue-s3'):
|
||||
def restart(self, mode='venue'):
|
||||
for suffix in (mode + '-app', mode + '-pg', mode + '-s3'):
|
||||
run('docker', 'stop', self.name + '-' + suffix)
|
||||
for suffix in ('venue-pg', 'venue-s3', 'venue-app'):
|
||||
for suffix in (mode + '-pg', mode + '-s3', mode + '-app'):
|
||||
run('docker', 'start', self.name + '-' + suffix)
|
||||
if suffix == 'venue-pg':
|
||||
wait_for(lambda: self.sql('venue', 'SELECT 1;') == '1', 'restarted PostgreSQL')
|
||||
elif suffix == 'venue-s3':
|
||||
wait_for(lambda: run('docker', 'exec', self.name + '-venue-s3', 'curl', '-fsS', 'http://127.0.0.1:9000/health') is not None, 'restarted RustFS')
|
||||
wait_for(lambda: self.apis['venue'].request('/api/test/ping') == {}, 'restarted venue')
|
||||
if suffix == mode + '-pg':
|
||||
wait_for(lambda: self.sql(mode, 'SELECT 1;') == '1', 'restarted PostgreSQL')
|
||||
elif suffix == mode + '-s3':
|
||||
wait_for(lambda: run('docker', 'exec', self.name + '-' + mode + '-s3', 'curl', '-fsS', 'http://127.0.0.1:9000/health') is not None, 'restarted RustFS')
|
||||
wait_for(lambda: self.apis[mode].request('/api/test/ping') == {}, 'restarted ' + mode)
|
||||
|
||||
def close(self):
|
||||
if self.server_thread.is_alive():
|
||||
@@ -303,9 +311,11 @@ def exercise(stand, data):
|
||||
public.request('/api/tickets/invalid', expected=404)
|
||||
venue.request('/api/tickets/invalid/accept', {}, expected=404)
|
||||
local_ticket = venue.request(f'/api/games/{game}/tickets', {'count': 1})['tickets'][0]
|
||||
data['local_ticket'] = local_ticket
|
||||
assert local_ticket['URL'].startswith(stand.public + '/tickets/')
|
||||
assert public.request('/api' + urllib.parse.urlparse(local_ticket['URL']).path)['ticket']['status'] == 'active'
|
||||
new_team = venue.request('/api/teams', {'gameID': game, 'name': 'TEST Local team', 'participantsCount': 2})
|
||||
data['new_team'] = new_team
|
||||
assert new_team['ID'] > max(t['ID'] for t in data['teams'])
|
||||
assert public.story(new_team)['game']['ID'] == game
|
||||
public.move(pending, 'p1', expected=400)
|
||||
@@ -372,9 +382,11 @@ def exercise(stand, data):
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--apply', action='store_true', help='Also verify stage 10 applying venue results to prod')
|
||||
parser.add_argument('--results', action='store_true', help='Also verify stage 9 result packages before/after restart')
|
||||
parser.add_argument('--browser', action='store_true', help='Also run Firefox against the real APIs (Firefox required)')
|
||||
args = parser.parse_args()
|
||||
args.results = args.results or args.apply
|
||||
with tempfile.TemporaryDirectory(prefix='ed029-flow-') as directory:
|
||||
stand = Stand(directory)
|
||||
try:
|
||||
@@ -383,6 +395,11 @@ def main():
|
||||
run('env', f'TEST_DATABASE_URL=host={pg_address} user=test password=test-only dbname=test sslmode=disable',
|
||||
'go', 'test', './internal/services/game_service', './internal/services/gameplay_service', './internal/services/mcp_service', './internal/repos/tickets_repo', './internal/app', cwd=ROOT / 'evening_detective_server')
|
||||
print('PASS: gameplay/lifecycle/MCP/app tests with isolated PostgreSQL', flush=True)
|
||||
prod_address = json.loads(run('docker', 'inspect', stand.name + '-prod-pg'))[0]['NetworkSettings']['Networks'][stand.name]['IPAddress']
|
||||
run('env', f'TEST_DATABASE_URL=host={prod_address} user=test password=test-only dbname=test sslmode=disable',
|
||||
'go', 'test', '-race', './internal/services/game_export_service', './internal/services/game_results_package_service', './internal/repos/game_returns_repo', '-count=1', cwd=ROOT / 'evening_detective_server')
|
||||
print('PASS: PostgreSQL/race result return regressions and real HTTP transport', flush=True)
|
||||
|
||||
fixtures = [fixture(stand, format) for format in ('offline', 'online')]
|
||||
for data in fixtures:
|
||||
package = data['package'] = export_package(stand.apis['prod'], data['game'])
|
||||
@@ -415,6 +432,33 @@ def main():
|
||||
assert [stand.apis['prod'].game(data['game']) for data in fixtures] == source_before
|
||||
assert [stand.apis['venue'].game(data['game']) for data in fixtures] == venue_before
|
||||
print('PASS: stage 9 real result packages, fresh grading, concurrent snapshots, restart and unchanged games', flush=True)
|
||||
if args.apply:
|
||||
prod_pg = json.loads(run('docker', 'inspect', stand.name + '-prod-pg'))[0]['NetworkSettings']['Networks'][stand.name]['IPAddress']
|
||||
prod_s3 = json.loads(run('docker', 'inspect', stand.name + '-prod-s3'))[0]['NetworkSettings']['Networks'][stand.name]['IPAddress']
|
||||
for attempt in range(2):
|
||||
if attempt:
|
||||
stand.restart('prod')
|
||||
for data in fixtures:
|
||||
result = run('env', f'NO_PROXY={prod_s3}', f'no_proxy={prod_s3}', f'TEST_DATABASE_URL=host={prod_pg} user=test password=test-only dbname=test sslmode=disable',
|
||||
f'ED029_RETURN_GAME_ID={data["game"]}', f'ED029_RETURN_SOURCE={stand.public}',
|
||||
f'ED029_RETURN_S3=http://{prod_s3}:9000', f'ED029_RESULTS_DIR={stand.directory}',
|
||||
'go', 'test', './internal/services/game_results_package_service', '-run', 'TestProdFlowReturns', '-count=1', '-v',
|
||||
cwd=ROOT / 'evening_detective_server')
|
||||
assert '--- PASS: TestProdFlowReturns' in result
|
||||
source = stand.apis['prod']
|
||||
returned = source.game(data['game'])
|
||||
assert returned['status'] == 'finish' and not returned.get('export')
|
||||
assert returned['name'] == 'TEST Online title after export'
|
||||
assert returned['description'] == 'TEST Online description'
|
||||
assert source.story(data['teams'][0])['game']['status'] == 'finish'
|
||||
new_team = next(t for t in returned['teams'] if t['ID'] == data['new_team']['ID'])
|
||||
assert new_team['password'] == data['new_team']['password']
|
||||
assert source.story(data['new_team'])['game']['status'] == 'finish'
|
||||
assert source.request('/api' + urllib.parse.urlparse(data['local_ticket']['URL']).path)['ticket']['status'] == 'active'
|
||||
assert source.request('/api' + urllib.parse.urlparse(data['tickets'][1]['URL']).path)['ticket']['status'] == 'used'
|
||||
scored = next(t for t in source.request(f'/api/games/{data["game"]}/results')['teams'] if t['team']['ID'] == data['teams'][0]['ID'])
|
||||
assert scored['totalScore'] == 0 and scored['team']['actionsCount'] == 2
|
||||
print('PASS: stage 10 atomic prod returns, correction, original/venue links, preserved presentation, cleared export and restart repeats', flush=True)
|
||||
finally:
|
||||
stand.close()
|
||||
if args.browser:
|
||||
|
||||
@@ -197,7 +197,59 @@ def check_browser_flow(stand, fixtures):
|
||||
assert js("return document.querySelector('.questions-block')===null"), 'Finished game exposes questions'
|
||||
navigate(f'/team-story/{captain["ID"]}?password=wrong')
|
||||
wait("document.body.innerText.includes('Не удалось загрузить дело')", 'wrong-password error')
|
||||
print(f'PASS: real Firefox {width}px {data["format"]}: export/download/import, tickets, moves, pause, clues, grading/results, errors and keyboard', flush=True)
|
||||
def download_results():
|
||||
navigate(f'/games/{game}?tab=settings')
|
||||
wait("document.body.innerText.includes('Скачать результаты')", 'results download button')
|
||||
before = {p.name for p in downloads.glob('*.zip')}
|
||||
click('Скачать результаты')
|
||||
until = time.monotonic() + 15
|
||||
while time.monotonic() < until:
|
||||
files = [p for p in downloads.glob('*.zip') if p.name not in before and p.stat().st_size and not p.with_suffix(p.suffix + '.part').exists()]
|
||||
if files:
|
||||
return max(files, key=lambda p: p.stat().st_mtime).read_bytes()
|
||||
time.sleep(.1)
|
||||
raise AssertionError('Results download missing')
|
||||
|
||||
def apply_results(raw):
|
||||
navigate(f'/games/{game}?tab=settings')
|
||||
wait("document.body.innerText.includes('Возврат результатов с площадки')", 'return block')
|
||||
assert js("return document.body.innerText.includes('Онлайн-правки этих данных потеряются') && document.body.innerText.includes('Оформление и музыка сохраняются')"), 'Replacement warning missing'
|
||||
js("const dt=new DataTransfer();dt.items.add(new File([new Uint8Array(arguments[0])],'results.zip',{type:'application/zip'}));const input=document.querySelector('input[type=file]');input.files=dt.files;input.dispatchEvent(new Event('change',{bubbles:true}));return true;", list(raw))
|
||||
stand.delay_results = .5
|
||||
click('Применить ZIP и перезаписать результаты')
|
||||
assert js("return [...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='Применить ZIP и перезаписать результаты').disabled"), 'Repeat apply is not blocked'
|
||||
wait("document.body.innerText.includes('Результаты приняты')", 'results accepted')
|
||||
stand.delay_results = 0
|
||||
wait("!document.body.innerText.includes('Игра в офлайн-режиме')", 'offline mark cleared')
|
||||
assert stand.apis['prod'].game(game)['status'] == 'finish'
|
||||
|
||||
first_results = download_results()
|
||||
login('prod')
|
||||
stand.sql('prod', f"UPDATE games SET name='TEST Preserved presentation', description='TEST Preserved description' WHERE id={game};")
|
||||
apply_results(first_results)
|
||||
assert stand.apis['prod'].game(game)['name'] == 'TEST Preserved presentation'
|
||||
assert stand.apis['prod'].story(captain)['game']['status'] == 'finish'
|
||||
assert stand.apis['prod'].request('/api' + urllib.parse.urlparse(data['tickets'][1]['URL']).path)['ticket']['status'] == 'used'
|
||||
assert stand.apis['prod'].request(f'/api/games/{game}/results')['teams'][0]['totalScore'] == 5
|
||||
login('venue')
|
||||
navigate(f'/games/{game}?tab=answers')
|
||||
wait("document.querySelector('.answers-score-input input')!==null", 'corrected grading')
|
||||
input_value('.answers-score-input input', '0')
|
||||
click('Сохранить баллы')
|
||||
wait("document.body.innerText.includes('Баллы сохранены')", 'correction saved')
|
||||
corrected_results = download_results()
|
||||
assert corrected_results != first_results, 'Results are not fresh'
|
||||
login('prod')
|
||||
stand.sql('prod', f"UPDATE team_answers SET answer='Online edit', score=9 WHERE team_id={captain['ID']};")
|
||||
apply_results(corrected_results)
|
||||
scores = stand.apis['prod'].request(f'/api/games/{game}/results')['teams']
|
||||
assert next(t for t in scores if t['team']['ID'] == captain['ID'])['totalScore'] == 0
|
||||
accepted = stand.sql('prod', f"SELECT applied_at FROM game_returns WHERE source_game_id={game} ORDER BY applied_at DESC LIMIT 1;")
|
||||
stand.restart('prod')
|
||||
apply_results(first_results)
|
||||
assert stand.sql('prod', f"SELECT applied_at FROM game_returns WHERE source_game_id={game} ORDER BY applied_at DESC LIMIT 1;") == accepted
|
||||
assert next(t for t in stand.apis['prod'].request(f'/api/games/{game}/results')['teams'] if t['team']['ID'] == captain['ID'])['totalScore'] == 0
|
||||
print(f'PASS: real Firefox {width}px {data["format"]}: export/download/import, tickets, moves, pause, clues, grading/results, result ZIP return, correction, online overwrite, preserved presentation, restart repeat, errors and keyboard', flush=True)
|
||||
command('Marionette:Quit', {'flags': ['eForceQuit']})
|
||||
finally:
|
||||
if sock:
|
||||
|
||||
@@ -14,6 +14,17 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location ~ ^/api/games/[0-9]+/results-package/(apply|download)$ {
|
||||
client_max_body_size 1g;
|
||||
client_body_timeout 1800s;
|
||||
proxy_read_timeout 1800s;
|
||||
proxy_send_timeout 1800s;
|
||||
proxy_request_buffering off;
|
||||
proxy_pass http://evening_detective_server:8090;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://evening_detective_server:8090;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@@ -33,6 +33,15 @@ server {
|
||||
proxy_pass http://evening_detective_server:8090;
|
||||
}
|
||||
|
||||
location ~ ^/api/games/[0-9]+/results-package/(apply|download)$ {
|
||||
client_max_body_size 1g;
|
||||
client_body_timeout 1800s;
|
||||
proxy_read_timeout 1800s;
|
||||
proxy_send_timeout 1800s;
|
||||
proxy_request_buffering off;
|
||||
proxy_pass http://evening_detective_server:8090;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://evening_detective_server:8090;
|
||||
}
|
||||
@@ -55,6 +64,15 @@ server {
|
||||
proxy_pass http://evening_detective_server:8090;
|
||||
}
|
||||
|
||||
location ~ ^/api/games/[0-9]+/results-package/(apply|download)$ {
|
||||
client_max_body_size 1g;
|
||||
client_body_timeout 1800s;
|
||||
proxy_read_timeout 1800s;
|
||||
proxy_send_timeout 1800s;
|
||||
proxy_request_buffering off;
|
||||
proxy_pass http://evening_detective_server:8090;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://evening_detective_server:8090;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user