Implement local task 09 training workflows
This commit is contained in:
parent
e081fa410b
commit
cec84ffcd0
45 changed files with 2679 additions and 366 deletions
125
backend/tests/test_card_exercise.py
Normal file
125
backend/tests/test_card_exercise.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Текстовая вводная 112: карточка без голоса, опроса и ДДС-оценки."""
|
||||
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with TestClient(app) as test_client:
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.journal = None
|
||||
yield test_client
|
||||
|
||||
|
||||
def wait_for(predicate, timeout=3):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
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 start(client):
|
||||
session_id = uuid4()
|
||||
context = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = context.__enter__()
|
||||
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Иванов", "mode": "training", "exercise": "card"})
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
return session_id, context
|
||||
|
||||
|
||||
def test_card_briefing_is_text_only_and_replayed_on_late_join(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
assert state.exercise.value == "card"
|
||||
assert state.dispatched_card is None
|
||||
assert state.slots is None and state.voice is None
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
briefing = read_until(trainee, "card.briefing")
|
||||
assert briefing["scenario_id"] == "fire-apartment-l2"
|
||||
assert "Помогите" in briefing["text"]
|
||||
assert "горит балкон" in briefing["text"]
|
||||
assert "ground_truth" not in briefing
|
||||
assert briefing["card"]["address"] is None
|
||||
assert "address" in briefing["required_fields"]
|
||||
trainee.send_bytes(b"\0" * 640)
|
||||
assert read_until(trainee, "error")["code"] == "unsupported_event"
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as late:
|
||||
assert read_until(late, "card.briefing")["text"] == briefing["text"]
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_correct_card_scores_100_without_call_or_dds_metrics(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
briefing = read_until(trainee, "card.briefing")
|
||||
assert "dds" not in briefing["required_fields"]
|
||||
trainee.send_json({"type": "kio.patch", "fields": {
|
||||
"address": "улица Ленина, 14", "floor": "5", "incident_type": "fire",
|
||||
"victims_count": 2, "description": "горит балкон",
|
||||
"signs": ["жилой дом", "балкон", "открытое пламя"],
|
||||
}})
|
||||
wait_for(lambda: hub.get(session_id).kio.incident_code)
|
||||
trainee.send_json({"type": "card.submit"})
|
||||
assert read_until(trainee, "call.ended")["reason"] == "complete"
|
||||
assert read_until(trainee, "score.ready")["session_id"] == str(session_id)
|
||||
received = read_until(station, "card.received")
|
||||
assert received["card"]["address"] == "улица Ленина, 14"
|
||||
read_until(station, "station.state")
|
||||
state = hub.get(session_id)
|
||||
assert state.dispatched_card is not None
|
||||
assert state.score["score_auto"] == 100.0
|
||||
assert not state.score["findings"]
|
||||
assert {item["key"] for item in state.score["metrics"]} == {
|
||||
"incident_signs", "address", "victims_count", "required_fields"
|
||||
}
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as late:
|
||||
assert read_until(late, "card.briefing")["card"]["address"] == "улица Ленина, 14"
|
||||
assert read_until(late, "call.ended")["reason"] == "complete"
|
||||
assert read_until(late, "score.ready")["session_id"] == str(session_id)
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_incomplete_card_has_only_card_findings(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
read_until(trainee, "card.briefing")
|
||||
trainee.send_json({"type": "card.submit"})
|
||||
read_until(trainee, "score.ready")
|
||||
trainee.send_json({"type": "kio.patch", "fields": {"address": "после сдачи"}})
|
||||
assert read_until(trainee, "error")["code"] == "unsupported_event"
|
||||
state = hub.get(session_id)
|
||||
assert state.kio.address is None
|
||||
assert state.score["score_auto"] < 100
|
||||
assert {item["code"] for item in state.score["findings"]} <= {"E2", "E5"}
|
||||
assert all(item["key"] not in {"answer_time", "interview_time", "dds_ack"}
|
||||
for item in state.score["metrics"])
|
||||
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||
assert report["missed_checklist"] == []
|
||||
assert report["reference_questions"] == []
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
141
backend/tests/test_dds_exercise.py
Normal file
141
backend/tests/test_dds_exercise.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""Готовая карточка → учебный звонок бригаде → числовая оценка ДДС."""
|
||||
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.domain.timers import TimerCode
|
||||
from app.session.hub import hub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with TestClient(app) as test_client:
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.journal = None
|
||||
yield test_client
|
||||
|
||||
|
||||
def wait_for(predicate, timeout=3):
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
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 start(client, exercise="dds"):
|
||||
session_id = uuid4()
|
||||
context = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = context.__enter__()
|
||||
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Иванов", "mode": "training", "exercise": exercise})
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
return session_id, context
|
||||
|
||||
|
||||
def test_dds_starts_with_prepared_card_without_call(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
assert state.exercise.value == "dds"
|
||||
assert state.dispatched_card is not None
|
||||
assert state.kio.address == "улица Ленина, 14"
|
||||
assert state.kio.notify
|
||||
assert state.timers.measured_ms(TimerCode.ANSWER) is None
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
card = read_until(station, "card.received")
|
||||
assert card["from_operator"] == "учебный сценарий"
|
||||
assert card["card"]["address"] == "улица Ленина, 14"
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
assert snapshot["crew_options"]
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
def test_dds_call_reports_gate_status_and_affect_numeric_score(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "card.received")
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
service = snapshot["services"][0]
|
||||
crew = next(option for option in snapshot["crew_options"] if option.startswith(service + " — "))
|
||||
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.status", "service": service, "status": "responding"})
|
||||
assert "доклада" in read_until(station, "error")["message"]
|
||||
station.send_json({"type": "crew.select", "crew": crew})
|
||||
selected = read_until(station, "station.state")["snapshot"]
|
||||
assert selected["crew_selected"] == crew
|
||||
station.send_json({"type": "phone.dial"})
|
||||
report = read_until(station, "phone.report")
|
||||
assert report["phase"] == "dispatched" and report["crew"] == crew
|
||||
assert read_until(station, "station.state")["snapshot"]["phone_reports"]
|
||||
station.send_json({"type": "card.status", "service": service, "status": "responding"})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "station.finish"})
|
||||
read_until(station, "score.ready")
|
||||
score = wait_for(lambda: hub.get(session_id).score)
|
||||
keys = {metric["key"] for metric in score["metrics"]}
|
||||
assert "dds_primary" in keys and "dds_contact" in keys
|
||||
assert "answer_time" not in keys and "interview_time" not in keys
|
||||
assert 0 < score["score_auto"] < 100
|
||||
assert all(not finding["code"].startswith("E") for finding in score["findings"])
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_default_exercise_remains_call(client):
|
||||
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": "Иванов", "mode": "training"})
|
||||
state = wait_for(lambda: hub.get(session_id))
|
||||
assert state.exercise.value == "call"
|
||||
assert state.dispatched_card is None
|
||||
|
||||
|
||||
def test_complete_dds_workflow_scores_without_call_penalties(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "card.received")
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
for service in snapshot["services"]:
|
||||
crew = next(option for option in snapshot["crew_options"]
|
||||
if option.startswith(service + " — "))
|
||||
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "crew.select", "crew": crew})
|
||||
read_until(station, "station.state")
|
||||
for expected_phase, status in (
|
||||
("dispatched", "responding"), ("arrived", "arrived"),
|
||||
("working", "working"), ("completed", "completed"),
|
||||
):
|
||||
station.send_json({"type": "phone.dial"})
|
||||
assert read_until(station, "phone.report")["phase"] == expected_phase
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.status", "service": service, "status": status})
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
assert snapshot["card"] == "completed"
|
||||
station.send_json({"type": "station.finish"})
|
||||
read_until(station, "score.ready")
|
||||
score = wait_for(lambda: hub.get(session_id).score)
|
||||
assert score["score_auto"] == 100.0
|
||||
assert not score["findings"]
|
||||
assert all(metric["key"].startswith("dds_") for metric in score["metrics"])
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
|
@ -120,3 +120,14 @@ def test_gasified_object_brings_mosgaz():
|
|||
KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True))
|
||||
)
|
||||
assert "МОСГАЗ" in card.notify
|
||||
|
||||
|
||||
def test_removed_modifier_recalculates_notify_without_stale_service():
|
||||
from app.domain.kio import KIO, FireDetails, apply_patch, derive_incident
|
||||
|
||||
card = derive_incident(
|
||||
KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True))
|
||||
)
|
||||
assert "МОСГАЗ" in card.notify
|
||||
updated = apply_patch(card, {"fire.gasified": False})
|
||||
assert "МОСГАЗ" not in updated.notify
|
||||
|
|
|
|||
121
backend/tests/test_report_export.py
Normal file
121
backend/tests/test_report_export.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Экспорт разбора: содержимое, безопасность CSV, кириллица и доступ."""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.auth import Principal
|
||||
from app.api.http import sessions
|
||||
from app.domain.events import SessionReport
|
||||
from app.domain.roles import Role
|
||||
from app.main import app
|
||||
from app.scoring.export import _cell, to_csv, to_pdf
|
||||
|
||||
|
||||
def sample_report(*, long: bool = False) -> SessionReport:
|
||||
at = datetime(2026, 9, 21, 12, 0, tzinfo=timezone.utc)
|
||||
long_text = "Заявитель сообщает о дыме в учебном помещении. " * (240 if long else 1)
|
||||
return SessionReport.model_validate({
|
||||
"session_id": str(uuid4()),
|
||||
"scenario_id": "=1+1",
|
||||
"mode": "training",
|
||||
"attempt": 2,
|
||||
"transcript": [
|
||||
{"ref": f"u{i}", "speaker": "caller", "text": long_text if i == 0 else "<вопрос> \t=cmd", "at": at}
|
||||
for i in range(18 if long else 2)
|
||||
],
|
||||
"findings": [{
|
||||
"code": "E1", "source": "slots", "summary": "+HYPERLINK(\"x\")",
|
||||
"fact": "Адрес не уточнён", "norm": "Уточнить адрес происшествия",
|
||||
"ref": "ГОСТ", "at": at,
|
||||
}],
|
||||
"metrics": [{
|
||||
"key": "address", "title": "Адрес", "fact": "Не назван",
|
||||
"norm": "Адрес должен быть уточнён", "passed": False,
|
||||
}],
|
||||
"competencies": [{"competency": "interview", "value": 65}],
|
||||
"reference_questions": [{"checklist_id": "q_address", "question": "Назовите адрес?"}],
|
||||
"missed_checklist": ["q_address"],
|
||||
"hints_used": [{"checklist_id": "q_address", "question": "Уточните адрес", "at": at}],
|
||||
"self_assessment": {"missed": ["q_address"], "comment": "@SUM(1,2)", "submitted_at": at},
|
||||
"self_assessment_diff": {"noticed": ["q_address"], "unnoticed": [], "overcautious": []},
|
||||
"notes": [{"transcript_ref": "u0", "text": "Внимательнее к адресу", "author": "Преподаватель"}],
|
||||
"score_auto": 70,
|
||||
"score_final": 75,
|
||||
"overridden_by": "Преподаватель",
|
||||
"override_comment": "Ручная корректировка",
|
||||
})
|
||||
|
||||
|
||||
def test_csv_contains_sections_and_blocks_formula_injection():
|
||||
rows = list(csv.reader(io.StringIO(to_csv(sample_report()).decode("utf-8-sig"))))
|
||||
assert rows[0] == ["Раздел", "№", "Поле", "Значение", "Дополнительно"]
|
||||
assert any(row[0] == "Транскрипт" and row[3] == "<вопрос> \t=cmd" for row in rows)
|
||||
assert any(row[0] == "Занятие" and row[3] == "'=1+1" for row in rows)
|
||||
assert any(row[0] == "Ошибки" and row[3] == "'+HYPERLINK(\"x\")" for row in rows)
|
||||
assert any(row[0] == "Самооценка" and row[3] == "'@SUM(1,2)" for row in rows)
|
||||
assert _cell(" =cmd") == "' =cmd"
|
||||
assert _cell("\tОбычный текст") == "'\tОбычный текст"
|
||||
|
||||
|
||||
def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
|
||||
data = to_pdf(sample_report(long=True))
|
||||
assert data.startswith(b"%PDF-")
|
||||
path = tmp_path / "report.pdf"
|
||||
path.write_bytes(data)
|
||||
# Poppler даёт проверку извлекаемого текста, не только сигнатуры файла.
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
if shutil.which("pdftotext") and shutil.which("pdfinfo"):
|
||||
info = subprocess.check_output(["pdfinfo", str(path)], text=True)
|
||||
pages = int(next(line.split(":", 1)[1].strip() for line in info.splitlines() if line.startswith("Pages:")))
|
||||
assert pages >= 2
|
||||
extracted = subprocess.check_output(["pdftotext", str(path), "-"], text=True)
|
||||
assert "Отчёт по учебному занятию" in extracted
|
||||
assert "Адрес должен быть уточнён" in extracted
|
||||
assert "Заявитель сообщает о дыме" in extracted
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
report = sample_report()
|
||||
state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4())
|
||||
monkeypatch.setattr(sessions, "_live", lambda session_id: (state, object()))
|
||||
monkeypatch.setattr(sessions, "build_report", lambda session_id, state, scenario: report)
|
||||
with TestClient(app) as test_client:
|
||||
test_client.post("/api/auth/dev-token")
|
||||
yield test_client, state, report
|
||||
|
||||
|
||||
def test_export_routes_return_downloads_with_json_report_rights(client):
|
||||
browser, state, report = client
|
||||
csv_response = browser.get(f"/api/sessions/{report.session_id}/report.csv")
|
||||
assert csv_response.status_code == 200
|
||||
assert csv_response.headers["content-type"].startswith("text/csv")
|
||||
assert csv_response.content.startswith(b"\xef\xbb\xbf")
|
||||
assert "attachment" in csv_response.headers["content-disposition"]
|
||||
|
||||
pdf_response = browser.get(f"/api/sessions/{report.session_id}/report.pdf")
|
||||
assert pdf_response.status_code == 200
|
||||
assert pdf_response.headers["content-type"] == "application/pdf"
|
||||
assert pdf_response.content.startswith(b"%PDF-")
|
||||
|
||||
state.score = None
|
||||
assert browser.get(f"/api/sessions/{report.session_id}/report.csv").status_code == 409
|
||||
assert browser.get(f"/api/sessions/{report.session_id}/report.pdf").status_code == 409
|
||||
|
||||
|
||||
def test_trainee_cannot_export_another_persons_report(client, monkeypatch):
|
||||
browser, state, report = client
|
||||
monkeypatch.setattr(
|
||||
sessions, "require",
|
||||
lambda request: Principal(login="trainee", full_name="Учебный", role=Role.TRAINEE, trainee_id=uuid4()),
|
||||
)
|
||||
for suffix in ("csv", "pdf"):
|
||||
assert browser.get(f"/api/sessions/{report.session_id}/report.{suffix}").status_code == 403
|
||||
140
backend/tests/test_scenario_editor.py
Normal file
140
backend/tests/test_scenario_editor.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""Преподавательский цикл: шаблон → черновик → проверка → утверждение."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.http import scenarios as scenarios_api
|
||||
from app.db.base import get_session
|
||||
from app.main import app
|
||||
from app.scenarios import store
|
||||
from app.scenarios.editor import merge_patch, template_copy, validate
|
||||
from app.scenarios.loader import ScenarioError, load_file
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""Минимальная транзакционная замена БД для проверки HTTP без Postgres."""
|
||||
|
||||
def __init__(self):
|
||||
self.rows = {}
|
||||
|
||||
def add(self, row):
|
||||
self.rows[row.id] = row
|
||||
|
||||
async def get(self, model, key):
|
||||
return self.rows.get(key)
|
||||
|
||||
async def commit(self):
|
||||
pass
|
||||
|
||||
async def scalars(self, query):
|
||||
return [row for row in self.rows.values() if row.status == "published"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
db = FakeSession()
|
||||
|
||||
async def no_audit(*args, **kwargs):
|
||||
pass
|
||||
|
||||
async def no_restore(*args, **kwargs):
|
||||
return 0
|
||||
|
||||
app.dependency_overrides[get_session] = lambda: db
|
||||
monkeypatch.setattr(scenarios_api, "audit", no_audit)
|
||||
monkeypatch.setattr(store, "restore_published", no_restore)
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_template_copy_is_local_independent_and_valid():
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
body = template_copy(source, "draft-example")
|
||||
assert body["id"] == "draft-example"
|
||||
assert body["extends"] is None
|
||||
assert body["ticket"] is None
|
||||
assert body["ground_truth"].keys() == {"address", "victims"}
|
||||
assert validate(body).ground_truth.dds == source.ground_truth.dds
|
||||
assert source.id != body["id"] # оригинал не меняется
|
||||
|
||||
|
||||
def test_editor_rejects_derived_truth_and_missing_fact():
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
body = template_copy(source, "draft-example")
|
||||
with pytest.raises(ScenarioError, match="ground_truth"):
|
||||
merge_patch(body, {"ground_truth": {"dds": "02"}})
|
||||
with pytest.raises(ScenarioError, match="id сценария"):
|
||||
merge_patch(body, {"id": "other"})
|
||||
broken = merge_patch(body, {"facts": []})
|
||||
with pytest.raises(ScenarioError, match="нужны факты"):
|
||||
validate(broken)
|
||||
|
||||
|
||||
def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
|
||||
source = next(s for s in store.all_scenarios() if s.id == "fire-apartment-l2")
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
response = client.post(
|
||||
"/api/scenarios/drafts/from-template",
|
||||
json={"source_id": source.id, "title": "Учебная копия"},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
draft = response.json()
|
||||
draft_id = draft["id"]
|
||||
assert draft["generation"] == "template_copy"
|
||||
assert draft["status"] == "draft"
|
||||
assert store.get(draft_id) is None
|
||||
assert client.get(f"/api/scenarios/{draft_id}").status_code == 404
|
||||
|
||||
changed = client.patch(
|
||||
f"/api/scenarios/drafts/{draft_id}",
|
||||
json={"first_line": "Соседи! В доме дым!"},
|
||||
)
|
||||
assert changed.status_code == 200, changed.text
|
||||
assert changed.json()["body"]["first_line"] == "Соседи! В доме дым!"
|
||||
check = client.post(f"/api/scenarios/drafts/{draft_id}/validate")
|
||||
assert check.status_code == 200 and check.json()["valid"]
|
||||
approved = client.post(f"/api/scenarios/drafts/{draft_id}/approve")
|
||||
assert approved.status_code == 200, approved.text
|
||||
assert approved.json()["status"] == "published"
|
||||
assert store.get(draft_id).first_line == "Соседи! В доме дым!"
|
||||
assert client.get(f"/api/scenarios/{draft_id}").status_code == 200
|
||||
assert client.patch(f"/api/scenarios/drafts/{draft_id}", json={"title": "нет"}).status_code == 404
|
||||
|
||||
|
||||
def test_invalid_draft_cannot_be_approved(client):
|
||||
client.post("/api/auth/dev-token")
|
||||
response = client.post(
|
||||
"/api/scenarios/drafts/from-template",
|
||||
json={"source_id": "fire-apartment-l2"},
|
||||
)
|
||||
draft_id = response.json()["id"]
|
||||
assert client.patch(f"/api/scenarios/drafts/{draft_id}", json={"facts": []}).status_code == 200
|
||||
check = client.post(f"/api/scenarios/drafts/{draft_id}/validate")
|
||||
assert check.json()["valid"] is False
|
||||
assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 422
|
||||
assert store.get(draft_id) is None
|
||||
|
||||
|
||||
def test_unauthenticated_user_cannot_manage_drafts(client):
|
||||
response = client.post(
|
||||
"/api/scenarios/drafts/from-template", json={"source_id": "fire-apartment-l2"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approved_scenario_restores_from_local_database():
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
db = FakeSession()
|
||||
row = await store.create_draft(db, source=source)
|
||||
approved = await store.approve_draft(db, row)
|
||||
store.set_library([source]) # имитация нового процесса после чтения YAML
|
||||
assert store.get(approved.id) is None
|
||||
assert await store.restore_published(db) == 1
|
||||
assert store.get(approved.id).title == approved.title
|
||||
assert await store.restore_published(db) == 0
|
||||
64
backend/tests/test_session_access.py
Normal file
64
backend/tests/test_session_access.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""HTTP-ссылки на занятие не дают курсанту чужую карточку или чек-лист."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.api.auth import Principal
|
||||
from app.api.http import sessions
|
||||
from app.domain.events import Exercise
|
||||
from app.domain.roles import Role
|
||||
|
||||
|
||||
def request() -> Request:
|
||||
return Request({"type": "http", "method": "GET", "path": "/", "headers": []})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trainee_cannot_read_foreign_session(monkeypatch):
|
||||
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
|
||||
monkeypatch.setattr(sessions, "require", lambda _: who)
|
||||
|
||||
async def row(_db, _session_id):
|
||||
return SimpleNamespace(trainee_id=uuid4())
|
||||
|
||||
monkeypatch.setattr(sessions.repo, "get_session", row)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await sessions.read(uuid4(), request(), db=object())
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trainee_cannot_read_foreign_checklist(monkeypatch):
|
||||
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
|
||||
monkeypatch.setattr(sessions, "require", lambda _: who)
|
||||
monkeypatch.setattr(sessions.hub, "get", lambda _: SimpleNamespace(trainee_id=uuid4(), ended=True))
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await sessions.checklist(uuid4(), request())
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trainee_cannot_bypass_self_assessment_via_report(monkeypatch):
|
||||
trainee_id = uuid4()
|
||||
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=trainee_id)
|
||||
monkeypatch.setattr(sessions, "require", lambda _: who)
|
||||
state = SimpleNamespace(
|
||||
trainee_id=trainee_id, exercise=Exercise.CALL, self_assessed=False,
|
||||
score={"score_auto": 100},
|
||||
)
|
||||
monkeypatch.setattr(sessions, "_live", lambda _: (state, object()))
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await sessions.report(uuid4(), request())
|
||||
assert error.value.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trainee_without_profile_cannot_list_everyones_sessions(monkeypatch):
|
||||
who = Principal(login="unlinked", full_name="Курсант", role=Role.TRAINEE, trainee_id=None)
|
||||
monkeypatch.setattr(sessions, "require", lambda _: who)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await sessions.listing(request(), db=object())
|
||||
assert error.value.status_code == 403
|
||||
|
|
@ -117,6 +117,62 @@ def test_interview_timer_starts_on_answer_and_stops_on_dispatch(client):
|
|||
assert state.dispatched_card is not None, "карточка не заморожена снимком"
|
||||
|
||||
|
||||
def test_ekp_dispatch_needs_no_manual_dds_and_reaches_station(client):
|
||||
with lesson(client) as (session_id, _):
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
read_until(trainee, "call.incoming")
|
||||
trainee.send_json({"type": "call.answer"})
|
||||
trainee.send_json({"type": "kio.patch", "fields": {
|
||||
"signs": ["жилой дом", "балкон", "открытое пламя"]
|
||||
}})
|
||||
auto = next(
|
||||
event for event in (trainee.receive_json() for _ in range(8))
|
||||
if event["type"] == "kio.patch" and event["source"] == "auto"
|
||||
)
|
||||
assert auto["fields"]["incident_code"]
|
||||
assert auto["fields"]["notify"]
|
||||
trainee.send_json({"type": "dds.dispatch"})
|
||||
received = read_until(station, "card.received")
|
||||
assert received["card"]["notify"]
|
||||
assert received["card"]["dds"] is None
|
||||
assert received["card"]["response_status"] == "transferred"
|
||||
assert state.dispatched_card is not None
|
||||
|
||||
|
||||
def test_legacy_dispatch_without_routing_needs_manual_dds(client):
|
||||
with lesson(client) as (session_id, _):
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
read_until(trainee, "call.incoming")
|
||||
trainee.send_json({"type": "dds.dispatch"})
|
||||
error = read_until(trainee, "error")
|
||||
assert "ДДС" in error["message"]
|
||||
assert state.dispatched_card is None
|
||||
trainee.send_json({"type": "dds.dispatch", "service": "01"})
|
||||
wait_for(lambda: state.dispatched_card)
|
||||
assert state.kio.dds.value == "01"
|
||||
|
||||
|
||||
def test_late_call_join_replays_incoming_and_current_kio(client):
|
||||
with lesson(client) as (session_id, _):
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as first:
|
||||
read_until(first, "call.incoming")
|
||||
first.send_json({"type": "kio.patch", "fields": {
|
||||
"address": "улица Ленина, 14",
|
||||
"signs": ["жилой дом", "балкон", "открытое пламя"],
|
||||
}})
|
||||
wait_for(lambda: hub.get(session_id).kio.incident_code)
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as late:
|
||||
incoming = read_until(late, "call.incoming")
|
||||
assert incoming["scenario_id"] == "fire-apartment-l2"
|
||||
assert "dds" not in incoming["required_fields"]
|
||||
card = read_until(late, "kio.patch")
|
||||
assert card["fields"]["address"] == "улица Ленина, 14"
|
||||
assert card["fields"]["notify"]
|
||||
|
||||
|
||||
def test_card_edit_reaches_observer(client):
|
||||
with lesson(client) as (session_id, _):
|
||||
with client.websocket_connect(f"/ws/observe/{session_id}") as observer:
|
||||
|
|
|
|||
63
backend/tests/test_ws_ownership.py
Normal file
63
backend/tests/test_ws_ownership.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Знание URL занятия не даёт курсанту доступ к чужому АРМ."""
|
||||
|
||||
import importlib
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.auth import Principal
|
||||
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:
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.journal = None
|
||||
yield test_client
|
||||
|
||||
|
||||
def test_trainee_cannot_open_foreign_or_unassigned_call_or_station(client, monkeypatch):
|
||||
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": "Назначенный", "mode": "training"})
|
||||
deadline = time.monotonic() + 3
|
||||
while hub.get(session_id) is None and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
state = hub.get(session_id)
|
||||
assert state is not None
|
||||
|
||||
owner_id, foreign_id = uuid4(), uuid4()
|
||||
modules = {
|
||||
"call": importlib.import_module("app.api.ws.call"),
|
||||
"station": importlib.import_module("app.api.ws.station"),
|
||||
}
|
||||
for assigned in (owner_id, None):
|
||||
state.trainee_id = assigned
|
||||
for role_name, module in modules.items():
|
||||
who = Principal(login="foreign", full_name="Чужой",
|
||||
role=Role.TRAINEE, trainee_id=foreign_id)
|
||||
monkeypatch.setattr(module, "principal_of", lambda _ws, user=who: user)
|
||||
with client.websocket_connect(f"/ws/{role_name}/{session_id}") as socket:
|
||||
event = socket.receive_json()
|
||||
assert event["type"] == "error" and event["code"] == "forbidden"
|
||||
|
||||
state.trainee_id = owner_id
|
||||
for role_name, module in modules.items():
|
||||
who = Principal(login="owner", full_name="Назначенный",
|
||||
role=Role.TRAINEE, trainee_id=owner_id)
|
||||
monkeypatch.setattr(module, "principal_of", lambda _ws, user=who: user)
|
||||
with client.websocket_connect(f"/ws/{role_name}/{session_id}") as socket:
|
||||
socket.send_json({"type": "unknown"})
|
||||
for _ in range(8):
|
||||
event = socket.receive_json()
|
||||
if event["type"] == "error":
|
||||
assert event["code"] == "unsupported_event"
|
||||
break
|
||||
else:
|
||||
raise AssertionError("владелец занятия не допущен")
|
||||
Loading…
Reference in a new issue