From 818eeaa1ac3b09b4e638fff947b1f2d52c7861a9 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sun, 11 Oct 2026 16:00:53 +0700 Subject: [PATCH] up --- docs/agent/COMPLETED_TASKS.md | 15 ++++++++ prod | 2 +- tests/venue/README.md | 6 +++- tests/venue/device_presence.py | 4 +-- tests/venue/flow.py | 13 ++++--- tests/venue/flow_browser.py | 6 ++-- tests/venue/team_uuid.py | 66 ++++++++++++++++++++++++++++++++++ 7 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 tests/venue/team_uuid.py diff --git a/docs/agent/COMPLETED_TASKS.md b/docs/agent/COMPLETED_TASKS.md index c1fa7c6..5eca080 100644 --- a/docs/agent/COMPLETED_TASKS.md +++ b/docs/agent/COMPLETED_TASKS.md @@ -2,6 +2,21 @@ Архив задач, переведённых в статус `DONE`. +### ED-031 — UUID v7 команд во всех окружениях + +- Статус: `DONE` +- Репозитории: `evening_detective_server`, `evening_detective_frontend`, инфраструктурные проверки +- Цель: независимое создание команд онлайн и на нескольких автономных площадках без пересечения ID; сохранение ID при переносе и возврате результатов. +- Решение: UUID v7 генерируется в Go; БД хранит UUID, API и фронтенд используют строки. Миграция заменяет старые ID, сохраняя игровые данные и связи; пакеты игры и результатов переходят на версию 2. +- Критерии: все способы создания команд используют v7; игровые операции, карта, QR, отзывы, MCP и устройства работают; перенос и возврат сохраняют UUID; миграция сохраняет данные. +- Ограничения: старые ссылки и ZIP не поддерживаются; перед обновлением завершить текущие переносы, сохранить резервные копии и согласованно обновить окружения. Новые зависимости, commit и push исключены. +- Проверка: миграция отдельной тестовой БД; `go test ./...`; `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm run build`; сценарии `tests/venue/flow.py`. +- Результат: UUID v7 генерируется существующей Go-зависимостью во всех способах создания команд; изменены UUID/FK/карта, API, frontend, QR, MCP, устройства, экспорт/импорт и возврат. Форматы пакетов версии 2 сохраняют UUID. Добавлена миграция заполненной БД; старые реестры переноса очищаются без удаления игрового прогресса и материалов. Документирован согласованный переход и откат из резервной копии. Новых зависимостей нет. +- Сопутствующее исправление: браузерная проверка обнаружила гонку загрузки прав при прямом открытии вкладки ответов. Загрузка теперь реактивна после получения роли и при выборе вкладки, без двойного запроса. +- Проверки: `make generate`, `npm run proto:gen`, `go test ./...` и полный Go-набор с изолированным PostgreSQL прошли; проверены независимые UUID v7 в трёх схемах, публичная регистрация, неверные ID и отклонение ZIP v1. `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm run build` и оба Node-теста присутствия устройств прошли. `tests/venue/flow.py --apply --browser` проверил миграцию, оба формата игры, перенос/возврат, конкурентность, повтор и рестарт; финальный frontend дополнительно прошёл Firefox 1440px online и 390px offline на отдельном временном стенде. `git diff --check` прошёл в корне и обоих репозиториях; выполнено независимое ревью и сверка стандартов Go/frontend. +- Ограничения проверки: миграции рабочих БД и развёртывание не выполнялись; реальные телефоны, камера и аппаратная работа без WAN не проверялись. Commit/push не выполнялись. Существовавшие изменения `prod`, `reproxy` и backend-бинарника сохранены. + + ### ED-029 — Автономная площадка и панель организатора - Статус: `DONE` — закрыта 2026-10-11 по явному указанию пользователя; код и автоматические проверки завершены, аппаратная приёмка этапа 15 не выполнена. diff --git a/prod b/prod index c66f7ce..1a3f9d8 160000 --- a/prod +++ b/prod @@ -1 +1 @@ -Subproject commit c66f7ce4979beb37d8728600f4609d4e2be8bccc +Subproject commit 1a3f9d8d3893af41c38df4dd0ba1df043b7d1919 diff --git a/tests/venue/README.md b/tests/venue/README.md index f5569a2..a554b6e 100644 --- a/tests/venue/README.md +++ b/tests/venue/README.md @@ -52,7 +52,7 @@ python3 tests/venue/flow.py --apply --browser конкурентность, новые локальные команды/билеты, ходы и материалы, запрет ходов до старта/на паузе/после завершения, перезапуск всех venue-сервисов, ответы, оценивание, результаты и повторный импорт без потери прогресса. Дополнительно -запускаются PostgreSQL-регрессии gameplay/lifecycle, tickets, MCP и app. Выдача +запускается полный Go-набор с PostgreSQL, включая gameplay/lifecycle, tickets, MCP и app. Выдача токенов писем имитируется только в тестовой БД; решения капитанов выполняются через штатный API. Возврат результатов на источник не выполняется. @@ -206,3 +206,7 @@ TR3000 V1 2.5.27-20260804-183625 без изменения настроек ро Автоматические проверки не заменяют эту приёмку. ED-029 закрыта по явному указанию пользователя 2026-10-11; аппаратная приёмка не выполнена. Фактический ZIP 1 ГиБ также не проверен. + +Flow также проверяет переход заполненной БД с числовых ID команд на UUID v7, +сохранность данных и ограничений, независимое создание команд в трёх схемах +и полную Go-проверку на временном PostgreSQL. `team_uuid.py` вызывается из flow. diff --git a/tests/venue/device_presence.py b/tests/venue/device_presence.py index 3e0a521..012e5aa 100644 --- a/tests/venue/device_presence.py +++ b/tests/venue/device_presence.py @@ -20,8 +20,8 @@ def check_devices(stand, data): 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'}, + for fields in ({'teamID': '', 'password': 'secret'}, {'password': 'secret'}, {'screenID': '3'}, + {'organizerGameID': data['game'], 'teamID': '019a1234-5678-7abc-8def-000000000001', 'password': 'secret'}, {'screenID': 'not-an-ID', 'accessToken': 'secret'}): public.request('/api/devices/heartbeat', {**request, **fields}, expected=400) team = data['teams'][0] diff --git a/tests/venue/flow.py b/tests/venue/flow.py index 81ee76d..5b1a5e0 100644 --- a/tests/venue/flow.py +++ b/tests/venue/flow.py @@ -190,7 +190,7 @@ class Stand: wait_for(lambda: self.sql(mode, 'SELECT 1;') == '1', mode + ' PostgreSQL') wait_for(lambda: run('docker', 'exec', self.name + '-' + mode + '-s3', 'curl', '-fsS', 'http://127.0.0.1:9000/health') is not None, mode + ' RustFS') for migration in sorted((ROOT / 'evening_detective_server/migrations').glob('*.sql')): - self.sql(mode, migration.read_text().split('-- +goose Down')[0]) + self.sql(mode, 'BEGIN;\n' + migration.read_text().split('-- +goose Down')[0] + '\nCOMMIT;') bootstrap = (ROOT / 'evening_detective_server/scripts/dev-user.sql').read_text() run('docker', 'exec', '-i', self.name + '-' + mode + '-pg', 'psql', '-U', 'test', '-d', 'test', '-v', 'ON_ERROR_STOP=1', '-v', 'dev_username=TEST organizer', '-v', 'dev_email=organizer@test.invalid', '-v', 'dev_password=test-only-password', input=bootstrap) @@ -208,6 +208,8 @@ class Stand: wait_for(lambda: api.request('/api/test/ping') == {}, mode + ' API') api.token = api.request('/api/auth/login', {'email': 'organizer@test.invalid', 'password': 'test-only-password'})['accessToken'] assert api.request('/api/ui/permissions')['environment'] == mode + from team_uuid import check_migration + check_migration(lambda query: self.sql('prod', query), ROOT / 'evening_detective_server/migrations') self.server_thread.start() print('PASS: separate prod/venue DB and RustFS in internal Docker network', flush=True) @@ -260,7 +262,7 @@ def fixture(stand, format): if participation != 'pending': # Simulate issuing the email token; decisions themselves use the public API. token = secrets.token_hex(32) - stand.sql('prod', f"UPDATE teams SET participation_token_hash=decode('{hashlib.sha256(token.encode()).hexdigest()}', 'hex') WHERE id={int(team['ID'])};") + stand.sql('prod', f"UPDATE teams SET participation_token_hash=decode('{hashlib.sha256(token.encode()).hexdigest()}', 'hex') WHERE id='{uuid.UUID(team['ID'])}';") source.request('/api/participation/answer', {'token': token, 'answer': participation}) tickets = source.request(f'/api/games/{game}/tickets', {'count': 5})['tickets'] used = '/api' + urllib.parse.urlparse(tickets[0]['URL']).path @@ -318,7 +320,8 @@ def exercise(stand, data): 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 uuid.UUID(new_team['ID']).version == 7 + assert new_team['ID'] not in {t['ID'] for t in data['teams']} assert public.story(new_team)['game']['ID'] == game public.move(pending, 'p1', expected=400) assert next(t for t in venue.game(game)['teams'] if t['ID'] == pending['ID'])['participationStatus'] == 'pending' @@ -395,8 +398,8 @@ def main(): stand.start() pg_address = json.loads(run('docker', 'inspect', stand.name + '-venue-pg'))[0]['NetworkSettings']['Networks'][stand.name]['IPAddress'] 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) + 'go', 'test', './...', '-count=1', cwd=ROOT / 'evening_detective_server') + print('PASS: full Go suite, independent team UUID v7 creation and registration 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') diff --git a/tests/venue/flow_browser.py b/tests/venue/flow_browser.py index a68c9c3..91b18c0 100644 --- a/tests/venue/flow_browser.py +++ b/tests/venue/flow_browser.py @@ -289,13 +289,13 @@ def check_browser_flow(stand, fixtures): js("document.querySelector('.game-button-run').click();return true;") wait("document.body.innerText.includes('TEST Place p1')", 'first move') assert len(stand.apis['venue'].story(captain)['actions']) == 1, 'Double submission created duplicate moves' - stand.sql('venue', f"UPDATE teams SET password='revoked' WHERE id={int(captain['ID'])};") + stand.sql('venue', f"UPDATE teams SET password='revoked' WHERE id='{captain['ID']}';") input_value('#run', 'p2') click('Поехали') wait("document.body.innerText.includes('команда не найдена')", 'refused move error') assert js("return document.querySelector('#run').value==='p2'"), 'Refused move erased the input' escaped_password = captain['password'].replace("'", "''") - stand.sql('venue', f"UPDATE teams SET password='{escaped_password}' WHERE id={int(captain['ID'])};") + stand.sql('venue', f"UPDATE teams SET password='{escaped_password}' WHERE id='{captain['ID']}';") stand.delay_actions = 0 stand.apis['venue'].request(f'/api/games/{game}/pause', {}, 'PUT') wait("document.querySelector('#run').disabled", 'pause polling') @@ -375,7 +375,7 @@ def check_browser_flow(stand, fixtures): 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']};") + 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 diff --git a/tests/venue/team_uuid.py b/tests/venue/team_uuid.py new file mode 100644 index 0000000..bfdad02 --- /dev/null +++ b/tests/venue/team_uuid.py @@ -0,0 +1,66 @@ +"""Migration checks on a private schema inside the disposable flow PostgreSQL.""" +import json +import uuid + + +def check_migration(sql, migrations): + schema = 'team_uuid_migration_test' + sql(f'CREATE SCHEMA {schema};') + + def query(statement): + return sql(f'SET search_path={schema};\n{statement}') + + try: + migration = migrations / '20261013120000_team_uuid.sql' + for path in sorted(migrations.glob('*.sql')): + if path.name >= migration.name: + break + query('BEGIN;\n' + path.read_text().split('-- +goose Down')[0] + '\nCOMMIT;') + query(""" + INSERT INTO users(id, username, email, password_hash) VALUES(1, 'Test', 'test@example.test', 'unused'); + INSERT INTO scenarios(id, name, author_id) VALUES(1, 'Test', 1); + INSERT INTO games(id, name, start_at, scenario_id) VALUES(1, 'Test', NOW(), 1); + INSERT INTO teams(id, name, game_id, creator_id, password) VALUES(1, 'First', 1, 1, 'qr'), (2, 'Second', 1, 1, 'qr2'); + INSERT INTO actions(team_id, code) VALUES(1, 'p'), (NULL, 'unbound'); + INSERT INTO applications(team_id, name, image) VALUES(1, 'Clue', 'original.pdf'), (NULL, 'Unbound', 'unbound.pdf'); + INSERT INTO team_answers(team_id, question_code, answer, score) VALUES(1, 'q', 'Answer', 5); + INSERT INTO game_reviews(game_id, team_id, rating, text) VALUES(1, 1, 5, 'Review'); + INSERT INTO game_map_items(game_id, kind, item_id) VALUES(1, 'team', 1), (1, 'team', 2), (1, 'screen', 3), (1, 'organizer_desk', 1); + INSERT INTO game_exports(id, game_id, snapshot, source_server, object_key) VALUES('export', 1, '{}', 'https://example.test', 'old.zip'); + INSERT INTO game_returns(package_id, sha256, export_id, source_game_id, prepared_at, mappings, local_actors) VALUES('return', 'hash', 'export', 1, 'old', '{}', '{}'); + INSERT INTO game_imports(id, package_id, source_server, source_game_id, sha256, manifest, original_data, object_keys) VALUES('import', 'package', 'https://example.test', 1, 'hash', '{}', '{}', ARRAY['original.pdf']); + """) + query('BEGIN;\n' + migration.read_text().split('-- +goose Down')[0] + '\nCOMMIT;') + result = json.loads(query(""" + SELECT json_build_object( + 'teams', (SELECT json_agg(json_build_object('id', id, 'name', name, 'password', password) ORDER BY name) FROM teams), + 'linked', (SELECT count(*) FROM teams t JOIN actions a ON a.team_id=t.id JOIN applications p ON p.team_id=t.id JOIN team_answers n ON n.team_id=t.id JOIN game_reviews r ON r.team_id=t.id WHERE t.name='First' AND a.code='p' AND p.image='original.pdf' AND n.answer='Answer' AND n.score=5 AND r.text='Review'), + 'unbound', (SELECT count(*) FROM actions WHERE team_id IS NULL) + (SELECT count(*) FROM applications WHERE team_id IS NULL), + 'map', (SELECT json_object_agg(kind || '/' || item_id, item_id) FROM game_map_items), + 'registries', (SELECT count(*) FROM game_exports) + (SELECT count(*) FROM game_imports) + (SELECT count(*) FROM game_returns), + 'sequence', to_regclass('teams_id_seq') + ); + """).splitlines()[-1]) + assert [team['name'] for team in result['teams']] == ['First', 'Second'] + assert [team['password'] for team in result['teams']] == ['qr', 'qr2'] + ids = [team['id'] for team in result['teams']] + assert len(set(ids)) == 2 and all(uuid.UUID(id).version == 7 for id in ids) + assert result['linked'] == 1 and result['unbound'] == 2 + assert result['registries'] == 0 and result['sequence'] is None + assert set(result['map']) == {*(f'team/{id}' for id in ids), 'screen/3', 'organizer_desk/1'} + for statement in ( + f"INSERT INTO team_answers(team_id, question_code) VALUES('{ids[0]}','q');", + f"INSERT INTO game_reviews(game_id, team_id, rating, text) VALUES(1,'{ids[0]}',5,'Duplicate');", + "INSERT INTO actions(team_id) VALUES('019a1234-5678-7abc-8def-000000000999');", + ): + try: + query(statement) + except RuntimeError: + pass + else: + raise AssertionError('Migration lost a uniqueness or foreign key constraint') + query(f"DELETE FROM teams WHERE id='{ids[0]}';") + assert query('SELECT (SELECT count(*) FROM team_answers) + (SELECT count(*) FROM game_reviews);').splitlines()[-1] == '0' + print('PASS: populated UUID v7 migration preserves data, nullable links, map, unique/FK/cascade constraints and clears legacy transfer registries', flush=True) + finally: + sql(f'DROP SCHEMA {schema} CASCADE;')