up
This commit is contained in:
@@ -133,6 +133,42 @@ python3 tests/venue/smoke.py --verify
|
||||
Тестовые данные остаются в локальной базе; повторное создание запускайте только
|
||||
когда нужен новый набор. Дождитесь ответа `/api/test/ping` перед проверкой.
|
||||
|
||||
### Учёт устройств (этап 12)
|
||||
|
||||
Venue автоматически учитывает браузеры игроков, организаторов и экранов:
|
||||
`POST /api/devices/heartbeat` публично регистрирует подключение, а
|
||||
`GET /api/devices?gameID=<ID>` возвращает сгруппированные устройства организатору,
|
||||
администратору или разработчику. Фильтр необязателен; при его использовании
|
||||
список содержит только подключения выбранной игры. Панель состояния — этап 13.
|
||||
|
||||
UUID браузерного профиля хранится отдельно от авторизации в localStorage;
|
||||
каждая вкладка и перезагрузка страницы создают новый UUID подключения.
|
||||
Без localStorage устройство получает временный UUID. Контекст страницы
|
||||
полностью заменяется при heartbeat и очищается при навигации. Организатор
|
||||
проверяется по JWT и правам игры, команда — по паролю и неотменённому участию,
|
||||
экран — по существующей сессии; игру команды и экрана определяет сервер.
|
||||
Credentials используются только для проверки и не сохраняются в реестре.
|
||||
|
||||
Heartbeat отправляется сразу и каждые 20 секунд без параллельных запросов,
|
||||
а также при возвращении вкладки и восстановлении сети. Ошибки не блокируют
|
||||
игру и не показывают toast; повтор выполняется по интервалу. В prod ответ
|
||||
содержит `enabled=false`, после чего браузер прекращает heartbeat;
|
||||
список устройств в prod недоступен.
|
||||
|
||||
Реестр хранится только в памяти backend: UUID, серверные времена,
|
||||
последние IP/User-Agent и проверенные ID игры/команды/экрана. IP не участвует
|
||||
в идентификации и авторизации. Связь актуальна строго менее 60 секунд;
|
||||
устройство доступно, если доступно хотя бы одно его подключение в списке.
|
||||
Подключения удаляются через 15 минут отсутствия при heartbeat или чтении.
|
||||
Предел — 1024 подключения; новые отклоняются с `ResourceExhausted`, если
|
||||
после очистки нет места. После перезапуска открытые браузеры регистрируются
|
||||
заново. Времена устройства вычисляются по сохранённым подключениям.
|
||||
|
||||
«Нет связи» означает отсутствие heartbeat сайта, а не отключение от Wi-Fi.
|
||||
UUID обозначает браузерный профиль, а не физический телефон; очистка
|
||||
localStorage создаёт новое устройство. История посещений, полный URL,
|
||||
имена игроков, email и credentials в список не попадают.
|
||||
|
||||
### Прежние домены и HTTPS (этап 3)
|
||||
|
||||
Подготовка в репозитории готова; проверка реального Cudy и Android/iPhone без WAN
|
||||
|
||||
+5
-2
@@ -27,7 +27,7 @@
|
||||
|
||||
### ED-029 — Автономная площадка и панель организатора
|
||||
|
||||
- Статус: `IN_PROGRESS` — этап 3 принят пользователем без WAN; этапы 4–10 завершены; аппаратная приёмка этапа 8 подтверждена пользователем.
|
||||
- Статус: `IN_PROGRESS` — этап 3 принят пользователем без WAN; этапы 4–12 завершены; аппаратная приёмка этапа 8 подтверждена пользователем.
|
||||
- Репозитории: корневой, `evening_detective_server`, `evening_detective_frontend`.
|
||||
- Цель: реализовать принятый пользователем план из 15 последовательных этапов: автономное окружение; локальные организаторы; прежние адреса через Cudy; пакет игры; экспорт со статусом офлайн-режима; локальный импорт; интерфейс передачи; полный билетный и игровой flow; пакет результатов; возврат онлайн; интерфейс возврата; устройства; панель состояния; сбор данных Cudy; Wi-Fi и испытание площадки.
|
||||
- Критерии: проверяемый результат каждого этапа; цикл «онлайн → ноутбук → проведение → онлайн» через интерфейс с сохранением QR, билетов и связей; повторные импорты без дублей; мониторинг устройств и Wi-Fi; полный прогон без WAN, 100 клиентов и проверка покрытия.
|
||||
@@ -78,7 +78,10 @@
|
||||
- Ограничение этапа 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 не выполнялись.
|
||||
- Результат этапа 12 (2026-10-11): добавлены потокобезопасный сервис присутствия в памяти, wiring, protobuf/OpenAPI `POST /api/devices/heartbeat` и `GET /api/devices` с фильтром игры. Проверяются UUID, взаимоисключающие контексты, JWT/существующие права игры, пароль команды и неотменённое участие без загрузки сценария/прогресса, существующая сессия экрана; ID игры команды/экрана определяет сервер. Реестр хранит только проверенные связи, UUID, серверные времена и последние IP/User-Agent; credentials не сохраняются и не выдаются. Полная замена/очистка контекста, граница связи строго менее 60 секунд, удаление через 15 минут при обновлении/чтении, предел 1024 с `ResourceExhausted` без вытеснения существующих записей. В prod heartbeat явно возвращает `enabled=false`, список недоступен.
|
||||
- Браузер этапа 12: общий heartbeat в App сразу/каждые 20 секунд без параллельных запросов, при видимости/восстановлении сети и смене контекста. UUID профиля отдельно от авторизации в localStorage с временным fallback, UUID подключения на экземпляр приложения. Контекст страницы очищается при уходе, отозванный lifecycle не может восстановить старые связи; смена игры/пароля пересоздаёт page-компонент. Generated client и обновление JWT организатора переиспользованы; публичные credentials команды/экрана не запускают обновление JWT. После `enabled=false` polling прекращается. Ошибки не показывают toast и повторяются по интервалу. UI списка устройств не добавлен.
|
||||
- Проверки этапа 12: штатная генерация обоих репозиториев, gofmt, `go test ./...`, race для presence service/app, frontend type-check/lint/lint:css/build, self-check UUID/store/composable и diff-проверки прошли. Unit-тесты с управляемыми часами покрывают повтор/вкладки/смену и очистку связей, границы 60 секунд/15 минут, лимит и конкурентное чтение/обновление; API-тесты — роли, режимы, safe errors и отсутствие секретов. `python3 tests/venue/flow.py --browser` прошёл изолированные реальные API/PostgreSQL/RustFS/Firefox: credentials/отменённое участие, UUID/поля, группировку вкладок игрока/организатора/экрана, фильтр и привязку экрана, SPA-навигацию, прекращение heartbeat и восстановление сети, повторную регистрацию штатным интервалом после перезапуска backend. Независимое ревью выявило два замечания (публичный heartbeat не должен обновлять JWT, обрезка User-Agent должна сохранять UTF-8); оба исправлены и покрыты регрессиями. Итоговое ручное ревью по Go/Frontend стандартам выполнено; замечаний не осталось.
|
||||
- Следующий шаг: этап 13 — панель состояния площадки. Этап 12 завершён; общая задача остаётся `IN_PROGRESS` и не переносится в архив. ZIP остаётся неподписанным. Фактический ZIP 1 ГиБ, нагрузка на 100 клиентов и покрытие Wi-Fi не проверялись; развёртывание, commit/push не выполнялись.
|
||||
|
||||
### ED-024 — Общая дизайн-система фронтенда
|
||||
|
||||
|
||||
@@ -117,3 +117,23 @@ backend, имитируя смену DNS; API-ответы не подменяю
|
||||
ответа применяется только для проверки блокировки кнопки. До пользовательских
|
||||
flow запускаются PostgreSQL/race-регрессии возврата и реального HTTP-применения.
|
||||
Работающие серверы не меняются; фактическая загрузка ZIP 1 ГиБ не проверяется.
|
||||
|
||||
### Учёт устройств (этап 12)
|
||||
|
||||
`python3 tests/venue/flow.py --browser` проверяет реальный публичный heartbeat,
|
||||
права чтения, prod/venue, UUID и сочетания полей, неверные credentials,
|
||||
отменённое участие, смену/очистку контекста, фильтр игры и отсутствие секретов.
|
||||
Firefox проверяет игрока, организатора и экран в отдельных вкладках одного
|
||||
профиля, привязку экрана, SPA-навигацию, новые подключения при перезагрузке,
|
||||
остановку heartbeat, восстановление сети и повторную регистрацию через
|
||||
интервал после перезапуска backend. Прогон изолирован от работающих серверов.
|
||||
|
||||
Границы 60 секунд/15 минут, лимит 1024, конкурентное чтение/обновление и
|
||||
безопасное ограничение User-Agent проверяются тестами сервиса с управляемыми
|
||||
часами (`go test -race ./internal/services/device_presence_service ./internal/app`).
|
||||
Фронтенд self-check: `node src/utils/__tests__/deviceID.test.ts`,
|
||||
`node src/stores/__tests__/devicePresence.test.ts` и
|
||||
`node src/composables/__tests__/devicePresence.test.ts`: временный UUID,
|
||||
отозванные lifecycle-контексты, отсутствие параллельных heartbeat,
|
||||
остановка после `enabled=false`, повтор ошибок по интервалу и независимость
|
||||
публичных credentials от JWT. Нагрузочный и аппаратный прогоны не включены.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Stage 12 API checks in the disposable real venue."""
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from flow import API, run, wait_for
|
||||
|
||||
|
||||
def check_devices(stand, data):
|
||||
venue, prod = stand.apis['venue'], stand.apis['prod']
|
||||
public = API(venue.base)
|
||||
request = {'deviceID': str(uuid.uuid4()), 'connectionID': str(uuid.uuid4())}
|
||||
disabled = API(prod.base).request('/api/devices/heartbeat', {})
|
||||
assert disabled['enabled'] is False and disabled['serverTime']
|
||||
prod.request('/api/devices', expected=400)
|
||||
public.request('/api/devices', expected=401)
|
||||
assert public.request('/api/devices/heartbeat', request)['enabled'] is True
|
||||
public.request('/api/devices/heartbeat', request)
|
||||
devices = venue.request('/api/devices')['devices']
|
||||
assert len(devices) == 1 and len(devices[0]['connections']) == 1
|
||||
original = devices[0]['connections'][0]
|
||||
public.request('/api/devices/heartbeat', {**request, 'organizerGameID': data['game']}, expected=401)
|
||||
public.request('/api/devices/heartbeat', {**request, 'deviceID': 'bad'}, expected=400)
|
||||
for fields in ({'teamID': 0, 'password': 'secret'}, {'password': 'secret'}, {'screenID': '3'},
|
||||
{'organizerGameID': data['game'], 'teamID': 1, 'password': 'secret'},
|
||||
{'screenID': 'not-an-ID', 'accessToken': 'secret'}):
|
||||
public.request('/api/devices/heartbeat', {**request, **fields}, expected=400)
|
||||
team = data['teams'][0]
|
||||
public.request('/api/devices/heartbeat', {**request, 'teamID': team['ID'], 'password': 'invalid'}, expected=404)
|
||||
cancelled = data['teams'][2]
|
||||
public.request('/api/devices/heartbeat', {**request, 'teamID': cancelled['ID'], 'password': cancelled['password']}, expected=404)
|
||||
public.request('/api/devices/heartbeat', {**request, 'screenID': '2147483647', 'accessToken': 'invalid'}, expected=401)
|
||||
assert venue.request('/api/devices')['devices'][0]['connections'][0] == original
|
||||
public.request('/api/devices/heartbeat', {**request, 'teamID': team['ID'], 'password': team['password']})
|
||||
tab = {**request, 'connectionID': str(uuid.uuid4()), 'organizerGameID': data['game']}
|
||||
venue.request('/api/devices/heartbeat', tab)
|
||||
screen = public.request('/api/screens', {})
|
||||
venue.request(f'/api/screens/{screen["screenID"]}/bind', {**screen, 'gameID': data['game']})
|
||||
screen_tab = {**request, 'connectionID': str(uuid.uuid4()), **screen}
|
||||
public.request('/api/devices/heartbeat', screen_tab)
|
||||
devices = venue.request('/api/devices?gameID=' + str(data['game']))['devices']
|
||||
assert len(devices) == 1 and len(devices[0]['connections']) == 3
|
||||
assert {c['kind'] for c in devices[0]['connections']} == {'organizer', 'team', 'screen'}
|
||||
serialized = json.dumps(devices)
|
||||
for secret in (team['password'], screen['accessToken'], 'password', 'accessToken', 'email', 'URL'):
|
||||
assert secret not in serialized
|
||||
public.request('/api/devices/heartbeat', request)
|
||||
assert len(venue.request('/api/devices?gameID=' + str(data['game']))['devices'][0]['connections']) == 2
|
||||
browser = next(c for c in venue.request('/api/devices')['devices'][0]['connections'] if c['ID'] == request['connectionID'])
|
||||
assert browser['kind'] == 'browser' and not any(browser.get(key) for key in ('gameID', 'teamID', 'screenID'))
|
||||
# The stand uses the same test-only JWT signing key for both servers.
|
||||
reader = API(venue.base)
|
||||
reader.token = prod.token
|
||||
assert reader.request('/api/devices')['devices']
|
||||
run('docker', 'stop', stand.name + '-venue-app')
|
||||
run('docker', 'start', stand.name + '-venue-app')
|
||||
wait_for(lambda: public.request('/api/test/ping') == {}, 'presence backend restart')
|
||||
assert not venue.request('/api/devices')['devices']
|
||||
public.request('/api/devices/heartbeat', request)
|
||||
assert len(venue.request('/api/devices')['devices']) == 1
|
||||
print('PASS: stage 12 public API, UUID/context validation, credentials/cancellation, tabs/filter/clearing, safe output, prod/venue and backend restart', flush=True)
|
||||
@@ -406,6 +406,8 @@ def main():
|
||||
(stand.directory / f'game-{data["game"]}.zip').write_bytes(package)
|
||||
imported = stand.apis['venue'].request('/api/games/import', package)
|
||||
assert imported['gameID'] == data['game'] and not imported['repeated']
|
||||
from device_presence import check_devices
|
||||
check_devices(stand, fixtures[0])
|
||||
for data in fixtures:
|
||||
exercise(stand, data)
|
||||
if args.results:
|
||||
|
||||
@@ -98,6 +98,65 @@ def check_browser_flow(stand, fixtures):
|
||||
click('Вход')
|
||||
wait("location.pathname==='/office'", 'login')
|
||||
|
||||
def check_presence(data):
|
||||
from flow import run, wait_for
|
||||
api = stand.apis['venue']
|
||||
device_id = js("return localStorage.getItem('evening-detective-device-ID')")
|
||||
assert device_id
|
||||
|
||||
def connections():
|
||||
devices = api.request('/api/devices')['devices']
|
||||
return next((d['connections'] for d in devices if d['ID'] == device_id), [])
|
||||
|
||||
def has_kind(kind):
|
||||
return any(c['kind'] == kind for c in connections())
|
||||
|
||||
wait_for(lambda: has_kind('organizer'), 'organizer presence')
|
||||
organizer_window = command('WebDriver:GetWindowHandle')
|
||||
if isinstance(organizer_window, dict):
|
||||
organizer_window = organizer_window['value']
|
||||
team_window = command('WebDriver:NewWindow', {'type': 'tab'})['handle']
|
||||
command('WebDriver:SwitchToWindow', {'handle': team_window})
|
||||
captain = data['teams'][0]
|
||||
navigate(f'/team-story/{captain["ID"]}?password={urllib.parse.quote(captain["password"])}')
|
||||
wait_for(lambda: has_kind('team'), 'player presence')
|
||||
screen_window = command('WebDriver:NewWindow', {'type': 'tab'})['handle']
|
||||
command('WebDriver:SwitchToWindow', {'handle': screen_window})
|
||||
navigate('/screen')
|
||||
wait_for(lambda: has_kind('screen'), 'screen presence')
|
||||
screen_connection = next(c for c in connections() if c['kind'] == 'screen')
|
||||
# Bind the actual browser session, whose credentials stay inside the browser.
|
||||
saved = js("return JSON.parse(sessionStorage.getItem('evening-detective-screen-session'))")
|
||||
assert saved['screenID'] == str(screen_connection['screenID'])
|
||||
api.request(f'/api/screens/{saved["screenID"]}/bind', {**saved, 'gameID': data['game']})
|
||||
js("window.dispatchEvent(new Event('online'));return true;")
|
||||
wait_for(lambda: any(c['ID'] == screen_connection['ID'] and c.get('gameID') == data['game'] for c in connections()), 'bound screen presence')
|
||||
assert {c['kind'] for c in connections()} >= {'organizer', 'team', 'screen'}
|
||||
command('WebDriver:CloseWindow')
|
||||
command('WebDriver:SwitchToWindow', {'handle': team_window})
|
||||
command('WebDriver:CloseWindow')
|
||||
command('WebDriver:SwitchToWindow', {'handle': organizer_window})
|
||||
old_id = next(c['ID'] for c in connections() if c['kind'] == 'organizer')
|
||||
click('Главная')
|
||||
wait("location.pathname==='/'", 'SPA navigation')
|
||||
wait_for(lambda: any(c['ID'] == old_id and c['kind'] == 'browser' for c in connections()), 'SPA clears context on same connection')
|
||||
# Drop only heartbeat requests; gameplay and authorization remain live.
|
||||
js("window.presenceFetch=window.fetch;window.fetch=(url,options)=>String(url).includes('/devices/heartbeat')?Promise.reject(new Error('test offline')):window.presenceFetch(url,options);return true;")
|
||||
before = next(c['lastSeenAt'] for c in connections() if c['ID'] == old_id)
|
||||
time.sleep(22)
|
||||
assert next(c['lastSeenAt'] for c in connections() if c['ID'] == old_id) == before
|
||||
js("window.fetch=window.presenceFetch;window.dispatchEvent(new Event('online'));return true;")
|
||||
wait_for(lambda: next(c['lastSeenAt'] for c in connections() if c['ID'] == old_id) != before, 'network recovery')
|
||||
run('docker', 'stop', stand.name + '-venue-app')
|
||||
run('docker', 'start', stand.name + '-venue-app')
|
||||
wait_for(lambda: api.request('/api/test/ping') == {}, 'browser backend restart')
|
||||
wait_for(lambda: any(c['ID'] == old_id for c in connections()), 'automatic interval re-registration', 30)
|
||||
navigate(f'/games/{data["game"]}')
|
||||
wait_for(lambda: has_kind('organizer'), 'reloaded organizer presence')
|
||||
new_id = next(c['ID'] for c in connections() if c['kind'] == 'organizer')
|
||||
assert new_id != old_id and js("return localStorage.getItem('evening-detective-device-ID')") == device_id
|
||||
print('PASS: stage 12 real Firefox organizer/player/screen, grouped tabs, SPA context clearing, reload UUID, stopped heartbeat/network recovery and interval re-registration after restart', flush=True)
|
||||
|
||||
command('WebDriver:NewSession', {'capabilities': {'alwaysMatch': {'acceptInsecureCerts': True}}})
|
||||
for width, data in zip((1440, 390), fixtures, strict=True):
|
||||
game, captain = data['game'], data['teams'][0]
|
||||
@@ -132,6 +191,8 @@ def check_browser_flow(stand, fixtures):
|
||||
assert js("return document.querySelector('[role=dialog]').contains(document.activeElement)"), 'Modal lost keyboard focus'
|
||||
click('Открыть игру')
|
||||
wait(f"location.pathname==='/games/{game}'", 'open imported game')
|
||||
if width == 1440:
|
||||
check_presence(data)
|
||||
navigate(f'/games/{game}?tab=payment')
|
||||
wait("document.body.innerText.includes('Проверить билет')", 'ticket checking')
|
||||
click('Проверить билет')
|
||||
|
||||
Reference in New Issue
Block a user