Implement DDS exercise, customer UI and local demo

This commit is contained in:
andreysk0304 2026-09-21 19:56:13 +03:00
commit 0526b11f91
46 changed files with 2504 additions and 253 deletions

View file

@ -0,0 +1,167 @@
"""Regressions for stale cookies and privileged admin operations."""
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
import app.api.auth as auth
from app.api.http import admin
from app.domain.roles import Role
from app.main import app
from app.session.hub import hub
@pytest.fixture
def client():
with TestClient(app) as test_client:
hub.journal = None
yield test_client
def test_account_change_revokes_http_and_new_websocket_handshakes(client):
assert client.post("/api/auth/dev-token").status_code == 200
assert client.get("/api/auth/me").status_code == 200
auth.invalidate_login("dev")
assert client.get("/api/auth/me").status_code == 401
with client.websocket_connect(f"/ws/control/{uuid4()}") as socket:
assert socket.receive_json()["code"] == "forbidden"
assert client.post("/api/auth/dev-token").status_code == 200
assert client.get("/api/auth/me").status_code == 200
def test_account_change_closes_an_existing_websocket(client):
assert client.post("/api/auth/dev-token").status_code == 200
with client.websocket_connect(f"/ws/control/{uuid4()}") as socket:
auth.invalidate_login("dev")
with pytest.raises(WebSocketDisconnect) as exc:
socket.receive_json()
assert exc.value.code == 1008
def test_cookie_from_previous_process_is_rejected(client, monkeypatch):
assert client.post("/api/auth/dev-token").status_code == 200
monkeypatch.setattr(auth, "_INSTANCE", "new-server-instance")
assert client.get("/api/auth/me").status_code == 401
class FakeDb:
def __init__(self, user):
self.user = user
self.commits = 0
self.added = []
async def get(self, _model, _id):
return self.user
def add(self, object_):
self.added.append(object_)
async def flush(self):
for object_ in self.added:
if getattr(object_, "id", None) is None:
object_.id = uuid4()
async def commit(self):
self.commits += 1
def fake_user(login="victim", role="instructor"):
return SimpleNamespace(
id=uuid4(), login=login, full_name="Проверка", role=role,
service=None, trainee_id=None, blocked=False,
password_hash="old", created_at=datetime.now(timezone.utc),
)
@pytest.mark.asyncio
async def test_admin_patch_revokes_cookie_after_commit(monkeypatch):
user = fake_user()
db = FakeDb(user)
calls = []
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
login="admin", full_name="Администратор", role=Role.ADMIN,
))
monkeypatch.setattr(admin, "invalidate_login", lambda login: calls.append((login, db.commits)))
async def no_audit(*_args, **_kwargs):
return None
monkeypatch.setattr(admin, "audit", no_audit)
await admin.patch_user(user.id, admin.UserPatch(blocked=True), object(), db)
assert user.blocked is True
assert calls == [("victim", 1)]
@pytest.mark.asyncio
async def test_admin_cannot_demote_self(monkeypatch):
user = fake_user(login="admin", role="admin")
db = FakeDb(user)
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
login="admin", full_name="Администратор", role=Role.ADMIN,
))
with pytest.raises(HTTPException) as exc:
await admin.patch_user(user.id, admin.UserPatch(role=Role.TRAINEE), object(), db)
assert exc.value.status_code == 409
assert db.commits == 0
@pytest.mark.asyncio
async def test_promotion_to_trainee_creates_profile(monkeypatch):
user = fake_user()
db = FakeDb(user)
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
login="admin", full_name="Администратор", role=Role.ADMIN,
))
async def no_audit(*_args, **_kwargs):
return None
monkeypatch.setattr(admin, "audit", no_audit)
await admin.patch_user(user.id, admin.UserPatch(role=Role.TRAINEE), object(), db)
assert user.role == "trainee"
assert user.trainee_id is not None
assert db.commits == 1
@pytest.mark.asyncio
async def test_backup_runs_off_event_loop_and_failure_is_audited(monkeypatch):
who = auth.Principal(login="admin", full_name="Администратор", role=Role.ADMIN)
monkeypatch.setattr(admin, "require", lambda _request, *_roles: who)
calls = []
async def fake_threadpool(fn):
calls.append(fn)
return fn()
async def fake_audit(*args, **kwargs):
calls.append((args, kwargs))
monkeypatch.setattr(admin, "run_in_threadpool", fake_threadpool)
monkeypatch.setattr(admin, "audit", fake_audit)
monkeypatch.setattr(admin.backup_service, "create", lambda: {
"name": "example.sql", "size_bytes": 1, "at": datetime.now(timezone.utc),
})
assert (await admin.make_backup(object())).name == "example.sql"
assert calls[0] is admin.backup_service.create
def broken():
raise admin.backup_service.BackupError("pg_dump failed")
monkeypatch.setattr(admin.backup_service, "create", broken)
with pytest.raises(HTTPException) as exc:
await admin.make_backup(object())
assert exc.value.status_code == 503
assert any(isinstance(item, tuple) and item[0][2] == "backup.failed" for item in calls)
def test_backup_error_redacts_database_credentials(monkeypatch):
dsn = "postgresql://user:supersecret@localhost:5432/example"
monkeypatch.setattr(admin, "get_settings", lambda: SimpleNamespace(database_url=dsn))
detail = admin._safe_backup_error(admin.backup_service.BackupError(f"bad DSN: {dsn}"))
assert "supersecret" not in detail
assert dsn not in detail

