lct-hack/scripts/smoke_persisted_report.py

218 lines
8.9 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Проверка: подробный JSON/CSV/PDF переживает рестарт backend и укладывается в 30 с."""
from __future__ import annotations
import argparse
import asyncio
import inspect
import json
import secrets
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from uuid import uuid4
import websockets
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "backend"))
from app.api.auth import hash_password # noqa: E402
from app.db.models import AuditLog, Session, Trainee, User # noqa: E402
def local_http(value: str) -> str:
from urllib.parse import urlsplit
parsed = urlsplit(value)
if parsed.scheme != "http" or parsed.hostname not in {"127.0.0.1", "localhost", "::1"}:
raise ValueError("backend должен быть локальным HTTP URL")
return value.rstrip("/")
def login_cookie(base: str, login: str, password: str) -> str:
request = urllib.request.Request(
f"{base}/api/auth/login",
data=json.dumps({"login": login, "password": password}).encode(),
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=10) as response:
cookie = response.headers.get("Set-Cookie", "").split(";", 1)[0]
if not cookie.startswith("lct_session="):
raise RuntimeError("login не выдал cookie")
return cookie
def get_timed(base: str, path: str, cookie: str) -> tuple[bytes, str, float]:
request = urllib.request.Request(f"{base}{path}", headers={"Cookie": cookie})
started = time.perf_counter()
with urllib.request.urlopen(request, timeout=35) as response:
body = response.read()
content_type = response.headers.get_content_type()
return body, content_type, (time.perf_counter() - started) * 1000
async def receive_type(socket, wanted: str, limit: int = 40) -> dict:
for _ in range(limit):
raw = await asyncio.wait_for(socket.recv(), timeout=10)
if isinstance(raw, str):
event = json.loads(raw)
if event.get("type") == wanted:
return event
raise RuntimeError(f"не получено событие {wanted}")
async def create_temp_user(database_url: str, login: str, password: str) -> None:
engine = create_async_engine(database_url)
try:
factory = async_sessionmaker(engine, expire_on_commit=False)
async with factory() as db:
db.add(User(
login=login,
full_name="Проверка архивного отчёта",
password_hash=hash_password(password),
role="instructor",
blocked=False,
))
await db.commit()
finally:
await engine.dispose()
async def cleanup(database_url: str, login: str, trainee_name: str, session_id) -> None:
engine = create_async_engine(database_url)
try:
factory = async_sessionmaker(engine, expire_on_commit=False)
async with factory() as db:
await db.execute(delete(Session).where(Session.id == session_id))
await db.execute(delete(User).where(User.login == login))
await db.execute(delete(Trainee).where(Trainee.name == trainee_name))
await db.execute(delete(AuditLog).where(AuditLog.actor == login))
await db.commit()
finally:
await engine.dispose()
async def complete_card(base: str, cookie: str, session_id: str, trainee_name: str) -> None:
ws_base = "ws" + base.removeprefix("http")
header = "additional_headers" if "additional_headers" in inspect.signature(
websockets.connect
).parameters else "extra_headers"
options = {header: {"Cookie": cookie}, "open_timeout": 10, "ping_interval": None}
async with websockets.connect(f"{ws_base}/ws/control/{session_id}", **options) as control:
await control.send(json.dumps({
"type": "scenario.start",
"scenario_id": "fire-apartment-l2",
"trainee": trainee_name,
"mode": "training",
"exercise": "card",
}, ensure_ascii=False))
# Control-сокет только отправляет директивы; подтверждение старта
# получают рабочие АРМ, поэтому ждём обработки, а не несуществующий ACK.
await asyncio.sleep(0.2)
async with websockets.connect(f"{ws_base}/ws/call/{session_id}", **options) as trainee:
await receive_type(trainee, "card.briefing")
await trainee.send(json.dumps({
"type": "kio.patch",
"fields": {
"address": "улица Ленина, 14",
"floor": "5",
"incident_type": "fire",
"victims_count": 2,
"description": "горит балкон",
"signs": ["жилой дом", "балкон", "открытое пламя"],
},
}, ensure_ascii=False))
await receive_type(trainee, "kio.patch")
await trainee.send(json.dumps({"type": "card.submit"}))
await receive_type(trainee, "score.ready")
def restart_backend(base: str) -> float:
started = time.perf_counter()
subprocess.run(["docker", "compose", "restart", "backend"], cwd=ROOT, check=True,
capture_output=True, text=True, timeout=60)
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(f"{base}/api/health", timeout=2) as response:
if response.status == 200:
return (time.perf_counter() - started) * 1000
except (OSError, urllib.error.URLError):
pass
time.sleep(0.25)
raise TimeoutError("backend не восстановился после restart")
async def run(args: argparse.Namespace) -> int:
base = local_http(args.backend_url)
session_id = uuid4()
suffix = uuid4().hex[:10]
login = f"report-smoke-{suffix}"
trainee_name = f"report-smoke-trainee-{suffix}"
password = secrets.token_urlsafe(24)
await create_temp_user(args.database_url, login, password)
try:
cookie = login_cookie(base, login, password)
await complete_card(base, cookie, str(session_id), trainee_name)
live_json, _, live_ms = get_timed(base, f"/api/sessions/{session_id}/report", cookie)
if json.loads(live_json)["session_id"] != str(session_id):
raise RuntimeError("живой отчёт относится к другой сессии")
restart_ms = await asyncio.to_thread(restart_backend, base)
cookie = login_cookie(base, login, password)
json_body, json_type, json_ms = get_timed(base, f"/api/sessions/{session_id}/report", cookie)
csv_body, csv_type, csv_ms = get_timed(base, f"/api/sessions/{session_id}/report.csv", cookie)
pdf_body, pdf_type, pdf_ms = get_timed(base, f"/api/sessions/{session_id}/report.pdf", cookie)
decoded = json.loads(json_body)
times = [json_ms, csv_ms, pdf_ms]
passed = (
decoded["session_id"] == str(session_id)
and decoded["score_final"] == 100
and csv_body.startswith(b"\xef\xbb\xbf")
and pdf_body.startswith(b"%PDF-")
and json_type == "application/json"
and csv_type == "text/csv"
and pdf_type == "application/pdf"
and max(times) <= 30_000
)
result = {
"checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"session_id": str(session_id),
"backend_restart_ms": round(restart_ms, 3),
"live_json_ms": round(live_ms, 3),
"persisted_json_ms": round(json_ms, 3),
"persisted_csv_ms": round(csv_ms, 3),
"persisted_pdf_ms": round(pdf_ms, 3),
"pdf_bytes": len(pdf_body),
"requirement_ms": 30_000,
"passed": passed,
"scope": "card exercise; real PostgreSQL; report fetched after backend restart",
}
rendered = json.dumps(result, ensure_ascii=False, indent=2)
print(rendered)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(rendered + "\n", encoding="utf-8")
return 0 if passed else 1
finally:
await cleanup(args.database_url, login, trainee_name, session_id)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--backend-url", default="http://127.0.0.1:8000")
parser.add_argument(
"--database-url",
default="postgresql+asyncpg://lct:lct@127.0.0.1:5432/lct",
)
parser.add_argument("--output", type=Path)
raise SystemExit(asyncio.run(run(parser.parse_args())))