up
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
+8
-5
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;')
|
||||
Reference in New Issue
Block a user