Связка 112→ДДС больше не подменяет таймеры оператора таймерами первой карточки ДДС, поэтому время заполнения карточки 112 снова попадает в оценку.
438 lines
16 KiB
Python
438 lines
16 KiB
Python
from __future__ import annotations
|
||
|
||
from datetime import UTC, datetime
|
||
from types import SimpleNamespace
|
||
from uuid import UUID
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.api import auth
|
||
from app.api.http import scenario_submissions
|
||
from app.api.http.scenario_submissions import reset_demo_submissions
|
||
from app.config import get_settings
|
||
from app.db.models import AuditLog, Group, Scenario, ScenarioSubmission, Trainee
|
||
from app.domain import ekp
|
||
from app.main import app
|
||
from app.scenarios import store
|
||
from app.session.dds import build_card
|
||
from app.session.timers import SessionTimers
|
||
|
||
|
||
@pytest.fixture
|
||
def client(monkeypatch):
|
||
monkeypatch.setenv("DEMO_NO_DB", "true")
|
||
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
|
||
get_settings.cache_clear()
|
||
store.reset_demo_drafts()
|
||
reset_demo_submissions()
|
||
try:
|
||
with TestClient(app) as test_client:
|
||
yield test_client
|
||
finally:
|
||
get_settings.cache_clear()
|
||
|
||
|
||
def _login(client: TestClient, role: str) -> None:
|
||
response = client.post("/api/auth/dev-token", params={"role": role})
|
||
assert response.status_code == 200, response.text
|
||
|
||
|
||
def _student_kio(
|
||
description="В мастерской виден дым из повреждённого оборудования.",
|
||
address="Москва, учебная улица, дом 10",
|
||
):
|
||
source = store.get("t01-1-fire-container")
|
||
assert source is not None
|
||
return {
|
||
"caller_name": "Учебный заявитель",
|
||
"caller_contact": "+7 900 000-00-00",
|
||
"address": address,
|
||
"description": description,
|
||
"incident_group": ekp.incident(source.ground_truth.incident_code).group,
|
||
"signs": source.signs,
|
||
"incident_type": "fire",
|
||
"dds": source.ground_truth.dds.value,
|
||
"victims_count": 0,
|
||
"fire": {"object_kind": "оборудование", "fire_nature": "задымление"},
|
||
}
|
||
|
||
|
||
def test_trainee_scenario_catalog_and_detail_only_expose_safe_self_practice_fields(client):
|
||
_login(client, "trainee")
|
||
listed = client.get("/api/scenarios").json()
|
||
item = next(row for row in listed if row["id"] == "t01-1-fire-container")
|
||
assert set(item) == {"id", "title", "level", "modes"}
|
||
assert "self" in item["modes"]
|
||
detail = client.get("/api/scenarios/t01-1-fire-container")
|
||
assert detail.status_code == 200
|
||
assert set(detail.json()) == {"id", "title", "level", "modes"}
|
||
assert client.get("/api/scenarios/t01-1-fire-container").json().get("ground_truth") is None
|
||
|
||
|
||
def test_student_submission_is_moderated_then_enters_dds_bank(client):
|
||
_login(client, "trainee")
|
||
created = client.post(
|
||
"/api/scenario-submissions",
|
||
json={
|
||
"title": "Пожар в мастерской",
|
||
"level": "L2",
|
||
"kio": _student_kio(
|
||
"В мастерской на первом этаже виден дым, люди вышли наружу.",
|
||
"Москва, улица Примерная, дом 12",
|
||
),
|
||
},
|
||
)
|
||
assert created.status_code == 201, created.text
|
||
submission_id = created.json()["id"]
|
||
assert created.json()["status"] == "pending"
|
||
assert created.json()["scenario_id"] is None
|
||
assert client.get("/api/scenarios").status_code == 200
|
||
assert not any("student-created" in item.get("topics", [])
|
||
for item in client.get("/api/scenarios").json())
|
||
|
||
# Курсанту разрешено видеть своё предложение, но не публиковать его.
|
||
own = client.get("/api/scenario-submissions").json()
|
||
assert [item["id"] for item in own] == [submission_id]
|
||
denied = client.post(
|
||
f"/api/scenario-submissions/{submission_id}/review",
|
||
json={"decision": "approve"},
|
||
)
|
||
assert denied.status_code == 403
|
||
|
||
client.post("/api/auth/logout")
|
||
_login(client, "instructor")
|
||
pending = client.get("/api/scenario-submissions").json()
|
||
assert pending[0]["title"] == "Пожар в мастерской"
|
||
approved = client.post(
|
||
f"/api/scenario-submissions/{submission_id}/review",
|
||
json={"decision": "approve", "comment": "Факты проверены."},
|
||
)
|
||
assert approved.status_code == 200, approved.text
|
||
body = approved.json()
|
||
assert body["status"] == "approved"
|
||
assert body["scenario_id"].startswith("student-")
|
||
|
||
scenario = store.get(body["scenario_id"])
|
||
assert scenario is not None
|
||
assert scenario.ground_truth.address == "Москва, улица Примерная, дом 12"
|
||
assert scenario.ground_truth.victims == 0
|
||
assert "student-created" in scenario.topics
|
||
listed = client.get("/api/scenarios").json()
|
||
published = next(item for item in listed if item["id"] == scenario.id)
|
||
assert published["source"] == "trainee"
|
||
assert published["outcome"] == "card"
|
||
assert published["dds"] == "01"
|
||
|
||
repeated = client.post(
|
||
f"/api/scenario-submissions/{submission_id}/review",
|
||
json={"decision": "approve"},
|
||
)
|
||
assert repeated.status_code == 409
|
||
|
||
|
||
def test_submitted_kio_is_kept_intact_and_becomes_the_dds_card_after_approval(client):
|
||
source = store.get("t01-1-fire-container")
|
||
assert source is not None
|
||
_login(client, "trainee")
|
||
created = client.post(
|
||
"/api/scenario-submissions",
|
||
json={
|
||
"title": "КИО курсанта: контейнер во дворе",
|
||
"level": "L2",
|
||
"kio": {
|
||
"caller_name": "Учебный заявитель",
|
||
"caller_contact": "+7 900 000-00-00",
|
||
"address": "Москва, учебная улица, дом 10",
|
||
"description": "Во дворе открыто горит мусорный контейнер.",
|
||
"incident_group": ekp.incident(source.ground_truth.incident_code).group,
|
||
"signs": source.signs,
|
||
"incident_type": "fire",
|
||
"dds": source.ground_truth.dds.value,
|
||
"victims_count": 0,
|
||
"fire": {
|
||
"object_kind": "мусорный контейнер",
|
||
"fire_nature": "открытое пламя",
|
||
},
|
||
},
|
||
},
|
||
)
|
||
assert created.status_code == 201, created.text
|
||
body = created.json()
|
||
assert body["status"] == "pending"
|
||
assert body["kio"]["caller_number"] is None
|
||
assert body["kio"]["caller_name"] == "Учебный заявитель"
|
||
assert not any("student-created" in item.get("topics", [])
|
||
for item in client.get("/api/scenarios").json())
|
||
|
||
client.post("/api/auth/logout")
|
||
_login(client, "instructor")
|
||
approved = client.post(
|
||
f"/api/scenario-submissions/{body['id']}/review",
|
||
json={"decision": "approve", "comment": "КИО проверена."},
|
||
)
|
||
assert approved.status_code == 200, approved.text
|
||
scenario = store.get(approved.json()["scenario_id"])
|
||
assert scenario is not None
|
||
assert scenario.student_card is not None
|
||
assert scenario.student_card.address == "Москва, учебная улица, дом 10"
|
||
assert scenario.student_card.caller_name == "Учебный заявитель"
|
||
assert scenario.student_card.victims_count == 0
|
||
assert scenario.student_card.fire.object_kind == "мусорный контейнер"
|
||
assert scenario.student_card.signs == source.signs
|
||
assert "moderated-kio" in scenario.topics
|
||
card = build_card(scenario, 0, SessionTimers().limits)
|
||
assert card.dispatched_card.address == scenario.student_card.address
|
||
assert card.dispatched_card.caller_name == "Учебный заявитель"
|
||
assert card.dispatched_card.fire.object_kind == "мусорный контейнер"
|
||
assert any(
|
||
item["id"] == scenario.id and item["source"] == "trainee"
|
||
for item in client.get("/api/scenarios").json()
|
||
)
|
||
|
||
|
||
def test_database_submission_path_persists_kio_and_publishes_on_approval(
|
||
client,
|
||
monkeypatch,
|
||
):
|
||
source = store.get("t01-1-fire-container")
|
||
assert source is not None
|
||
trainee_id = UUID("00000000-0000-4000-8000-000000000112")
|
||
group_id = UUID("00000000-0000-4000-8000-000000000113")
|
||
|
||
class FakeDb:
|
||
submission = None
|
||
scenario_row = None
|
||
|
||
def __init__(self):
|
||
self.audit_rows = []
|
||
self.commit_rows = []
|
||
|
||
async def get(self, model, _key):
|
||
if model is Trainee:
|
||
return SimpleNamespace(id=trainee_id, group_id=group_id)
|
||
if model is Group:
|
||
return SimpleNamespace(id=group_id, owner_login="demo-instructor")
|
||
raise AssertionError(f"unexpected model: {model}")
|
||
|
||
def add(self, row):
|
||
if isinstance(row, ScenarioSubmission):
|
||
self.submission = row
|
||
row.status = "pending"
|
||
row.created_at = datetime.now(UTC)
|
||
elif isinstance(row, Scenario):
|
||
self.scenario_row = row
|
||
elif isinstance(row, AuditLog):
|
||
self.audit_rows.append(row)
|
||
else:
|
||
raise AssertionError(f"unexpected row: {type(row)}")
|
||
|
||
async def commit(self):
|
||
self.commit_rows.append(tuple(self.audit_rows))
|
||
|
||
async def scalar(self, _query):
|
||
return self.submission
|
||
|
||
fake_db = FakeDb()
|
||
|
||
async def session_override():
|
||
yield fake_db
|
||
|
||
monkeypatch.setitem(
|
||
app.dependency_overrides,
|
||
scenario_submissions.submission_session,
|
||
session_override,
|
||
)
|
||
_login(client, "trainee")
|
||
created = client.post(
|
||
"/api/scenario-submissions",
|
||
json={
|
||
"title": "КИО в DB-пути",
|
||
"level": "L2",
|
||
"kio": {
|
||
"caller_name": "Учебный заявитель",
|
||
"address": "Москва, тестовая улица, дом 3",
|
||
"description": "Во дворе открыто горит мусорный контейнер.",
|
||
"incident_group": ekp.incident(source.ground_truth.incident_code).group,
|
||
"signs": source.signs,
|
||
"incident_type": "fire",
|
||
"dds": source.ground_truth.dds.value,
|
||
"victims_count": 0,
|
||
"fire": {"object_kind": "мусорный контейнер"},
|
||
},
|
||
},
|
||
)
|
||
assert created.status_code == 201, created.text
|
||
assert fake_db.submission.kio["address"] == "Москва, тестовая улица, дом 3"
|
||
assert created.json()["status"] == "pending"
|
||
assert fake_db.commit_rows[0][0].action == "scenario.submission.create"
|
||
|
||
client.post("/api/auth/logout")
|
||
_login(client, "instructor")
|
||
approved = client.post(
|
||
f"/api/scenario-submissions/{created.json()['id']}/review",
|
||
json={"decision": "approve", "comment": "Проверено."},
|
||
)
|
||
assert approved.status_code == 200, approved.text
|
||
assert fake_db.scenario_row.owner_login == "demo-instructor"
|
||
assert fake_db.commit_rows[1][-1].action == "scenario.submission.approve"
|
||
persisted_kio = fake_db.scenario_row.body["student_card"]
|
||
assert persisted_kio["address"] == "Москва, тестовая улица, дом 3"
|
||
assert approved.json()["status"] == "approved"
|
||
|
||
|
||
def test_rejection_requires_comment_and_returns_proposal_to_student(client, monkeypatch):
|
||
audit_rows = []
|
||
|
||
async def capture_audit(*args):
|
||
audit_rows.append(args)
|
||
|
||
monkeypatch.setattr(scenario_submissions, "audit", capture_audit)
|
||
_login(client, "trainee")
|
||
response = client.post(
|
||
"/api/scenario-submissions",
|
||
json={
|
||
"title": "Обстановка на объекте",
|
||
"level": "L1",
|
||
"kio": _student_kio(
|
||
"В помещении обнаружено повреждение инженерного оборудования."
|
||
),
|
||
},
|
||
)
|
||
submission_id = response.json()["id"]
|
||
client.post("/api/auth/logout")
|
||
_login(client, "instructor")
|
||
|
||
missing_reason = client.post(
|
||
f"/api/scenario-submissions/{submission_id}/review", json={"decision": "reject"}
|
||
)
|
||
assert missing_reason.status_code == 422
|
||
rejected = client.post(
|
||
f"/api/scenario-submissions/{submission_id}/review",
|
||
json={
|
||
"decision": "reject",
|
||
"comment": "Уточните место и наблюдаемые признаки.",
|
||
},
|
||
)
|
||
assert rejected.status_code == 200
|
||
assert rejected.json()["status"] == "rejected"
|
||
assert audit_rows[-1][-1] == "comment_chars=38"
|
||
assert "Уточните место" not in audit_rows[-1][-1]
|
||
|
||
client.post("/api/auth/logout")
|
||
_login(client, "trainee")
|
||
own = client.get("/api/scenario-submissions").json()
|
||
assert own[0]["status"] == "rejected"
|
||
assert own[0]["review_comment"] == "Уточните место и наблюдаемые признаки."
|
||
assert not any("student-created" in item.get("topics", [])
|
||
for item in client.get("/api/scenarios").json())
|
||
|
||
|
||
def test_submission_validation_rejects_short_description(client):
|
||
_login(client, "trainee")
|
||
response = client.post(
|
||
"/api/scenario-submissions",
|
||
json={
|
||
"title": "Короткая заявка",
|
||
"level": "L1",
|
||
"kio": _student_kio("дым"),
|
||
},
|
||
)
|
||
assert response.status_code == 422
|
||
|
||
|
||
def test_submission_requires_structured_kio_not_legacy_free_text(client):
|
||
_login(client, "trainee")
|
||
response = client.post(
|
||
"/api/scenario-submissions",
|
||
json={
|
||
"title": "Только текст",
|
||
"level": "L1",
|
||
"incident_type": "fire",
|
||
"description": "В мастерской обнаружены дым и повреждение оборудования.",
|
||
"address": "Москва, учебная улица, дом 10",
|
||
},
|
||
)
|
||
assert response.status_code == 422
|
||
|
||
|
||
def test_submission_rejects_whitespace_only_title(client):
|
||
_login(client, "trainee")
|
||
response = client.post(
|
||
"/api/scenario-submissions",
|
||
json={"title": " ", "level": "L1", "kio": _student_kio()},
|
||
)
|
||
assert response.status_code == 422
|
||
|
||
|
||
def test_submission_uses_street_and_building_when_address_is_blank(client):
|
||
_login(client, "trainee")
|
||
kio = _student_kio()
|
||
kio.update({"address": " ", "street": "Учебная улица", "building": "12"})
|
||
response = client.post(
|
||
"/api/scenario-submissions",
|
||
json={"title": "Проверка адреса", "level": "L1", "kio": kio},
|
||
)
|
||
assert response.status_code == 201, response.text
|
||
assert response.json()["address"] == "Учебная улица 12"
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("group_id", "group_owner", "detail"),
|
||
[
|
||
(None, None, "trainee_group_required_for_review"),
|
||
(
|
||
UUID("00000000-0000-4000-8000-000000000001"),
|
||
None,
|
||
"instructor_group_required_for_review",
|
||
),
|
||
],
|
||
)
|
||
def test_submission_requires_a_group_with_a_moderating_instructor(
|
||
client,
|
||
monkeypatch,
|
||
group_id,
|
||
group_owner,
|
||
detail,
|
||
):
|
||
trainee_id = UUID("00000000-0000-4000-8000-000000000112")
|
||
monkeypatch.setattr(
|
||
scenario_submissions,
|
||
"require",
|
||
lambda *_args, **_kwargs: auth.Principal(
|
||
login="student",
|
||
full_name="Курсант",
|
||
role=auth.Role.TRAINEE,
|
||
trainee_id=trainee_id,
|
||
),
|
||
)
|
||
|
||
class FakeDb:
|
||
async def get(self, model, _key):
|
||
if model.__name__ == "Trainee":
|
||
return SimpleNamespace(group_id=group_id)
|
||
return SimpleNamespace(owner_login=group_owner)
|
||
|
||
def add(self, _row):
|
||
raise AssertionError("proposal must not be stored without a moderator")
|
||
|
||
async def session_override():
|
||
yield FakeDb()
|
||
|
||
app.dependency_overrides[scenario_submissions.submission_session] = session_override
|
||
try:
|
||
response = client.post(
|
||
"/api/scenario-submissions",
|
||
json={
|
||
"title": "Пожар в мастерской",
|
||
"level": "L1",
|
||
"kio": _student_kio(
|
||
"В мастерской обнаружены дым и повреждение оборудования."
|
||
),
|
||
},
|
||
)
|
||
finally:
|
||
app.dependency_overrides.pop(scenario_submissions.submission_session, None)
|
||
|
||
assert response.status_code == 409
|
||
assert response.json()["detail"] == detail
|