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