feat: add realtime challenge client and local smoke test
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import asyncio
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "backend"))
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
|
||||
import django # noqa: E402
|
||||
|
||||
django.setup()
|
||||
|
||||
from django.conf import settings # noqa: E402
|
||||
from websockets.asyncio.client import connect # noqa: E402
|
||||
|
||||
from accounts.models import User # noqa: E402
|
||||
from contest.models import ( # noqa: E402
|
||||
Contest,
|
||||
ContestAttempt,
|
||||
RatingHistory,
|
||||
RealtimeMatch,
|
||||
)
|
||||
|
||||
|
||||
class ApiClient:
|
||||
def __init__(self, base_url):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.cookies = http.cookiejar.CookieJar()
|
||||
self.opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(self.cookies)
|
||||
)
|
||||
|
||||
def request(self, method, path, payload=None, extra_headers=None):
|
||||
body = json.dumps(payload).encode() if payload is not None else None
|
||||
headers = {"Accept": "application/json"}
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
csrf_token = self.cookie("csrftoken")
|
||||
if csrf_token:
|
||||
headers["X-CSRFToken"] = csrf_token
|
||||
headers.update(extra_headers or {})
|
||||
request = urllib.request.Request(
|
||||
f"{self.base_url}{path}",
|
||||
data=body,
|
||||
headers=headers,
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with self.opener.open(request, timeout=10) as response:
|
||||
content = response.read()
|
||||
if not content:
|
||||
return None
|
||||
if "application/json" in response.headers.get("Content-Type", ""):
|
||||
return json.loads(content)
|
||||
return content.decode()
|
||||
except urllib.error.HTTPError as exc:
|
||||
content = exc.read().decode()
|
||||
raise RuntimeError(f"{method} {path} -> HTTP {exc.code}: {content}") from exc
|
||||
|
||||
def get(self, path):
|
||||
return self.request("GET", path)
|
||||
|
||||
def post(self, path, payload, extra_headers=None):
|
||||
return self.request("POST", path, payload, extra_headers)
|
||||
|
||||
def cookie(self, name):
|
||||
return next((item.value for item in self.cookies if item.name == name), "")
|
||||
|
||||
@property
|
||||
def cookie_header(self):
|
||||
return "; ".join(f"{item.name}={item.value}" for item in self.cookies)
|
||||
|
||||
|
||||
async def receive_until(websocket, event_type, reason=None):
|
||||
for _ in range(10):
|
||||
payload = json.loads(await asyncio.wait_for(websocket.recv(), timeout=5))
|
||||
if payload.get("type") == event_type and (
|
||||
reason is None or payload.get("reason") == reason
|
||||
):
|
||||
return payload
|
||||
raise RuntimeError(f"未收到 WebSocket 事件: type={event_type}, reason={reason}")
|
||||
|
||||
|
||||
def websocket_url(base_url, path):
|
||||
parsed = urlparse(base_url)
|
||||
scheme = "wss" if parsed.scheme == "https" else "ws"
|
||||
return f"{scheme}://{parsed.netloc}{path}"
|
||||
|
||||
|
||||
async def run_flow(base_url, first_client, second_client, contest):
|
||||
created = first_client.post(
|
||||
f"/api/v1/contests/{contest.slug}/challenges/",
|
||||
{},
|
||||
)
|
||||
code = created["challenge_code"]
|
||||
print(f"[1/7] 玩家 A 创建联机码: {code}")
|
||||
|
||||
async with connect(
|
||||
websocket_url(base_url, created["websocket_path"]),
|
||||
additional_headers={"Cookie": first_client.cookie_header},
|
||||
open_timeout=10,
|
||||
) as first_socket:
|
||||
await receive_until(first_socket, "connected")
|
||||
joined = second_client.post(
|
||||
"/api/v1/contests/challenges/join/",
|
||||
{"challenge_code": code},
|
||||
)
|
||||
await receive_until(first_socket, "state", "matched")
|
||||
print("[2/7] 玩家 B 加入,玩家 A 收到 matched 事件")
|
||||
|
||||
first_state = first_client.get(
|
||||
f"/api/v1/contests/matches/{created['match_id']}/"
|
||||
)
|
||||
if first_state["status"] != "active":
|
||||
raise RuntimeError("匹配后状态不是 active")
|
||||
|
||||
async with connect(
|
||||
websocket_url(base_url, joined["websocket_path"]),
|
||||
additional_headers={"Cookie": second_client.cookie_header},
|
||||
open_timeout=10,
|
||||
) as second_socket:
|
||||
await receive_until(second_socket, "connected")
|
||||
await first_socket.send(
|
||||
json.dumps({"type": "progress", "answered_count": 1})
|
||||
)
|
||||
progress = await receive_until(second_socket, "progress")
|
||||
if progress["answered_count"] != 1:
|
||||
raise RuntimeError("答题进度同步失败")
|
||||
print("[3/7] 两个 WebSocket 已连接,答题进度同步成功")
|
||||
|
||||
first_attempt = first_state["attempt"]
|
||||
second_attempt = joined["attempt"]
|
||||
first_answers = [
|
||||
{"order": item["order"], "answer": "0"}
|
||||
for item in first_attempt["questions"]
|
||||
]
|
||||
second_answers = [
|
||||
{"order": item["order"], "answer": "0"}
|
||||
for item in second_attempt["questions"]
|
||||
]
|
||||
first_result = first_client.post(
|
||||
f"/api/v1/contests/attempts/{first_attempt['attempt_id']}/submit/",
|
||||
{"answers": first_answers},
|
||||
{"Idempotency-Key": f"local-first-{created['match_id']}"},
|
||||
)
|
||||
if first_result["status"] != "active":
|
||||
raise RuntimeError("首位玩家提交后比赛不应立即完成")
|
||||
if "correct_answer" in first_result["attempt"]["questions"][0]:
|
||||
raise RuntimeError("首位玩家提前看到了正确答案")
|
||||
await receive_until(second_socket, "state", "submitted")
|
||||
print("[4/7] 玩家 A 提交后答案锁定,未提前泄露正确答案")
|
||||
|
||||
second_result = second_client.post(
|
||||
f"/api/v1/contests/attempts/{second_attempt['attempt_id']}/submit/",
|
||||
{"answers": second_answers},
|
||||
{"Idempotency-Key": f"local-second-{created['match_id']}"},
|
||||
)
|
||||
if second_result["status"] != "completed":
|
||||
raise RuntimeError("双方提交后比赛没有完成")
|
||||
await receive_until(first_socket, "state", "completed")
|
||||
print("[5/7] 玩家 B 提交后双方收到 completed 事件")
|
||||
|
||||
final_state = first_client.get(
|
||||
f"/api/v1/contests/matches/{created['match_id']}/"
|
||||
)
|
||||
if "correct_answer" not in final_state["attempt"]["questions"][0]:
|
||||
raise RuntimeError("完成后没有公开题目解析")
|
||||
if final_state["result"] is None:
|
||||
raise RuntimeError("完成后没有胜负与 Rating 结果")
|
||||
print("[6/7] 最终比分、Rating 和题目解析均可读取")
|
||||
print("[7/7] 本地联机码约战端到端测试通过")
|
||||
|
||||
|
||||
def cleanup(users):
|
||||
user_ids = [user.id for user in users]
|
||||
matches = RealtimeMatch.objects.filter(
|
||||
Q(player_one_id__in=user_ids) | Q(player_two_id__in=user_ids)
|
||||
)
|
||||
match_ids = list(matches.values_list("id", flat=True))
|
||||
ContestAttempt.objects.filter(
|
||||
Q(user_id__in=user_ids) | Q(match_id__in=match_ids)
|
||||
).delete()
|
||||
RatingHistory.objects.filter(match_id__in=match_ids).delete()
|
||||
matches.delete()
|
||||
User.objects.filter(id__in=user_ids).delete()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="本地实时联机码约战端到端测试")
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
|
||||
args = parser.parse_args()
|
||||
hostname = urlparse(args.base_url).hostname
|
||||
if not settings.DEBUG or hostname not in {"127.0.0.1", "localhost"}:
|
||||
raise RuntimeError("该脚本只允许在 DEBUG=true 的本机地址运行")
|
||||
stamp = str(int(time.time() * 1000))
|
||||
password = "LocalRealtime2026!"
|
||||
users = [
|
||||
User.objects.create_user(
|
||||
username=f"local_ws_a_{stamp}",
|
||||
password=password,
|
||||
nickname="本地联机 A",
|
||||
),
|
||||
User.objects.create_user(
|
||||
username=f"local_ws_b_{stamp}",
|
||||
password=password,
|
||||
nickname="本地联机 B",
|
||||
),
|
||||
]
|
||||
try:
|
||||
contest = Contest.objects.filter(
|
||||
kind=Contest.Kind.REALTIME,
|
||||
status=Contest.Status.PUBLISHED,
|
||||
track="standard",
|
||||
).first()
|
||||
if contest is None:
|
||||
raise RuntimeError("缺少标准赛道实时比赛,请先执行 make seed")
|
||||
clients = [ApiClient(args.base_url), ApiClient(args.base_url)]
|
||||
for client, user in zip(clients, users):
|
||||
client.get("/")
|
||||
client.post(
|
||||
"/api/v1/accounts/login/",
|
||||
{"username": user.username, "password": password},
|
||||
)
|
||||
asyncio.run(run_flow(args.base_url, clients[0], clients[1], contest))
|
||||
finally:
|
||||
cleanup(users)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user