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.domain.events import Exercise, SessionMode from app.main import app from app.scenarios import store from app.session.dds import prepare_card from app.session.state import SessionState @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 state = SessionState( session_id=UUID("00000000-0000-4000-8000-000000000701"), scenario_id=scenario.id, scenario_title=scenario.title, level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS, ) prepare_card(state, scenario) assert state.dispatched_card is not None assert state.dispatched_card.address == scenario.student_card.address assert state.dispatched_card.caller_name == "Учебный заявитель" assert state.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