2026-09-26 18:12:27 +03:00
|
|
|
|
"""Журнал сессий. Для полного прогона используй временную БД из `make test-db`;
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
если её нет — пропускаются, чтобы `make test` оставался запускаемым везде.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
import time
|
|
|
|
|
|
from types import SimpleNamespace
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from fastapi import HTTPException
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
from sqlalchemy import delete, select, text, update
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from app.api import auth
|
|
|
|
|
|
from app.api.http import (
|
|
|
|
|
|
groups as groups_api, materials as materials_api,
|
|
|
|
|
|
scenario_submissions, scenarios as scenarios_api, sessions as sessions_api,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.api.ws import control as control_ws, station as station_ws
|
|
|
|
|
|
from app.api.http.scenarios import _hidden_scenario_ids
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
from app.config import get_settings
|
|
|
|
|
|
from app.db import repo
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from app.db.models import (
|
|
|
|
|
|
AuditLog, Group, LearningMaterial, MaterialAssignment, Scenario, ScenarioSubmission,
|
|
|
|
|
|
Score, SelfAssessment, Session, Trainee, User, Utterance,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.db.repo import SessionNodeConflict, ensure_session
|
2026-09-26 22:48:24 +00:00
|
|
|
|
from app.domain.events import Exercise, SessionMode, Speaker
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from app.domain.roles import Role
|
|
|
|
|
|
from app.main import LIBRARY, app
|
|
|
|
|
|
from app.scenarios import store
|
|
|
|
|
|
from app.session.checkpoint import load_state
|
2026-09-26 21:38:09 +00:00
|
|
|
|
from app.session.dds import build_card
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from app.session.hub import hub
|
2026-09-26 22:48:24 +00:00
|
|
|
|
from app.session.pg_store import PostgresSessionStore
|
|
|
|
|
|
from app.session.store import (
|
|
|
|
|
|
LessonRequest,
|
|
|
|
|
|
ScoreCalculated,
|
|
|
|
|
|
ScoreOverridden,
|
|
|
|
|
|
SelfAssessed,
|
|
|
|
|
|
SessionLeaseLost,
|
|
|
|
|
|
UtteranceAppended,
|
|
|
|
|
|
)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from app.session.state import SessionState
|
2026-09-26 21:38:09 +00:00
|
|
|
|
from app.session.timers import SessionTimers
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 22:48:24 +00:00
|
|
|
|
def _state(session_id, scenario, **fields) -> SessionState:
|
|
|
|
|
|
return SessionState(
|
|
|
|
|
|
session_id=session_id, scenario_id=scenario.id, scenario_title=scenario.title,
|
|
|
|
|
|
level=scenario.level, mode=SessionMode.TRAINING, **fields,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _open(store, scenario, session_id, trainee_name, trainee_id=None, owner_login=None):
|
|
|
|
|
|
"""Открыть занятие и вернуть его идентичность — как делает пульт."""
|
|
|
|
|
|
seen = {}
|
|
|
|
|
|
|
|
|
|
|
|
def build(identity):
|
|
|
|
|
|
seen["identity"] = identity
|
|
|
|
|
|
return _state(
|
|
|
|
|
|
session_id, scenario, owner_login=owner_login, trainee_id=identity.trainee_id,
|
|
|
|
|
|
attempt=identity.attempt, backend_fencing_epoch=identity.fencing_epoch,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
state = await store.open(LessonRequest(
|
|
|
|
|
|
session_id=session_id, scenario_id=scenario.id, mode="training",
|
|
|
|
|
|
trainee_name=trainee_name, trainee_id=trainee_id, owner_login=owner_login,
|
|
|
|
|
|
backend_node_id=None,
|
|
|
|
|
|
), build)
|
|
|
|
|
|
return state, seen["identity"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _entry(text):
|
|
|
|
|
|
from app.domain.events import Speaker, TranscriptEntry
|
|
|
|
|
|
|
|
|
|
|
|
return TranscriptEntry(ref="u1", speaker=Speaker.CALLER, text=text,
|
|
|
|
|
|
at=datetime.now(timezone.utc))
|
|
|
|
|
|
|
|
|
|
|
|
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
async def db():
|
|
|
|
|
|
"""Свой движок на каждый тест: глобальный в app.db.base кэшируется и
|
2026-09-18 16:28:47 +03:00
|
|
|
|
привязывается к первому событийному циклу, а pytest даёт новый на каждый тест.
|
|
|
|
|
|
|
|
|
|
|
|
Доступность проверяется подключением к порту, а не через engine.connect():
|
|
|
|
|
|
у asyncpg нет таймаута по умолчанию, и когда Postgres действительно недоступен
|
|
|
|
|
|
(Docker не поднят), connect() висит на TCP-таймауте ОС — минуты, а не секунды,
|
|
|
|
|
|
и `make test` зависает вместо того, чтобы пропустить тест. Тот же баг был
|
|
|
|
|
|
когда-то и в test_profile.py — здесь чинится тем же способом."""
|
|
|
|
|
|
import socket
|
|
|
|
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
|
|
|
|
|
|
url = urlparse(get_settings().database_url)
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
try:
|
2026-09-18 16:28:47 +03:00
|
|
|
|
socket.create_connection((url.hostname or "localhost", url.port or 5432), timeout=2).close()
|
|
|
|
|
|
except OSError as exc:
|
2026-09-26 18:12:27 +03:00
|
|
|
|
pytest.skip(f"Postgres недоступен ({exc}) — запусти `make test-db`")
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
|
2026-09-18 16:28:47 +03:00
|
|
|
|
engine = create_async_engine(get_settings().database_url, poolclass=None)
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
maker = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
|
|
async with maker() as session:
|
|
|
|
|
|
yield session
|
|
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
async def scenario(db):
|
|
|
|
|
|
row = Scenario(
|
|
|
|
|
|
id=f"test-{uuid4().hex[:8]}",
|
|
|
|
|
|
title="Тестовый",
|
|
|
|
|
|
incident_type="fire",
|
|
|
|
|
|
level="L1",
|
|
|
|
|
|
topics=[],
|
|
|
|
|
|
modes=["training"],
|
|
|
|
|
|
body={},
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(row)
|
|
|
|
|
|
await db.commit()
|
2026-09-26 18:12:27 +03:00
|
|
|
|
scenario_id = row.id
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
yield row
|
2026-09-26 18:12:27 +03:00
|
|
|
|
await db.execute(delete(Session).where(Session.scenario_id == scenario_id))
|
|
|
|
|
|
await db.execute(delete(Scenario).where(Scenario.id == scenario_id))
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_attempts_count_up(db, scenario):
|
|
|
|
|
|
"""Дельта попыток — измеримый цикл: разбор → повтор → дельта."""
|
|
|
|
|
|
group = await repo.ensure_group(db, f"группа-{uuid4().hex[:6]}")
|
|
|
|
|
|
trainee = await repo.ensure_trainee(db, f"курсант-{uuid4().hex[:6]}", group)
|
|
|
|
|
|
|
|
|
|
|
|
first = await repo.create_session(
|
|
|
|
|
|
db, scenario_id=scenario.id, mode="training", trainee_id=trainee.id, group_id=group.id
|
|
|
|
|
|
)
|
|
|
|
|
|
second = await repo.create_session(
|
|
|
|
|
|
db, scenario_id=scenario.id, mode="exam", trainee_id=trainee.id, group_id=group.id
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert (first.attempt, second.attempt) == (1, 2)
|
|
|
|
|
|
assert second.group_id == group.id, "группа размечается с первой миграции"
|
2026-09-26 18:12:27 +03:00
|
|
|
|
await db.execute(delete(Trainee).where(Trainee.id == trainee.id))
|
|
|
|
|
|
await db.execute(delete(Group).where(Group.id == group.id))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_group_analytics_uses_scoped_persisted_scores(db, scenario, monkeypatch):
|
|
|
|
|
|
"""HTTP analytics handler aggregates real scores and excludes foreign ownership."""
|
|
|
|
|
|
from starlette.requests import Request
|
|
|
|
|
|
|
|
|
|
|
|
group = await repo.ensure_group(
|
|
|
|
|
|
db, f"аналитика-{uuid4().hex[:8]}", owner_login="analytics-owner"
|
|
|
|
|
|
)
|
|
|
|
|
|
first = await repo.ensure_trainee(db, f"курсант-a-{uuid4().hex[:8]}", group)
|
|
|
|
|
|
second = await repo.ensure_trainee(db, f"курсант-b-{uuid4().hex[:8]}", group)
|
|
|
|
|
|
await repo.ensure_trainee(db, f"курсант-c-{uuid4().hex[:8]}", group) # enrolled, no score
|
|
|
|
|
|
|
|
|
|
|
|
persisted_sessions = []
|
|
|
|
|
|
async def add_scored_attempt(trainee, score_value, codes, *, owner, explicit_group):
|
|
|
|
|
|
session = await repo.create_session(
|
|
|
|
|
|
db, scenario_id=scenario.id, mode="training", trainee_id=trainee.id,
|
|
|
|
|
|
group_id=group.id if explicit_group else None, owner_login=owner,
|
|
|
|
|
|
)
|
|
|
|
|
|
session.ended_at = datetime.now(timezone.utc)
|
|
|
|
|
|
db.add(Score(
|
|
|
|
|
|
session_id=session.id, score_auto=score_value, score_final=score_value,
|
|
|
|
|
|
report={"summary": {"codes": codes}},
|
|
|
|
|
|
))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
persisted_sessions.append(session.id)
|
|
|
|
|
|
|
|
|
|
|
|
await add_scored_attempt(first, 40, {"E1": 2, "D1": 1}, owner="analytics-owner", explicit_group=True)
|
|
|
|
|
|
await add_scored_attempt(first, 60, {"E1": 1}, owner="analytics-owner", explicit_group=True)
|
|
|
|
|
|
# Legacy-compatible association through the current trainee group.
|
|
|
|
|
|
await add_scored_attempt(second, 80, {"E1": 1}, owner="analytics-owner", explicit_group=False)
|
|
|
|
|
|
await add_scored_attempt(second, 0, {"E5": 5}, owner="another-owner", explicit_group=True)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
groups_api, "require",
|
|
|
|
|
|
lambda _request, *_roles: SimpleNamespace(login="analytics-owner", role=Role.INSTRUCTOR),
|
|
|
|
|
|
)
|
|
|
|
|
|
request = Request({"type": "http", "session": {}})
|
|
|
|
|
|
result = await groups_api.analytics(group.id, request, db)
|
|
|
|
|
|
|
|
|
|
|
|
assert result.enrolled_trainees == 3
|
|
|
|
|
|
assert result.active_trainees == 2
|
|
|
|
|
|
assert result.scored_attempts == 3
|
|
|
|
|
|
assert result.average_score == 60.0
|
|
|
|
|
|
by_code = {error.code: error for error in result.errors}
|
|
|
|
|
|
assert set(by_code) == {"E1", "D1"}
|
|
|
|
|
|
assert (by_code["E1"].affected_trainees, by_code["E1"].occurrences, by_code["E1"].rate_percent) == (2, 4, 100.0)
|
|
|
|
|
|
assert (by_code["D1"].affected_trainees, by_code["D1"].occurrences, by_code["D1"].rate_percent) == (1, 1, 50.0)
|
|
|
|
|
|
assert all(error.recommendation for error in result.errors)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
groups_api, "require",
|
|
|
|
|
|
lambda _request, *_roles: SimpleNamespace(login="another-owner", role=Role.INSTRUCTOR),
|
|
|
|
|
|
)
|
|
|
|
|
|
with pytest.raises(HTTPException) as forbidden:
|
|
|
|
|
|
await groups_api.analytics(group.id, request, db)
|
|
|
|
|
|
assert forbidden.value.status_code == 404
|
|
|
|
|
|
assert forbidden.value.detail == "group_not_found"
|
|
|
|
|
|
|
|
|
|
|
|
await db.execute(delete(Session).where(Session.id.in_(persisted_sessions)))
|
|
|
|
|
|
await db.execute(delete(Trainee).where(Trainee.group_id == group.id))
|
|
|
|
|
|
await db.delete(group)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_learning_materials_are_scoped_to_teacher_and_assigned_trainee(
|
|
|
|
|
|
db, monkeypatch,
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Material listing/mutation and learner access use durable tenant rows."""
|
|
|
|
|
|
from starlette.requests import Request
|
|
|
|
|
|
|
|
|
|
|
|
suffix = uuid4().hex[:8]
|
|
|
|
|
|
group_a = await repo.ensure_group(db, f"materials-a-{suffix}", owner_login="teacher-a")
|
|
|
|
|
|
group_b = await repo.ensure_group(db, f"materials-b-{suffix}", owner_login="teacher-b")
|
|
|
|
|
|
trainee_a = await repo.ensure_trainee(db, f"materials-student-a-{suffix}", group_a)
|
|
|
|
|
|
trainee_b = await repo.ensure_trainee(db, f"materials-student-b-{suffix}", group_b)
|
|
|
|
|
|
material_a = LearningMaterial(
|
|
|
|
|
|
title="Местная памятка A", description="", level="L1", kind="text",
|
|
|
|
|
|
body="Текст для группы A", active=True, created_by="teacher-a",
|
|
|
|
|
|
)
|
|
|
|
|
|
material_b = LearningMaterial(
|
|
|
|
|
|
title="Местная памятка B", description="", level="L1", kind="text",
|
|
|
|
|
|
body="Текст для группы B", active=True, created_by="teacher-b",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([material_a, material_b])
|
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
assignment = MaterialAssignment(
|
|
|
|
|
|
material_id=material_a.id, trainee_id=trainee_a.id, assigned_by="teacher-a",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(assignment)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
request = Request({"type": "http", "session": {}})
|
|
|
|
|
|
identity = {"login": "teacher-a", "role": Role.INSTRUCTOR, "trainee_id": None}
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
materials_api, "require",
|
|
|
|
|
|
lambda _request, *_roles: SimpleNamespace(
|
|
|
|
|
|
login=identity["login"], role=identity["role"], trainee_id=identity["trainee_id"],
|
|
|
|
|
|
full_name="Test user",
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
teacher_a_materials = await materials_api.listing(request, db=db)
|
|
|
|
|
|
assert [item.id for item in teacher_a_materials] == [material_a.id]
|
|
|
|
|
|
assert teacher_a_materials[0].assignment_count == 1
|
|
|
|
|
|
|
|
|
|
|
|
identity["login"] = "teacher-b"
|
|
|
|
|
|
teacher_b_materials = await materials_api.listing(request, db=db)
|
|
|
|
|
|
assert [item.id for item in teacher_b_materials] == [material_b.id]
|
|
|
|
|
|
|
|
|
|
|
|
identity["login"] = "teacher-a"
|
|
|
|
|
|
with pytest.raises(HTTPException) as edit_foreign:
|
|
|
|
|
|
await materials_api.update(
|
|
|
|
|
|
material_b.id, materials_api.MaterialPatch(title="Подмена"), request, db,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert edit_foreign.value.status_code == 404
|
|
|
|
|
|
with pytest.raises(HTTPException) as assign_foreign_trainee:
|
|
|
|
|
|
await materials_api.assign(material_a.id, trainee_b.id, request, db)
|
|
|
|
|
|
assert assign_foreign_trainee.value.status_code == 404
|
|
|
|
|
|
with pytest.raises(HTTPException) as assign_foreign_group:
|
|
|
|
|
|
await materials_api.assign_group(material_a.id, group_b.id, request, db)
|
|
|
|
|
|
assert assign_foreign_group.value.status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
identity.update(login="student-a", role=Role.TRAINEE, trainee_id=trainee_a.id)
|
|
|
|
|
|
student_a_materials = await materials_api.listing(request, db=db)
|
|
|
|
|
|
assert [item.id for item in student_a_materials] == [material_a.id]
|
|
|
|
|
|
identity.update(login="student-b", trainee_id=trainee_b.id)
|
|
|
|
|
|
assert await materials_api.listing(request, db=db) == []
|
|
|
|
|
|
with pytest.raises(HTTPException) as complete_unassigned:
|
|
|
|
|
|
await materials_api.complete(material_a.id, request, db)
|
|
|
|
|
|
assert complete_unassigned.value.status_code == 403
|
|
|
|
|
|
assert complete_unassigned.value.detail == "material_not_assigned"
|
|
|
|
|
|
|
|
|
|
|
|
await db.execute(delete(MaterialAssignment).where(
|
|
|
|
|
|
MaterialAssignment.material_id.in_([material_a.id, material_b.id])
|
|
|
|
|
|
))
|
|
|
|
|
|
await db.execute(delete(LearningMaterial).where(
|
|
|
|
|
|
LearningMaterial.id.in_([material_a.id, material_b.id])
|
|
|
|
|
|
))
|
|
|
|
|
|
await db.execute(delete(Trainee).where(Trainee.id.in_([trainee_a.id, trainee_b.id])))
|
|
|
|
|
|
await db.execute(delete(Group).where(Group.id.in_([group_a.id, group_b.id])))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_archived_session_reports_are_owner_scoped_in_postgres(db, scenario, monkeypatch):
|
|
|
|
|
|
"""JSON/CSV/PDF report handlers deny persisted sessions owned by others."""
|
|
|
|
|
|
from starlette.requests import Request
|
|
|
|
|
|
|
|
|
|
|
|
trainee = await repo.ensure_trainee(db, f"report-owner-{uuid4().hex[:8]}")
|
|
|
|
|
|
stranger = await repo.ensure_trainee(db, f"report-stranger-{uuid4().hex[:8]}")
|
|
|
|
|
|
session = await repo.create_session(
|
|
|
|
|
|
db, scenario_id=scenario.id, mode="training", trainee_id=trainee.id,
|
|
|
|
|
|
owner_login="report-owner",
|
|
|
|
|
|
)
|
|
|
|
|
|
session.ended_at = datetime.now(timezone.utc)
|
|
|
|
|
|
db.add(Score(
|
|
|
|
|
|
session_id=session.id, score_auto=61, score_final=61,
|
|
|
|
|
|
report={"full_report": {"private_marker": "archived report payload"}},
|
|
|
|
|
|
))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
request = Request({"type": "http", "session": {}})
|
|
|
|
|
|
identity = {"login": "not-the-owner", "role": Role.INSTRUCTOR, "trainee_id": None}
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
sessions_api, "require",
|
|
|
|
|
|
lambda _request, *_roles: SimpleNamespace(
|
|
|
|
|
|
login=identity["login"], role=identity["role"], trainee_id=identity["trainee_id"],
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
for endpoint in (sessions_api.report, sessions_api.report_csv, sessions_api.report_pdf):
|
|
|
|
|
|
with pytest.raises(HTTPException) as forbidden:
|
|
|
|
|
|
await endpoint(session.id, request, db)
|
|
|
|
|
|
assert forbidden.value.status_code == 404
|
|
|
|
|
|
assert forbidden.value.detail == "session_not_found"
|
|
|
|
|
|
|
|
|
|
|
|
identity.update(login="other-trainee", role=Role.TRAINEE, trainee_id=stranger.id)
|
|
|
|
|
|
for endpoint in (sessions_api.report, sessions_api.report_csv, sessions_api.report_pdf):
|
|
|
|
|
|
with pytest.raises(HTTPException) as forbidden:
|
|
|
|
|
|
await endpoint(session.id, request, db)
|
|
|
|
|
|
assert forbidden.value.status_code == 403
|
|
|
|
|
|
assert forbidden.value.detail == "not_your_session"
|
|
|
|
|
|
|
|
|
|
|
|
await db.delete(session)
|
|
|
|
|
|
await db.execute(delete(Trainee).where(Trainee.id.in_([trainee.id, stranger.id])))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_session_history_and_detail_are_owner_scoped_in_postgres(db, scenario, monkeypatch):
|
|
|
|
|
|
"""Session list/detail handlers apply durable teacher and learner ownership."""
|
|
|
|
|
|
from starlette.requests import Request
|
|
|
|
|
|
|
|
|
|
|
|
trainee_a = await repo.ensure_trainee(db, f"history-a-{uuid4().hex[:8]}")
|
|
|
|
|
|
trainee_b = await repo.ensure_trainee(db, f"history-b-{uuid4().hex[:8]}")
|
|
|
|
|
|
owned = await repo.create_session(
|
|
|
|
|
|
db, scenario_id=scenario.id, mode="training", trainee_id=trainee_a.id,
|
|
|
|
|
|
owner_login="history-teacher-a",
|
|
|
|
|
|
)
|
|
|
|
|
|
foreign = await repo.create_session(
|
|
|
|
|
|
db, scenario_id=scenario.id, mode="exam", trainee_id=trainee_b.id,
|
|
|
|
|
|
owner_login="history-teacher-b",
|
|
|
|
|
|
)
|
|
|
|
|
|
owned.ended_at = datetime.now(timezone.utc)
|
|
|
|
|
|
foreign.ended_at = datetime.now(timezone.utc)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
request = Request({"type": "http", "session": {}})
|
|
|
|
|
|
identity = {"login": "history-teacher-a", "role": Role.INSTRUCTOR, "trainee_id": None}
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
sessions_api, "require",
|
|
|
|
|
|
lambda _request, *_roles: SimpleNamespace(
|
|
|
|
|
|
login=identity["login"], role=identity["role"], trainee_id=identity["trainee_id"],
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
teacher_rows = await sessions_api.listing(request, since=None, db=db)
|
|
|
|
|
|
assert [row.session_id for row in teacher_rows] == [owned.id]
|
|
|
|
|
|
with pytest.raises(HTTPException) as instructor_read:
|
|
|
|
|
|
await sessions_api.read(foreign.id, request, db)
|
|
|
|
|
|
assert instructor_read.value.status_code == 404
|
|
|
|
|
|
assert instructor_read.value.detail == "session_not_found"
|
|
|
|
|
|
|
|
|
|
|
|
identity.update(login="learner-a", role=Role.TRAINEE, trainee_id=trainee_a.id)
|
|
|
|
|
|
trainee_rows = await sessions_api.listing(request, since=None, db=db)
|
|
|
|
|
|
assert [row.session_id for row in trainee_rows] == [owned.id]
|
|
|
|
|
|
with pytest.raises(HTTPException) as trainee_read:
|
|
|
|
|
|
await sessions_api.read(foreign.id, request, db)
|
|
|
|
|
|
assert trainee_read.value.status_code == 403
|
|
|
|
|
|
assert trainee_read.value.detail == "not_your_session"
|
|
|
|
|
|
|
|
|
|
|
|
await db.execute(delete(Session).where(Session.id.in_([owned.id, foreign.id])))
|
|
|
|
|
|
await db.execute(delete(Trainee).where(Trainee.id.in_([trainee_a.id, trainee_b.id])))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_station_command_ids_survive_a_real_postgres_checkpoint(db, scenario):
|
|
|
|
|
|
session = await repo.create_session(
|
|
|
|
|
|
db, scenario_id=scenario.id, mode="training", session_id=uuid4(),
|
|
|
|
|
|
)
|
|
|
|
|
|
command_id = str(uuid4())
|
2026-09-26 22:48:24 +00:00
|
|
|
|
state = _state(session.id, scenario, processed_station_commands=[command_id])
|
|
|
|
|
|
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
|
|
|
|
|
await pg.commit(state)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
|
|
|
|
|
|
observer_engine = create_async_engine(get_settings().database_url)
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with observer_engine.connect() as observer:
|
|
|
|
|
|
payload, checkpoint_at = (await observer.execute(
|
|
|
|
|
|
select(Session.live_state, Session.checkpoint_at)
|
|
|
|
|
|
.where(Session.id == session.id)
|
|
|
|
|
|
)).one()
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await observer_engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
assert command_id in payload["processed_station_commands"]
|
|
|
|
|
|
restored = load_state(payload, checkpoint_at)
|
|
|
|
|
|
assert command_id in restored.processed_station_commands
|
|
|
|
|
|
await db.execute(delete(Session).where(Session.id == session.id))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_security_audit_older_than_six_months_remains_queryable(db, monkeypatch):
|
|
|
|
|
|
"""The full-TZ minimum retention period must be observable on PostgreSQL."""
|
|
|
|
|
|
from app.api.http import admin as admin_api
|
|
|
|
|
|
|
|
|
|
|
|
marker = f"retention-{uuid4().hex[:16]}"
|
|
|
|
|
|
old_at = datetime.now(timezone.utc) - timedelta(days=190)
|
|
|
|
|
|
row = AuditLog(
|
|
|
|
|
|
at=old_at, actor="retention-test", role="admin", action=marker,
|
|
|
|
|
|
object_id=None, detail="retention integration check",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(row)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
admin_api, "require",
|
|
|
|
|
|
lambda *_args, **_kwargs: auth.Principal(
|
|
|
|
|
|
login="integration-admin", full_name="Интеграционный администратор", role=Role.ADMIN,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with TestClient(app) as client:
|
|
|
|
|
|
login = client.post("/api/auth/dev-token")
|
|
|
|
|
|
assert login.status_code == 200, login.text
|
|
|
|
|
|
response = client.get("/api/admin/audit", params={"action": marker})
|
|
|
|
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
|
records = response.json()
|
|
|
|
|
|
assert len(records) == 1
|
|
|
|
|
|
assert records[0]["action"] == marker
|
|
|
|
|
|
assert records[0]["detail"] == "retention integration check"
|
|
|
|
|
|
|
|
|
|
|
|
persisted = await db.get(AuditLog, row.id)
|
|
|
|
|
|
assert persisted is not None
|
|
|
|
|
|
assert persisted.at <= datetime.now(timezone.utc) - timedelta(days=180)
|
|
|
|
|
|
await db.delete(persisted)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_websocket_score_override_and_audit_commit_atomically(db, scenario):
|
|
|
|
|
|
session = await repo.create_session(
|
|
|
|
|
|
db, scenario_id=scenario.id, mode="training", owner_login="score-teacher"
|
|
|
|
|
|
)
|
|
|
|
|
|
score = Score(
|
|
|
|
|
|
session_id=session.id, score_auto=70, score_final=70,
|
2026-09-26 22:48:24 +00:00
|
|
|
|
report={"score_auto": 70, "full_report": {"score_auto": 70, "score_final": 70}},
|
2026-09-26 18:12:27 +03:00
|
|
|
|
)
|
|
|
|
|
|
db.add(score)
|
|
|
|
|
|
await db.commit()
|
2026-09-26 22:48:24 +00:00
|
|
|
|
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
|
|
|
|
|
state = _state(session.id, scenario, owner_login="score-teacher")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
|
|
|
|
|
|
# Force the audit insert to fail after the score row is staged. PostgreSQL
|
2026-09-26 22:48:24 +00:00
|
|
|
|
# must roll back both, and the commit must fail the whole operation.
|
|
|
|
|
|
with pytest.raises(Exception):
|
|
|
|
|
|
await pg.commit(state, [ScoreOverridden(85, "x" * 81, "instructor", "manual review")])
|
2026-09-26 18:12:27 +03:00
|
|
|
|
await db.refresh(score)
|
|
|
|
|
|
assert score.score_final == 70
|
|
|
|
|
|
assert score.overridden_by is None
|
|
|
|
|
|
assert await db.scalar(
|
|
|
|
|
|
select(AuditLog.id).where(
|
|
|
|
|
|
AuditLog.action == "score.override", AuditLog.object_id == str(session.id)
|
|
|
|
|
|
)
|
|
|
|
|
|
) is None
|
|
|
|
|
|
|
2026-09-26 22:48:24 +00:00
|
|
|
|
await pg.commit(state, [ScoreOverridden(85, "score-teacher", "instructor", "manual review")])
|
2026-09-26 18:12:27 +03:00
|
|
|
|
await db.refresh(score)
|
|
|
|
|
|
assert score.score_auto == 70
|
|
|
|
|
|
assert score.score_final == 85
|
|
|
|
|
|
assert score.overridden_by == "score-teacher"
|
2026-09-26 22:48:24 +00:00
|
|
|
|
assert score.report["score_final"] == 85
|
2026-09-26 18:12:27 +03:00
|
|
|
|
assert score.report["full_report"]["score_final"] == 85
|
|
|
|
|
|
audit_row = await db.scalar(
|
|
|
|
|
|
select(AuditLog).where(
|
|
|
|
|
|
AuditLog.action == "score.override", AuditLog.object_id == str(session.id)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
assert audit_row is not None
|
|
|
|
|
|
assert audit_row.actor == "score-teacher"
|
|
|
|
|
|
assert "manual review" not in audit_row.detail
|
|
|
|
|
|
await db.delete(audit_row)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_initial_result_and_calculation_audit_commit_atomically(db, scenario):
|
|
|
|
|
|
session = await repo.create_session(
|
|
|
|
|
|
db, scenario_id=scenario.id, mode="training", owner_login="score-teacher"
|
|
|
|
|
|
)
|
2026-09-26 22:48:24 +00:00
|
|
|
|
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
|
|
|
|
|
state = _state(session.id, scenario, owner_login="score-teacher")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
|
|
|
|
|
|
# Invalid JSON makes PostgreSQL reject the score transaction; no orphaned
|
|
|
|
|
|
# calculation-audit row may remain (or vice versa).
|
2026-09-26 22:48:24 +00:00
|
|
|
|
with pytest.raises(Exception):
|
|
|
|
|
|
await pg.commit(state, [ScoreCalculated(71, {"bad": object()})])
|
2026-09-26 18:12:27 +03:00
|
|
|
|
assert await db.scalar(
|
|
|
|
|
|
select(Score.id).where(Score.session_id == session.id)
|
|
|
|
|
|
) is None
|
|
|
|
|
|
assert await db.scalar(select(AuditLog.id).where(
|
|
|
|
|
|
AuditLog.action == "score.calculate", AuditLog.object_id == str(session.id)
|
|
|
|
|
|
)) is None
|
|
|
|
|
|
|
2026-09-26 22:48:24 +00:00
|
|
|
|
await pg.commit(state, [ScoreCalculated(71, {"full_report": {"score_auto": 71}})])
|
2026-09-26 18:12:27 +03:00
|
|
|
|
score = await db.scalar(select(Score).where(Score.session_id == session.id))
|
|
|
|
|
|
audit_row = await db.scalar(select(AuditLog).where(
|
|
|
|
|
|
AuditLog.action == "score.calculate", AuditLog.object_id == str(session.id)
|
|
|
|
|
|
))
|
|
|
|
|
|
assert score is not None and score.score_final == 71
|
|
|
|
|
|
assert audit_row is not None and audit_row.actor == "system"
|
|
|
|
|
|
await db.delete(audit_row)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_api_session_creation_and_audit_commit_atomically(db, scenario, monkeypatch):
|
|
|
|
|
|
teacher = f"session-teacher-{uuid4().hex[:12]}"
|
|
|
|
|
|
group_name = f"session-group-{uuid4().hex[:12]}"
|
|
|
|
|
|
trainee_name = f"session-trainee-{uuid4().hex[:12]}"
|
|
|
|
|
|
principal = auth.Principal(
|
|
|
|
|
|
login=teacher, full_name="Интеграционный преподаватель", role=Role.INSTRUCTOR,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.api.http import sessions as sessions_api
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(sessions_api, "require", lambda *_args, **_kwargs: principal)
|
|
|
|
|
|
result = await sessions_api.create(
|
|
|
|
|
|
sessions_api.SessionCreate(
|
|
|
|
|
|
scenario_id=scenario.id, mode="training",
|
|
|
|
|
|
group=group_name, trainee=trainee_name,
|
|
|
|
|
|
),
|
|
|
|
|
|
object(),
|
|
|
|
|
|
db,
|
|
|
|
|
|
)
|
|
|
|
|
|
session_id = result.session_id
|
|
|
|
|
|
persisted = await db.get(Session, session_id)
|
|
|
|
|
|
assert persisted is not None and persisted.owner_login == teacher
|
|
|
|
|
|
group = await db.get(Group, persisted.group_id)
|
|
|
|
|
|
assert group is not None and group.owner_login == teacher
|
|
|
|
|
|
trainee = await db.get(Trainee, persisted.trainee_id)
|
|
|
|
|
|
assert trainee is not None and trainee.group_id == group.id
|
|
|
|
|
|
audit_rows = (await db.scalars(select(AuditLog).where(
|
|
|
|
|
|
AuditLog.object_id.in_([
|
|
|
|
|
|
str(session_id), str(group.id), str(trainee.id),
|
|
|
|
|
|
]),
|
|
|
|
|
|
))).all()
|
|
|
|
|
|
assert {row.action for row in audit_rows} == {
|
|
|
|
|
|
"group.create", "trainee.profile.create", "session.create",
|
|
|
|
|
|
}
|
|
|
|
|
|
assert all(row.actor == teacher for row in audit_rows)
|
|
|
|
|
|
await db.execute(delete(AuditLog).where(AuditLog.id.in_([row.id for row in audit_rows])))
|
|
|
|
|
|
await db.execute(delete(Session).where(Session.id == session_id))
|
|
|
|
|
|
await db.delete(trainee)
|
|
|
|
|
|
await db.delete(group)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_http_session_creation_cannot_claim_another_teachers_trainee(
|
|
|
|
|
|
db, scenario, monkeypatch,
|
|
|
|
|
|
):
|
|
|
|
|
|
teacher = f"session-owner-{uuid4().hex[:12]}"
|
|
|
|
|
|
other_teacher = f"session-foreign-{uuid4().hex[:12]}"
|
|
|
|
|
|
suffix = uuid4().hex[:12]
|
|
|
|
|
|
foreign_group = Group(name=f"foreign-group-{suffix}", owner_login=other_teacher)
|
|
|
|
|
|
db.add(foreign_group)
|
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
foreign_trainee = Trainee(name=f"foreign-trainee-{suffix}", group_id=foreign_group.id)
|
|
|
|
|
|
db.add(foreign_trainee)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
principal = auth.Principal(
|
|
|
|
|
|
login=teacher, full_name="Преподаватель", role=Role.INSTRUCTOR,
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setattr(sessions_api, "require", lambda *_args, **_kwargs: principal)
|
|
|
|
|
|
own_group_name = f"new-own-group-{suffix}"
|
|
|
|
|
|
with pytest.raises(HTTPException) as denied:
|
|
|
|
|
|
await sessions_api.create(
|
|
|
|
|
|
sessions_api.SessionCreate(
|
|
|
|
|
|
scenario_id=scenario.id, mode="training", group=own_group_name,
|
|
|
|
|
|
trainee=foreign_trainee.name,
|
|
|
|
|
|
),
|
|
|
|
|
|
object(),
|
|
|
|
|
|
db,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert denied.value.status_code == 404
|
|
|
|
|
|
assert denied.value.detail == "trainee_not_found"
|
|
|
|
|
|
# Group creation and audit/session inserts from this request must roll back too.
|
|
|
|
|
|
assert await db.scalar(select(Group.id).where(Group.name == own_group_name)) is None
|
|
|
|
|
|
assert await db.scalar(select(Session.id).where(Session.owner_login == teacher)) is None
|
|
|
|
|
|
|
|
|
|
|
|
await db.delete(foreign_trainee)
|
|
|
|
|
|
await db.delete(foreign_group)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_websocket_lesson_start_cannot_claim_foreign_group_trainee(db, scenario):
|
|
|
|
|
|
teacher = f"ws-session-owner-{uuid4().hex[:12]}"
|
|
|
|
|
|
other_teacher = f"ws-session-foreign-{uuid4().hex[:12]}"
|
|
|
|
|
|
suffix = uuid4().hex[:12]
|
|
|
|
|
|
group = Group(name=f"ws-foreign-group-{suffix}", owner_login=other_teacher)
|
|
|
|
|
|
db.add(group)
|
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
trainee = Trainee(name=f"ws-foreign-trainee-{suffix}", group_id=group.id)
|
|
|
|
|
|
db.add(trainee)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
session_id = uuid4()
|
2026-09-26 22:48:24 +00:00
|
|
|
|
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
2026-09-26 18:12:27 +03:00
|
|
|
|
with pytest.raises(PermissionError, match="другой учебной групп"):
|
2026-09-26 22:48:24 +00:00
|
|
|
|
await _open(pg, scenario, session_id, trainee.name, trainee.id, owner_login=teacher)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
assert await db.get(Session, session_id) is None
|
|
|
|
|
|
assert await db.scalar(select(AuditLog.id).where(
|
|
|
|
|
|
AuditLog.action == "lesson.start", AuditLog.object_id == str(session_id)
|
|
|
|
|
|
)) is None
|
|
|
|
|
|
|
|
|
|
|
|
await db.delete(trainee)
|
|
|
|
|
|
await db.delete(group)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_websocket_lesson_start_audit_is_committed_with_session(db, scenario):
|
|
|
|
|
|
session_id = uuid4()
|
2026-09-26 22:48:24 +00:00
|
|
|
|
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
|
|
|
|
|
_, identity = await _open(pg, scenario, session_id, "Курсант", owner_login="lesson-teacher")
|
|
|
|
|
|
assert identity.attempt == 1 and identity.trainee_id is not None
|
|
|
|
|
|
assert identity.service is None
|
|
|
|
|
|
assert identity.fencing_epoch == 1
|
2026-09-26 18:12:27 +03:00
|
|
|
|
persisted = await db.get(Session, session_id)
|
2026-09-26 22:48:24 +00:00
|
|
|
|
assert persisted.live_state is not None, "первый снимок пишется вместе со строкой занятия"
|
2026-09-26 18:12:27 +03:00
|
|
|
|
audit_row = await db.scalar(select(AuditLog).where(
|
|
|
|
|
|
AuditLog.action == "lesson.start", AuditLog.object_id == str(session_id)
|
|
|
|
|
|
))
|
|
|
|
|
|
assert persisted is not None and persisted.owner_login == "lesson-teacher"
|
|
|
|
|
|
assert audit_row is not None and audit_row.actor == "lesson-teacher"
|
|
|
|
|
|
await db.delete(audit_row)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 22:48:24 +00:00
|
|
|
|
async def test_store_commit_is_one_transaction_with_one_fencing_check(db, scenario):
|
|
|
|
|
|
"""Реплика, финиш и оценка — одна транзакция и один `(node_id, epoch)`-чек."""
|
|
|
|
|
|
from sqlalchemy import event
|
|
|
|
|
|
|
|
|
|
|
|
from app.session.store import LessonEnded
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
session_id = uuid4()
|
2026-09-26 22:48:24 +00:00
|
|
|
|
maker = async_sessionmaker(db.bind, expire_on_commit=False)
|
|
|
|
|
|
opened = []
|
|
|
|
|
|
|
|
|
|
|
|
def counting_maker():
|
|
|
|
|
|
opened.append(True)
|
|
|
|
|
|
return maker()
|
|
|
|
|
|
|
|
|
|
|
|
pg = PostgresSessionStore(counting_maker, node_id="node-a")
|
|
|
|
|
|
state, _ = await _open(pg, scenario, session_id, "Курсант", owner_login="lesson-teacher")
|
|
|
|
|
|
statements = []
|
|
|
|
|
|
|
|
|
|
|
|
def seen(_conn, _cursor, statement, *_args):
|
|
|
|
|
|
statements.append(statement)
|
|
|
|
|
|
|
|
|
|
|
|
event.listen(db.bind.sync_engine, "before_cursor_execute", seen)
|
|
|
|
|
|
opened.clear()
|
|
|
|
|
|
try:
|
|
|
|
|
|
entry = state.append(Speaker.CALLER, "адрес: Ленина, 14")
|
|
|
|
|
|
state.ended_at = datetime.now(timezone.utc)
|
|
|
|
|
|
await pg.commit(state, [
|
|
|
|
|
|
UtteranceAppended(entry),
|
|
|
|
|
|
LessonEnded(state.ended_at, "complete"),
|
|
|
|
|
|
ScoreCalculated(71, {"score_auto": 71}),
|
|
|
|
|
|
])
|
|
|
|
|
|
finally:
|
|
|
|
|
|
event.remove(db.bind.sync_engine, "before_cursor_execute", seen)
|
|
|
|
|
|
|
|
|
|
|
|
assert len(opened) == 1, "одна операция — одна транзакция"
|
|
|
|
|
|
fence_checks = [sql for sql in statements
|
|
|
|
|
|
if sql.startswith("UPDATE sessions SET backend_lease_until")]
|
|
|
|
|
|
assert len(fence_checks) == 1
|
|
|
|
|
|
row = await db.get(Session, session_id)
|
|
|
|
|
|
await db.refresh(row)
|
|
|
|
|
|
assert row.end_reason == "complete" and row.live_state is None
|
|
|
|
|
|
assert await db.scalar(select(Utterance.text).where(Utterance.session_id == session_id)) == (
|
|
|
|
|
|
"адрес: Ленина, 14"
|
2026-09-26 18:12:27 +03:00
|
|
|
|
)
|
2026-09-26 22:48:24 +00:00
|
|
|
|
await db.execute(delete(AuditLog).where(AuditLog.object_id == str(session_id)))
|
|
|
|
|
|
await db.execute(delete(Session).where(Session.id == session_id))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_old_backend_fencing_epoch_cannot_append_transcript(db, scenario):
|
|
|
|
|
|
session_id = uuid4()
|
|
|
|
|
|
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-a")
|
|
|
|
|
|
state, identity = await _open(pg, scenario, session_id, "Курсант", owner_login="lesson-teacher")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
await db.execute(
|
|
|
|
|
|
update(Session)
|
|
|
|
|
|
.where(Session.id == session_id)
|
2026-09-26 22:48:24 +00:00
|
|
|
|
.values(backend_node_id="node-b", backend_fencing_epoch=identity.fencing_epoch + 1)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
with pytest.raises(SessionLeaseLost):
|
2026-09-26 22:48:24 +00:00
|
|
|
|
await pg.commit(state, [UtteranceAppended(_entry("проверка fencing"))])
|
2026-09-26 18:12:27 +03:00
|
|
|
|
assert await db.scalar(select(Utterance.id).where(Utterance.session_id == session_id)) is None
|
|
|
|
|
|
await db.execute(delete(AuditLog).where(AuditLog.object_id == str(session_id)))
|
|
|
|
|
|
await db.execute(delete(Session).where(Session.id == session_id))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_expired_backend_lease_is_atomically_claimed_and_restored(db, scenario):
|
|
|
|
|
|
session_id = uuid4()
|
2026-09-26 22:48:24 +00:00
|
|
|
|
old = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-a")
|
|
|
|
|
|
snapshot, identity = await _open(old, scenario, session_id, "Курсант", owner_login="lease-teacher")
|
|
|
|
|
|
snapshot.exercise = Exercise.DDS
|
|
|
|
|
|
await old.commit(snapshot)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
row = await db.get(Session, session_id)
|
|
|
|
|
|
assert row is not None
|
|
|
|
|
|
row.backend_lease_until = datetime.now(timezone.utc) - timedelta(seconds=1)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
2026-09-26 22:48:24 +00:00
|
|
|
|
new = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-b")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
restored = await new.claim_expired(session_id)
|
|
|
|
|
|
assert len(restored) == 1
|
|
|
|
|
|
assert restored[0].session_id == session_id
|
2026-09-26 22:48:24 +00:00
|
|
|
|
assert restored[0].backend_fencing_epoch == identity.fencing_epoch + 1
|
2026-09-26 18:12:27 +03:00
|
|
|
|
persisted = await db.get(Session, session_id)
|
|
|
|
|
|
assert persisted is not None
|
|
|
|
|
|
await db.refresh(persisted)
|
|
|
|
|
|
assert persisted.backend_node_id == "node-b"
|
2026-09-26 22:48:24 +00:00
|
|
|
|
assert persisted.backend_fencing_epoch == identity.fencing_epoch + 1
|
2026-09-26 18:12:27 +03:00
|
|
|
|
|
|
|
|
|
|
with pytest.raises(SessionLeaseLost):
|
2026-09-26 22:48:24 +00:00
|
|
|
|
await old.commit(snapshot, [UtteranceAppended(_entry("stale owner"))])
|
2026-09-26 18:12:27 +03:00
|
|
|
|
assert await db.scalar(select(Utterance.id).where(Utterance.session_id == session_id)) is None
|
|
|
|
|
|
await db.execute(delete(AuditLog).where(AuditLog.object_id == str(session_id)))
|
|
|
|
|
|
await db.execute(delete(Session).where(Session.id == session_id))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_self_assessment_and_audit_commit_atomically(db, scenario):
|
|
|
|
|
|
trainee = await repo.ensure_trainee(db, f"reflection-{uuid4().hex[:12]}")
|
|
|
|
|
|
session = await repo.create_session(
|
|
|
|
|
|
db, scenario_id=scenario.id, mode="training", trainee_id=trainee.id,
|
|
|
|
|
|
owner_login="reflection-teacher",
|
|
|
|
|
|
)
|
2026-09-26 22:48:24 +00:00
|
|
|
|
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
|
|
|
|
|
state = _state(session.id, scenario, trainee_id=trainee.id)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
|
|
|
|
|
|
class InvalidText:
|
|
|
|
|
|
def __len__(self):
|
|
|
|
|
|
return 12
|
|
|
|
|
|
|
|
|
|
|
|
# Let the DB reject the staged assessment, and verify its audit rolls back too.
|
2026-09-26 22:48:24 +00:00
|
|
|
|
with pytest.raises(Exception):
|
|
|
|
|
|
await pg.commit(state, [SelfAssessed(["q_address"], InvalidText(), datetime.now(timezone.utc))])
|
2026-09-26 18:12:27 +03:00
|
|
|
|
assert await db.scalar(select(SelfAssessment.id).where(
|
|
|
|
|
|
SelfAssessment.session_id == session.id
|
|
|
|
|
|
)) is None
|
|
|
|
|
|
assert await db.scalar(select(AuditLog.id).where(
|
|
|
|
|
|
AuditLog.action == "self_assessment.submit", AuditLog.object_id == str(session.id)
|
|
|
|
|
|
)) is None
|
|
|
|
|
|
|
2026-09-26 22:48:24 +00:00
|
|
|
|
await pg.commit(state, [SelfAssessed(
|
|
|
|
|
|
["q_address"], "адрес уточнил поздно", datetime.now(timezone.utc),
|
|
|
|
|
|
)])
|
2026-09-26 18:12:27 +03:00
|
|
|
|
assessment = await db.scalar(select(SelfAssessment).where(
|
|
|
|
|
|
SelfAssessment.session_id == session.id
|
|
|
|
|
|
))
|
|
|
|
|
|
audit_row = await db.scalar(select(AuditLog).where(
|
|
|
|
|
|
AuditLog.action == "self_assessment.submit", AuditLog.object_id == str(session.id)
|
|
|
|
|
|
))
|
|
|
|
|
|
assert assessment is not None and assessment.missed == ["q_address"]
|
|
|
|
|
|
assert audit_row is not None and audit_row.actor == f"trainee:{trainee.id}"
|
|
|
|
|
|
assert "адрес уточнил поздно" not in audit_row.detail
|
|
|
|
|
|
await db.delete(audit_row)
|
|
|
|
|
|
await db.delete(assessment)
|
|
|
|
|
|
await db.delete(trainee)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_peer_auth_version_change_closes_active_socket(db):
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import weakref
|
|
|
|
|
|
|
|
|
|
|
|
login = f"peer-{uuid4().hex[:16]}"
|
|
|
|
|
|
user = User(
|
|
|
|
|
|
login=login, password_hash="unused", full_name="Peer account",
|
|
|
|
|
|
role="instructor", auth_version=0,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(user)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
auth.prime_generations({login: 0})
|
|
|
|
|
|
|
|
|
|
|
|
class FakeSocket:
|
|
|
|
|
|
closed = False
|
|
|
|
|
|
|
|
|
|
|
|
async def close(self, **_kwargs):
|
|
|
|
|
|
self.closed = True
|
|
|
|
|
|
|
|
|
|
|
|
socket = FakeSocket()
|
|
|
|
|
|
auth._active_sockets[login] = weakref.WeakKeyDictionary({
|
|
|
|
|
|
socket: asyncio.get_running_loop(),
|
|
|
|
|
|
})
|
|
|
|
|
|
user.auth_version = 1
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
await auth.sync_generations()
|
|
|
|
|
|
await asyncio.sleep(0.01)
|
|
|
|
|
|
assert auth._generations[login] == 1
|
|
|
|
|
|
assert socket.closed
|
|
|
|
|
|
|
|
|
|
|
|
auth._active_sockets.pop(login, None)
|
|
|
|
|
|
auth._generations.pop(login, None)
|
|
|
|
|
|
await db.delete(user)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_trainee_scenario_visibility_uses_real_group_owner_and_hides_other_instructors(db):
|
|
|
|
|
|
suffix = uuid4().hex
|
|
|
|
|
|
group = Group(name=f"visibility-{suffix}", owner_login=f"teacher-a-{suffix}")
|
|
|
|
|
|
db.add(group)
|
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
trainee = Trainee(name=f"visibility-student-{suffix}", group_id=group.id)
|
|
|
|
|
|
own = Scenario(
|
|
|
|
|
|
id=f"visibility-own-{suffix}", title="Своя", incident_type="fire", level="L1",
|
|
|
|
|
|
topics=[], modes=["self"], owner_login=group.owner_login, body={},
|
|
|
|
|
|
)
|
|
|
|
|
|
other = Scenario(
|
|
|
|
|
|
id=f"visibility-other-{suffix}", title="Чужая", incident_type="fire", level="L1",
|
|
|
|
|
|
topics=[], modes=["self"], owner_login=f"teacher-b-{suffix}", body={},
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([trainee, own, other])
|
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
|
|
|
|
|
|
who = SimpleNamespace(role=Role.TRAINEE, login="student", trainee_id=trainee.id)
|
|
|
|
|
|
hidden = await _hidden_scenario_ids(db, who)
|
|
|
|
|
|
assert own.id not in hidden
|
|
|
|
|
|
assert other.id in hidden
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
|
|
|
|
|
|
await db.execute(delete(Trainee).where(Trainee.id == trainee.id))
|
2026-09-26 18:12:27 +03:00
|
|
|
|
await db.execute(delete(Scenario).where(Scenario.id.in_([own.id, other.id])))
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
await db.execute(delete(Group).where(Group.id == group.id))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
async def test_kio_submission_moderation_and_dds_card_persist_on_real_postgres(
|
|
|
|
|
|
db, monkeypatch,
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Exercise actual HTTP routes, PostgreSQL rows, and DDS template handoff."""
|
|
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
|
|
|
|
|
from app.domain import ekp
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("DEMO_NO_DB", "false")
|
|
|
|
|
|
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
|
|
|
|
|
|
get_settings.cache_clear()
|
|
|
|
|
|
store.load_from_disk(LIBRARY)
|
|
|
|
|
|
source = store.get("t01-1-fire-container")
|
|
|
|
|
|
assert source is not None
|
|
|
|
|
|
suffix = uuid4().hex
|
|
|
|
|
|
# The development websocket identity is `dev`; the owned group/scenario and
|
|
|
|
|
|
# the actual control socket must share that owner for this integration path.
|
|
|
|
|
|
teacher_login = "dev"
|
|
|
|
|
|
group = Group(name=f"dds-e2e-{suffix}", owner_login=teacher_login)
|
|
|
|
|
|
db.add(group)
|
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
trainee = Trainee(name=f"student-{suffix}", group_id=group.id)
|
|
|
|
|
|
db.add(trainee)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
trainee_id, group_id = trainee.id, group.id
|
|
|
|
|
|
|
|
|
|
|
|
role = {"value": Role.TRAINEE}
|
|
|
|
|
|
trainee_principal = auth.Principal(
|
|
|
|
|
|
login=f"student-{suffix}", full_name="Интеграционный курсант",
|
|
|
|
|
|
role=Role.TRAINEE, trainee_id=trainee_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
instructor_principal = auth.Principal(
|
|
|
|
|
|
login=teacher_login, full_name="Интеграционный преподаватель", role=Role.INSTRUCTOR,
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
scenario_submissions, "require",
|
|
|
|
|
|
lambda *_args, **_kwargs: trainee_principal
|
|
|
|
|
|
if role["value"] is Role.TRAINEE else instructor_principal,
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
scenarios_api, "require",
|
|
|
|
|
|
lambda *_args, **_kwargs: instructor_principal,
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setattr(control_ws, "principal_of", lambda _ws: instructor_principal)
|
|
|
|
|
|
monkeypatch.setattr(station_ws, "principal_of", lambda _ws: trainee_principal)
|
|
|
|
|
|
|
|
|
|
|
|
scenario_id = None
|
|
|
|
|
|
live_session_id = uuid4()
|
|
|
|
|
|
try:
|
|
|
|
|
|
with TestClient(app) as client:
|
|
|
|
|
|
login = client.post("/api/auth/dev-token")
|
|
|
|
|
|
assert login.status_code == 200, login.text
|
|
|
|
|
|
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": "открытое пламя"},
|
|
|
|
|
|
}
|
|
|
|
|
|
created = client.post("/api/scenario-submissions", json={
|
|
|
|
|
|
"title": "PostgreSQL end-to-end KIO", "level": "L2", "kio": kio,
|
|
|
|
|
|
})
|
|
|
|
|
|
assert created.status_code == 201, created.text
|
|
|
|
|
|
submission_id = UUID(created.json()["id"])
|
|
|
|
|
|
pending = await db.get(ScenarioSubmission, submission_id)
|
|
|
|
|
|
assert pending is not None and pending.status == "pending"
|
|
|
|
|
|
assert pending.kio["address"] == kio["address"]
|
|
|
|
|
|
|
|
|
|
|
|
role["value"] = Role.INSTRUCTOR
|
|
|
|
|
|
visible = client.get("/api/scenario-submissions")
|
|
|
|
|
|
assert visible.status_code == 200
|
|
|
|
|
|
assert any(item["id"] == str(submission_id) for item in visible.json())
|
|
|
|
|
|
approved = client.post(
|
|
|
|
|
|
f"/api/scenario-submissions/{submission_id}/review",
|
|
|
|
|
|
json={"decision": "approve", "comment": "Проверено."},
|
|
|
|
|
|
)
|
|
|
|
|
|
assert approved.status_code == 200, approved.text
|
|
|
|
|
|
scenario_id = approved.json()["scenario_id"]
|
|
|
|
|
|
await db.refresh(pending)
|
|
|
|
|
|
assert pending.status == "approved" and pending.scenario_id == scenario_id
|
|
|
|
|
|
published = await db.get(Scenario, scenario_id)
|
|
|
|
|
|
assert published is not None and published.status == "published"
|
|
|
|
|
|
assert published.owner_login == teacher_login
|
|
|
|
|
|
assert published.body["student_card"]["address"] == kio["address"]
|
|
|
|
|
|
|
|
|
|
|
|
# The instructor's catalog/detail views on a peer process must see
|
|
|
|
|
|
# publication from the shared DB without waiting for its restart.
|
|
|
|
|
|
store._library.pop(scenario_id, None)
|
|
|
|
|
|
store._demo_scenario_owners.pop(scenario_id, None)
|
|
|
|
|
|
catalog_response = client.get("/api/scenarios")
|
|
|
|
|
|
assert catalog_response.status_code == 200, catalog_response.text
|
|
|
|
|
|
catalog_item = next(
|
|
|
|
|
|
item for item in catalog_response.json() if item["id"] == scenario_id
|
|
|
|
|
|
)
|
|
|
|
|
|
assert catalog_item["can_manage"] is True
|
|
|
|
|
|
store._library.pop(scenario_id, None)
|
|
|
|
|
|
detail_response = client.get(f"/api/scenarios/{scenario_id}")
|
|
|
|
|
|
assert detail_response.status_code == 200, detail_response.text
|
|
|
|
|
|
assert detail_response.json()["student_card"]["address"] == kio["address"]
|
|
|
|
|
|
|
|
|
|
|
|
scenario = store.get(scenario_id)
|
|
|
|
|
|
assert scenario is not None and scenario.student_card is not None
|
2026-09-26 21:38:09 +00:00
|
|
|
|
card = build_card(scenario, 0, SessionTimers().limits)
|
|
|
|
|
|
assert card.dispatched_card.address == kio["address"]
|
|
|
|
|
|
assert card.dispatched_card.caller_name == kio["caller_name"]
|
|
|
|
|
|
assert card.dispatched_card.fire.object_kind == "мусорный контейнер"
|
2026-09-26 18:12:27 +03:00
|
|
|
|
|
|
|
|
|
|
# Simulate a peer backend with a cold process-local scenario cache:
|
|
|
|
|
|
# scenario.start must resolve the approved row from shared Postgres.
|
|
|
|
|
|
store._library.pop(scenario.id, None)
|
|
|
|
|
|
store._demo_scenario_owners.pop(scenario.id, None)
|
|
|
|
|
|
assert store.get(scenario.id) is None
|
|
|
|
|
|
session_id = live_session_id
|
|
|
|
|
|
with client.websocket_connect(f"/ws/control/{session_id}") as control:
|
|
|
|
|
|
control.send_json({
|
|
|
|
|
|
"type": "scenario.start", "scenario_id": scenario.id,
|
|
|
|
|
|
"scenario_ids": [scenario.id], "trainee": trainee.name,
|
|
|
|
|
|
"trainee_id": str(trainee.id),
|
|
|
|
|
|
"dds_service": scenario.student_card.notify[0],
|
|
|
|
|
|
"mode": "training", "exercise": "dds",
|
|
|
|
|
|
})
|
|
|
|
|
|
deadline = time.monotonic() + 3
|
|
|
|
|
|
while hub.get(session_id) is None and time.monotonic() < deadline:
|
|
|
|
|
|
time.sleep(0.01)
|
|
|
|
|
|
assert hub.get(session_id) is not None, "control socket did not start DDS session"
|
|
|
|
|
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
|
|
|
|
|
def read_until(wanted):
|
|
|
|
|
|
received = []
|
|
|
|
|
|
for _ in range(30):
|
|
|
|
|
|
message = station.receive_json()
|
|
|
|
|
|
received.append(message["type"])
|
|
|
|
|
|
if message["type"] == "error":
|
|
|
|
|
|
raise AssertionError(
|
|
|
|
|
|
f"station rejected: {message.get('code')}: "
|
|
|
|
|
|
f"{message.get('message')}"
|
|
|
|
|
|
)
|
|
|
|
|
|
if message["type"] == wanted:
|
|
|
|
|
|
return message
|
|
|
|
|
|
raise AssertionError(f"missing {wanted}; received {received}")
|
|
|
|
|
|
|
|
|
|
|
|
received = read_until("card.received")
|
|
|
|
|
|
assert received["card"]["address"] == kio["address"]
|
|
|
|
|
|
state_event = read_until("station.state")
|
|
|
|
|
|
snapshot = state_event["snapshot"]
|
|
|
|
|
|
service = snapshot["managed_service"]
|
|
|
|
|
|
assert service == scenario.student_card.notify[0]
|
|
|
|
|
|
crew = snapshot["crew_options"][0]
|
|
|
|
|
|
|
|
|
|
|
|
def read_status(expected):
|
|
|
|
|
|
deadline = time.monotonic() + 3
|
|
|
|
|
|
while time.monotonic() < deadline:
|
|
|
|
|
|
state = hub.get(session_id)
|
2026-09-26 21:38:09 +00:00
|
|
|
|
if state and state.desk.active and any(
|
2026-09-26 18:12:27 +03:00
|
|
|
|
mark.service == service and mark.status.value == expected
|
2026-09-26 21:38:09 +00:00
|
|
|
|
for mark in state.desk.active.status_log
|
2026-09-26 18:12:27 +03:00
|
|
|
|
):
|
|
|
|
|
|
break
|
|
|
|
|
|
time.sleep(0.01)
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise AssertionError(f"DDS status did not reach {expected}")
|
|
|
|
|
|
for _ in range(30):
|
|
|
|
|
|
message = station.receive_json()
|
|
|
|
|
|
if message["type"] == "error":
|
|
|
|
|
|
raise AssertionError(
|
|
|
|
|
|
f"station rejected: {message.get('code')}: "
|
|
|
|
|
|
f"{message.get('message')}"
|
|
|
|
|
|
)
|
|
|
|
|
|
if (message["type"] == "station.state"
|
|
|
|
|
|
and message["snapshot"]["statuses"][service] == expected):
|
|
|
|
|
|
return message["snapshot"]
|
|
|
|
|
|
raise AssertionError(f"station did not report status {expected}")
|
|
|
|
|
|
|
|
|
|
|
|
station.send_json({"type": "crew.select", "crew": crew})
|
|
|
|
|
|
station.send_json({
|
|
|
|
|
|
"type": "card.status", "service": service,
|
|
|
|
|
|
"status": "accepted",
|
|
|
|
|
|
"comment": "Основание: подтверждение старшего группы.\nСведения: карточка принята.",
|
|
|
|
|
|
})
|
|
|
|
|
|
snapshot = read_status("accepted")
|
|
|
|
|
|
for status, comment in [
|
|
|
|
|
|
("responding", "Основание: доклад старшего группы.\nСведения: выезд."),
|
|
|
|
|
|
("arrived", "Основание: доклад старшего группы.\nСведения: прибытие."),
|
|
|
|
|
|
("working", "Основание: доклад старшего группы.\nСведения: начало работ."),
|
|
|
|
|
|
("completed", "Основание: доклад старшего группы.\nСведения: завершение работ."),
|
|
|
|
|
|
]:
|
|
|
|
|
|
station.send_json({
|
|
|
|
|
|
"type": "card.status", "service": service,
|
|
|
|
|
|
"status": status, "comment": comment,
|
|
|
|
|
|
})
|
|
|
|
|
|
snapshot = read_status(status)
|
|
|
|
|
|
station.send_json({"type": "station.finish"})
|
|
|
|
|
|
read_until("score.ready")
|
|
|
|
|
|
|
|
|
|
|
|
report = client.get(f"/api/sessions/{session_id}/report")
|
|
|
|
|
|
assert report.status_code == 200, report.text
|
|
|
|
|
|
body = report.json()
|
|
|
|
|
|
assert body["exercise"] == "dds"
|
|
|
|
|
|
assert body["card_results"][0]["scenario_id"] == scenario.id
|
|
|
|
|
|
assert body["score_auto"] == 100
|
|
|
|
|
|
assert body["score_final"] == 100
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
action.get("type") == "card.status" and action.get("status") == "completed"
|
|
|
|
|
|
for action in body["card_results"][0]["actions"]
|
|
|
|
|
|
)
|
|
|
|
|
|
hub.drop(session_id)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
hub.drop(live_session_id)
|
|
|
|
|
|
await db.execute(delete(Session).where(Session.id == live_session_id))
|
|
|
|
|
|
if scenario_id is not None:
|
|
|
|
|
|
store._library.pop(scenario_id, None)
|
|
|
|
|
|
store._demo_scenario_owners.pop(scenario_id, None)
|
|
|
|
|
|
await db.execute(delete(ScenarioSubmission).where(
|
|
|
|
|
|
ScenarioSubmission.scenario_id == scenario_id
|
|
|
|
|
|
))
|
|
|
|
|
|
await db.execute(delete(Scenario).where(Scenario.id == scenario_id))
|
|
|
|
|
|
await db.execute(delete(Trainee).where(Trainee.id == trainee_id))
|
|
|
|
|
|
await db.execute(delete(Group).where(Group.id == group_id))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
get_settings.cache_clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
async def test_transcript_keeps_order_and_anchors(db, scenario):
|
|
|
|
|
|
session = await repo.create_session(db, scenario_id=scenario.id, mode="training")
|
|
|
|
|
|
at = datetime.now(timezone.utc)
|
|
|
|
|
|
|
|
|
|
|
|
await repo.append_utterance(
|
|
|
|
|
|
db, session_id=session.id, ref="u1", speaker="caller", text="Алло! Горим!", at=at, mood="panic"
|
|
|
|
|
|
)
|
|
|
|
|
|
await repo.append_utterance(
|
|
|
|
|
|
db, session_id=session.id, ref="u2", speaker="operator", text="Назовите адрес", at=at
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rows = await repo.transcript(db, session.id)
|
|
|
|
|
|
assert [row.ref for row in rows] == ["u1", "u2"]
|
|
|
|
|
|
assert rows[0].mood == "panic"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_history_filters_by_mode(db, scenario):
|
|
|
|
|
|
await repo.create_session(db, scenario_id=scenario.id, mode="training")
|
|
|
|
|
|
await repo.create_session(db, scenario_id=scenario.id, mode="exam")
|
|
|
|
|
|
|
|
|
|
|
|
exams = await repo.history(db, mode="exam")
|
|
|
|
|
|
assert exams, "контрольные сессии не нашлись"
|
|
|
|
|
|
assert all(row.mode == "exam" for row in exams)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
async def test_dds_history_http_is_durable_and_owner_scoped(db, scenario, monkeypatch):
|
|
|
|
|
|
own = await repo.create_session(
|
|
|
|
|
|
db, scenario_id=scenario.id, mode="training", owner_login="dev"
|
|
|
|
|
|
)
|
|
|
|
|
|
foreign = await repo.create_session(
|
|
|
|
|
|
db, scenario_id=scenario.id, mode="training", owner_login="other-instructor"
|
|
|
|
|
|
)
|
|
|
|
|
|
own.ended_at = datetime.now(timezone.utc)
|
|
|
|
|
|
foreign.ended_at = datetime.now(timezone.utc)
|
|
|
|
|
|
card_id = uuid4()
|
|
|
|
|
|
db.add_all([
|
|
|
|
|
|
Score(
|
|
|
|
|
|
session_id=own.id, score_auto=75, score_final=80,
|
|
|
|
|
|
report={"full_report": {"exercise": "dds", "card_results": [{
|
|
|
|
|
|
"card_id": str(card_id), "scenario_id": scenario.id,
|
|
|
|
|
|
"score_auto": 75, "reply_text": "Назначаю бригаду",
|
|
|
|
|
|
"title": "Пожар в квартире", "address": "улица Тестовая, 7",
|
|
|
|
|
|
"incident_type": "fire", "victims_count": 2,
|
|
|
|
|
|
"received_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
|
"managed_service": "01", "recipient_services": ["01", "03"],
|
|
|
|
|
|
}]}},
|
|
|
|
|
|
),
|
|
|
|
|
|
Score(
|
|
|
|
|
|
session_id=foreign.id, score_auto=100, score_final=100,
|
|
|
|
|
|
report={"full_report": {"exercise": "dds", "card_results": [{
|
|
|
|
|
|
"card_id": str(uuid4()), "scenario_id": scenario.id,
|
|
|
|
|
|
"score_auto": 100,
|
|
|
|
|
|
}]}},
|
|
|
|
|
|
),
|
|
|
|
|
|
])
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
async def no_catalog_restore(_db):
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(store, "restore_published", no_catalog_restore)
|
|
|
|
|
|
with TestClient(app) as client:
|
|
|
|
|
|
token = client.post("/api/auth/dev-token")
|
|
|
|
|
|
assert token.status_code == 200, token.text
|
|
|
|
|
|
response = client.get("/api/sessions/dds-history")
|
|
|
|
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
|
records = response.json()
|
|
|
|
|
|
assert len(records) == 1
|
|
|
|
|
|
assert records[0]["session_id"] == str(own.id)
|
|
|
|
|
|
assert records[0]["card_id"] == str(card_id)
|
|
|
|
|
|
assert records[0]["title"] == "Пожар в квартире"
|
|
|
|
|
|
assert records[0]["address"] == "улица Тестовая, 7"
|
|
|
|
|
|
assert records[0]["score_final"] == 80
|
|
|
|
|
|
access = await db.scalar(select(AuditLog).where(
|
|
|
|
|
|
AuditLog.action == "dds.history.read",
|
|
|
|
|
|
AuditLog.actor == "dev",
|
|
|
|
|
|
AuditLog.detail == "cards=1",
|
|
|
|
|
|
))
|
|
|
|
|
|
assert access is not None
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await db.execute(delete(Session).where(Session.id.in_([own.id, foreign.id])))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_session_backend_owner_persists_and_rejects_another_node(db, scenario):
|
|
|
|
|
|
"""The PostgreSQL path must persist node affinity, not only the ORM unit path."""
|
|
|
|
|
|
session = await repo.create_session(
|
|
|
|
|
|
db,
|
|
|
|
|
|
scenario_id=scenario.id,
|
|
|
|
|
|
mode="training",
|
|
|
|
|
|
owner_login="teacher-node-test",
|
|
|
|
|
|
backend_node_id="node-a",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(SessionNodeConflict):
|
|
|
|
|
|
await ensure_session(
|
|
|
|
|
|
db,
|
|
|
|
|
|
session_id=session.id,
|
|
|
|
|
|
scenario_id=scenario.id,
|
|
|
|
|
|
mode="training",
|
|
|
|
|
|
owner_login="teacher-node-test",
|
|
|
|
|
|
backend_node_id="node-b",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
persisted = await repo.get_session(db, session.id)
|
|
|
|
|
|
assert persisted is not None
|
|
|
|
|
|
assert persisted.backend_node_id == "node-a"
|
|
|
|
|
|
|
|
|
|
|
|
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
async def test_hints_are_logged(db, scenario):
|
|
|
|
|
|
"""Счёт подсказок — материал разбора, а не вычитаемое из баллов,
|
|
|
|
|
|
но он обязан быть в журнале."""
|
|
|
|
|
|
session = await repo.create_session(db, scenario_id=scenario.id, mode="training")
|
|
|
|
|
|
await repo.record_hint(
|
|
|
|
|
|
db,
|
|
|
|
|
|
session_id=session.id,
|
|
|
|
|
|
checklist_id="q_people",
|
|
|
|
|
|
question="Есть ли люди в помещении?",
|
|
|
|
|
|
at=datetime.now(timezone.utc),
|
|
|
|
|
|
)
|
|
|
|
|
|
count = await db.scalar(
|
|
|
|
|
|
text("select count(*) from hint_uses where session_id = :sid").bindparams(sid=session.id)
|
|
|
|
|
|
)
|
|
|
|
|
|
assert count == 1
|