View file

@ -139,3 +139,124 @@ def test_complete_dds_workflow_scores_without_call_penalties(client):
assert all(metric["key"].startswith("dds_") for metric in score["metrics"])
finally:
control.__exit__(None, None, None)
def test_sequential_dds_cards_keep_separate_state_and_scores(client):
session_id = uuid4()
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
control = control_ctx.__enter__()
control.send_json({
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
"trainee": "Иванов", "mode": "training", "exercise": "dds",
})
wait_for(lambda: hub.get(session_id))
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
first = read_until(station, "card.received")
first_card_id = first["card"]["card_id"]
assert (first["card_index"], first["card_total"]) == (1, 2)
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
read_until(station, "station.state")
station.send_json({"type": "card.reply", "card_id": first_card_id,
"text": "Сообщение принято, дежурная бригада направлена на место."})
assert read_until(station, "station.state")["snapshot"]["reply_text"].startswith("Сообщение")
station.send_json({"type": "card.next", "card_id": first_card_id})
second = read_until(station, "card.received")
second_card_id = second["card"]["card_id"]
assert second_card_id != first_card_id
assert second["card"]["incident_type"] == "medical"
assert (second["card_index"], second["card_total"]) == (2, 2)
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["reply_text"] == ""
assert snapshot["statuses"].get(service) == "added" or service not in snapshot["statuses"]
assert len(snapshot["completed_cards"]) == 1
assert snapshot["completed_cards"][0]["card_id"] == first_card_id
station.send_json({"type": "card.reply", "card_id": first_card_id,
"text": "Запоздалый ответ к прошлой карточке"})
assert "не к текущей" in read_until(station, "error")["message"]
assert hub.get(session_id).reply_text == ""
station.send_json({"type": "card.next", "card_id": first_card_id})
assert "ID" in read_until(station, "error")["message"]
assert hub.get(session_id).dds_card_index == 1
with client.websocket_connect(f"/ws/station/{session_id}") as station:
assert read_until(station, "card.received")["card"]["card_id"] == second_card_id
assert read_until(station, "station.state")["snapshot"]["card_index"] == 2
station.send_json({"type": "card.reply", "card_id": second_card_id,
"text": "Сообщение принято, бригада направлена на место происшествия."})
read_until(station, "station.state")
station.send_json({"type": "card.next", "card_id": second_card_id})
read_until(station, "score.ready")
state = hub.get(session_id)
assert state.ended and len(state.dds_completed) == 2
assert len(state.score["card_results"]) == 2
assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2"
assert state.score["card_results"][1]["scenario_id"] == "t20-2-stroke"
assert {item["key"] for item in state.score["metrics"]} >= {"dds_reply", "dds_primary"}
assert all(item["code"].startswith("D") for item in state.score["findings"])
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["scenario_id"] == "fire-apartment-l2"
assert len(report["card_results"]) == 2
assert report["missed_checklist"] == [] and report["reference_questions"] == []
first_actions = report["card_results"][0]["actions"]
assert {item["type"] for item in first_actions} >= {"card.status", "card.reply"}
assert report["card_results"][0]["duration_ms"] >= 0
finally:
control_ctx.__exit__(None, None, None)
def test_instructor_can_end_multi_card_early_without_grading_future_cards(client):
session_id = uuid4()
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
control = control_ctx.__enter__()
control.send_json({
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
"trainee": "Иванов", "mode": "training", "exercise": "dds",
})
wait_for(lambda: hub.get(session_id))
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
read_until(station, "station.state")
control.send_json({"type": "session.stop"})
read_until(station, "score.ready")
state = hub.get(session_id)
assert state.ended
assert len(state.score["card_results"]) == 1
assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2"
finally:
control_ctx.__exit__(None, None, None)
def test_each_dds_card_uses_its_own_scenario_weights():
from pathlib import Path
from app.domain.events import Exercise, SessionMode
from app.scenarios.loader import load_file
from app.session.dds import prepare_card
from app.session.finish import score_current_dds
from app.session.state import SessionState
root = Path(__file__).resolve().parents[2] / "scenarios"
base = load_file(root / "fire-apartment-l2.yaml", root)
first = base.model_copy(deep=True)
second = base.model_copy(deep=True)
first.score_weights = {"dds_reply": 7.0}
second.score_weights = {"dds_reply": 2.0}
state = SessionState(
session_id=uuid4(), scenario_id=base.id, scenario_title=base.title,
level=base.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
dds_scenarios=[first, second],
)
prepare_card(state, first)
first_record = score_current_dds(state)
state.dds_card_index = 1
prepare_card(state, second)
second_record = score_current_dds(state)
assert next(item.weight for item in first_record.metrics if item.key == "dds_reply") == 7.0
assert next(item.weight for item in second_record.metrics if item.key == "dds_reply") == 2.0

