up
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user