From dad1e76680b597445a1f8826e34bc6722e9f7cbe Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sun, 11 Oct 2026 13:27:02 +0700 Subject: [PATCH] up --- docs/agent/TASKS.md | 13 ++++++-- tests/venue/README.md | 23 +++++++++++++ tests/venue/flow.py | 66 ++++++++++++++++++++++++++++++------- tests/venue/flow_browser.py | 54 +++++++++++++++++++++++++++++- venue/nginx.conf | 11 +++++++ venue/nginx.https.conf | 18 ++++++++++ 6 files changed, 170 insertions(+), 15 deletions(-) diff --git a/docs/agent/TASKS.md b/docs/agent/TASKS.md index 490e42b..cb05ac3 100644 --- a/docs/agent/TASKS.md +++ b/docs/agent/TASKS.md @@ -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 — Общая дизайн-система фронтенда diff --git a/tests/venue/README.md b/tests/venue/README.md index 8f6364f..cf6ef0d 100644 --- a/tests/venue/README.md +++ b/tests/venue/README.md @@ -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 ГиБ не проверяется. diff --git a/tests/venue/flow.py b/tests/venue/flow.py index 2860945..32357cb 100644 --- a/tests/venue/flow.py +++ b/tests/venue/flow.py @@ -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: diff --git a/tests/venue/flow_browser.py b/tests/venue/flow_browser.py index db844c3..b336b6e 100644 --- a/tests/venue/flow_browser.py +++ b/tests/venue/flow_browser.py @@ -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: diff --git a/venue/nginx.conf b/venue/nginx.conf index 0af22d7..bfb3b31 100644 --- a/venue/nginx.conf +++ b/venue/nginx.conf @@ -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; diff --git a/venue/nginx.https.conf b/venue/nginx.https.conf index 1f8b8ad..e1d8f8a 100644 --- a/venue/nginx.https.conf +++ b/venue/nginx.https.conf @@ -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; }