View file

@ -0,0 +1,75 @@
"""Явный локальный демо-режим не зависит от Postgres."""
import time
from uuid import uuid4
from fastapi.testclient import TestClient
from app.api.auth import DEMO_TRAINEE_ID
from app.config import get_settings
from app.main import app
from app.session.hub import hub
def _wait_for(predicate, timeout=3):
until = time.monotonic() + timeout
while time.monotonic() < until:
value = predicate()
if value:
return value
time.sleep(0.02)
raise AssertionError("занятие не стартовало")
def _read_until(socket, wanted):
for _ in range(20):
event = socket.receive_json()
if event["type"] == wanted:
return event
raise AssertionError(f"не пришло событие {wanted}")
def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch):
monkeypatch.setenv("DEMO_NO_DB", "true")
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
monkeypatch.setenv("VOICE_ENABLED", "false")
get_settings.cache_clear()
try:
with TestClient(app) as client:
health = client.get("/api/health").json()
assert health["status"] == "ok" and health["demo_no_db"] is True
assert health["scenarios_loaded"] > 0
assert hub.journal is None
assert client.post("/api/auth/login", json={
"login": "demo-instructor", "password": "demo"
}).json()["role"] == "instructor"
assert client.post("/api/auth/login", json={
"login": "unknown", "password": "demo"
}).status_code == 401
assert client.post("/api/auth/dev-token?role=trainee").json()["trainee_id"] == str(DEMO_TRAINEE_ID)
assert client.post("/api/auth/dev-token").json()["role"] == "instructor"
assert client.get("/api/trainees").json() == [
{"id": str(DEMO_TRAINEE_ID), "name": "Демо-курсант", "group": None}
]
# БД-зависимые экраны получают быстрый и явный отказ, не ждут TCP timeout.
assert client.get("/api/sessions").json()["detail"] == "database_disabled_demo"
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_id": str(DEMO_TRAINEE_ID),
"mode": "training", "exercise": "dds",
})
state = _wait_for(lambda: hub.get(session_id))
assert state.trainee_id == DEMO_TRAINEE_ID
who = client.post("/api/auth/login", json={
"login": "demo-trainee", "password": "demo"
}).json()
assert who["trainee_id"] == str(DEMO_TRAINEE_ID)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
assert _read_until(station, "card.received")["card"]["address"]
assert _read_until(station, "station.state")["snapshot"]["card_index"] == 1
finally:
get_settings.cache_clear()

View file

