242 lines
8.9 KiB
Python
242 lines
8.9 KiB
Python
#!/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()
|