2026-09-17 21:42:12 +03:00
|
|
|
|
"""Профиль курсанта: история попыток и дельта между ними.
|
|
|
|
|
|
|
|
|
|
|
|
Тест сквозной — занятия проводятся через сокеты с включённым журналом,
|
|
|
|
|
|
профиль читается по HTTP. Без Postgres пропускается.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import time
|
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
from app.config import get_settings
|
|
|
|
|
|
from app.main import app
|
|
|
|
|
|
from app.session.hub import hub
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
|
|
|
|
def client():
|
|
|
|
|
|
# Доступность базы проверяется подключением к порту: синхронного драйвера
|
|
|
|
|
|
# в проекте нет, и попытка проверить им даёт ложный пропуск теста.
|
|
|
|
|
|
import socket
|
|
|
|
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
|
|
|
|
|
|
url = urlparse(get_settings().database_url)
|
|
|
|
|
|
try:
|
|
|
|
|
|
socket.create_connection((url.hostname or "localhost", url.port or 5432), timeout=2).close()
|
|
|
|
|
|
except OSError as exc:
|
|
|
|
|
|
pytest.skip(f"Postgres недоступен ({exc}) — подними `make dev`")
|
|
|
|
|
|
with TestClient(app) as test_client:
|
feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.
- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
call и station — обучающемуся и преподавателю. Отказ приходит событием
error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.
Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.
Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.
181 тест зелёный (14 новых), make typecheck чистый.
2026-09-20 09:01:05 +03:00
|
|
|
|
# Сокеты закрыты ролями (lct-23): тесты входят так же,
|
|
|
|
|
|
# как `make lesson`, — через dev-token за флагом.
|
|
|
|
|
|
test_client.post("/api/auth/dev-token")
|
2026-09-17 21:42:12 +03:00
|
|
|
|
yield test_client
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def wait_for(predicate, timeout: float = 5.0):
|
|
|
|
|
|
deadline = time.monotonic() + timeout
|
|
|
|
|
|
while time.monotonic() < deadline:
|
|
|
|
|
|
value = predicate()
|
|
|
|
|
|
if value:
|
|
|
|
|
|
return value
|
|
|
|
|
|
time.sleep(0.02)
|
|
|
|
|
|
raise AssertionError("не дождались")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_lesson(client, trainee: str, address: str | None) -> None:
|
|
|
|
|
|
"""Провести занятие: снять трубку, заполнить карточку, завершить, сдать самооценку."""
|
|
|
|
|
|
session_id = uuid4()
|
|
|
|
|
|
with client.websocket_connect(f"/ws/control/{session_id}") as control:
|
|
|
|
|
|
control.send_json({
|
|
|
|
|
|
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
|
|
|
|
|
"trainee": trainee, "mode": "training",
|
|
|
|
|
|
})
|
|
|
|
|
|
wait_for(lambda: hub.get(session_id))
|
|
|
|
|
|
state = hub.get(session_id)
|
|
|
|
|
|
with client.websocket_connect(f"/ws/call/{session_id}") as call:
|
|
|
|
|
|
call.send_json({"type": "call.answer"})
|
|
|
|
|
|
if address:
|
|
|
|
|
|
call.send_json({"type": "kio.patch", "fields": {"address": address, "dds": "01"}})
|
|
|
|
|
|
if state.slots:
|
|
|
|
|
|
state.slots.hear("Назовите адрес")
|
|
|
|
|
|
call.send_json({"type": "dds.dispatch", "service": "01"})
|
|
|
|
|
|
call.send_json({"type": "call.hangup"})
|
|
|
|
|
|
wait_for(lambda: state.score is not None)
|
|
|
|
|
|
call.send_json({"type": "self_assessment.submit", "missed": [], "comment": ""})
|
|
|
|
|
|
wait_for(lambda: state.self_assessed)
|
|
|
|
|
|
time.sleep(0.3) # журнал пишет асинхронно
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_shows_attempts_and_delta(client):
|
|
|
|
|
|
name = f"Курсант-{uuid4().hex[:6]}"
|
|
|
|
|
|
run_lesson(client, name, address=None) # первая попытка — ничего не добыл
|
|
|
|
|
|
run_lesson(client, name, address="улица Ленина, 14") # вторая — спросил адрес
|
|
|
|
|
|
|
|
|
|
|
|
listing = client.get("/api/trainees").json()
|
|
|
|
|
|
trainee = next(item for item in listing if item["name"] == name)
|
|
|
|
|
|
|
|
|
|
|
|
profile = client.get(f"/api/trainees/{trainee['id']}/profile").json()
|
|
|
|
|
|
assert [item["attempt"] for item in profile["attempts"]] == [1, 2], profile["attempts"]
|
|
|
|
|
|
assert all(item["score"] is not None for item in profile["attempts"]), "оценка попытки не записана"
|
|
|
|
|
|
|
|
|
|
|
|
assert profile["competencies"], "радар пуст"
|
|
|
|
|
|
assert profile["deltas"], "дельта между попытками не посчитана"
|
|
|
|
|
|
delta = profile["deltas"][0]
|
|
|
|
|
|
assert delta["from_attempt"] == 1 and delta["to_attempt"] == 2
|
|
|
|
|
|
if delta["facts_got"] is not None:
|
|
|
|
|
|
assert delta["facts_got"] >= 0, "во второй попытке фактов добыто не меньше"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_of_unknown_trainee_is_404(client):
|
|
|
|
|
|
assert client.get(f"/api/trainees/{uuid4()}/profile").status_code == 404
|