@ -0,0 +1,75 @@
"""Групповая сводка считает людей, а не число их повторных попыток."""
from uuid import uuid4
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from app.api import auth
from app.api.http import groups as group_api
from app.api.auth import Principal
from app.domain.roles import Role
from app.scoring.group import ScoredAttempt, summarize
def test_group_errors_count_distinct_trainees_and_give_actions():
first, second = uuid4(), uuid4()
result = summarize([
ScoredAttempt(first, 40.0, {"E1": 2, "D1": 1}),
ScoredAttempt(first, 60.0, {"E1": 1}),
ScoredAttempt(second, 80.0, {"E1": 1}),
], enrolled=3)
assert result["active_trainees"] == 2
assert result["scored_attempts"] == 3
assert result["average_score"] == 60.0
assert result["errors"][0]["code"] == "E1"
assert result["errors"][0]["affected_trainees"] == 2
assert result["errors"][0]["occurrences"] == 4
assert result["errors"][0]["rate_percent"] == 100.0
assert result["errors"][0]["recommendation"]
def test_group_without_scored_attempts_has_no_fake_recommendations():
result = summarize([], enrolled=5)
assert result == {
"enrolled_trainees": 5,
"active_trainees": 0,
"scored_attempts": 0,
"average_score": None,
"errors": [],
}
def test_unknown_codes_do_not_break_group_summary():
result = summarize([ScoredAttempt(uuid4(), 75.0, {"unknown": 2, "E5": 1})], enrolled=1)
assert [item["code"] for item in result["errors"]] == ["E5"]
@pytest.mark.asyncio
async def test_group_creation_requires_staff_and_returns_new_group(monkeypatch):
class FakeDb:
def add(self, group):
group.id = uuid4()
async def commit(self):
pass
async def no_audit(*args):
pass
monkeypatch.setattr(group_api, "audit", no_audit)
instructor = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
request = Request({"type": "http", "session": {}})
auth._issue_session(request, instructor)
created = await group_api.create(group_api.GroupCreate(name=" Группа 1 "), request, FakeDb())
assert created.name == "Группа 1"
assert created.id
trainee = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE)
forbidden = Request({"type": "http", "session": {}})
auth._issue_session(forbidden, trainee)
with pytest.raises(HTTPException) as exc:
await group_api.create(group_api.GroupCreate(name="Чужая"), forbidden, FakeDb())
assert exc.value.status_code == 403

View file

@ -0,0 +1,39 @@
"""Веса метрик настраиваются сценарием и не меняют факт проверки."""
from pathlib import Path
import pytest
from pydantic import ValidationError
from app.domain.events import Metric
from app.scenarios.loader import load_file
from app.scenarios.schema import Scenario
from app.scoring.gost import GostResult
from app.scoring.weights import apply_weights
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
def test_weight_override_changes_score_but_not_findings():
result = GostResult(metrics=[
Metric(key="address", title="Адрес", fact="пусто", norm="заполнен", passed=False, weight=2),
Metric(key="required_fields", title="Поля", fact="есть", norm="есть", passed=True, weight=2),
])
assert result.score == 50.0
apply_weights(result, {"address": 6.0})
assert result.score == 25.0
assert result.metrics[0].fact == "пусто"
def test_scenario_accepts_only_known_finite_weights():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
body = source.model_dump(mode="json")
body["score_weights"] = {"address": 3.0, "dds_ack": 0.0}
assert Scenario.model_validate(body).score_weights["address"] == 3.0
body["score_weights"] = {"typo": 3.0}
with pytest.raises(ValidationError, match="неизвестные метрики"):
Scenario.model_validate(body)
body["score_weights"] = {"address": 11.0}
with pytest.raises(ValidationError, match="от 0 до 10"):
Scenario.model_validate(body)

View file

@ -366,7 +366,34 @@ def test_instructor_correction_keeps_the_automatic_score(client):
assert corrected["score_final"] == 80.0
assert corrected["score_auto"] == auto, "автооценка должна сохраниться рядом"
assert corrected["overridden_by"] == "преподаватель"
assert corrected["overridden_by"] == "dev"
def test_ws_score_override_rejects_other_session_and_invalid_value(client):
with lesson(client) as (session_id, control):
state = hub.get(session_id)
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
trainee.send_json({"type": "call.answer"})
trainee.send_json({"type": "call.hangup"})
wait_for(lambda: state.score is not None)
auto = state.score["score_auto"]
control.send_json({
"type": "score.override", "session_id": str(uuid4()),
"verdict": "90", "comment": "не тот номер",
})
control.send_json({
"type": "score.override", "session_id": str(session_id),
"verdict": "nan", "comment": "не число",
})
time.sleep(0.1)
assert state.score["score_auto"] == auto
assert "score_final" not in state.score
control.send_json({
"type": "score.override", "session_id": str(session_id),
"verdict": "85", "comment": "ручная проверка",
})
wait_for(lambda: state.score.get("score_final") == 85)
assert state.score["overridden_by"] == "dev"
def test_soft_directive_changes_how_the_caller_sounds(client):