lct-hack/backend/tests/test_db.py

1141 lines
50 KiB
Python
Raw Normal View History

"""Журнал сессий. Для полного прогона используй временную БД из `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` оставался запускаемым везде.
"""
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
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
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
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
from app.domain.events import Exercise, SessionMode
from app.domain.roles import Role
from app.main import LIBRARY, app
from app.scenarios import store
from app.session.checkpoint import load_state
from app.session.dds import build_card
from app.session.hub import hub
from app.session.journal import DbJournal, SessionLeaseLost
from app.session.state import SessionState
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
@pytest.fixture
async def db():
"""Свой движок на каждый тест: глобальный в app.db.base кэшируется и
привязывается к первому событийному циклу, а 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:
socket.create_connection((url.hostname or "localhost", url.port or 5432), timeout=2).close()
except OSError as exc:
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
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()
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
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, "группа размечается с первой миграции"
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())
state = SessionState(
session_id=session.id,
scenario_id=scenario.id,
scenario_title=scenario.title,
level=scenario.level,
mode=SessionMode.TRAINING,
processed_station_commands=[command_id],
)
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False))
await journal.checkpoint(state)
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):
from app.session.journal import DbJournal
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,
report={"full_report": {"score_auto": 70, "score_final": 70}},
)
db.add(score)
await db.commit()
maker = async_sessionmaker(db.bind, expire_on_commit=False)
journal = DbJournal(maker)
# Force the audit insert to fail after the score row is staged. PostgreSQL
# must roll back both, and the websocket handler can then refuse the change.
saved = await journal.score_override(session.id, 85, "x" * 81, "manual review")
assert not saved
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
saved = await journal.score_override(
session.id, 85, "score-teacher", "manual review"
)
assert saved
await db.refresh(score)
assert score.score_auto == 70
assert score.score_final == 85
assert score.overridden_by == "score-teacher"
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):
from app.session.journal import DbJournal
session = await repo.create_session(
db, scenario_id=scenario.id, mode="training", owner_login="score-teacher"
)
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False))
# Invalid JSON makes PostgreSQL reject the score transaction; no orphaned
# calculation-audit row may remain (or vice versa).
assert not await journal.score(session.id, 71, {"bad": object()})
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
assert await journal.score(session.id, 71, {"full_report": {"score_auto": 71}})
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()
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False))
with pytest.raises(PermissionError, match="другой учебной групп"):
await journal.start_lesson(
session_id, scenario.id, "training", trainee.name, trainee.id,
owner_login=teacher,
)
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()
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False))
result = await journal.start_lesson(
session_id, scenario.id, "training", "Курсант", owner_login="lesson-teacher"
)
assert result is not None and result[0] == 1 and result[1] is not None
assert result[2] is None
assert result[3] == 1
persisted = await db.get(Session, session_id)
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()
async def test_old_backend_fencing_epoch_cannot_append_transcript(db, scenario):
session_id = uuid4()
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-a")
started = await journal.start_lesson(
session_id, scenario.id, "training", "Курсант", owner_login="lesson-teacher"
)
assert started is not None
await db.execute(
update(Session)
.where(Session.id == session_id)
.values(backend_node_id="node-b", backend_fencing_epoch=started[3] + 1)
)
await db.commit()
entry = SimpleNamespace(
ref="u1", speaker=SimpleNamespace(value="caller"), text="проверка fencing",
at=datetime.now(timezone.utc), mood=None,
)
with pytest.raises(SessionLeaseLost):
await journal.utterance(session_id, entry)
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()
old = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-a")
initial = await old.start_lesson(
session_id, scenario.id, "training", "Курсант", owner_login="lease-teacher"
)
assert initial is not None
snapshot = SessionState(
session_id=session_id,
scenario_id=scenario.id,
scenario_title=scenario.title,
level=scenario.level,
mode=SessionMode.TRAINING,
owner_login="lease-teacher",
exercise=Exercise.DDS,
backend_fencing_epoch=initial[3],
)
await old.checkpoint(snapshot)
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()
new = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-b")
restored = await new.claim_expired(session_id)
assert len(restored) == 1
assert restored[0].session_id == session_id
assert restored[0].backend_fencing_epoch == initial[3] + 1
persisted = await db.get(Session, session_id)
assert persisted is not None
await db.refresh(persisted)
assert persisted.backend_node_id == "node-b"
assert persisted.backend_fencing_epoch == initial[3] + 1
entry = SimpleNamespace(
ref="u1", speaker=SimpleNamespace(value="caller"), text="stale owner",
at=datetime.now(timezone.utc), mood=None,
)
with pytest.raises(SessionLeaseLost):
await old.utterance(session_id, entry)
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):
from app.session.journal import DbJournal
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",
)
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False))
class InvalidText:
def __len__(self):
return 12
# Let the DB reject the staged assessment, and verify its audit rolls back too.
assert not await journal.self_assessment(
session.id, ["q_address"], InvalidText(), datetime.now(timezone.utc)
)
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
assert await journal.self_assessment(
session.id, ["q_address"], "адрес уточнил поздно", datetime.now(timezone.utc)
)
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))
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()
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
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 == "мусорный контейнер"
# 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)
if state and state.desk.active and any(
mark.service == service and mark.status.value == expected
for mark in state.desk.active.status_log
):
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)
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