Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -6,6 +6,8 @@
|
|||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
os.environ.setdefault("DEV_AUTH_BYPASS", "true")
|
||||
|
||||
|
|
@ -14,6 +16,24 @@ import pytest # noqa: E402
|
|||
from app.config import get_settings # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def postgres_access():
|
||||
"""Skip DB integration cases when the configured PostgreSQL is unreachable.
|
||||
|
||||
`/api/health` is a liveness endpoint and deliberately does not probe the
|
||||
database. Use a short TCP check so sandbox/network-denied runs are reported
|
||||
as unverified integration tests instead of misleading application failures.
|
||||
"""
|
||||
url = urlparse(get_settings().database_url)
|
||||
if url.scheme not in {"postgres", "postgresql", "postgresql+asyncpg"}:
|
||||
pytest.skip("PostgreSQL integration test requires a PostgreSQL DATABASE_URL")
|
||||
try:
|
||||
with socket.create_connection((url.hostname or "localhost", url.port or 5432), timeout=2):
|
||||
pass
|
||||
except OSError as exc:
|
||||
pytest.skip(f"PostgreSQL unavailable ({exc})")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def _dev_auth():
|
||||
"""Флаг обхода читается один раз при создании настроек."""
|
||||
|
|
|
|||
34
backend/tests/test_address_matching.py
Normal file
34
backend/tests/test_address_matching.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from app.api.ws.station import _address_matches
|
||||
from app.scoring.address import address_matches
|
||||
|
||||
|
||||
def test_street_abbreviation_matches_but_similarly_named_street_does_not():
|
||||
expected = "Дубнинская улица, дом 10"
|
||||
assert address_matches(expected, "ул. Дубнинская, д. 10")
|
||||
assert not _address_matches(expected, "Дубининская улица, дом 10")
|
||||
|
||||
|
||||
def test_street_type_is_part_of_the_operational_address():
|
||||
expected = "Москва, улица Ленина, дом 10"
|
||||
assert address_matches(expected, "г. Москва, ул. Ленина, д. 10")
|
||||
assert not address_matches(expected, "Москва, переулок Ленина, дом 10")
|
||||
assert not address_matches(
|
||||
expected, "Москва, улица Ленина и переулок Ленина, дом 10"
|
||||
)
|
||||
|
||||
|
||||
def test_house_and_apartment_numbers_cannot_be_swapped():
|
||||
expected = "дом 10, квартира 20"
|
||||
assert address_matches(expected, "д. 10, кв. 20, подъезд 3")
|
||||
assert not address_matches(expected, "дом 20, квартира 10")
|
||||
|
||||
|
||||
def test_corpus_and_building_numbers_keep_their_roles():
|
||||
expected = "Москва, улица Мира, дом 5, корпус 1, квартира 20"
|
||||
assert address_matches(expected, "г. Москва, ул. Мира, д. 5, корп. 1, кв. 20")
|
||||
assert not address_matches(expected, "Москва, ул. Мира, дом 1, корп. 5, кв. 20")
|
||||
|
||||
|
||||
def test_empty_or_partial_operational_address_is_not_a_match():
|
||||
assert not address_matches("улица Мира, дом 5", "")
|
||||
assert not address_matches("улица Мира, дом 5", "улица Мира")
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
"""
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
|
@ -47,12 +48,6 @@ def as_instructor(client):
|
|||
|
||||
|
||||
|
||||
def db_alive(client) -> bool:
|
||||
"""Часть точек без Postgres работать не может, и это не повод падать:
|
||||
на машине разработчика база может быть не поднята."""
|
||||
return client.get("/api/health").status_code == 200
|
||||
|
||||
|
||||
# ── границы роли ──
|
||||
|
||||
|
||||
|
|
@ -61,6 +56,7 @@ def test_instructor_cannot_open_admin(as_instructor):
|
|||
в административных функциях прямо."""
|
||||
assert as_instructor.get("/api/admin/users").status_code == 403
|
||||
assert as_instructor.get("/api/admin/audit").status_code == 403
|
||||
assert as_instructor.get("/api/admin/audit.csv").status_code == 403
|
||||
assert as_instructor.get("/api/admin/status").status_code == 403
|
||||
assert as_instructor.get("/api/admin/diagnostics").status_code == 403
|
||||
assert as_instructor.get("/api/admin/config.xml").status_code == 403
|
||||
|
|
@ -68,6 +64,52 @@ def test_instructor_cannot_open_admin(as_instructor):
|
|||
|
||||
def test_anonymous_cannot_open_admin(client):
|
||||
assert client.get("/api/admin/users").status_code == 401
|
||||
assert client.get("/api/admin/audit.csv").status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [Role.INSTRUCTOR, Role.TRAINEE])
|
||||
def test_non_admin_roles_cannot_reach_any_admin_endpoint(client, monkeypatch, role):
|
||||
"""Exercise the complete current admin route surface with valid requests.
|
||||
|
||||
Stub only the DB dependency: every handler must reject the principal before
|
||||
reading or mutating any admin data. Keep this endpoint inventory explicit
|
||||
so a new admin route is added to the negative-role gate.
|
||||
"""
|
||||
import app.api.auth as auth_module
|
||||
from app.api.http import admin as admin_api
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_module,
|
||||
"current",
|
||||
lambda _request: Principal(login="not-admin", full_name="Пользователь", role=role),
|
||||
)
|
||||
|
||||
async def empty_session():
|
||||
yield object()
|
||||
|
||||
app.dependency_overrides[admin_api.get_session] = empty_session
|
||||
calls = [
|
||||
("GET", "/api/admin/config.xml", None),
|
||||
("GET", "/api/admin/users", None),
|
||||
("POST", "/api/admin/users", {
|
||||
"login": "new.user", "full_name": "Новый пользователь",
|
||||
"password": "long-enough-password", "role": "instructor",
|
||||
}),
|
||||
("PATCH", f"/api/admin/users/{uuid.uuid4()}", {"blocked": True}),
|
||||
("GET", "/api/admin/audit", None),
|
||||
("GET", "/api/admin/audit.csv", None),
|
||||
("GET", "/api/admin/diagnostics", None),
|
||||
("GET", "/api/admin/diagnostics.json", None),
|
||||
("GET", "/api/admin/status", None),
|
||||
("GET", "/api/admin/backups", None),
|
||||
("POST", "/api/admin/backups", None),
|
||||
]
|
||||
try:
|
||||
for method, path, payload in calls:
|
||||
response = client.request(method, path, json=payload)
|
||||
assert response.status_code == 403, (role, method, path, response.text)
|
||||
finally:
|
||||
app.dependency_overrides.pop(admin_api.get_session, None)
|
||||
|
||||
|
||||
def test_admin_downloads_safe_xml_configuration(as_admin):
|
||||
|
|
@ -78,6 +120,7 @@ def test_admin_downloads_safe_xml_configuration(as_admin):
|
|||
root = ET.fromstring(response.content)
|
||||
assert root.tag == "lctConfiguration"
|
||||
assert root.find("./workstations/workstation[@role='admin']") is not None
|
||||
assert root.find("./workstations/workstation[@role='admin']/screen[@path='/wall']") is not None
|
||||
assert root.find("./timerLimits/timer[@code='dds_ack']") is not None
|
||||
lowered = response.content.lower()
|
||||
assert b"session_secret" not in lowered
|
||||
|
|
@ -141,15 +184,56 @@ def test_audit_api_applies_actor_action_and_offset_filters(as_admin):
|
|||
assert "audit_log.actor" in str(statement.whereclause)
|
||||
|
||||
|
||||
def test_audit_csv_streams_full_filtered_log_and_neutralizes_formulas(as_admin):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.main import app
|
||||
from app.api.http import admin as admin_module
|
||||
|
||||
row = SimpleNamespace(
|
||||
at=datetime(2026, 1, 2, tzinfo=timezone.utc), actor="=1+1", role="admin",
|
||||
action="login.failed", object_id=None, detail='строка; "подробности"',
|
||||
)
|
||||
captured = {}
|
||||
|
||||
class FakeDb:
|
||||
async def stream_scalars(self, statement):
|
||||
captured["statement"] = statement
|
||||
|
||||
async def values():
|
||||
yield row
|
||||
|
||||
return values()
|
||||
|
||||
async def fake_session():
|
||||
yield FakeDb()
|
||||
|
||||
app.dependency_overrides[admin_module.get_session] = fake_session
|
||||
try:
|
||||
response = as_admin.get(
|
||||
"/api/admin/audit.csv", params={"action": "login.failed", "actor": "=1+1"}
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(admin_module.get_session, None)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.headers["content-disposition"].endswith('filename="lct-audit.csv"')
|
||||
assert response.content.startswith(b"\xef\xbb\xbf")
|
||||
text = response.content.decode("utf-8-sig")
|
||||
assert ",\'=1+1," in text
|
||||
assert '"строка; ""подробности"""' in text
|
||||
statement = captured["statement"]
|
||||
assert statement._limit_clause is None, "CSV must not truncate older audit rows"
|
||||
assert "audit_log.action" in str(statement.whereclause)
|
||||
assert "audit_log.actor" in str(statement.whereclause)
|
||||
|
||||
|
||||
# ── учётные записи ──
|
||||
|
||||
|
||||
def test_admin_creates_a_trainee_with_a_trainee_card(as_admin):
|
||||
def test_admin_creates_a_trainee_with_a_trainee_card(as_admin, postgres_access):
|
||||
"""У обучающегося должна появиться карточка курсанта: на ней висят
|
||||
профиль, история и проверка «это твой разбор» (lct-23)."""
|
||||
if not db_alive(as_admin):
|
||||
pytest.skip("нет базы")
|
||||
|
||||
login = f"курсант-{uuid.uuid4().hex[:8]}"
|
||||
response = as_admin.post(
|
||||
"/api/admin/users",
|
||||
|
|
@ -166,14 +250,15 @@ def test_admin_creates_a_trainee_with_a_trainee_card(as_admin):
|
|||
assert body["role"] == "trainee"
|
||||
assert body["service"] == "ДДС района"
|
||||
|
||||
audit_rows = as_admin.get("/api/admin/audit", params={"action": "user.create"}).json()
|
||||
assert any(row["object_id"] == login and row["detail"] == "Обучающийся"
|
||||
for row in audit_rows), "создание пользователя и audit row должны фиксироваться вместе"
|
||||
|
||||
listing = as_admin.get("/api/admin/users").json()
|
||||
assert any(user["login"] == login for user in listing)
|
||||
|
||||
|
||||
def test_duplicate_login_is_refused(as_admin):
|
||||
if not db_alive(as_admin):
|
||||
pytest.skip("нет базы")
|
||||
|
||||
def test_duplicate_login_is_refused(as_admin, postgres_access):
|
||||
login = f"двойник-{uuid.uuid4().hex[:8]}"
|
||||
payload = {
|
||||
"login": login, "full_name": "Первый", "password": "длинный-пароль", "role": "instructor",
|
||||
|
|
@ -182,6 +267,8 @@ def test_duplicate_login_is_refused(as_admin):
|
|||
second = as_admin.post("/api/admin/users", json=payload)
|
||||
assert second.status_code == 409
|
||||
assert second.json()["detail"] == "login_taken"
|
||||
audit_rows = as_admin.get("/api/admin/audit", params={"action": "user.create"}).json()
|
||||
assert sum(row["object_id"] == login for row in audit_rows) == 1
|
||||
|
||||
|
||||
def test_short_password_is_refused(as_admin):
|
||||
|
|
@ -192,11 +279,8 @@ def test_short_password_is_refused(as_admin):
|
|||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_admin_cannot_block_himself(as_admin):
|
||||
def test_admin_cannot_block_himself(as_admin, postgres_access):
|
||||
"""Иначе стенд остаётся без администратора до похода в базу руками."""
|
||||
if not db_alive(as_admin):
|
||||
pytest.skip("нет базы")
|
||||
|
||||
created = as_admin.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
|
|
@ -215,20 +299,14 @@ def test_admin_cannot_block_himself(as_admin):
|
|||
# ── состояние стенда ──
|
||||
|
||||
|
||||
def test_status_names_every_component(as_admin):
|
||||
if not db_alive(as_admin):
|
||||
pytest.skip("нет базы")
|
||||
|
||||
def test_status_names_every_component(as_admin, postgres_access):
|
||||
names = {item["name"] for item in as_admin.get("/api/admin/status").json()}
|
||||
assert {"База данных", "Модели речи", "Эмбеддинги", "Провайдер LLM",
|
||||
"Классификатор ЕКП", "Резервное копирование", "Секрет сессии",
|
||||
"Нагрузка backend"} <= names
|
||||
|
||||
|
||||
def test_admin_gets_live_metrics_and_downloadable_failure_report(as_admin):
|
||||
if not db_alive(as_admin):
|
||||
pytest.skip("нет базы")
|
||||
|
||||
def test_admin_gets_live_metrics_and_downloadable_failure_report(as_admin, postgres_access):
|
||||
response = as_admin.get("/api/admin/diagnostics")
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
|
|
@ -256,11 +334,8 @@ def test_diagnostic_journal_redacts_credentials():
|
|||
assert "never-show" not in event["message"]
|
||||
|
||||
|
||||
def test_default_session_secret_is_reported_as_a_problem(as_admin):
|
||||
def test_default_session_secret_is_reported_as_a_problem(as_admin, postgres_access):
|
||||
"""На стенде это дыра, и увидеть её должен администратор, а не проверяющий."""
|
||||
if not db_alive(as_admin):
|
||||
pytest.skip("нет базы")
|
||||
|
||||
secret = next(
|
||||
item for item in as_admin.get("/api/admin/status").json() if item["name"] == "Секрет сессии"
|
||||
)
|
||||
|
|
@ -304,6 +379,12 @@ def test_backup_failure_explains_what_is_missing(as_admin, monkeypatch):
|
|||
"""Кнопка не должна молча ничего не делать: если снять копию нечем,
|
||||
администратор видит, чего именно не хватает."""
|
||||
from app.admin import backup as backup_service
|
||||
from app.api.http import admin as admin_api
|
||||
|
||||
async def audit_is_available(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(admin_api, "audit_required", audit_is_available)
|
||||
|
||||
def broken():
|
||||
raise backup_service.BackupError("нет ни pg_dump, ни docker")
|
||||
|
|
@ -387,6 +468,34 @@ def test_backup_dsn_decodes_escaped_credentials_without_exposing_them(monkeypatc
|
|||
raise AssertionError("invalid DATABASE_URL must be rejected")
|
||||
|
||||
|
||||
def test_backup_endpoint_redacts_url_encoded_and_decoded_database_password(
|
||||
as_admin, monkeypatch,
|
||||
):
|
||||
from app.api.http import admin as admin_api
|
||||
from app.admin import backup as backup_service
|
||||
from app.admin.backup import BackupError
|
||||
|
||||
dsn = "postgresql://backup:p%40ss%3Aword@db.example:5433/lct"
|
||||
monkeypatch.setattr(
|
||||
admin_api, "get_settings", lambda: SimpleNamespace(database_url=dsn)
|
||||
)
|
||||
async def audit_is_available(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def fail_with_decoded_password():
|
||||
raise BackupError("connection failed for postgresql://backup:p@ss:word@db.example/lct")
|
||||
|
||||
monkeypatch.setattr(admin_api, "audit_required", audit_is_available)
|
||||
monkeypatch.setattr(backup_service, "create", fail_with_decoded_password)
|
||||
response = as_admin.post("/api/admin/backups")
|
||||
assert response.status_code == 503
|
||||
safe = response.json()["detail"]
|
||||
|
||||
assert "p@ss:word" not in safe
|
||||
assert "p%40ss%3Aword" not in safe
|
||||
assert "connection failed" in safe
|
||||
|
||||
|
||||
def test_backup_directory_failure_is_retryable_backup_error(monkeypatch, tmp_path):
|
||||
from app.admin import backup as backup_service
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
проверяют разграничение, для которого база не нужна.
|
||||
"""
|
||||
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
|
@ -46,10 +48,17 @@ def test_broken_hash_does_not_let_anyone_in():
|
|||
assert not verify_password("не хеш вовсе", "что угодно")
|
||||
|
||||
|
||||
def test_malformed_stored_hash_is_not_written_to_logs(caplog):
|
||||
stored_hash = "private-stored-hash-marker"
|
||||
assert not verify_password(stored_hash, "candidate-password")
|
||||
assert stored_hash not in caplog.text
|
||||
assert "InvalidHash" in caplog.text
|
||||
|
||||
|
||||
# ── вход ──
|
||||
|
||||
|
||||
def test_unknown_login_and_wrong_password_look_the_same(client):
|
||||
def test_unknown_login_and_wrong_password_look_the_same(client, postgres_access):
|
||||
"""Иначе форма входа превращается в список действующих учётных записей."""
|
||||
first = client.post("/api/auth/login", json={"login": "нет-такого", "password": "x"})
|
||||
assert first.status_code == 401
|
||||
|
|
@ -78,9 +87,186 @@ def test_dev_token_gives_an_instructor(client):
|
|||
assert client.get("/api/auth/me").json()["role"] == "instructor"
|
||||
|
||||
|
||||
def test_logout_clears_the_session(client):
|
||||
client.post("/api/auth/dev-token")
|
||||
client.post("/api/auth/logout")
|
||||
def test_directory_login_issues_the_mapped_role_and_identity(client, monkeypatch):
|
||||
from app import directory
|
||||
from app.api import auth
|
||||
from app.config import get_settings
|
||||
from app.directory import DirectoryIdentity
|
||||
|
||||
# This route test supplies its own account and sessionmaker below. Mark an
|
||||
# empty auth-generation snapshot fresh as if startup had loaded the empty
|
||||
# test directory; otherwise the production middleware correctly fails
|
||||
# closed with 503 when the sandbox cannot reach PostgreSQL.
|
||||
monkeypatch.setattr(auth, "_generations", {})
|
||||
monkeypatch.setattr(auth, "_generations_synced_at", time.monotonic())
|
||||
|
||||
provisioned = {}
|
||||
|
||||
class EmptyDb:
|
||||
async def scalar(self, query):
|
||||
if "users.auth_version" in str(query) and "user" in provisioned:
|
||||
return provisioned["user"].auth_version
|
||||
return None
|
||||
|
||||
class DbContext:
|
||||
async def __aenter__(self):
|
||||
return EmptyDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
settings = get_settings().model_copy(update={"ldap_enabled": True})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: DbContext())
|
||||
identity = DirectoryIdentity(
|
||||
login="trainee.one",
|
||||
full_name="Курсант Один",
|
||||
role=Role.TRAINEE,
|
||||
service="01",
|
||||
subject="directory-guid-1",
|
||||
)
|
||||
|
||||
async def authenticate(login, password):
|
||||
assert login == "trainee.one"
|
||||
assert password == "directory-password"
|
||||
return identity
|
||||
|
||||
async def provision(_identity):
|
||||
provisioned["user"] = SimpleNamespace(
|
||||
login=identity.login,
|
||||
full_name=identity.full_name,
|
||||
role=identity.role.value,
|
||||
service=identity.service,
|
||||
trainee_id=uuid4(),
|
||||
auth_version=0,
|
||||
blocked=False,
|
||||
)
|
||||
return provisioned["user"]
|
||||
|
||||
async def audit(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(directory, "authenticate", authenticate)
|
||||
monkeypatch.setattr(auth, "_directory_account", provision)
|
||||
monkeypatch.setattr(auth, "audit", audit)
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={"login": "trainee.one", "password": "directory-password"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["role"] == "trainee"
|
||||
assert response.json()["service"] == "01"
|
||||
assert client.get("/api/auth/me").json()["login"] == "trainee.one"
|
||||
|
||||
|
||||
def test_directory_outage_does_not_fall_back_or_issue_a_session(client, monkeypatch):
|
||||
from app import directory
|
||||
from app.api import auth
|
||||
from app.config import get_settings
|
||||
from app.directory import DirectoryUnavailable
|
||||
|
||||
class EmptyDb:
|
||||
async def scalar(self, _query):
|
||||
return None
|
||||
|
||||
class DbContext:
|
||||
async def __aenter__(self):
|
||||
return EmptyDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"get_settings",
|
||||
lambda: get_settings().model_copy(update={"ldap_enabled": True}),
|
||||
)
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: DbContext())
|
||||
|
||||
async def unavailable(*_args):
|
||||
raise DirectoryUnavailable("directory service unavailable")
|
||||
|
||||
async def audit(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(directory, "authenticate", unavailable)
|
||||
monkeypatch.setattr(auth, "audit", audit)
|
||||
response = client.post(
|
||||
"/api/auth/login", json={"login": "trainee.one", "password": "anything"}
|
||||
)
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == "directory_unavailable"
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
def test_blocked_directory_account_attempt_is_audited(client, monkeypatch):
|
||||
from app import directory
|
||||
from app.api import auth
|
||||
from app.config import get_settings
|
||||
from app.directory import DirectoryIdentity
|
||||
|
||||
class EmptyDb:
|
||||
async def scalar(self, _query):
|
||||
return None
|
||||
|
||||
class DbContext:
|
||||
async def __aenter__(self):
|
||||
return EmptyDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
settings = get_settings().model_copy(update={"ldap_enabled": True})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: DbContext())
|
||||
identity = DirectoryIdentity(
|
||||
login="trainee.one",
|
||||
full_name="Курсант Один",
|
||||
role=Role.TRAINEE,
|
||||
service="01",
|
||||
subject="directory-guid-blocked",
|
||||
)
|
||||
|
||||
async def authenticate(*_args):
|
||||
return identity
|
||||
|
||||
async def provision(_identity):
|
||||
return SimpleNamespace(
|
||||
login=identity.login,
|
||||
full_name=identity.full_name,
|
||||
role=identity.role.value,
|
||||
service=identity.service,
|
||||
trainee_id=uuid4(),
|
||||
auth_version=0,
|
||||
blocked=True,
|
||||
)
|
||||
|
||||
audit_events = []
|
||||
|
||||
async def audit(*args):
|
||||
audit_events.append(args)
|
||||
|
||||
monkeypatch.setattr(directory, "authenticate", authenticate)
|
||||
monkeypatch.setattr(auth, "_directory_account", provision)
|
||||
monkeypatch.setattr(auth, "audit", audit)
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={"login": "trainee.one", "password": "directory-password"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "blocked"
|
||||
assert any(event[2] == "login.blocked" for event in audit_events)
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
def test_logout_clears_and_revokes_the_session(client, postgres_access):
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
stale_cookie = client.cookies.get("lct_session")
|
||||
response = client.post("/api/auth/logout")
|
||||
assert response.status_code == 200, response.text
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
# Replaying a copied pre-logout cookie must not restore the authenticated session.
|
||||
client.cookies.set("lct_session", stale_cookie)
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
"""Regressions for stale cookies and privileged admin operations."""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import weakref
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
|
|
@ -9,13 +13,78 @@ from fastapi import HTTPException
|
|||
from fastapi.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
import app.api.auth as auth
|
||||
from app.api import auth
|
||||
from app.api.http import admin
|
||||
from app.domain.roles import Role
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"headers, scope, expected",
|
||||
[
|
||||
({"origin": "http://training.lan", "host": "training.lan"}, {"scheme": "ws"}, True),
|
||||
(
|
||||
{
|
||||
"origin": "https://training.lan:5443",
|
||||
"host": "backend:8000",
|
||||
"x-forwarded-host": "training.lan:5443",
|
||||
"x-forwarded-proto": "https",
|
||||
},
|
||||
{"scheme": "ws"},
|
||||
True,
|
||||
),
|
||||
({"origin": "https://attacker.invalid", "host": "training.lan"}, {"scheme": "ws"}, False),
|
||||
({"origin": "http://training.lan:5173", "host": "training.lan:8000"}, {"scheme": "ws"}, False),
|
||||
(
|
||||
{
|
||||
"origin": "http://training.lan",
|
||||
"host": "backend:8000",
|
||||
"x-forwarded-host": "training.lan",
|
||||
"x-forwarded-proto": "https",
|
||||
},
|
||||
{"scheme": "wss"},
|
||||
False,
|
||||
),
|
||||
({"host": "training.lan"}, {"scheme": "ws"}, True),
|
||||
({"origin": "not a URL", "host": "training.lan"}, {"scheme": "ws"}, False),
|
||||
],
|
||||
)
|
||||
def test_websocket_origin_policy(headers, scope, expected):
|
||||
assert auth.websocket_origin_allowed(SimpleNamespace(headers=headers, scope=scope)) is expected
|
||||
|
||||
|
||||
def test_nginx_proxies_preserve_external_host_for_websocket_origin_validation():
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
for config in ("nginx.conf.template", "nginx.tls.conf.template"):
|
||||
text = (project_root / "frontend" / config).read_text(encoding="utf-8")
|
||||
match = re.search(r"location /ws/ \{(.*?)^ \}", text, re.MULTILINE | re.DOTALL)
|
||||
assert match is not None, f"{config}: missing WebSocket proxy block"
|
||||
websocket_location = match.group(1)
|
||||
assert "proxy_set_header X-Forwarded-Host $http_host;" in websocket_location
|
||||
tls = (project_root / "frontend" / "nginx.tls.conf.template").read_text(encoding="utf-8")
|
||||
match = re.search(r"location /ws/ \{(.*?)^ \}", tls, re.MULTILINE | re.DOTALL)
|
||||
assert match is not None
|
||||
tls_websocket_location = match.group(1)
|
||||
assert "proxy_set_header X-Forwarded-Proto https;" in tls_websocket_location
|
||||
|
||||
|
||||
def test_cluster_nginx_pins_all_session_channels_and_session_apis_to_one_hash_key():
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
for config in ("nginx.cluster.conf.template", "nginx.cluster.tls.conf.template"):
|
||||
text = (project_root / "frontend" / config).read_text(encoding="utf-8")
|
||||
assert "hash $session_route_key consistent;" in text
|
||||
assert "server backend:8000" in text and "server backend-b:8000" in text
|
||||
assert re.search(
|
||||
r"~\^/ws/\(\?:control\|call\|observe\|station\)/\(\[0-9a-fA-F-\]\{36\}\)",
|
||||
text,
|
||||
), f"{config}: all WebSocket channels must extract the same session UUID"
|
||||
assert re.search(
|
||||
r"~\^/api/sessions/\(\[0-9a-fA-F-\]\{36\}\)", text
|
||||
), f"{config}: session REST endpoints must use the same routing key"
|
||||
assert text.count("proxy_pass http://backend_cluster;") == 2
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
# Each TestClient represents a fresh backend process. In particular,
|
||||
|
|
@ -38,6 +107,43 @@ def test_account_change_revokes_http_and_new_websocket_handshakes(client):
|
|||
assert client.get("/api/auth/me").status_code == 200
|
||||
|
||||
|
||||
def test_generation_sync_preserves_synthetic_dev_account(monkeypatch):
|
||||
class FakeResult:
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
class FakeDb:
|
||||
async def execute(self, _query):
|
||||
return FakeResult()
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({"dev": 7})
|
||||
await auth.sync_generations()
|
||||
assert auth._generations["dev"] == 7
|
||||
auth._generations.pop("dev", None)
|
||||
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=True))
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_cross_origin_browser_websocket_is_rejected_before_handshake(client):
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect(
|
||||
f"/ws/control/{uuid4()}", headers={"origin": "https://attacker.invalid"}
|
||||
):
|
||||
pytest.fail("cross-origin websocket must not be accepted")
|
||||
assert exc.value.code == 1008
|
||||
|
||||
|
||||
def test_account_change_closes_an_existing_websocket(client):
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
with client.websocket_connect(f"/ws/control/{uuid4()}") as socket:
|
||||
|
|
@ -59,6 +165,334 @@ def test_cookie_survives_generation_cache_reload_when_account_is_unchanged(clien
|
|||
assert client.get("/api/auth/me").status_code == 200
|
||||
|
||||
|
||||
def test_login_is_not_issued_when_security_audit_cannot_be_written(client, monkeypatch):
|
||||
from app.config import get_settings
|
||||
|
||||
user = SimpleNamespace(
|
||||
login="audit-login", auth_provider="local", password_hash="hash",
|
||||
blocked=False, role="instructor", full_name="Преподаватель",
|
||||
service=None, trainee_id=None, auth_version=0,
|
||||
)
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, _statement):
|
||||
return user
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
settings = get_settings().model_copy(update={"demo_no_db": False, "ldap_enabled": False})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
monkeypatch.setattr(auth, "verify_password", lambda *_args: True)
|
||||
|
||||
async def audit_failure(*_args, **_kwargs):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(auth, "audit", audit_failure)
|
||||
response = client.post(
|
||||
"/api/auth/login", json={"login": user.login, "password": "valid"}
|
||||
)
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == "audit_unavailable"
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_storage_failure_does_not_log_user_supplied_detail(caplog, monkeypatch):
|
||||
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
|
||||
def broken_session():
|
||||
raise RuntimeError("private-user-comment-must-not-reach-logs")
|
||||
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: broken_session)
|
||||
assert not await auth.audit(
|
||||
"teacher", "instructor", "score.override", "session-id",
|
||||
"sensitive comment must not be logged",
|
||||
)
|
||||
assert "private-user-comment-must-not-reach-logs" not in caplog.text
|
||||
assert "sensitive comment" not in caplog.text
|
||||
assert "RuntimeError" in caplog.text
|
||||
|
||||
|
||||
def test_demo_logout_revokes_replayed_cookie(client, monkeypatch):
|
||||
settings = auth.get_settings().model_copy(update={"demo_no_db": True})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
stale_cookie = client.cookies.get("lct_session")
|
||||
assert client.post("/api/auth/logout").status_code == 200
|
||||
client.cookies.set("lct_session", stale_cookie)
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
def test_peer_node_generation_sync_closes_revoked_websocket(monkeypatch):
|
||||
login = "peer-revoked"
|
||||
|
||||
class FakeResult:
|
||||
def all(self):
|
||||
return [(login, 4)]
|
||||
|
||||
class FakeDb:
|
||||
async def execute(self, _query):
|
||||
return FakeResult()
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
class FakeSocket:
|
||||
closed = False
|
||||
|
||||
async def close(self, **_kwargs):
|
||||
self.closed = True
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({login: 3})
|
||||
socket = FakeSocket()
|
||||
auth._active_sockets[login] = weakref.WeakKeyDictionary({
|
||||
socket: asyncio.get_running_loop(),
|
||||
})
|
||||
await auth.sync_generations()
|
||||
await asyncio.sleep(0.01)
|
||||
assert auth._generations[login] == 4
|
||||
assert socket.closed
|
||||
auth._active_sockets.pop(login, None)
|
||||
auth._generations.pop(login, None)
|
||||
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_revocation_does_not_log_error_if_socket_already_disconnected():
|
||||
class DisconnectedSocket:
|
||||
async def close(self, **_kwargs):
|
||||
raise WebSocketDisconnect(code=1006)
|
||||
|
||||
asyncio.run(auth._close_revoked(DisconnectedSocket()))
|
||||
|
||||
|
||||
def test_auth_middleware_rejects_cookie_with_old_database_epoch(monkeypatch):
|
||||
login = "stale-cookie"
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, _query):
|
||||
return 5
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
observed = {}
|
||||
|
||||
class InnerApp:
|
||||
async def __call__(self, scope, _receive, _send):
|
||||
observed["session"] = dict(scope["session"])
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({login: 5})
|
||||
cookie_session = {
|
||||
"principal": {"login": login},
|
||||
"auth_instance": auth._INSTANCE,
|
||||
"auth_generation": 4,
|
||||
}
|
||||
scope = {"type": "http", "session": cookie_session}
|
||||
async def unused_receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
async def unused_send(_message):
|
||||
return None
|
||||
middleware = auth.AuthVersionMiddleware(InnerApp())
|
||||
await middleware(scope, unused_receive, unused_send)
|
||||
assert observed["session"] == {}
|
||||
auth._generations.pop(login, None)
|
||||
|
||||
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_auth_middleware_fails_closed_when_generation_cache_is_stale_but_allows_logout(monkeypatch):
|
||||
from app.config import get_settings
|
||||
|
||||
class BrokenSession:
|
||||
async def __aenter__(self):
|
||||
raise OSError("database unavailable")
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
class InnerApp:
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
|
||||
async def __call__(self, _scope, _receive, _send):
|
||||
self.called = True
|
||||
|
||||
async def run():
|
||||
login = "db-outage-user"
|
||||
auth.prime_generations({login: 0})
|
||||
monkeypatch.setattr(
|
||||
auth, "_generations_synced_at",
|
||||
auth.time.monotonic() - auth.AUTH_GENERATION_MAX_AGE_SECONDS - 1,
|
||||
)
|
||||
principal = auth.Principal(
|
||||
login=login, full_name="Учётная запись", role=Role.INSTRUCTOR
|
||||
)
|
||||
scope = {"type": "http", "path": "/api/admin/users", "session": {
|
||||
"principal": principal.model_dump(mode="json"),
|
||||
"auth_instance": auth._INSTANCE,
|
||||
"auth_generation": 0,
|
||||
}}
|
||||
messages = []
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
async def send(message):
|
||||
messages.append(message)
|
||||
protected = InnerApp()
|
||||
await auth.AuthVersionMiddleware(protected)(scope, receive, send)
|
||||
assert not protected.called
|
||||
assert messages[0]["status"] == 503
|
||||
|
||||
logout_scope = {**scope, "path": "/api/auth/logout", "session": dict(scope["session"])}
|
||||
logout = InnerApp()
|
||||
await auth.AuthVersionMiddleware(logout)(logout_scope, receive, send)
|
||||
assert logout.called, "logout must reach the route so it can clear the cookie"
|
||||
|
||||
settings = get_settings().model_copy(update={"demo_no_db": False, "dev_auth_bypass": False})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: BrokenSession())
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_auth_middleware_uses_fresh_generation_cache_without_per_request_database_query(monkeypatch):
|
||||
from app.config import get_settings
|
||||
|
||||
class InnerApp:
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
|
||||
async def __call__(self, _scope, _receive, _send):
|
||||
self.called = True
|
||||
|
||||
async def run():
|
||||
login = "cached-generation-user"
|
||||
auth.prime_generations({login: 6})
|
||||
principal = auth.Principal(
|
||||
login=login, full_name="Учётная запись", role=Role.INSTRUCTOR
|
||||
)
|
||||
scope = {"type": "http", "path": "/api/admin/users", "session": {
|
||||
"principal": principal.model_dump(mode="json"),
|
||||
"auth_instance": auth._INSTANCE,
|
||||
"auth_generation": 6,
|
||||
}}
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def send(_message):
|
||||
return None
|
||||
|
||||
protected = InnerApp()
|
||||
await auth.AuthVersionMiddleware(protected)(scope, receive, send)
|
||||
assert protected.called, "a fresh, matching epoch should reach role-protected route auth"
|
||||
auth._generations.pop(login, None)
|
||||
|
||||
settings = get_settings().model_copy(update={"demo_no_db": False})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(
|
||||
auth, "get_sessionmaker",
|
||||
lambda: (_ for _ in ()).throw(AssertionError("middleware must use its synced cache")),
|
||||
)
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_stale_generation_sync_closes_existing_authenticated_websockets():
|
||||
class FakeWebSocket:
|
||||
def __init__(self):
|
||||
self.closed_with = None
|
||||
|
||||
async def close(self, code, reason):
|
||||
self.closed_with = (code, reason)
|
||||
|
||||
async def run():
|
||||
login = "stale-cache-socket-user"
|
||||
websocket = FakeWebSocket()
|
||||
sockets = auth._active_sockets.setdefault(
|
||||
login, weakref.WeakKeyDictionary()
|
||||
)
|
||||
sockets[websocket] = asyncio.get_running_loop()
|
||||
try:
|
||||
auth._close_unverified_sockets()
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0)
|
||||
assert websocket.closed_with == (
|
||||
1013, "Состояние доступа временно недоступно",
|
||||
)
|
||||
finally:
|
||||
auth._active_sockets.pop(login, None)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_generation_watcher_fails_closed_after_database_sync_error(monkeypatch):
|
||||
class FakeWebSocket:
|
||||
def __init__(self):
|
||||
self.closed_with = None
|
||||
|
||||
async def close(self, code, reason):
|
||||
self.closed_with = (code, reason)
|
||||
|
||||
class StopWatcher(Exception):
|
||||
pass
|
||||
|
||||
async def run():
|
||||
login = "sync-error-socket-user"
|
||||
websocket = FakeWebSocket()
|
||||
auth._active_sockets.setdefault(
|
||||
login, weakref.WeakKeyDictionary()
|
||||
)[websocket] = asyncio.get_running_loop()
|
||||
|
||||
async def broken_sync():
|
||||
raise OSError("database unavailable")
|
||||
|
||||
await_original_sleep = asyncio.sleep
|
||||
|
||||
async def stop_after_iteration(_seconds):
|
||||
raise StopWatcher()
|
||||
|
||||
monkeypatch.setattr(auth, "sync_generations", broken_sync)
|
||||
monkeypatch.setattr(auth.asyncio, "sleep", stop_after_iteration)
|
||||
monkeypatch.setattr(
|
||||
auth, "_generations_synced_at",
|
||||
auth.time.monotonic() - auth.AUTH_GENERATION_MAX_AGE_SECONDS - 1,
|
||||
)
|
||||
try:
|
||||
try:
|
||||
await auth.watch_generations()
|
||||
except StopWatcher:
|
||||
pass
|
||||
await await_original_sleep(0)
|
||||
await await_original_sleep(0)
|
||||
assert websocket.closed_with == (
|
||||
1013, "Состояние доступа временно недоступно",
|
||||
)
|
||||
finally:
|
||||
auth._active_sockets.pop(login, None)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
class FakeDb:
|
||||
def __init__(self, user):
|
||||
self.user = user
|
||||
|
|
@ -84,7 +518,8 @@ def fake_user(login="victim", role="instructor"):
|
|||
return SimpleNamespace(
|
||||
id=uuid4(), login=login, full_name="Проверка", role=role,
|
||||
service=None, trainee_id=None, blocked=False,
|
||||
password_hash="old", auth_version=0, created_at=datetime.now(timezone.utc),
|
||||
password_hash="old", auth_provider="local", directory_subject=None,
|
||||
auth_version=0, created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -101,13 +536,34 @@ async def test_admin_patch_revokes_cookie_after_commit(monkeypatch):
|
|||
lambda login, version=None: calls.append((login, version, db.commits)),
|
||||
)
|
||||
|
||||
async def no_audit(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(admin, "audit", no_audit)
|
||||
await admin.patch_user(user.id, admin.UserPatch(blocked=True), object(), db)
|
||||
assert user.blocked is True
|
||||
assert calls == [("victim", 1, 1)]
|
||||
assert db.added[-1].action == "user.update"
|
||||
assert db.added[-1].object_id == "victim"
|
||||
assert "заблокирован" in db.added[-1].detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_patch_never_revokes_or_reports_success_when_audit_commit_fails(monkeypatch):
|
||||
user = fake_user()
|
||||
|
||||
class BrokenCommitDb(FakeDb):
|
||||
async def commit(self):
|
||||
raise RuntimeError("audit table unavailable")
|
||||
|
||||
db = BrokenCommitDb(user)
|
||||
invalidations = []
|
||||
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
|
||||
login="admin", full_name="Администратор", role=Role.ADMIN,
|
||||
))
|
||||
monkeypatch.setattr(admin, "invalidate_login", lambda *args: invalidations.append(args))
|
||||
|
||||
with pytest.raises(RuntimeError, match="audit table unavailable"):
|
||||
await admin.patch_user(user.id, admin.UserPatch(blocked=True), object(), db)
|
||||
|
||||
assert invalidations == [], "сессию отзываем только после атомарного commit"
|
||||
assert db.added[-1].action == "user.update"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -131,36 +587,43 @@ async def test_promotion_to_trainee_creates_profile(monkeypatch):
|
|||
login="admin", full_name="Администратор", role=Role.ADMIN,
|
||||
))
|
||||
|
||||
async def no_audit(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(admin, "audit", no_audit)
|
||||
await admin.patch_user(user.id, admin.UserPatch(role=Role.TRAINEE), object(), db)
|
||||
assert user.role == "trainee"
|
||||
assert user.trainee_id is not None
|
||||
assert db.commits == 1
|
||||
assert db.added[-1].action == "user.update"
|
||||
assert db.added[-1].detail == "роль trainee"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_runs_off_event_loop_and_failure_is_audited(monkeypatch):
|
||||
who = auth.Principal(login="admin", full_name="Администратор", role=Role.ADMIN)
|
||||
monkeypatch.setattr(admin, "require", lambda _request, *_roles: who)
|
||||
calls = []
|
||||
threadpool_calls = []
|
||||
audit_calls = []
|
||||
outcome_calls = []
|
||||
|
||||
async def fake_threadpool(fn):
|
||||
calls.append(fn)
|
||||
threadpool_calls.append(fn)
|
||||
return fn()
|
||||
|
||||
async def fake_audit_required(*args, **kwargs):
|
||||
audit_calls.append((args, kwargs))
|
||||
|
||||
async def fake_audit(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
outcome_calls.append((args, kwargs))
|
||||
|
||||
monkeypatch.setattr(admin, "run_in_threadpool", fake_threadpool)
|
||||
monkeypatch.setattr(admin, "audit_required", fake_audit_required)
|
||||
monkeypatch.setattr(admin, "audit", fake_audit)
|
||||
monkeypatch.setattr(admin.backup_service, "create", lambda: {
|
||||
"name": "example.sql", "size_bytes": 1, "at": datetime.now(timezone.utc),
|
||||
})
|
||||
assert (await admin.make_backup(object())).name == "example.sql"
|
||||
assert calls[0] is admin.backup_service.create
|
||||
assert threadpool_calls == [admin.backup_service.create]
|
||||
assert [item[0][2] for item in audit_calls] == [
|
||||
"backup.create.requested", "backup.create",
|
||||
]
|
||||
|
||||
def broken():
|
||||
raise admin.backup_service.BackupError("pg_dump failed")
|
||||
|
|
@ -169,7 +632,40 @@ async def test_backup_runs_off_event_loop_and_failure_is_audited(monkeypatch):
|
|||
with pytest.raises(HTTPException) as exc:
|
||||
await admin.make_backup(object())
|
||||
assert exc.value.status_code == 503
|
||||
assert any(isinstance(item, tuple) and item[0][2] == "backup.failed" for item in calls)
|
||||
assert outcome_calls[-1][0][2] == "backup.failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_success_is_not_returned_when_audit_is_unavailable(monkeypatch):
|
||||
who = auth.Principal(login="admin", full_name="Администратор", role=Role.ADMIN)
|
||||
monkeypatch.setattr(admin, "require", lambda _request, *_roles: who)
|
||||
created = []
|
||||
|
||||
async def fake_threadpool(fn):
|
||||
return fn()
|
||||
|
||||
audit_actions = []
|
||||
|
||||
async def fail_after_backup(*args, **_kwargs):
|
||||
audit_actions.append(args[2])
|
||||
if args[2] == "backup.create":
|
||||
raise HTTPException(status_code=503, detail="audit_unavailable")
|
||||
|
||||
def create_backup():
|
||||
created.append("example.sql")
|
||||
return {"name": "example.sql", "size_bytes": 1, "at": datetime.now(timezone.utc)}
|
||||
|
||||
monkeypatch.setattr(admin, "run_in_threadpool", fake_threadpool)
|
||||
monkeypatch.setattr(admin, "audit_required", fail_after_backup)
|
||||
monkeypatch.setattr(admin.backup_service, "create", create_backup)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await admin.make_backup(object())
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
assert exc.value.detail == "audit_unavailable"
|
||||
assert audit_actions == ["backup.create.requested", "backup.create"]
|
||||
assert created == ["example.sql"] # artifact exists; the response does not misreport audit success
|
||||
|
||||
|
||||
def test_backup_error_redacts_database_credentials(monkeypatch):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from scripts import backup_loop
|
||||
|
||||
|
|
@ -18,3 +19,42 @@ def test_overdue_backup_is_due_immediately(monkeypatch):
|
|||
now = datetime.now(timezone.utc)
|
||||
monkeypatch.setattr(backup_loop, "listing", lambda: [{"at": now - timedelta(days=2)}])
|
||||
assert backup_loop.seconds_until_due(now, 86_400) == 0
|
||||
|
||||
|
||||
def test_scheduler_waits_only_remainder_after_slow_backup(monkeypatch):
|
||||
settings = SimpleNamespace(
|
||||
backup_interval_seconds=86_400,
|
||||
backup_retry_seconds=300,
|
||||
backup_keep=14,
|
||||
)
|
||||
copies = []
|
||||
waits = []
|
||||
|
||||
def listing():
|
||||
return copies
|
||||
|
||||
def create():
|
||||
# Model pg_dump taking 20 minutes before finishing the scheduler cycle.
|
||||
copies.append({
|
||||
"name": "recent.sql",
|
||||
"at": datetime.now(timezone.utc) - timedelta(minutes=20),
|
||||
})
|
||||
return {"name": "recent.sql", "size_bytes": 123}
|
||||
|
||||
def sleep(seconds):
|
||||
waits.append(seconds)
|
||||
raise RuntimeError("stop after observing next scheduled wait")
|
||||
|
||||
monkeypatch.setattr(backup_loop, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(backup_loop, "listing", listing)
|
||||
monkeypatch.setattr(backup_loop, "create", create)
|
||||
monkeypatch.setattr(backup_loop, "prune", lambda keep: 0)
|
||||
monkeypatch.setattr(backup_loop.time, "sleep", sleep)
|
||||
|
||||
try:
|
||||
backup_loop.run_forever()
|
||||
except RuntimeError as exc:
|
||||
assert str(exc) == "stop after observing next scheduled wait"
|
||||
|
||||
assert len(waits) == 1
|
||||
assert 85_190 <= waits[0] <= 85_200
|
||||
|
|
|
|||
47
backend/tests/test_call_privacy.py
Normal file
47
backend/tests/test_call_privacy.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.ws import call
|
||||
from app.dialog.slots import TurnResult
|
||||
from app.domain.events import Exercise, TranscriptEntry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_dialogue_provider_error_does_not_log_prompt_or_provider_body(caplog, monkeypatch):
|
||||
secret = "private-incident-address-from-provider-error"
|
||||
|
||||
class Caller:
|
||||
async def reply(self, *_args):
|
||||
raise RuntimeError(secret)
|
||||
|
||||
class Slots:
|
||||
def hear(self, text):
|
||||
return TurnResult(text=text)
|
||||
|
||||
def revealed_facts(self):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(call.hub, "journal", None)
|
||||
monkeypatch.setattr(call.hub, "to_trainee", lambda *_args: None)
|
||||
monkeypatch.setattr(call.hub, "to_observers", lambda *_args: None)
|
||||
state = SimpleNamespace(
|
||||
ended=False,
|
||||
exercise=Exercise.CARD,
|
||||
dispatched_card=None,
|
||||
caller=Caller(),
|
||||
persona=object(),
|
||||
scenario=SimpleNamespace(facts=[], checklist=[]),
|
||||
slots=Slots(),
|
||||
text_revealed_facts={},
|
||||
append=lambda speaker, text: TranscriptEntry(
|
||||
ref="transcript-ref", speaker=speaker, text=text, at=datetime.now(timezone.utc),
|
||||
),
|
||||
)
|
||||
|
||||
await call._handle(uuid4(), state, SimpleNamespace(type="text.turn", text="where is the incident"))
|
||||
|
||||
assert secret not in caplog.text
|
||||
assert "RuntimeError" in caplog.text
|
||||
|
|
@ -1,17 +1,26 @@
|
|||
"""Текстовая вводная 112: карточка без голоса, опроса и ДДС-оценки."""
|
||||
"""Текстовое упражнение 112: переписка с заявителем без голоса."""
|
||||
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.http import sessions as sessions_http
|
||||
from app.main import app
|
||||
from app.api.ws.call import _text_turn
|
||||
from app.scenarios import store
|
||||
from app.scoring.grammar import basic_check
|
||||
from app.session.hub import hub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
def client(monkeypatch):
|
||||
async def audit_in_memory(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(sessions_http, "audit_required", audit_in_memory)
|
||||
with TestClient(app) as test_client:
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.journal = None
|
||||
|
|
@ -36,13 +45,22 @@ def read_until(socket, wanted):
|
|||
raise AssertionError(f"событие {wanted} не пришло")
|
||||
|
||||
|
||||
def start(client, *, handoff_to_dds=False):
|
||||
async def rules_only_grammar(text):
|
||||
return basic_check(text)
|
||||
|
||||
|
||||
def start(client, *, handoff_to_dds=False, criteria=None, scenario_ids=None):
|
||||
session_id = uuid4()
|
||||
context = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = context.__enter__()
|
||||
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Иванов", "mode": "training", "exercise": "card",
|
||||
"handoff_to_dds": handoff_to_dds})
|
||||
payload = {"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Иванов", "mode": "training", "exercise": "card",
|
||||
"handoff_to_dds": handoff_to_dds}
|
||||
if scenario_ids is not None:
|
||||
payload["scenario_ids"] = scenario_ids
|
||||
if criteria is not None:
|
||||
payload["criteria"] = criteria
|
||||
control.send_json(payload)
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
return session_id, context
|
||||
|
||||
|
|
@ -53,12 +71,13 @@ def test_card_briefing_is_text_only_and_replayed_on_late_join(client):
|
|||
state = hub.get(session_id)
|
||||
assert state.exercise.value == "card"
|
||||
assert state.dispatched_card is None
|
||||
assert state.slots is None and state.voice is None
|
||||
assert state.voice is None
|
||||
assert state.caller is not None and state.persona is not None
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
briefing = read_until(trainee, "card.briefing")
|
||||
assert briefing["scenario_id"] == "fire-apartment-l2"
|
||||
assert "Помогите" in briefing["text"]
|
||||
assert "горит балкон" in briefing["text"]
|
||||
assert "горит балкон" not in briefing["text"]
|
||||
assert "ground_truth" not in briefing
|
||||
assert briefing["card"]["address"] is None
|
||||
assert "address" in briefing["required_fields"]
|
||||
|
|
@ -70,7 +89,7 @@ def test_card_briefing_is_text_only_and_replayed_on_late_join(client):
|
|||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_correct_card_scores_100_without_call_or_dds_metrics(client):
|
||||
def test_correct_card_scores_100_including_grammar_without_call_or_dds_metrics(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
|
|
@ -79,7 +98,7 @@ def test_correct_card_scores_100_without_call_or_dds_metrics(client):
|
|||
assert "dds" not in briefing["required_fields"]
|
||||
trainee.send_json({"type": "kio.patch", "fields": {
|
||||
"address": "улица Ленина, 14", "floor": "5", "incident_type": "fire",
|
||||
"victims_count": 2, "description": "горит балкон",
|
||||
"victims_count": 2, "description": "Горит балкон.",
|
||||
"signs": ["жилой дом", "балкон", "открытое пламя"],
|
||||
}})
|
||||
wait_for(lambda: hub.get(session_id).kio.incident_code)
|
||||
|
|
@ -93,9 +112,17 @@ def test_correct_card_scores_100_without_call_or_dds_metrics(client):
|
|||
assert state.dispatched_card is not None
|
||||
assert state.score["score_auto"] == 100.0
|
||||
assert not state.score["findings"]
|
||||
grammar_metric = next(item for item in state.score["metrics"]
|
||||
if item["key"] == "description_grammar")
|
||||
assert grammar_metric["passed"]
|
||||
assert {item["key"] for item in state.score["metrics"]} == {
|
||||
"incident_signs", "address", "victims_count", "required_fields"
|
||||
"incident_signs", "address", "victims_count", "required_fields",
|
||||
"description_grammar", "card_fill_time",
|
||||
}
|
||||
assert state.score["summary"]["card_fill_ms"] is not None
|
||||
fill_metric = next(item for item in state.score["metrics"] if item["key"] == "card_fill_time")
|
||||
assert fill_metric["passed"]
|
||||
assert "норматива" in fill_metric["fact"]
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as late:
|
||||
assert read_until(late, "card.briefing")["card"]["address"] == "улица Ленина, 14"
|
||||
assert read_until(late, "call.ended")["reason"] == "complete"
|
||||
|
|
@ -104,6 +131,114 @@ def test_correct_card_scores_100_without_call_or_dds_metrics(client):
|
|||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_text_exercise_allows_questions_and_returns_grounded_caller_reply(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
read_until(trainee, "card.briefing")
|
||||
trainee.send_json({"type": "text.turn", "text": "Что горит?"})
|
||||
accepted = read_until(trainee, "text.turn.accepted")
|
||||
assert accepted["text"] == "Что горит?"
|
||||
reply = read_until(trainee, "caller.utterance")
|
||||
assert "балкон" in reply["text"] or "загорел" in reply["text"]
|
||||
assert hub.get(session_id).text_revealed_facts["f_what_burns"] == "горит балкон, дым пошёл в квартиру"
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_natural_request_for_precise_address_reveals_refinement_without_embedder(client):
|
||||
scenario = store.get("t01-1-fire-container")
|
||||
assert scenario is not None
|
||||
address = next(fact for fact in scenario.facts if fact.id == "f_address")
|
||||
assert address.refined == scenario.ground_truth.address
|
||||
state = SimpleNamespace(
|
||||
scenario=scenario,
|
||||
slots=None,
|
||||
text_revealed_facts={address.id: address.value},
|
||||
)
|
||||
|
||||
turn = _text_turn(state, "а точнее можете назвать на ближайшем доме?")
|
||||
|
||||
assert turn.refined == ["f_address"]
|
||||
assert turn.matched == ["q_address_check"]
|
||||
assert state.text_revealed_facts[address.id] == address.refined
|
||||
|
||||
|
||||
def test_card_fill_overrun_is_reported_as_e3_with_actual_and_norm(client):
|
||||
session_id, control = start(client, criteria={"card_fill_time_limit_seconds": 60})
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
read_until(trainee, "card.briefing")
|
||||
state = hub.get(session_id)
|
||||
assert state.timers.limits[next(code for code in state.timers.limits
|
||||
if code.value == "card_fill")] == 60_000
|
||||
state.timers.timers[next(code for code in state.timers.timers
|
||||
if code.value == "card_fill")].started_at = time.monotonic() - 61
|
||||
trainee.send_json({"type": "card.submit"})
|
||||
read_until(trainee, "call.ended")
|
||||
read_until(trainee, "score.ready")
|
||||
state = hub.get(session_id)
|
||||
metric = next(item for item in state.score["metrics"] if item["key"] == "card_fill_time")
|
||||
assert not metric["passed"]
|
||||
assert "61 с" in metric["fact"] and "+1 с" in metric["fact"]
|
||||
assert metric["norm"] == "сдать карточку за 60 с"
|
||||
finding = next(item for item in state.score["findings"] if item["code"] == "E3")
|
||||
assert "Время заполнения карточки" in finding["summary"]
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_grammar_criterion_flags_incorrect_card_description(client, monkeypatch):
|
||||
monkeypatch.setattr("app.session.finish.assess", rules_only_grammar)
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
read_until(trainee, "card.briefing")
|
||||
trainee.send_json({"type": "kio.patch", "fields": {
|
||||
"address": "улица Ленина, 14", "floor": "5", "incident_type": "fire",
|
||||
"victims_count": 2, "description": "горит балкон",
|
||||
"signs": ["жилой дом", "балкон", "открытое пламя"],
|
||||
}})
|
||||
wait_for(lambda: hub.get(session_id).kio.incident_code)
|
||||
trainee.send_json({"type": "card.submit"})
|
||||
read_until(trainee, "call.ended")
|
||||
read_until(trainee, "score.ready")
|
||||
|
||||
score = wait_for(lambda: hub.get(session_id).score)
|
||||
metric = next(item for item in score["metrics"] if item["key"] == "description_grammar")
|
||||
assert not metric["passed"]
|
||||
assert metric["fact"]
|
||||
assert any(item["code"] == "E4" for item in score["findings"])
|
||||
e4 = next(item for item in score["findings"] if item["code"] == "E4")
|
||||
assert e4["source"] == "grammar"
|
||||
assert score["score_auto"] < 100
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_disabled_grammar_criterion_does_not_change_card_score(client, monkeypatch):
|
||||
monkeypatch.setattr("app.session.finish.assess", rules_only_grammar)
|
||||
session_id, control = start(client, criteria={"require_correct_grammar": False})
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
read_until(trainee, "card.briefing")
|
||||
trainee.send_json({"type": "kio.patch", "fields": {
|
||||
"address": "улица Ленина, 14", "floor": "5", "incident_type": "fire",
|
||||
"victims_count": 2, "description": "горит балкон",
|
||||
"signs": ["жилой дом", "балкон", "открытое пламя"],
|
||||
}})
|
||||
wait_for(lambda: hub.get(session_id).kio.incident_code)
|
||||
trainee.send_json({"type": "card.submit"})
|
||||
read_until(trainee, "call.ended")
|
||||
read_until(trainee, "score.ready")
|
||||
|
||||
score = wait_for(lambda: hub.get(session_id).score)
|
||||
assert score["score_auto"] == 100
|
||||
assert not any(item["key"] == "description_grammar" for item in score["metrics"])
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_incomplete_card_has_only_card_findings(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
|
|
@ -155,7 +290,8 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
|
|||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
service = snapshot["services"][0]
|
||||
crew = next(item for item in snapshot["crew_options"] if item.startswith(service + " — "))
|
||||
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
|
||||
station.send_json({"type": "card.status", "service": service, "status": "accepted",
|
||||
"comment": "Старший группы подтвердил приём карточки."})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "crew.select", "crew": crew})
|
||||
read_until(station, "station.state")
|
||||
|
|
@ -167,7 +303,8 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
|
|||
"request": "Прошу подтвердить выезд и доложить о прибытии"})
|
||||
assert read_until(station, "phone.report")["phase"] == "dispatched"
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.status", "service": service, "status": "responding"})
|
||||
station.send_json({"type": "card.status", "service": service, "status": "responding",
|
||||
"comment": "Старший группы сообщил о начале реагирования."})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "station.finish"})
|
||||
read_until(station, "score.ready")
|
||||
|
|
@ -180,3 +317,45 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
|
|||
assert score["score_auto"] < 100
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_handoff_queue_mixes_trainee_card_then_selected_prepared_card(client):
|
||||
session_id, control = start(
|
||||
client, handoff_to_dds=True,
|
||||
scenario_ids=["fire-apartment-l2", "t01-1-fire-container"],
|
||||
)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
read_until(trainee, "card.briefing")
|
||||
trainee.send_json({"type": "kio.patch", "fields": {
|
||||
"address": "улица Ленина, 14", "incident_type": "fire",
|
||||
"victims_count": 2, "description": "горит балкон",
|
||||
"signs": ["жилой дом", "балкон", "открытое пламя"],
|
||||
}})
|
||||
wait_for(lambda: hub.get(session_id).kio.incident_code)
|
||||
trainee.send_json({"type": "card.submit"})
|
||||
read_until(trainee, "call.ended")
|
||||
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
first = read_until(station, "card.received")
|
||||
assert first["from_operator"] == "Иванов"
|
||||
assert first["card"]["address"] == "улица Ленина, 14"
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
assert snapshot["card_total"] == 2
|
||||
assert {item["scenario_id"] for item in snapshot["queue_cards"]} == {
|
||||
"fire-apartment-l2", "t01-1-fire-container"
|
||||
}
|
||||
station.send_json({"type": "station.finish"})
|
||||
read_until(station, "score.ready")
|
||||
|
||||
state = wait_for(lambda: hub.get(session_id).score)
|
||||
assert state["card_results"]
|
||||
assert [item["scenario_id"] for item in state["card_results"]] == [
|
||||
"fire-apartment-l2", "t01-1-fire-container"
|
||||
]
|
||||
assert {item["key"] for item in state["metrics"]} >= {
|
||||
"address", "incident_signs", "dds_primary"
|
||||
}
|
||||
assert state["score_auto"] < 100
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
54
backend/tests/test_db_pool_config.py
Normal file
54
backend/tests/test_db_pool_config.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def test_database_pool_defaults_fit_two_backend_node_budget():
|
||||
settings = Settings(_env_file=None)
|
||||
assert settings.db_pool_size == 20
|
||||
assert settings.db_pool_max_overflow == 10
|
||||
# Two backend nodes use at most 60 application connections, leaving room
|
||||
# under PostgreSQL's common 100-connection default for admin/backup work.
|
||||
assert 2 * (settings.db_pool_size + settings.db_pool_max_overflow) == 60
|
||||
|
||||
|
||||
@pytest.mark.parametrize("values", [{"db_pool_size": 0}, {"db_pool_max_overflow": -1}])
|
||||
def test_database_pool_rejects_invalid_limits(values):
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(_env_file=None, **values)
|
||||
|
||||
|
||||
def test_engine_uses_configured_pool_limits(monkeypatch):
|
||||
from app.db import base
|
||||
|
||||
observed = {}
|
||||
sentinel = object()
|
||||
|
||||
def capture(url, **options):
|
||||
observed["url"] = url
|
||||
observed.update(options)
|
||||
return sentinel
|
||||
|
||||
settings = SimpleNamespace(
|
||||
database_url="postgresql+asyncpg://lct:test@localhost/lct",
|
||||
db_pool_size=12,
|
||||
db_pool_max_overflow=7,
|
||||
)
|
||||
monkeypatch.setattr(base, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(base, "create_async_engine", capture)
|
||||
base.reset()
|
||||
try:
|
||||
assert base.get_engine() is sentinel
|
||||
finally:
|
||||
base.reset()
|
||||
|
||||
assert observed == {
|
||||
"url": settings.database_url,
|
||||
"pool_pre_ping": True,
|
||||
"pool_size": 12,
|
||||
"max_overflow": 7,
|
||||
"hide_parameters": True,
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"""Готовая карточка → учебный звонок бригаде → числовая оценка ДДС."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
|
@ -7,17 +8,48 @@ from uuid import uuid4
|
|||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.http import sessions as sessions_http
|
||||
from app.api.ws.control import _start
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_session
|
||||
from app.domain.events import Exercise, ScenarioStart, SessionMode
|
||||
from app.domain.timers import TimerCode
|
||||
from app.main import app
|
||||
from app.scenarios import store
|
||||
from app.session.hub import hub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with TestClient(app) as test_client:
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.journal = None
|
||||
yield test_client
|
||||
def client(monkeypatch):
|
||||
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
|
||||
get_settings.cache_clear()
|
||||
|
||||
async def session_override():
|
||||
# These tests exercise live in-memory sessions; no endpoint below needs
|
||||
# persistence, but the report route still requires its DB dependency.
|
||||
yield object()
|
||||
|
||||
async def audit_override(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setitem(app.dependency_overrides, get_session, session_override)
|
||||
async def optional_session_override():
|
||||
yield None
|
||||
|
||||
monkeypatch.setitem(
|
||||
app.dependency_overrides,
|
||||
sessions_http.optional_session,
|
||||
optional_session_override,
|
||||
)
|
||||
monkeypatch.setattr(sessions_http, "audit_required", audit_override)
|
||||
monkeypatch.setattr("app.api.ws.control.audit", audit_override)
|
||||
try:
|
||||
with TestClient(app) as test_client:
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.journal = None
|
||||
yield test_client
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def wait_for(predicate, timeout=3):
|
||||
|
|
@ -31,23 +63,39 @@ def wait_for(predicate, timeout=3):
|
|||
|
||||
|
||||
def read_until(socket, wanted):
|
||||
received = []
|
||||
for _ in range(20):
|
||||
event = socket.receive_json()
|
||||
received.append(event["type"])
|
||||
if event["type"] == wanted:
|
||||
return event
|
||||
raise AssertionError(f"событие {wanted} не пришло")
|
||||
raise AssertionError(f"событие {wanted} не пришло; получены: {received}")
|
||||
|
||||
|
||||
def start(client, exercise="dds", criteria=None, dds_service=None, scenario_id="fire-apartment-l2"):
|
||||
def start(
|
||||
client,
|
||||
exercise="dds",
|
||||
criteria=None,
|
||||
dds_service=None,
|
||||
scenario_id="fire-apartment-l2",
|
||||
random_scenario_ids=None,
|
||||
):
|
||||
session_id = uuid4()
|
||||
context = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = context.__enter__()
|
||||
payload = {"type": "scenario.start", "scenario_id": scenario_id,
|
||||
"trainee": "Иванов", "mode": "training", "exercise": exercise}
|
||||
payload = {
|
||||
"type": "scenario.start",
|
||||
"scenario_id": scenario_id,
|
||||
"trainee": "Иванов",
|
||||
"mode": "training",
|
||||
"exercise": exercise,
|
||||
}
|
||||
if criteria is not None:
|
||||
payload["criteria"] = criteria
|
||||
if dds_service is not None:
|
||||
payload["dds_service"] = dds_service
|
||||
if random_scenario_ids is not None:
|
||||
payload["random_scenario_ids"] = random_scenario_ids
|
||||
control.send_json(payload)
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
return session_id, context
|
||||
|
|
@ -59,11 +107,18 @@ def complete_phone_call(station, state, expected_phase):
|
|||
assert greeting["speaker"] == "crew"
|
||||
read_until(station, "station.state")
|
||||
if expected_phase == "dispatched":
|
||||
station.send_json({"type": "phone.brief", "address": state.dispatched_card.address,
|
||||
"incident": state.scenario_title,
|
||||
"request": "Прошу подтвердить выезд и сообщить о прибытии"})
|
||||
station.send_json(
|
||||
{
|
||||
"type": "phone.brief",
|
||||
"address": state.dispatched_card.address,
|
||||
"incident": state.scenario_title,
|
||||
"request": "Прошу подтвердить выезд и сообщить о прибытии",
|
||||
}
|
||||
)
|
||||
else:
|
||||
station.send_json({"type": "phone.check", "text": "Сообщите текущую обстановку по карточке"})
|
||||
station.send_json(
|
||||
{"type": "phone.check", "text": "Сообщите текущую обстановку по карточке"}
|
||||
)
|
||||
assert read_until(station, "phone.line")["speaker"] == "dispatcher"
|
||||
assert read_until(station, "phone.line")["speaker"] == "crew"
|
||||
report = read_until(station, "phone.report")
|
||||
|
|
@ -93,6 +148,183 @@ def test_dds_starts_with_prepared_card_without_call(client):
|
|||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_repeated_ack_and_crew_selection_do_not_duplicate_dds_log(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "card.received")
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
crew = snapshot["crew_options"][0]
|
||||
|
||||
station.send_json({"type": "card.ack", "comment": "Основание: карточка передана диспетчеру."})
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.ack", "comment": "Основание: карточка передана диспетчеру."})
|
||||
station.send_json({"type": "crew.select", "crew": crew})
|
||||
read_until(station, "station.state")
|
||||
assert sum(action == "card.ack" for action, *_ in state.dds_log) == 1
|
||||
|
||||
station.send_json({"type": "crew.select", "crew": crew})
|
||||
station.send_json({"type": "zone.decision", "in_zone": True})
|
||||
read_until(station, "station.state")
|
||||
assert sum(action == "crew.select" for action, *_ in state.dds_log) == 1
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_zone_decision_is_one_shot_and_restored_in_station_snapshot(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "card.received")
|
||||
initial = read_until(station, "station.state")["snapshot"]
|
||||
assert initial["zone_decision"] is None
|
||||
|
||||
station.send_json({"type": "zone.decision", "in_zone": True})
|
||||
accepted = read_until(station, "station.state")["snapshot"]
|
||||
assert accepted["zone_decision"] is True
|
||||
state = hub.get(session_id)
|
||||
assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1
|
||||
|
||||
station.send_json({"type": "zone.decision", "in_zone": True})
|
||||
assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1
|
||||
station.send_json({"type": "zone.decision", "in_zone": False})
|
||||
error = read_until(station, "error")
|
||||
assert "уже записано" in error["message"]
|
||||
assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1
|
||||
|
||||
station.close()
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "card.received")
|
||||
restored = read_until(station, "station.state")["snapshot"]
|
||||
assert restored["zone_decision"] is True
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_server_randomly_selects_only_from_instructor_filtered_card_pool(
|
||||
client, monkeypatch
|
||||
):
|
||||
pool = ["fire-apartment-l2", "t01-1-fire-container"]
|
||||
monkeypatch.setattr(
|
||||
"app.api.ws.control.secrets.choice", lambda scenarios: scenarios[-1]
|
||||
)
|
||||
session_id, control = start(client, random_scenario_ids=pool)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
assert state.scenario_id == "t01-1-fire-container"
|
||||
assert state.dispatched_card is not None
|
||||
assert [item.id for item in state.dds_scenarios] == pool[::-1]
|
||||
assert state.dds_next_scenario_index == 2
|
||||
assert state.dds_next_arrival_at is None
|
||||
finally:
|
||||
hub.stop_ticker(session_id)
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario_id", [
|
||||
"t01-3-child-other-region",
|
||||
"t02-2-megafon-consultation",
|
||||
"t07-2-headache-ryazan",
|
||||
"t11-3-lost-in-forest",
|
||||
"t12-2-heart-pain",
|
||||
"t16-2-child-bicycle-volzhsky",
|
||||
"t19-1-field-fire",
|
||||
"t19-2-snake-bite",
|
||||
"t27-3-wall-crack",
|
||||
"t29-2-accident-fight",
|
||||
])
|
||||
def test_dds_rejects_non_card_outcomes_instead_of_making_fake_cards(
|
||||
client, monkeypatch, scenario_id
|
||||
):
|
||||
scenario = store.get(scenario_id)
|
||||
assert scenario is not None and scenario.outcome.value in {"consultation", "transfer_region"}
|
||||
emitted = []
|
||||
monkeypatch.setattr(
|
||||
hub, "to_observers", lambda session_id, event: emitted.append(event)
|
||||
)
|
||||
session_id = uuid4()
|
||||
event = ScenarioStart(
|
||||
scenario_id=scenario.id,
|
||||
scenario_ids=[scenario.id],
|
||||
trainee="Иванов",
|
||||
mode=SessionMode.TRAINING,
|
||||
exercise=Exercise.DDS,
|
||||
)
|
||||
|
||||
asyncio.run(_start(session_id, event))
|
||||
|
||||
assert hub.get(session_id) is None
|
||||
assert emitted[-1].code.value == "scenario_invalid"
|
||||
assert "готовые карточки" in emitted[-1].message
|
||||
|
||||
|
||||
def test_instructor_live_registry_shows_owned_dds_session_and_deadline_state(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
response = client.get("/api/sessions/active")
|
||||
assert response.status_code == 200
|
||||
rows = response.json()
|
||||
row = next(item for item in rows if item["session_id"] == str(session_id))
|
||||
assert row["exercise"] == "dds"
|
||||
assert row["scenario_title"]
|
||||
assert row["dds_card_total"] == 1
|
||||
assert row["dds_open_cards"] == 1
|
||||
assert row["dds_overdue_cards"] == 0
|
||||
assert row["dds_snapshot"]["queue_cards"][0]["active"] is True
|
||||
assert row["dds_snapshot"]["queue_cards"][0]["title"]
|
||||
assert row["dds_snapshot"]["queue_cards"][0]["service_status"] == "added"
|
||||
assert row["dds_snapshot"]["phone_reports"] == []
|
||||
state = hub.get(session_id)
|
||||
live_card = state.dds_live_cards[0]
|
||||
live_card.timers.on_event("dds.open")
|
||||
live_card.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 181
|
||||
overdue_response = client.get("/api/sessions/active")
|
||||
overdue_row = next(
|
||||
item
|
||||
for item in overdue_response.json()
|
||||
if item["session_id"] == str(session_id)
|
||||
)
|
||||
assert overdue_row["dds_work_overdue_cards"] == 1
|
||||
assert row["dds_statuses"]
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_instructor_live_registry_includes_current_crew_report(client, monkeypatch):
|
||||
from app.api import auth
|
||||
|
||||
async def keep_test_auth_state_fresh():
|
||||
auth.prime_generations({})
|
||||
|
||||
auth.prime_generations({})
|
||||
monkeypatch.setattr(auth, "sync_generations", keep_test_auth_state_fresh)
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
|
||||
read_until(station, "station.state")
|
||||
crew = state.crew_options()[0]
|
||||
station.send_json({"type": "crew.select", "crew": crew})
|
||||
read_until(station, "station.state")
|
||||
service = state.crew_service(crew)
|
||||
station.send_json({
|
||||
"type": "card.status", "service": service, "status": "accepted",
|
||||
"comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята.",
|
||||
})
|
||||
read_until(station, "station.state")
|
||||
report = complete_phone_call(station, state, "dispatched")
|
||||
|
||||
rows = client.get("/api/sessions/active").json()
|
||||
row = next(item for item in rows if item["session_id"] == str(session_id))
|
||||
saved_report = row["dds_snapshot"]["phone_reports"][0]
|
||||
assert saved_report["crew"] == report["crew"]
|
||||
assert saved_report["phase"] == "dispatched"
|
||||
assert saved_report["text"] == report["text"]
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_ticket_dds_card_uses_source_caller_identity_and_phone(client):
|
||||
session_id, control = start(client, scenario_id="t01-1-fire-container")
|
||||
try:
|
||||
|
|
@ -128,25 +360,47 @@ def test_dds_can_change_only_its_own_service_status(client):
|
|||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
assert snapshot["services"] == ["МВД"]
|
||||
assert "Служба 101" in snapshot["recipient_services"]
|
||||
station.send_json({
|
||||
"type": "card.status", "service": "Служба 101", "status": "accepted",
|
||||
})
|
||||
station.send_json(
|
||||
{
|
||||
"type": "card.status",
|
||||
"service": "Служба 101",
|
||||
"status": "accepted",
|
||||
}
|
||||
)
|
||||
assert "только своей ДДС" in read_until(station, "error")["message"]
|
||||
station.send_json({
|
||||
"type": "card.status", "service": "МВД", "status": "accepted",
|
||||
})
|
||||
assert read_until(station, "station.state")["snapshot"]["statuses"]["МВД"] == "accepted"
|
||||
station.send_json(
|
||||
{
|
||||
"type": "card.status",
|
||||
"service": "МВД",
|
||||
"status": "accepted",
|
||||
"comment": "Основание: доклад старшего.\nСведения: карточка для нашей службы.",
|
||||
}
|
||||
)
|
||||
assert (
|
||||
read_until(station, "station.state")["snapshot"]["statuses"]["МВД"]
|
||||
== "accepted"
|
||||
)
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_instructor_criteria_change_timer_and_success_threshold(client):
|
||||
session_id, control = start(client, criteria={
|
||||
"decision_time_limit_seconds": 45,
|
||||
"allowed_errors": 50,
|
||||
"require_correct_grammar": False,
|
||||
"score_weights": {"dds_ack": 3.5},
|
||||
})
|
||||
def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypatch):
|
||||
from app.api import auth
|
||||
|
||||
async def keep_test_auth_state_fresh():
|
||||
auth.prime_generations({})
|
||||
|
||||
auth.prime_generations({})
|
||||
monkeypatch.setattr(auth, "sync_generations", keep_test_auth_state_fresh)
|
||||
session_id, control = start(
|
||||
client,
|
||||
criteria={
|
||||
"decision_time_limit_seconds": 45,
|
||||
"allowed_errors": 50,
|
||||
"require_correct_grammar": False,
|
||||
"score_weights": {"dds_ack": 3.5},
|
||||
},
|
||||
)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
assert state.criteria.decision_time_limit_seconds == 45
|
||||
|
|
@ -155,11 +409,30 @@ def test_instructor_criteria_change_timer_and_success_threshold(client):
|
|||
card = read_until(station, "card.received")
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
service = snapshot["services"][0]
|
||||
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
|
||||
station.send_json(
|
||||
{"type": "card.status", "service": service, "status": "accepted",
|
||||
"comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята."}
|
||||
)
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.reply", "card_id": card["card"]["card_id"],
|
||||
"text": "Сообщение приняты, бригада направлено."})
|
||||
station.send_json(
|
||||
{
|
||||
"type": "card.reply",
|
||||
"card_id": card["card"]["card_id"],
|
||||
"text": "Сообщение приняты, бригада направлено.",
|
||||
}
|
||||
)
|
||||
read_until(station, "station.state")
|
||||
# Simulate the acknowledgement being lost during reconnect. The
|
||||
# buffered replacement may be replayed, but its journal is unique.
|
||||
station.send_json(
|
||||
{
|
||||
"type": "card.reply",
|
||||
"card_id": card["card"]["card_id"],
|
||||
"text": "Сообщение приняты, бригада направлено.",
|
||||
}
|
||||
)
|
||||
read_until(station, "station.state")
|
||||
assert len(state.reply_log) == 1
|
||||
station.send_json({"type": "station.finish"})
|
||||
read_until(station, "score.ready")
|
||||
|
||||
|
|
@ -167,10 +440,20 @@ def test_instructor_criteria_change_timer_and_success_threshold(client):
|
|||
ack = next(item for item in score["metrics"] if item["key"] == "dds_ack")
|
||||
assert ack["norm"] == "≤ 45 с"
|
||||
assert ack["weight"] == 3.5
|
||||
assert not any(item["key"] in {"dds_reply", "dds_grammar"} for item in score["metrics"])
|
||||
reply_metric = next(item for item in score["metrics"] if item["key"] == "dds_reply")
|
||||
assert reply_metric["passed"]
|
||||
assert not any(item["key"] == "dds_grammar" for item in score["metrics"])
|
||||
assert (
|
||||
next(item for item in score["metrics"] if item["key"] == "dds_work_time")[
|
||||
"passed"
|
||||
]
|
||||
is False
|
||||
)
|
||||
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||
assert report["criteria"] == {
|
||||
"decision_time_limit_seconds": 45,
|
||||
"card_fill_time_limit_seconds": 180,
|
||||
"dds_card_work_time_limit_seconds": 180,
|
||||
"allowed_errors": 50,
|
||||
"require_correct_grammar": False,
|
||||
"score_weights": {"dds_ack": 3.5},
|
||||
|
|
@ -180,6 +463,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client):
|
|||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_dds_statuses_do_not_require_phone_reports(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
|
|
@ -187,19 +471,100 @@ def test_dds_statuses_do_not_require_phone_reports(client):
|
|||
read_until(station, "card.received")
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
service = snapshot["services"][0]
|
||||
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
|
||||
station.send_json(
|
||||
{"type": "card.status", "service": service, "status": "accepted",
|
||||
"comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята."}
|
||||
)
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.status", "service": service, "status": "responding"})
|
||||
assert read_until(station, "station.state")["snapshot"]["statuses"][service] == "responding"
|
||||
station.send_json(
|
||||
{"type": "card.status", "service": service, "status": "responding",
|
||||
"comment": "Основание: доклад старшего.\nСведения: начало реагирования подтверждено."}
|
||||
)
|
||||
assert (
|
||||
read_until(station, "station.state")["snapshot"]["statuses"][service]
|
||||
== "responding"
|
||||
)
|
||||
station.send_json({"type": "station.finish"})
|
||||
read_until(station, "score.ready")
|
||||
score = wait_for(lambda: hub.get(session_id).score)
|
||||
keys = {metric["key"] for metric in score["metrics"]}
|
||||
assert "dds_primary" in keys and "dds_progress" in keys
|
||||
assert "dds_contact" not in keys and "dds_crew" not in keys
|
||||
crew_metric = next(
|
||||
metric for metric in score["metrics"] if metric["key"] == "dds_crew"
|
||||
)
|
||||
assert not crew_metric["passed"]
|
||||
assert "dds_contact" not in keys
|
||||
assert "answer_time" not in keys and "interview_time" not in keys
|
||||
assert 0 < score["score_auto"] < 100
|
||||
assert all(not finding["code"].startswith("E") for finding in score["findings"])
|
||||
assert any(
|
||||
finding["code"] == "D2" and "бригады" in finding["summary"]
|
||||
for finding in score["findings"]
|
||||
)
|
||||
assert any(
|
||||
finding["code"] == "E3" and "времени отработки" in finding["summary"]
|
||||
for finding in score["findings"]
|
||||
)
|
||||
finding_codes = {finding["code"] for finding in score["findings"]}
|
||||
penalty_codes = {
|
||||
"dds_primary": {"D1"},
|
||||
"dds_ack": {"D1"},
|
||||
"dds_decision": {"D2", "D3"},
|
||||
"dds_crew": {"D2"},
|
||||
"dds_progress": {"D6"},
|
||||
"dds_completion": {"D6"},
|
||||
"dds_reply": {"D5"},
|
||||
"dds_work_time": {"E3"},
|
||||
}
|
||||
unexplained = [
|
||||
metric["key"]
|
||||
for metric in score["metrics"]
|
||||
if not metric["passed"]
|
||||
and not penalty_codes.get(metric["key"], set()).intersection(finding_codes)
|
||||
]
|
||||
assert not unexplained, f"проваленные метрики без кода и пояснения: {unexplained}"
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_dds_d5_comment_is_explanatory_and_does_not_change_numeric_score(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
card = read_until(station, "card.received")["card"]
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
service = snapshot["services"][0]
|
||||
crew = next(
|
||||
option
|
||||
for option in snapshot["crew_options"]
|
||||
if option.startswith(service + " — ")
|
||||
)
|
||||
station.send_json(
|
||||
{"type": "card.status", "service": service, "status": "accepted",
|
||||
"comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята."}
|
||||
)
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "crew.select", "crew": crew})
|
||||
read_until(station, "station.state")
|
||||
for status in ("responding", "arrived", "working", "completed"):
|
||||
station.send_json(
|
||||
{"type": "card.status", "service": service, "status": status,
|
||||
"comment": f"Основание: доклад ответственной службы.\nСведения: этап {status}."}
|
||||
)
|
||||
read_until(station, "station.state")
|
||||
station.send_json(
|
||||
{
|
||||
"type": "card.reply",
|
||||
"card_id": card["card_id"],
|
||||
"text": "Все принято.",
|
||||
}
|
||||
)
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "station.finish"})
|
||||
read_until(station, "score.ready")
|
||||
score = wait_for(lambda: hub.get(session_id).score)
|
||||
assert score["score_auto"] == 100.0
|
||||
d5 = next(finding for finding in score["findings"] if finding["code"] == "D5")
|
||||
assert "получателя" in d5["summary"]
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
|
@ -211,8 +576,15 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
|
|||
read_until(station, "card.received")
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
service = snapshot["services"][0]
|
||||
crew = next(option for option in snapshot["crew_options"] if option.startswith(service + " — "))
|
||||
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
|
||||
crew = next(
|
||||
option
|
||||
for option in snapshot["crew_options"]
|
||||
if option.startswith(service + " — ")
|
||||
)
|
||||
station.send_json(
|
||||
{"type": "card.status", "service": service, "status": "accepted",
|
||||
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
|
||||
)
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "crew.select", "crew": crew})
|
||||
read_until(station, "station.state")
|
||||
|
|
@ -221,18 +593,37 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
|
|||
pending = read_until(station, "station.state")["snapshot"]
|
||||
assert pending["phone_pending"]["phase"] == "dispatched"
|
||||
assert not pending["phone_reports"]
|
||||
station.send_json({"type": "card.status", "service": service, "status": "responding"})
|
||||
assert read_until(station, "station.state")["snapshot"]["statuses"][service] == "responding"
|
||||
station.send_json({"type": "phone.brief", "address": "другая улица, дом 99",
|
||||
"incident": "Пожар в квартире",
|
||||
"request": "Прошу направить бригаду"})
|
||||
station.send_json(
|
||||
{"type": "card.status", "service": service, "status": "responding",
|
||||
"comment": "Основание: доклад старшего.\nСведения: начало реагирования подтверждено."}
|
||||
)
|
||||
assert (
|
||||
read_until(station, "station.state")["snapshot"]["statuses"][service]
|
||||
== "responding"
|
||||
)
|
||||
station.send_json(
|
||||
{
|
||||
"type": "phone.brief",
|
||||
"address": "другая улица, дом 99",
|
||||
"incident": "Пожар в квартире",
|
||||
"request": "Прошу направить бригаду",
|
||||
}
|
||||
)
|
||||
assert "адрес" in read_until(station, "error")["message"]
|
||||
station.send_json({"type": "phone.brief", "address": "улица Ленина, 14",
|
||||
"incident": "Ничего не произошло",
|
||||
"request": "Прошу направить бригаду"})
|
||||
station.send_json(
|
||||
{
|
||||
"type": "phone.brief",
|
||||
"address": "улица Ленина, 14",
|
||||
"incident": "Ничего не произошло",
|
||||
"request": "Прошу направить бригаду",
|
||||
}
|
||||
)
|
||||
assert "характер" in read_until(station, "error")["message"]
|
||||
station.send_json({"type": "phone.hangup"})
|
||||
assert read_until(station, "station.state")["snapshot"]["phone_pending"] is None
|
||||
assert (
|
||||
read_until(station, "station.state")["snapshot"]["phone_pending"]
|
||||
is None
|
||||
)
|
||||
assert not hub.get(session_id).phone_reports
|
||||
complete_phone_call(station, hub.get(session_id), "dispatched")
|
||||
read_until(station, "station.state")
|
||||
|
|
@ -240,7 +631,9 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
|
|||
station.send_json({"type": "phone.dial"})
|
||||
read_until(station, "phone.line")
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "phone.check", "text": "Здравствуйте, хорошая погода"})
|
||||
station.send_json(
|
||||
{"type": "phone.check", "text": "Здравствуйте, хорошая погода"}
|
||||
)
|
||||
assert "обстановку" in read_until(station, "error")["message"]
|
||||
assert len(hub.get(session_id).phone_reports) == 1
|
||||
finally:
|
||||
|
|
@ -250,24 +643,43 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
|
|||
def test_default_exercise_remains_call(client):
|
||||
session_id = uuid4()
|
||||
with client.websocket_connect(f"/ws/control/{session_id}") as control:
|
||||
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Иванов", "mode": "training"})
|
||||
control.send_json(
|
||||
{
|
||||
"type": "scenario.start",
|
||||
"scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Иванов",
|
||||
"mode": "training",
|
||||
}
|
||||
)
|
||||
state = wait_for(lambda: hub.get(session_id))
|
||||
assert state.exercise.value == "call"
|
||||
assert state.dispatched_card is None
|
||||
|
||||
|
||||
def test_complete_dds_workflow_scores_100_without_any_call(client):
|
||||
def test_complete_dds_workflow_scores_100_with_assignment_without_call(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "card.received")
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
for service in snapshot["services"]:
|
||||
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
|
||||
station.send_json(
|
||||
{"type": "card.status", "service": service, "status": "accepted",
|
||||
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
|
||||
)
|
||||
read_until(station, "station.state")
|
||||
crew = next(
|
||||
option
|
||||
for option in snapshot["crew_options"]
|
||||
if option.startswith(service + " — ")
|
||||
)
|
||||
station.send_json({"type": "crew.select", "crew": crew})
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
for status in ("responding", "arrived", "working", "completed"):
|
||||
station.send_json({"type": "card.status", "service": service, "status": status})
|
||||
station.send_json(
|
||||
{"type": "card.status", "service": service, "status": status,
|
||||
"comment": f"Основание: доклад старшего.\nСведения: этап {status}."}
|
||||
)
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
assert snapshot["card"] == "completed"
|
||||
station.send_json({"type": "station.finish"})
|
||||
|
|
@ -276,8 +688,115 @@ def test_complete_dds_workflow_scores_100_without_any_call(client):
|
|||
assert score["score_auto"] == 100.0
|
||||
assert not score["findings"]
|
||||
assert all(metric["key"].startswith("dds_") for metric in score["metrics"])
|
||||
assert not any(metric["key"] in {"dds_contact", "dds_crew", "dds_reply", "dds_grammar"}
|
||||
for metric in score["metrics"])
|
||||
crew_metric = next(
|
||||
metric for metric in score["metrics"] if metric["key"] == "dds_crew"
|
||||
)
|
||||
assert crew_metric["passed"]
|
||||
assert next(
|
||||
metric for metric in score["metrics"] if metric["key"] == "dds_work_time"
|
||||
)["passed"]
|
||||
assert not any(
|
||||
metric["key"] in {"dds_contact", "dds_grammar"}
|
||||
for metric in score["metrics"]
|
||||
)
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client):
|
||||
session_id, control = start(
|
||||
client, criteria={"dds_card_work_time_limit_seconds": 60}
|
||||
)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "card.received")
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
service = snapshot["services"][0]
|
||||
station.send_json(
|
||||
{"type": "card.status", "service": service, "status": "accepted",
|
||||
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
|
||||
)
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
state.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61
|
||||
crew = next(
|
||||
option
|
||||
for option in snapshot["crew_options"]
|
||||
if option.startswith(service + " — ")
|
||||
)
|
||||
station.send_json({"type": "crew.select", "crew": crew})
|
||||
read_until(station, "station.state")
|
||||
for status in ("responding", "arrived", "working", "completed"):
|
||||
station.send_json(
|
||||
{"type": "card.status", "service": service, "status": status,
|
||||
"comment": f"Основание: доклад старшего.\nСведения: этап {status}."}
|
||||
)
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "station.finish"})
|
||||
read_until(station, "score.ready")
|
||||
score = wait_for(lambda: state.score)
|
||||
metric = next(
|
||||
item for item in score["metrics"] if item["key"] == "dds_work_time"
|
||||
)
|
||||
assert not metric["passed"]
|
||||
assert "61 с" in metric["fact"] and "+1 с" in metric["fact"]
|
||||
finding = next(item for item in score["findings"] if item["code"] == "E3")
|
||||
assert "времени отработки карточки" in finding["summary"]
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_complete_dds_workflow_with_training_calls_and_status_updates(client):
|
||||
session_id, control = start(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "card.received")
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
service = snapshot["services"][0]
|
||||
crew = next(
|
||||
option
|
||||
for option in snapshot["crew_options"]
|
||||
if option.startswith(service + " — ")
|
||||
)
|
||||
|
||||
station.send_json(
|
||||
{"type": "card.status", "service": service, "status": "accepted",
|
||||
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
|
||||
)
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "crew.select", "crew": crew})
|
||||
read_until(station, "station.state")
|
||||
complete_phone_call(station, state, "dispatched")
|
||||
read_until(station, "station.state")
|
||||
|
||||
for status, phase in (
|
||||
("responding", "arrived"),
|
||||
("arrived", "working"),
|
||||
("working", "completed"),
|
||||
("completed", None),
|
||||
):
|
||||
station.send_json(
|
||||
{"type": "card.status", "service": service, "status": status,
|
||||
"comment": f"Основание: доклад старшего.\nСведения: этап {status}."}
|
||||
)
|
||||
read_until(station, "station.state")
|
||||
if phase:
|
||||
complete_phone_call(station, state, phase)
|
||||
read_until(station, "station.state")
|
||||
|
||||
station.send_json({"type": "station.finish"})
|
||||
read_until(station, "score.ready")
|
||||
score = wait_for(lambda: state.score)
|
||||
assert score["score_auto"] == 100.0
|
||||
assert [report.phase for report in state.phone_reports] == [
|
||||
"dispatched",
|
||||
"arrived",
|
||||
"working",
|
||||
"completed",
|
||||
]
|
||||
assert all(report.crew == crew for report in state.phone_reports)
|
||||
assert not score["findings"]
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
|
@ -286,12 +805,17 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
|
|||
session_id = uuid4()
|
||||
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = control_ctx.__enter__()
|
||||
control.send_json({
|
||||
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
|
||||
"trainee": "Иванов", "mode": "training", "exercise": "dds",
|
||||
})
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
control.send_json(
|
||||
{
|
||||
"type": "scenario.start",
|
||||
"scenario_id": "fire-apartment-l2",
|
||||
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
|
||||
"trainee": "Иванов",
|
||||
"mode": "training",
|
||||
"exercise": "dds",
|
||||
}
|
||||
)
|
||||
state = wait_for(lambda: hub.get(session_id))
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
first = read_until(station, "card.received")
|
||||
|
|
@ -302,16 +826,30 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
|
|||
first_row, second_row = snapshot["queue_cards"]
|
||||
second_card_id = second_row["card_id"]
|
||||
assert first_row["active"] is True and second_row["active"] is False
|
||||
assert abs(
|
||||
datetime.fromisoformat(first_row["received_at"]).timestamp()
|
||||
- datetime.fromisoformat(second_row["received_at"]).timestamp()
|
||||
) < 1
|
||||
first_live = next(
|
||||
item
|
||||
for item in state.dds_live_cards
|
||||
if str(item.card_id) == first_card_id
|
||||
)
|
||||
assert TimerCode.DDS_WORK not in first_live.timers.timers
|
||||
assert (
|
||||
abs(
|
||||
datetime.fromisoformat(first_row["received_at"]).timestamp()
|
||||
- datetime.fromisoformat(second_row["received_at"]).timestamp()
|
||||
)
|
||||
< 1
|
||||
)
|
||||
first_service = snapshot["services"][0]
|
||||
station.send_json({"type": "card.status", "service": first_service,
|
||||
"status": "accepted"})
|
||||
station.send_json(
|
||||
{"type": "card.status", "service": first_service, "status": "accepted",
|
||||
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
|
||||
)
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
first_elapsed = next(item for item in snapshot["queue_cards"]
|
||||
if item["card_id"] == first_card_id)["elapsed_ms"]
|
||||
first_elapsed = next(
|
||||
item
|
||||
for item in snapshot["queue_cards"]
|
||||
if item["card_id"] == first_card_id
|
||||
)["elapsed_ms"]
|
||||
|
||||
time.sleep(0.03)
|
||||
# Card switching must publish its own fresh station snapshot; do
|
||||
|
|
@ -320,28 +858,48 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
|
|||
station.send_json({"type": "card.open", "card_id": second_card_id})
|
||||
second = read_until(station, "card.received")
|
||||
assert second["card"]["card_id"] == second_card_id
|
||||
second_live = next(
|
||||
item
|
||||
for item in state.dds_live_cards
|
||||
if str(item.card_id) == second_card_id
|
||||
)
|
||||
assert TimerCode.DDS_WORK in second_live.timers.timers
|
||||
assert second_live.timers.timers[TimerCode.DDS_WORK].started_at is not None
|
||||
assert second["card"]["incident_type"] == "medical"
|
||||
assert (second["card_index"], second["card_total"]) == (2, 2)
|
||||
snapshot = read_until(station, "station.state")["snapshot"]
|
||||
second_queue_row = next(item for item in snapshot["queue_cards"]
|
||||
if item["card_id"] == second_card_id)
|
||||
second_queue_row = next(
|
||||
item
|
||||
for item in snapshot["queue_cards"]
|
||||
if item["card_id"] == second_card_id
|
||||
)
|
||||
assert second_queue_row["active"] is True
|
||||
assert second_queue_row["elapsed_ms"] >= first_elapsed
|
||||
first_queue_row = next(item for item in snapshot["queue_cards"]
|
||||
if item["card_id"] == first_card_id)
|
||||
first_queue_row = next(
|
||||
item
|
||||
for item in snapshot["queue_cards"]
|
||||
if item["card_id"] == first_card_id
|
||||
)
|
||||
assert first_queue_row["service_status"] == "accepted"
|
||||
assert first_queue_row["timer_stopped"] is True
|
||||
|
||||
second_service = snapshot["services"][0]
|
||||
station.send_json({"type": "card.status", "service": second_service,
|
||||
"status": "accepted"})
|
||||
station.send_json(
|
||||
{"type": "card.status", "service": second_service, "status": "accepted",
|
||||
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
|
||||
)
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.open", "card_id": first_card_id})
|
||||
assert read_until(station, "card.received")["card"]["card_id"] == first_card_id
|
||||
assert (
|
||||
read_until(station, "card.received")["card"]["card_id"] == first_card_id
|
||||
)
|
||||
restored = read_until(station, "station.state")["snapshot"]
|
||||
assert restored["statuses"][first_service] == "accepted"
|
||||
station.send_json({"type": "card.next", "card_id": first_card_id})
|
||||
assert read_until(station, "card.received")["card"]["card_id"] == second_card_id
|
||||
assert (
|
||||
read_until(station, "card.received")["card"]["card_id"]
|
||||
== second_card_id
|
||||
)
|
||||
after_close = read_until(station, "station.state")["snapshot"]
|
||||
assert len(after_close["queue_cards"]) == 1
|
||||
assert after_close["statuses"][second_service] == "accepted"
|
||||
|
|
@ -354,9 +912,11 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
|
|||
assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2"
|
||||
assert state.score["card_results"][1]["scenario_id"] == "t20-2-stroke"
|
||||
assert "dds_primary" in {item["key"] for item in state.score["metrics"]}
|
||||
assert not any(item["key"] in {"dds_reply", "dds_grammar"}
|
||||
for item in state.score["metrics"])
|
||||
assert all(item["code"].startswith("D") for item in state.score["findings"])
|
||||
assert not any(
|
||||
item["key"] == "dds_grammar"
|
||||
for item in state.score["metrics"]
|
||||
)
|
||||
assert any(item["code"] == "E3" for item in state.score["findings"])
|
||||
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||
assert report["scenario_id"] == "fire-apartment-l2"
|
||||
assert len(report["card_results"]) == 2
|
||||
|
|
@ -372,11 +932,16 @@ def test_instructor_end_grades_all_concurrently_issued_cards(client):
|
|||
session_id = uuid4()
|
||||
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
control = control_ctx.__enter__()
|
||||
control.send_json({
|
||||
"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
|
||||
"trainee": "Иванов", "mode": "training", "exercise": "dds",
|
||||
})
|
||||
control.send_json(
|
||||
{
|
||||
"type": "scenario.start",
|
||||
"scenario_id": "fire-apartment-l2",
|
||||
"scenario_ids": ["fire-apartment-l2", "t20-2-stroke"],
|
||||
"trainee": "Иванов",
|
||||
"mode": "training",
|
||||
"exercise": "dds",
|
||||
}
|
||||
)
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
|
|
@ -388,7 +953,8 @@ def test_instructor_end_grades_all_concurrently_issued_cards(client):
|
|||
assert state.ended
|
||||
assert len(state.score["card_results"]) == 2
|
||||
assert [item["scenario_id"] for item in state.score["card_results"]] == [
|
||||
"fire-apartment-l2", "t20-2-stroke",
|
||||
"fire-apartment-l2",
|
||||
"t20-2-stroke",
|
||||
]
|
||||
finally:
|
||||
control_ctx.__exit__(None, None, None)
|
||||
|
|
@ -410,8 +976,12 @@ def test_each_dds_card_uses_its_own_scenario_weights():
|
|||
first.score_weights = {"dds_primary": 7.0}
|
||||
second.score_weights = {"dds_primary": 2.0}
|
||||
state = SessionState(
|
||||
session_id=uuid4(), scenario_id=base.id, scenario_title=base.title,
|
||||
level=base.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||
session_id=uuid4(),
|
||||
scenario_id=base.id,
|
||||
scenario_title=base.title,
|
||||
level=base.level.value,
|
||||
mode=SessionMode.TRAINING,
|
||||
exercise=Exercise.DDS,
|
||||
dds_scenarios=[first, second],
|
||||
)
|
||||
prepare_card(state, first)
|
||||
|
|
@ -419,5 +989,11 @@ def test_each_dds_card_uses_its_own_scenario_weights():
|
|||
state.dds_card_index = 1
|
||||
prepare_card(state, second)
|
||||
second_record = score_current_dds(state)
|
||||
assert next(item.weight for item in first_record.metrics if item.key == "dds_primary") == 7.0
|
||||
assert next(item.weight for item in second_record.metrics if item.key == "dds_primary") == 2.0
|
||||
assert (
|
||||
next(item.weight for item in first_record.metrics if item.key == "dds_primary")
|
||||
== 7.0
|
||||
)
|
||||
assert (
|
||||
next(item.weight for item in second_record.metrics if item.key == "dds_primary")
|
||||
== 2.0
|
||||
)
|
||||
|
|
|
|||
|
|
@ -53,8 +53,9 @@ def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch)
|
|||
{"id": str(DEMO_TRAINEE_ID), "name": "Демо-курсант", "group": None,
|
||||
"service": "Служба 101"}
|
||||
]
|
||||
# БД-зависимые экраны получают быстрый и явный отказ, не ждут TCP timeout.
|
||||
assert client.get("/api/sessions").json()["detail"] == "database_disabled_demo"
|
||||
# Пустая volatile-история доступна в демо без PostgreSQL.
|
||||
assert client.get("/api/sessions").status_code == 200
|
||||
assert client.get("/api/sessions").json() == []
|
||||
|
||||
session_id = uuid4()
|
||||
with client.websocket_connect(f"/ws/control/{session_id}") as control:
|
||||
|
|
@ -65,6 +66,9 @@ def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch)
|
|||
})
|
||||
state = _wait_for(lambda: hub.get(session_id))
|
||||
assert state.trainee_id == DEMO_TRAINEE_ID
|
||||
listed = client.get("/api/sessions").json()
|
||||
assert len(listed) == 1 and listed[0]["session_id"] == str(session_id)
|
||||
assert listed[0]["scenario_id"] == "fire-apartment-l2"
|
||||
who = client.post("/api/auth/login", json={
|
||||
"login": "demo-trainee", "password": "demo"
|
||||
}).json()
|
||||
|
|
|
|||
397
backend/tests/test_directory.py
Normal file
397
backend/tests/test_directory.py
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from app.config import Settings
|
||||
from app.db.models import AuditLog, Trainee, User
|
||||
from app.directory import (
|
||||
DirectoryDenied,
|
||||
DirectoryUnavailable,
|
||||
_authenticate_sync,
|
||||
map_groups,
|
||||
)
|
||||
from app.domain.roles import Role
|
||||
|
||||
|
||||
def test_directory_role_and_service_mappings_are_explicit_and_unambiguous():
|
||||
roles = {"CN=LCT Trainees,DC=training,DC=lan": "trainee"}
|
||||
services = {"CN=DDS 01,DC=training,DC=lan": "01"}
|
||||
assert map_groups(
|
||||
["cn=dds 01,dc=training,dc=lan", "cn=lct trainees,dc=training,dc=lan"],
|
||||
roles,
|
||||
services,
|
||||
) == (Role.TRAINEE, "01")
|
||||
with pytest.raises(DirectoryDenied):
|
||||
map_groups([], roles, services)
|
||||
with pytest.raises(DirectoryDenied):
|
||||
map_groups(
|
||||
[
|
||||
"CN=LCT Trainees,DC=training,DC=lan",
|
||||
"CN=Other Trainees,DC=training,DC=lan",
|
||||
],
|
||||
{
|
||||
"CN=LCT Trainees,DC=training,DC=lan": "trainee",
|
||||
"CN=Other Trainees,DC=training,DC=lan": "instructor",
|
||||
},
|
||||
{},
|
||||
)
|
||||
with pytest.raises(DirectoryDenied):
|
||||
map_groups(
|
||||
[
|
||||
"CN=LCT Trainees,DC=training,DC=lan",
|
||||
"CN=DDS 01,DC=training,DC=lan",
|
||||
"CN=DDS 02,DC=training,DC=lan",
|
||||
],
|
||||
roles,
|
||||
{
|
||||
"CN=DDS 01,DC=training,DC=lan": "01",
|
||||
"CN=DDS 02,DC=training,DC=lan": "02",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_directory_role_mapping_rejects_unknown_privilege_names():
|
||||
with pytest.raises(DirectoryUnavailable, match="invalid application role"):
|
||||
map_groups(
|
||||
["CN=LCT Admins,DC=training,DC=lan"],
|
||||
{"CN=LCT Admins,DC=training,DC=lan": "superuser"},
|
||||
{},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url, expected_tls",
|
||||
[
|
||||
("ldaps://dc.training.lan:636", "ldaps"),
|
||||
("ldap://dc.training.lan:389", "starttls"),
|
||||
],
|
||||
)
|
||||
def test_directory_search_then_user_bind_uses_tls_and_escapes_login(
|
||||
monkeypatch, url, expected_tls
|
||||
):
|
||||
import ldap3
|
||||
|
||||
calls = []
|
||||
|
||||
class Attribute:
|
||||
def __init__(self, value=None, values=None):
|
||||
self.value = value
|
||||
self.values = values or []
|
||||
|
||||
entry = SimpleNamespace(
|
||||
entry_dn="CN=Training User,OU=People,DC=training,DC=lan",
|
||||
sAMAccountName=Attribute("Training.User"),
|
||||
displayName=Attribute("Учебный пользователь"),
|
||||
memberOf=Attribute(values=["CN=LCT Trainees,DC=training,DC=lan"]),
|
||||
objectGUID=Attribute(bytes(range(16))),
|
||||
entryUUID=Attribute(None),
|
||||
)
|
||||
|
||||
class FakeServer:
|
||||
def __init__(self, host, **kwargs):
|
||||
calls.append(("server", host, kwargs))
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, server, **kwargs):
|
||||
calls.append(("connection", kwargs))
|
||||
self.entries = [entry]
|
||||
self.bound = False
|
||||
self.result = {"result": 0}
|
||||
|
||||
def open(self):
|
||||
calls.append(("open",))
|
||||
return True
|
||||
|
||||
def start_tls(self):
|
||||
calls.append(("start_tls",))
|
||||
return True
|
||||
|
||||
def bind(self):
|
||||
calls.append(("service_bind",))
|
||||
self.bound = True
|
||||
return True
|
||||
|
||||
def search(self, **kwargs):
|
||||
calls.append(("search", kwargs))
|
||||
return True
|
||||
|
||||
def rebind(self, user, password):
|
||||
calls.append(("user_bind", user, password))
|
||||
self.bound = password == "correct-password"
|
||||
self.result = {"result": 0 if self.bound else 49}
|
||||
return self.bound
|
||||
|
||||
def unbind(self):
|
||||
calls.append(("unbind",))
|
||||
|
||||
monkeypatch.setattr(ldap3, "Server", FakeServer)
|
||||
monkeypatch.setattr(ldap3, "Connection", FakeConnection)
|
||||
monkeypatch.setattr(
|
||||
ldap3, "Tls", lambda **kwargs: calls.append(("tls", kwargs)) or object()
|
||||
)
|
||||
settings = Settings(
|
||||
ldap_enabled=True,
|
||||
ldap_url=url,
|
||||
ldap_base_dn="DC=training,DC=lan",
|
||||
ldap_bind_dn="CN=Reader,DC=training,DC=lan",
|
||||
ldap_bind_password="service-secret",
|
||||
ldap_role_groups={"CN=LCT Trainees,DC=training,DC=lan": "trainee"},
|
||||
)
|
||||
|
||||
result = _authenticate_sync("Training.*(User", "correct-password", settings)
|
||||
assert result.login == "training.user"
|
||||
assert result.role is Role.TRAINEE
|
||||
assert result.subject == "03020100-0504-0706-0809-0a0b0c0d0e0f"
|
||||
search_call = next(call for call in calls if call[0] == "search")
|
||||
assert r"Training.\2a\28User" in search_call[1]["search_filter"]
|
||||
user_bind = next(call for call in calls if call[0] == "user_bind")
|
||||
assert user_bind[1] == entry.entry_dn
|
||||
if expected_tls == "starttls":
|
||||
assert calls.index(("start_tls",)) < calls.index(("service_bind",))
|
||||
else:
|
||||
server_call = next(call for call in calls if call[0] == "server")
|
||||
assert server_call[2]["use_ssl"] is True
|
||||
assert not any(call[0] == "start_tls" for call in calls)
|
||||
|
||||
|
||||
def test_invalid_directory_password_is_denied(monkeypatch):
|
||||
import ldap3
|
||||
|
||||
class Attribute:
|
||||
def __init__(self, value=None, values=None):
|
||||
self.value = value
|
||||
self.values = values or []
|
||||
|
||||
entry = SimpleNamespace(
|
||||
entry_dn="CN=Training User,DC=training,DC=lan",
|
||||
sAMAccountName=Attribute("trainee"),
|
||||
displayName=Attribute("Trainee"),
|
||||
memberOf=Attribute(values=["CN=LCT Trainees,DC=training,DC=lan"]),
|
||||
objectGUID=Attribute("stable-guid"),
|
||||
entryUUID=Attribute(None),
|
||||
)
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.entries = [entry]
|
||||
self.bound = False
|
||||
self.result = {"result": 0}
|
||||
|
||||
def open(self):
|
||||
return True
|
||||
|
||||
def bind(self):
|
||||
self.bound = True
|
||||
return True
|
||||
|
||||
def search(self, **kwargs):
|
||||
return True
|
||||
|
||||
def rebind(self, user, password):
|
||||
self.bound = False
|
||||
self.result = {"result": 49}
|
||||
return False
|
||||
|
||||
def unbind(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(ldap3, "Server", lambda *args, **kwargs: object())
|
||||
monkeypatch.setattr(ldap3, "Connection", FakeConnection)
|
||||
monkeypatch.setattr(ldap3, "Tls", lambda **kwargs: object())
|
||||
settings = Settings(
|
||||
ldap_enabled=True,
|
||||
ldap_url="ldaps://dc.training.lan",
|
||||
ldap_base_dn="DC=training,DC=lan",
|
||||
ldap_bind_dn="CN=Reader,DC=training,DC=lan",
|
||||
ldap_bind_password="service-secret",
|
||||
ldap_role_groups={"CN=LCT Trainees,DC=training,DC=lan": "trainee"},
|
||||
)
|
||||
with pytest.raises(DirectoryDenied, match="invalid directory credentials"):
|
||||
_authenticate_sync("trainee", "wrong-password", settings)
|
||||
|
||||
|
||||
def test_directory_account_without_stable_identifier_is_rejected(monkeypatch):
|
||||
import ldap3
|
||||
|
||||
class Attribute:
|
||||
def __init__(self, value=None, values=None):
|
||||
self.value = value
|
||||
self.values = values or []
|
||||
|
||||
entry = SimpleNamespace(
|
||||
entry_dn="CN=Training User,DC=training,DC=lan",
|
||||
sAMAccountName=Attribute("trainee"),
|
||||
displayName=Attribute("Trainee"),
|
||||
memberOf=Attribute(values=["CN=LCT Trainees,DC=training,DC=lan"]),
|
||||
objectGUID=Attribute(None),
|
||||
entryUUID=Attribute(None),
|
||||
)
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.entries = [entry]
|
||||
|
||||
def open(self):
|
||||
return True
|
||||
|
||||
def bind(self):
|
||||
return True
|
||||
|
||||
def search(self, **kwargs):
|
||||
return True
|
||||
|
||||
def unbind(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(ldap3, "Server", lambda *args, **kwargs: object())
|
||||
monkeypatch.setattr(ldap3, "Connection", FakeConnection)
|
||||
monkeypatch.setattr(ldap3, "Tls", lambda **kwargs: object())
|
||||
settings = Settings(
|
||||
ldap_enabled=True,
|
||||
ldap_url="ldaps://dc.training.lan",
|
||||
ldap_base_dn="DC=training,DC=lan",
|
||||
ldap_bind_dn="CN=Reader,DC=training,DC=lan",
|
||||
ldap_bind_password="service-secret",
|
||||
ldap_role_groups={"CN=LCT Trainees,DC=training,DC=lan": "trainee"},
|
||||
)
|
||||
|
||||
with pytest.raises(DirectoryUnavailable, match="objectGUID or entryUUID"):
|
||||
_authenticate_sync("trainee", "correct-password", settings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_directory_account_is_jit_provisioned_and_role_sync_revokes_sessions(
|
||||
monkeypatch,
|
||||
):
|
||||
from app.api import auth
|
||||
|
||||
class FakeDb:
|
||||
user = None
|
||||
trainee = None
|
||||
audits = []
|
||||
|
||||
async def scalar(self, _query):
|
||||
return self.user
|
||||
|
||||
def add(self, row):
|
||||
if isinstance(row, Trainee):
|
||||
row.id = UUID("00000000-0000-4000-8000-000000000321")
|
||||
self.trainee = row
|
||||
elif isinstance(row, User):
|
||||
self.user = row
|
||||
elif isinstance(row, AuditLog):
|
||||
self.audits.append(row)
|
||||
|
||||
async def get(self, model, _key):
|
||||
return self.trainee if model is Trainee else None
|
||||
|
||||
async def flush(self):
|
||||
pass
|
||||
|
||||
async def commit(self):
|
||||
pass
|
||||
|
||||
async def rollback(self):
|
||||
pass
|
||||
|
||||
async def refresh(self, _row):
|
||||
pass
|
||||
|
||||
class Context:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.db
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
fake_db = FakeDb()
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: Context(fake_db))
|
||||
identity = SimpleNamespace(
|
||||
login="trainee.one",
|
||||
full_name="Курсант Один",
|
||||
role=Role.TRAINEE,
|
||||
service="01",
|
||||
subject="stable-object-guid",
|
||||
)
|
||||
|
||||
user = await auth._directory_account(identity)
|
||||
assert user.auth_provider == "ldap"
|
||||
assert user.directory_subject == "stable-object-guid"
|
||||
assert user.role == "trainee"
|
||||
assert user.service == "01"
|
||||
assert user.trainee_id == UUID("00000000-0000-4000-8000-000000000321")
|
||||
assert user.password_hash != "correct-password"
|
||||
assert [row.action for row in fake_db.audits] == ["user.provision.ldap"]
|
||||
|
||||
auth._generations[user.login] = user.auth_version
|
||||
identity = SimpleNamespace(
|
||||
**{**vars(identity), "full_name": "Курсант Одинов", "service": "02"}
|
||||
)
|
||||
updated = await auth._directory_account(identity)
|
||||
assert updated.auth_version == 1
|
||||
assert updated.full_name == "Курсант Одинов"
|
||||
assert updated.service == "02"
|
||||
assert [row.action for row in fake_db.audits] == [
|
||||
"user.provision.ldap", "user.sync.ldap",
|
||||
]
|
||||
assert auth._generations[user.login] == 0 # persistent value is loaded at login
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocked_directory_account_is_returned_without_directory_sync(monkeypatch):
|
||||
from app.api import auth
|
||||
|
||||
user = User(
|
||||
id=UUID("00000000-0000-4000-8000-000000000987"),
|
||||
login="trainee.one",
|
||||
full_name="Старое имя",
|
||||
role="trainee",
|
||||
service="01",
|
||||
trainee_id=None,
|
||||
blocked=True,
|
||||
password_hash="unused",
|
||||
auth_provider="ldap",
|
||||
directory_subject="stable-object-guid",
|
||||
auth_version=4,
|
||||
)
|
||||
|
||||
class FakeDb:
|
||||
commits = 0
|
||||
|
||||
async def scalar(self, _query):
|
||||
return user
|
||||
|
||||
async def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
class Context:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.db
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
fake_db = FakeDb()
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: Context(fake_db))
|
||||
identity = SimpleNamespace(
|
||||
login="trainee.one",
|
||||
full_name="Новое имя из каталога",
|
||||
role=Role.ADMIN,
|
||||
service=None,
|
||||
subject="stable-object-guid",
|
||||
)
|
||||
|
||||
returned = await auth._directory_account(identity)
|
||||
assert returned is user
|
||||
assert user.full_name == "Старое имя"
|
||||
assert user.role == "trainee"
|
||||
assert user.auth_version == 4
|
||||
assert fake_db.commits == 0
|
||||
282
backend/tests/test_dispatcher_scoring.py
Normal file
282
backend/tests/test_dispatcher_scoring.py
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain.statuses import ServiceStatus, StatusEntry
|
||||
from app.domain.taxonomy import ErrorCode
|
||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||
|
||||
|
||||
def test_late_primary_status_has_d1_finding_even_when_status_exists():
|
||||
findings = evaluate_dispatcher(
|
||||
entries=[StatusEntry(
|
||||
service="Служба 101",
|
||||
status=ServiceStatus.ACCEPTED,
|
||||
at=datetime.now(timezone.utc),
|
||||
)],
|
||||
services=["Служба 101"],
|
||||
deadline_ms=30_000,
|
||||
elapsed_ms=31_000,
|
||||
)
|
||||
|
||||
d1 = next(finding for finding in findings if finding.code is ErrorCode.D1)
|
||||
assert "31 с" in d1.fact
|
||||
|
||||
|
||||
def test_primary_status_within_deadline_does_not_have_d1_finding():
|
||||
findings = evaluate_dispatcher(
|
||||
entries=[StatusEntry(
|
||||
service="Служба 101",
|
||||
status=ServiceStatus.ACCEPTED,
|
||||
at=datetime.now(timezone.utc),
|
||||
)],
|
||||
services=["Служба 101"],
|
||||
deadline_ms=30_000,
|
||||
elapsed_ms=30_000,
|
||||
)
|
||||
|
||||
assert ErrorCode.D1 not in [finding.code for finding in findings]
|
||||
|
||||
|
||||
def test_d5_flags_comment_without_recipient_but_not_a_named_crew():
|
||||
at = datetime.now(timezone.utc)
|
||||
base = StatusEntry(
|
||||
service="Служба 101",
|
||||
status=ServiceStatus.ACCEPTED,
|
||||
at=at,
|
||||
comment="Основание: не обслуживаем.\nСведения: информация передана.",
|
||||
)
|
||||
findings = evaluate_dispatcher(
|
||||
entries=[base],
|
||||
services=["Служба 101"],
|
||||
deadline_ms=30_000,
|
||||
elapsed_ms=1_000,
|
||||
)
|
||||
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
|
||||
assert "получателя" in d5.summary
|
||||
assert "передана" in d5.fact
|
||||
|
||||
complete = evaluate_dispatcher(
|
||||
entries=[base.model_copy(update={"comment": "Основание: принято.\nСведения: карточка передана бригаде 12."})],
|
||||
services=["Служба 101"],
|
||||
deadline_ms=30_000,
|
||||
elapsed_ms=1_000,
|
||||
)
|
||||
assert ErrorCode.D5 not in [finding.code for finding in complete]
|
||||
|
||||
|
||||
def test_recipient_in_one_status_comment_does_not_mask_another_status_comment():
|
||||
at = datetime.now(timezone.utc)
|
||||
entries = [
|
||||
StatusEntry(
|
||||
service="Служба 101", status=ServiceStatus.ACCEPTED,
|
||||
at=at, comment="Основание: карточка принята.\nСведения: переданы бригаде 12.",
|
||||
),
|
||||
StatusEntry(
|
||||
service="Служба 101", status=ServiceStatus.RESPONDING,
|
||||
at=at, comment="Основание: доклад.\nСведения: подтверждено начало движения.",
|
||||
),
|
||||
]
|
||||
|
||||
findings = evaluate_dispatcher(
|
||||
entries=entries,
|
||||
services=["Служба 101"],
|
||||
crew_assignments={"Служба 101": "Бригада 12"},
|
||||
deadline_ms=30_000,
|
||||
elapsed_ms=1_000,
|
||||
)
|
||||
|
||||
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
|
||||
assert "Начало реагирования" in d5.fact
|
||||
assert "Бригаде 12 переданы сведения" not in d5.fact
|
||||
|
||||
|
||||
def test_missing_manual_status_comment_has_a_d5_finding():
|
||||
findings = evaluate_dispatcher(
|
||||
entries=[StatusEntry(
|
||||
service="Служба 101",
|
||||
status=ServiceStatus.ACCEPTED,
|
||||
at=datetime.now(timezone.utc),
|
||||
)],
|
||||
services=["Служба 101"],
|
||||
deadline_ms=30_000,
|
||||
elapsed_ms=1_000,
|
||||
)
|
||||
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
|
||||
assert "не заполнены комментарии" in d5.fact
|
||||
|
||||
|
||||
def test_each_manual_status_comment_is_part_of_the_numeric_reply_metric():
|
||||
at = datetime.now(timezone.utc)
|
||||
state = SimpleNamespace(
|
||||
managed_services=lambda: ["Служба 101"],
|
||||
status_log=[
|
||||
StatusEntry(
|
||||
service="Служба 101", status=ServiceStatus.ACCEPTED,
|
||||
at=at, comment="Основание: карточка принята.\nСведения: принято в работу.",
|
||||
),
|
||||
StatusEntry(
|
||||
service="Служба 101", status=ServiceStatus.RESPONDING,
|
||||
at=at, comment="",
|
||||
),
|
||||
],
|
||||
crew_assignments={"Служба 101": "Бригада 1"},
|
||||
dispatched_at=at,
|
||||
)
|
||||
|
||||
reply = next(
|
||||
metric for metric in dispatcher_metrics(state, 30_000)
|
||||
if metric.key == "dds_reply"
|
||||
)
|
||||
|
||||
assert not reply.passed
|
||||
assert "к каждой ручной отметке" in reply.norm
|
||||
|
||||
findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=["Служба 101"],
|
||||
crew_assignments=state.crew_assignments,
|
||||
deadline_ms=30_000,
|
||||
elapsed_ms=0,
|
||||
)
|
||||
d5 = next(finding for finding in findings if finding.code is ErrorCode.D5)
|
||||
assert "Начало реагирования" in d5.fact
|
||||
|
||||
|
||||
def test_d5_does_not_treat_address_house_number_as_recipient():
|
||||
entry = StatusEntry(
|
||||
service="Служба 101",
|
||||
status=ServiceStatus.ACCEPTED,
|
||||
at=datetime.now(timezone.utc),
|
||||
comment="Основание: доклад.\nСведения: в доме 101 проведён осмотр.",
|
||||
)
|
||||
findings = evaluate_dispatcher(
|
||||
entries=[entry],
|
||||
services=["Служба 101"],
|
||||
deadline_ms=30_000,
|
||||
elapsed_ms=1_000,
|
||||
)
|
||||
assert ErrorCode.D5 in [finding.code for finding in findings]
|
||||
|
||||
|
||||
def test_scenario_may_define_a_valid_decline_for_duplicate_or_territory():
|
||||
at = datetime.now(timezone.utc)
|
||||
entries = [StatusEntry(
|
||||
service="Служба 101", status=ServiceStatus.DECLINED, at=at,
|
||||
comment="Основание: дубль.\nСведения: передано в дежурную часть.",
|
||||
)]
|
||||
findings = evaluate_dispatcher(
|
||||
entries=entries, services=["Служба 101"], deadline_ms=30_000,
|
||||
elapsed_ms=1_000, expected_decision="decline",
|
||||
expected_decision_reason="дублирующая карточка",
|
||||
)
|
||||
assert ErrorCode.D3 not in [finding.code for finding in findings]
|
||||
state = SimpleNamespace(
|
||||
managed_services=lambda: ["Служба 101"], status_log=entries,
|
||||
crew_assignments={}, dispatched_at=at,
|
||||
)
|
||||
metrics = dispatcher_metrics(
|
||||
state, 30_000, expected_decision="decline",
|
||||
expected_decision_reason="дублирующая карточка",
|
||||
)
|
||||
decision = next(metric for metric in metrics if metric.key == "dds_decision")
|
||||
assert decision.passed
|
||||
assert "дублирующая карточка" in decision.norm
|
||||
|
||||
|
||||
def test_nonempty_unstructured_comment_fails_reply_metric_but_structured_passes():
|
||||
at = datetime.now(timezone.utc)
|
||||
state = SimpleNamespace(
|
||||
managed_services=lambda: ["Служба 101"],
|
||||
status_log=[StatusEntry(
|
||||
service="Служба 101", status=ServiceStatus.ACCEPTED,
|
||||
at=at, comment="бригада на связи",
|
||||
)],
|
||||
crew_assignments={"Служба 101": "Бригада 12"},
|
||||
dispatched_at=at,
|
||||
)
|
||||
reply = next(m for m in dispatcher_metrics(state, 30_000) if m.key == "dds_reply")
|
||||
assert not reply.passed
|
||||
findings = evaluate_dispatcher(
|
||||
entries=state.status_log, services=["Служба 101"],
|
||||
crew_assignments=state.crew_assignments,
|
||||
deadline_ms=30_000, elapsed_ms=0,
|
||||
)
|
||||
assert any("не разделяют основание и сведения" in f.summary for f in findings)
|
||||
|
||||
state.status_log[0] = state.status_log[0].model_copy(update={
|
||||
"comment": "Основание: доклад старшего.\nСведения: бригада на связи.",
|
||||
})
|
||||
reply = next(m for m in dispatcher_metrics(state, 30_000) if m.key == "dds_reply")
|
||||
assert reply.passed
|
||||
|
||||
def test_accepting_card_with_scenario_expected_decline_is_explained():
|
||||
findings = evaluate_dispatcher(
|
||||
entries=[StatusEntry(
|
||||
service="Служба 101", status=ServiceStatus.ACCEPTED,
|
||||
at=datetime.now(timezone.utc),
|
||||
)],
|
||||
services=["Служба 101"], deadline_ms=30_000, elapsed_ms=1_000,
|
||||
expected_decision="decline", expected_decision_reason="не наша территория",
|
||||
)
|
||||
decision_finding = next(
|
||||
finding for finding in findings
|
||||
if finding.code is ErrorCode.D2 and "вопреки эталону" in finding.summary
|
||||
)
|
||||
assert decision_finding.norm == "не наша территория"
|
||||
|
||||
|
||||
def test_scenario_decline_expectation_requires_an_explicit_reason():
|
||||
from app.scenarios.schema import DdsDecision
|
||||
|
||||
with pytest.raises(ValidationError, match="reason обязателен"):
|
||||
DdsDecision(expected="decline")
|
||||
assert DdsDecision(expected="decline", reason="дубль").expected == "decline"
|
||||
|
||||
|
||||
def test_incomplete_work_path_has_d6_for_failed_progress_metrics():
|
||||
at = datetime.now(timezone.utc)
|
||||
entries = [
|
||||
StatusEntry(service="Служба 101", status=ServiceStatus.ACCEPTED, at=at),
|
||||
StatusEntry(service="Служба 101", status=ServiceStatus.RESPONDING, at=at),
|
||||
]
|
||||
state = SimpleNamespace(
|
||||
managed_services=lambda: ["Служба 101"], status_log=entries,
|
||||
crew_assignments={"Служба 101": "Бригада 1"}, dispatched_at=at,
|
||||
)
|
||||
metrics = dispatcher_metrics(state, 30_000)
|
||||
assert not next(item for item in metrics if item.key == "dds_progress").passed
|
||||
assert not next(item for item in metrics if item.key == "dds_completion").passed
|
||||
findings = evaluate_dispatcher(
|
||||
entries=entries, services=["Служба 101"],
|
||||
crew_assignments={"Служба 101": "Бригада 1"},
|
||||
deadline_ms=30_000, elapsed_ms=0,
|
||||
)
|
||||
d6 = next(finding for finding in findings if finding.code is ErrorCode.D6)
|
||||
assert "Прибытие" in d6.fact and "Проведение работ" in d6.fact
|
||||
|
||||
|
||||
def test_reasoned_refusal_after_acceptance_is_a_valid_terminal_path():
|
||||
at = datetime.now(timezone.utc)
|
||||
entries = [
|
||||
StatusEntry(service="Служба 101", status=ServiceStatus.ACCEPTED, at=at),
|
||||
StatusEntry(
|
||||
service="Служба 101", status=ServiceStatus.REFUSED, at=at,
|
||||
comment="Бригаде переданы сведения, выезд не выполнялся по причине угрозы.",
|
||||
),
|
||||
]
|
||||
state = SimpleNamespace(
|
||||
managed_services=lambda: ["Служба 101"], status_log=entries,
|
||||
crew_assignments={"Служба 101": "Бригада 1"}, dispatched_at=at,
|
||||
)
|
||||
metrics = dispatcher_metrics(state, 30_000)
|
||||
assert next(item for item in metrics if item.key == "dds_progress").passed
|
||||
assert next(item for item in metrics if item.key == "dds_completion").passed
|
||||
findings = evaluate_dispatcher(
|
||||
entries=entries, services=["Служба 101"],
|
||||
crew_assignments={"Служба 101": "Бригада 1"},
|
||||
deadline_ms=30_000, elapsed_ms=0,
|
||||
)
|
||||
assert ErrorCode.D6 not in [finding.code for finding in findings]
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"""Тесты контракта. Домен — общий шов, ломать его молча нельзя."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
|
@ -27,6 +27,16 @@ def test_interview_normative_is_75_seconds():
|
|||
assert NORMATIVES[TimerCode.INTERVIEW].limit_ms == 75_000
|
||||
|
||||
|
||||
def test_card_fill_normative_defaults_to_three_minutes_and_is_configurable():
|
||||
from app.domain.events import LessonCriteria
|
||||
|
||||
assert NORMATIVES[TimerCode.CARD_FILL].limit_ms == 180_000
|
||||
assert LessonCriteria().card_fill_time_limit_seconds == 180
|
||||
assert LessonCriteria(card_fill_time_limit_seconds=240).card_fill_time_limit_seconds == 240
|
||||
with pytest.raises(ValidationError):
|
||||
LessonCriteria(card_fill_time_limit_seconds=20)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"elapsed_ms,expected",
|
||||
[(0, TimerState.OK), (59_000, TimerState.OK), (70_000, TimerState.WARN), (94_000, TimerState.VIOLATED)],
|
||||
|
|
@ -65,7 +75,7 @@ def test_server_events_round_trip_through_json():
|
|||
"type": "caller.utterance",
|
||||
"utterance_id": str(uuid4()),
|
||||
"text": "Алло! Помогите! Горим!",
|
||||
"at": datetime.now(timezone.utc).isoformat(),
|
||||
"at": datetime.now(UTC).isoformat(),
|
||||
"mood": "panic",
|
||||
}
|
||||
assert adapter.validate_python(payload).text.startswith("Алло")
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
производен и не выбирается руками, сценарий размечается признаками.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from app.domain import ekp
|
||||
from app.domain.kio import KIO, derive_incident
|
||||
from app.scenarios.loader import load_library
|
||||
|
|
@ -19,43 +19,240 @@ def test_reference_loads_whole_book():
|
|||
assert reference.version == "046.24"
|
||||
assert len(reference.incidents) == 1283
|
||||
assert len(reference.groups) == 23
|
||||
assert all(incident.type for incident in reference.incidents), "код без итогового типа"
|
||||
assert all(incident.type for incident in reference.incidents), (
|
||||
"код без итогового типа"
|
||||
)
|
||||
|
||||
|
||||
def test_all_customer_ticket_cards_are_complete_and_classified():
|
||||
cards = [scenario for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
||||
if scenario.ticket is not None]
|
||||
cards = [
|
||||
scenario
|
||||
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
||||
if scenario.ticket is not None
|
||||
]
|
||||
assert len(cards) == 96
|
||||
assert {scenario.ticket for scenario in cards} == set(range(1, 33))
|
||||
for ticket in range(1, 33):
|
||||
assert {scenario.position for scenario in cards if scenario.ticket == ticket} == {1, 2, 3}
|
||||
assert all(scenario.facts and scenario.signs and scenario.ground_truth.address
|
||||
and scenario.ground_truth.incident_code
|
||||
and ekp.incident(scenario.ground_truth.incident_code)
|
||||
for scenario in cards)
|
||||
unknown_victim_counts = {scenario.id for scenario in cards
|
||||
if scenario.ground_truth.victims is None}
|
||||
assert unknown_victim_counts == {
|
||||
"t02-2-megafon-consultation", "t02-3-car-in-water", "t03-2-loud-music",
|
||||
"t04-1-balcony-fire", "t04-3-open-door", "t12-3-men-on-bridge-rail",
|
||||
"t07-3-lost-elderly", "t08-1-mall-smoke", "t14-1-grass-fire-azs", "t17-1-fire-alarm",
|
||||
"t12-1-restaurant-smoke", "t30-3-gas-smell-house", "t31-3-gas-pipe-whistle",
|
||||
"t11-3-lost-in-forest", "t16-1-smoke-column",
|
||||
"t18-1-unknown-fire", "t24-1-parking-quarrel", "t25-1-drunk-at-stop",
|
||||
"t23-3-lost-child", "t27-1-suspicious-car", "t28-1-stranger-at-door", "t29-1-ticking-box",
|
||||
"t29-3-threat-to-blow-up", "t30-1-car-theft-yesterday",
|
||||
"t31-1-car-theft-witnessed", "t32-1-carjacking", "t32-3-street-lights",
|
||||
assert {
|
||||
scenario.position for scenario in cards if scenario.ticket == ticket
|
||||
} == {1, 2, 3}
|
||||
assert all(scenario.facts and scenario.ground_truth.address for scenario in cards)
|
||||
unclassified_ids = {
|
||||
"t02-3-car-in-water", # источник не уточняет, был ли человек в воде
|
||||
"t03-2-loud-music", # время, нужное для признака тишины, не дано
|
||||
"t05-3-worker-in-pit", # падение в котлован не означает обрушение/коммуникации
|
||||
"t22-3-suicide-sms", # намерение в СМС не подтверждает попытку/приготовление
|
||||
"t26-3-death-care-home", # точный код для смерти в центре не подтверждён
|
||||
"t32-1-carjacking", # срок угона для кода не дан
|
||||
"t32-3-street-lights", # время суток для признака не дано
|
||||
}
|
||||
assert {scenario.id for scenario in cards if not scenario.signs} == unclassified_ids
|
||||
assert all(
|
||||
scenario.ground_truth.incident_code is None and not scenario.ground_truth.notify
|
||||
for scenario in cards
|
||||
if scenario.id in unclassified_ids
|
||||
)
|
||||
assert all(
|
||||
scenario.signs
|
||||
and scenario.ground_truth.incident_code
|
||||
and ekp.incident(scenario.ground_truth.incident_code)
|
||||
for scenario in cards
|
||||
if scenario.id not in unclassified_ids
|
||||
)
|
||||
unknown_victim_counts = {
|
||||
scenario.id for scenario in cards if scenario.ground_truth.victims is None
|
||||
}
|
||||
assert unknown_victim_counts == {
|
||||
"t02-2-megafon-consultation",
|
||||
"t02-3-car-in-water",
|
||||
"t03-2-loud-music",
|
||||
"t04-1-balcony-fire",
|
||||
"t04-3-open-door",
|
||||
"t12-3-men-on-bridge-rail",
|
||||
"t07-3-lost-elderly",
|
||||
"t08-1-mall-smoke",
|
||||
"t14-1-grass-fire-azs",
|
||||
"t17-1-fire-alarm",
|
||||
"t12-1-restaurant-smoke",
|
||||
"t30-3-gas-smell-house",
|
||||
"t31-3-gas-pipe-whistle",
|
||||
"t11-3-lost-in-forest",
|
||||
"t16-1-smoke-column",
|
||||
"t18-1-unknown-fire",
|
||||
"t24-1-parking-quarrel",
|
||||
"t25-1-drunk-at-stop",
|
||||
"t23-3-lost-child",
|
||||
"t27-1-suspicious-car",
|
||||
"t28-1-stranger-at-door",
|
||||
"t29-1-ticking-box",
|
||||
"t29-3-threat-to-blow-up",
|
||||
"t30-1-car-theft-yesterday",
|
||||
"t31-1-car-theft-witnessed",
|
||||
"t32-1-carjacking",
|
||||
"t32-3-street-lights",
|
||||
}
|
||||
|
||||
|
||||
def test_ticket_two_preserves_moscow_and_does_not_invent_missing_victim_data():
|
||||
root = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
cards = {
|
||||
scenario.id: scenario
|
||||
for scenario in load_library(root)
|
||||
if scenario.id
|
||||
in {
|
||||
"t02-1-smoke-chute",
|
||||
"t02-2-megafon-consultation",
|
||||
"t02-3-car-in-water",
|
||||
}
|
||||
}
|
||||
assert len(cards) == 3
|
||||
for scenario in cards.values():
|
||||
assert scenario.ground_truth.address.startswith("Москва,")
|
||||
address_fact = next(fact for fact in scenario.facts if fact.id == "f_address")
|
||||
assert address_fact.value.startswith("Москва,")
|
||||
|
||||
consultation = cards["t02-2-megafon-consultation"]
|
||||
victim_fact = next(fact for fact in consultation.facts if fact.id == "f_victims")
|
||||
assert victim_fact.value == "в исходном билете сведения о пострадавших не указаны"
|
||||
assert consultation.ground_truth.victims is None
|
||||
|
||||
|
||||
def test_ticket_14_refined_address_retains_azs_and_approach_landmarks():
|
||||
scenario = next(
|
||||
scenario
|
||||
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
||||
if scenario.id == "t14-1-grass-fire-azs"
|
||||
)
|
||||
address = next(fact for fact in scenario.facts if fact.id == "f_address")
|
||||
for landmark in ("АЗС", "Роснефть", "не доезжая до Расторгуевского шоссе"):
|
||||
assert landmark in address.value
|
||||
assert landmark in address.refined
|
||||
assert landmark in scenario.ground_truth.address
|
||||
assert "25 км по столбам в сторону Москвы" in address.refined
|
||||
assert "владение 2" in scenario.ground_truth.address
|
||||
|
||||
|
||||
def test_ticket_05_02_does_not_invent_callers_age():
|
||||
scenario = next(
|
||||
scenario
|
||||
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
||||
if scenario.id == "t05-2-wrong-medicine"
|
||||
)
|
||||
who = next(fact for fact in scenario.facts if fact.id == "f_who")
|
||||
assert who.value == "Иванова Ирина Петровна, дата рождения 10.03.1975"
|
||||
assert "на вид" not in who.value
|
||||
|
||||
|
||||
def test_ticket_25_01_refined_address_keeps_stop_and_side_landmarks():
|
||||
scenario = next(
|
||||
scenario
|
||||
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
||||
if scenario.id == "t25-1-drunk-at-stop"
|
||||
)
|
||||
address = next(fact for fact in scenario.facts if fact.id == "f_address")
|
||||
assert "62" in address.refined
|
||||
assert "Штурвальная" in address.refined
|
||||
assert "на стороне улицы Фабрициуса, дом 18" in address.refined
|
||||
|
||||
|
||||
def test_ticket_address_code_is_not_guessed_to_be_an_intercom():
|
||||
root = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
expected_codes = {
|
||||
"t04-3-open-door": "45В",
|
||||
"t05-2-wrong-medicine": "142",
|
||||
"t17-1-fire-alarm": "2215",
|
||||
"t18-2-husband-wont-wake": "5В",
|
||||
"t22-1-flat-fight": "2В",
|
||||
"t23-1-drunk-husband": "80В",
|
||||
"t28-1-stranger-at-door": "100",
|
||||
"t29-3-threat-to-blow-up": "67",
|
||||
"t31-3-gas-pipe-whistle": "5В",
|
||||
}
|
||||
cards = {
|
||||
scenario.id: scenario
|
||||
for scenario in load_library(root)
|
||||
if scenario.id in expected_codes
|
||||
}
|
||||
assert cards.keys() == expected_codes.keys()
|
||||
for scenario_id, code in expected_codes.items():
|
||||
address = cards[scenario_id].ground_truth.address
|
||||
assert address.endswith(f"код {code}")
|
||||
assert "домофон" not in address.casefold()
|
||||
|
||||
|
||||
def test_ticket_09_02_retains_the_source_motorway_designation_in_caller_facts():
|
||||
scenario = next(
|
||||
scenario
|
||||
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
||||
if scenario.id == "t09-2-labour"
|
||||
)
|
||||
address_fact = next(fact for fact in scenario.facts if fact.id == "f_address")
|
||||
assert "Горьковского шоссе (М7)" in address_fact.value
|
||||
assert "Горьковского шоссе (М7)" in scenario.ground_truth.address
|
||||
|
||||
|
||||
def test_ticket_18_01_retains_both_source_motorway_designations_in_caller_facts():
|
||||
scenario = next(
|
||||
scenario
|
||||
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
||||
if scenario.id == "t18-1-unknown-fire"
|
||||
)
|
||||
address_fact = next(fact for fact in scenario.facts if fact.id == "f_address")
|
||||
assert "М3" in address_fact.value
|
||||
assert "М1" in address_fact.value
|
||||
assert "М3" in scenario.ground_truth.address
|
||||
assert "М1" in scenario.ground_truth.address
|
||||
|
||||
|
||||
def test_ticket_8_2_preserves_both_highway_designations_from_source():
|
||||
scenario = next(
|
||||
item
|
||||
for item in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
||||
if item.id == "t08-2-unconscious-roadside"
|
||||
)
|
||||
address_fact = next(fact for fact in scenario.facts if fact.id == "f_address")
|
||||
for designation in ("М3", "М1"):
|
||||
assert designation in address_fact.value
|
||||
assert designation in scenario.ground_truth.address
|
||||
|
||||
|
||||
def test_ticket_five_preserves_source_observation_without_inventing_age():
|
||||
cards = {
|
||||
item.id: item
|
||||
for item in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
||||
if item.id in {"t05-1-window-fire-people", "t05-2-wrong-medicine"}
|
||||
}
|
||||
assert len(cards) == 2
|
||||
fire_facts = {fact.id: fact.value for fact in cards["t05-1-window-fire-people"].facts}
|
||||
assert fire_facts["f_observer"] == "заявитель наблюдает за пожаром с улицы"
|
||||
medicine_facts = {fact.id: fact.value for fact in cards["t05-2-wrong-medicine"].facts}
|
||||
assert medicine_facts["f_who"] == "Иванова Ирина Петровна, дата рождения 10.03.1975"
|
||||
|
||||
|
||||
def test_ticket_seven_preserves_ambiguous_address_code_verbatim():
|
||||
scenario = next(
|
||||
item
|
||||
for item in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
||||
if item.id == "t07-3-lost-elderly"
|
||||
)
|
||||
assert scenario.ground_truth.address.endswith("код 5В")
|
||||
assert "домофон" not in scenario.ground_truth.address
|
||||
|
||||
|
||||
def test_unknown_ticket_victim_count_is_not_scored():
|
||||
from app.scoring.card import evaluate_card
|
||||
|
||||
cards = [scenario for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
||||
if scenario.ticket is not None and scenario.ground_truth.victims is None]
|
||||
cards = [
|
||||
scenario
|
||||
for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
|
||||
if scenario.ticket is not None and scenario.ground_truth.victims is None
|
||||
]
|
||||
assert len(cards) == 27
|
||||
assert all("victims_count" not in {metric.key for metric in evaluate_card(scenario, KIO()).metrics}
|
||||
for scenario in cards)
|
||||
assert all(
|
||||
"victims_count"
|
||||
not in {metric.key for metric in evaluate_card(scenario, KIO()).metrics}
|
||||
for scenario in cards
|
||||
)
|
||||
|
||||
|
||||
def test_signs_give_the_code_from_the_book():
|
||||
|
|
@ -89,7 +286,10 @@ def test_category_filters_all_three_incident_choices_and_card_derivation():
|
|||
assert fire.signs[2] in ekp.signs_at_level(3, fire.signs[:2], group=fire.group)
|
||||
card = derive_incident(KIO(incident_group=fire.group, signs=fire.signs))
|
||||
assert card.incident_code == fire.code
|
||||
assert derive_incident(KIO(incident_group=other_group, signs=fire.signs)).incident_code is None
|
||||
assert (
|
||||
derive_incident(KIO(incident_group=other_group, signs=fire.signs)).incident_code
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_service_rows_hidden_from_operator_are_not_offered():
|
||||
|
|
@ -156,9 +356,14 @@ def test_victims_bring_the_ambulance():
|
|||
в поле `victims_count` поднимают скорую (docs/spec/DATASET.md)."""
|
||||
from app.domain.kio import KIO, derive_incident
|
||||
|
||||
quiet = derive_incident(KIO(signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"]))
|
||||
quiet = derive_incident(
|
||||
KIO(signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"])
|
||||
)
|
||||
hurt = derive_incident(
|
||||
KIO(signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"], victims_count=5)
|
||||
KIO(
|
||||
signs=["Драка", "Улица общественное место", "Массовая (от 10 человек)"],
|
||||
victims_count=5,
|
||||
)
|
||||
)
|
||||
assert "СМП" not in quiet.notify
|
||||
assert "СМП" in hurt.notify
|
||||
|
|
@ -169,7 +374,10 @@ def test_gasified_object_brings_mosgaz():
|
|||
from app.domain.kio import KIO, FireDetails, derive_incident
|
||||
|
||||
card = derive_incident(
|
||||
KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True))
|
||||
KIO(
|
||||
signs=["жилой дом", "балкон", "открытое пламя"],
|
||||
fire=FireDetails(gasified=True),
|
||||
)
|
||||
)
|
||||
assert "МОСГАЗ" in card.notify
|
||||
|
||||
|
|
@ -178,7 +386,10 @@ def test_removed_modifier_recalculates_notify_without_stale_service():
|
|||
from app.domain.kio import KIO, FireDetails, apply_patch, derive_incident
|
||||
|
||||
card = derive_incident(
|
||||
KIO(signs=["жилой дом", "балкон", "открытое пламя"], fire=FireDetails(gasified=True))
|
||||
KIO(
|
||||
signs=["жилой дом", "балкон", "открытое пламя"],
|
||||
fire=FireDetails(gasified=True),
|
||||
)
|
||||
)
|
||||
assert "МОСГАЗ" in card.notify
|
||||
updated = apply_patch(card, {"fire.gasified": False})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Групповая сводка считает людей, а не число их повторных попыток."""
|
||||
|
||||
from uuid import uuid4
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -9,6 +10,7 @@ from starlette.requests import Request
|
|||
from app.api import auth
|
||||
from app.api.http import groups as group_api
|
||||
from app.api.auth import Principal
|
||||
from app.db.models import AuditLog
|
||||
from app.domain.roles import Role
|
||||
from app.scoring.group import ScoredAttempt, summarize
|
||||
|
||||
|
|
@ -47,25 +49,53 @@ def test_unknown_codes_do_not_break_group_summary():
|
|||
assert [item["code"] for item in result["errors"]] == ["E5"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_insight_fails_closed_if_audit_cannot_be_saved(monkeypatch):
|
||||
who = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
|
||||
monkeypatch.setattr(group_api, "require", lambda _request, *_roles: who)
|
||||
|
||||
async def fake_analytics(*_args, **_kwargs):
|
||||
return SimpleNamespace(scored_attempts=1, model_dump=lambda **_kwargs: {})
|
||||
|
||||
async def fake_insight(_data):
|
||||
return {"summary": "Повторить уточнение адреса", "priorities": []}
|
||||
|
||||
async def unavailable_audit(*_args, **_kwargs):
|
||||
raise HTTPException(status_code=503, detail="audit_unavailable")
|
||||
|
||||
monkeypatch.setattr(group_api, "_analytics", fake_analytics)
|
||||
monkeypatch.setattr(group_api, "generate_group_insight", fake_insight)
|
||||
monkeypatch.setattr(group_api, "audit_required", unavailable_audit)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await group_api.ai_insight(uuid4(), object(), object())
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
assert exc.value.detail == "audit_unavailable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_creation_requires_staff_and_returns_new_group(monkeypatch):
|
||||
class FakeDb:
|
||||
def add(self, group):
|
||||
group.id = uuid4()
|
||||
def __init__(self):
|
||||
self.added = []
|
||||
self.commits = 0
|
||||
|
||||
def add(self, row):
|
||||
self.added.append(row)
|
||||
|
||||
async def commit(self):
|
||||
pass
|
||||
|
||||
async def no_audit(*args):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(group_api, "audit", no_audit)
|
||||
self.commits += 1
|
||||
instructor = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
|
||||
request = Request({"type": "http", "session": {}})
|
||||
auth._issue_session(request, instructor)
|
||||
created = await group_api.create(group_api.GroupCreate(name=" Группа 1 "), request, FakeDb())
|
||||
db = FakeDb()
|
||||
created = await group_api.create(group_api.GroupCreate(name=" Группа 1 "), request, db)
|
||||
assert created.name == "Группа 1"
|
||||
assert created.id
|
||||
assert db.commits == 1
|
||||
audit_row = next(row for row in db.added if isinstance(row, AuditLog))
|
||||
assert audit_row.action == "group.create" and audit_row.object_id == str(created.id)
|
||||
|
||||
trainee = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE)
|
||||
forbidden = Request({"type": "http", "session": {}})
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
"""Живые проверки LLM: клиент, кэш и звонящий своими словами.
|
||||
"""Живые проверки локальной LLM: клиент, кэш и ответы звонящего.
|
||||
|
||||
Читают `backend/.env.test` — бесплатная модель через OpenRouter. Без этого файла
|
||||
или без сети тест пропускается: обычные тесты в сеть не ходят вовсе.
|
||||
|
||||
Запускать отдельно (`make test-llm`): бесплатная рассуждающая модель отвечает
|
||||
десятки секунд, и в общем прогоне ей не место.
|
||||
Читают `backend/.env.test` с `LLM_PROVIDER=local` и loopback URL. Запускайте
|
||||
после `make local-llm` отдельной командой `make test-llm-local`. Сеть не нужна;
|
||||
основной pytest намеренно исключает медленный инференс.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -15,37 +13,97 @@ import pytest
|
|||
ENV_TEST = Path(__file__).resolve().parents[1] / ".env.test"
|
||||
|
||||
|
||||
def _load_test_env() -> bool:
|
||||
if not ENV_TEST.exists():
|
||||
return False
|
||||
def _read_test_env() -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for line in ENV_TEST.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
os.environ[key.strip()] = value.strip()
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
return True
|
||||
values[key.strip()] = value.strip()
|
||||
return values
|
||||
|
||||
|
||||
HAS_TEST_ENV = ENV_TEST.is_file()
|
||||
pytestmark = [
|
||||
pytest.mark.llm,
|
||||
pytest.mark.skipif(not _load_test_env(), reason="нет backend/.env.test — живые проверки LLM пропущены"),
|
||||
pytest.mark.skipif(not HAS_TEST_ENV, reason="нет backend/.env.test — живые проверки LLM пропущены"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def local_llm_test_environment():
|
||||
"""Изолировать live-конфиг: collection обычных тестов не меняет env."""
|
||||
if not HAS_TEST_ENV:
|
||||
yield
|
||||
return
|
||||
|
||||
values = _read_test_env()
|
||||
original = {key: os.environ.get(key) for key in values}
|
||||
for key, value in values.items():
|
||||
os.environ[key] = value
|
||||
|
||||
from app.config import get_settings
|
||||
from app.dialog.llm import is_loopback_url
|
||||
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
settings = get_settings()
|
||||
if (
|
||||
settings.llm_provider != "local"
|
||||
or not is_loopback_url(settings.llm_base_url)
|
||||
or settings.llm_api_key
|
||||
):
|
||||
raise pytest.UsageError(
|
||||
"test-llm-local требует LLM_PROVIDER=local, loopback URL и пустой LLM_API_KEY"
|
||||
)
|
||||
yield
|
||||
finally:
|
||||
for key, value in original.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
from app.dialog.llm import LlmClient
|
||||
|
||||
# Бесплатная рассуждающая модель думает по минуте: в живой проверке
|
||||
# это допустимо, в занятии — нет, там таймаут 8 секунд и откат на заготовки.
|
||||
# Локальная модель может быть медленной на слабом CPU; в занятии таймаут
|
||||
# короче, и при отказе используются проверенные заготовки.
|
||||
llm = LlmClient(timeout=180)
|
||||
yield llm
|
||||
await llm.aclose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def database_client(postgres_access):
|
||||
"""Подключить проверку кэша к PostgreSQL, когда тестовый стенд её дал.
|
||||
|
||||
Живые inference smoke должны работать и без БД, но проверка устойчивого
|
||||
кэша имеет смысл только в отдельной DB-интеграционной цели.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.base import get_sessionmaker
|
||||
from app.db.models import LlmCache
|
||||
from app.dialog.llm import LlmClient
|
||||
|
||||
sessionmaker = get_sessionmaker()
|
||||
try:
|
||||
async with sessionmaker() as db:
|
||||
await db.scalar(select(LlmCache.context_hash).limit(1))
|
||||
except Exception as exc: # noqa: BLE001 — кэш необязателен для обычного inference smoke
|
||||
if os.environ.get("DATABASE_URL"):
|
||||
raise
|
||||
pytest.skip(f"таблица кэша LLM недоступна: {type(exc).__name__}")
|
||||
|
||||
db_client = LlmClient(sessionmaker=sessionmaker, timeout=180)
|
||||
yield db_client
|
||||
await db_client.aclose()
|
||||
|
||||
|
||||
async def test_provider_answers(client):
|
||||
from app.dialog.llm import LlmRequest, LlmUnavailable
|
||||
|
||||
|
|
@ -58,7 +116,7 @@ async def test_provider_answers(client):
|
|||
try:
|
||||
text = await client.complete(request, use_cache=False)
|
||||
except LlmUnavailable as exc:
|
||||
pytest.skip(f"провайдер недоступен: {exc}")
|
||||
pytest.fail(f"локальная модель недоступна: {exc}")
|
||||
assert text, "пустой ответ модели"
|
||||
|
||||
|
||||
|
|
@ -81,14 +139,14 @@ async def test_caller_speaks_only_revealed_facts(client):
|
|||
# Оператор спрашивает не об адресе — адрес прозвучать не должен.
|
||||
reply = await caller.reply(slots.hear("Что у вас случилось?"), persona, slots)
|
||||
except LlmUnavailable as exc:
|
||||
pytest.skip(f"провайдер недоступен: {exc}")
|
||||
pytest.fail(f"локальная модель недоступна: {exc}")
|
||||
|
||||
assert caller.fallbacks == 0, "ответила не модель, а заготовка"
|
||||
assert "Ленина" not in reply.text, f"звонящий выдал адрес без вопроса: «{reply.text}»"
|
||||
assert len(reply.text) < 300, "звонящий пишет объяснительную вместо крика"
|
||||
|
||||
|
||||
async def test_same_context_comes_from_cache(client):
|
||||
async def test_same_context_comes_from_cache(database_client):
|
||||
"""Кэш по хешу контекста: та же реплика на том же месте занятия звучит
|
||||
одинаково у каждой группы и не стоит второго запроса."""
|
||||
from app.dialog.llm import LlmRequest, LlmUnavailable
|
||||
|
|
@ -100,17 +158,15 @@ async def test_same_context_comes_from_cache(client):
|
|||
max_tokens=400,
|
||||
)
|
||||
try:
|
||||
first = await client.complete(request)
|
||||
first = await database_client.complete(request)
|
||||
except LlmUnavailable as exc:
|
||||
pytest.skip(f"провайдер недоступен: {exc}")
|
||||
pytest.fail(f"локальная модель недоступна: {exc}")
|
||||
|
||||
import time
|
||||
|
||||
started = time.monotonic()
|
||||
second = await client.complete(request)
|
||||
second = await database_client.complete(request)
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
if client._sessionmaker is None:
|
||||
pytest.skip("кэш выключен: база недоступна")
|
||||
assert second == first, "кэш вернул другой ответ"
|
||||
assert elapsed < 1.0, f"второй запрос занял {elapsed:.2f} с — кэш не сработал"
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ from app.dialog.persona import PersonaState
|
|||
from app.scoring.grammar import assess, basic_check
|
||||
from app.dialog.slots import SlotMachine
|
||||
from app.voice.models import WhisperRecognizer
|
||||
from scripts import local_llms
|
||||
from scripts import local_stt
|
||||
from tests.test_slots import SCENARIO, StemEmbedder
|
||||
from tests.test_refinement import SCENARIO as REFINED_SCENARIO
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -42,10 +44,11 @@ def test_offline_model_address_must_be_literal_loopback():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_llm_uses_loopback_without_api_key(monkeypatch):
|
||||
@pytest.mark.parametrize("api_key", ["", "leftover-cloud-key"])
|
||||
async def test_local_llm_never_sends_an_api_key(monkeypatch, api_key):
|
||||
monkeypatch.setenv("OFFLINE", "true")
|
||||
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||
monkeypatch.setenv("LLM_API_KEY", "")
|
||||
monkeypatch.setenv("LLM_API_KEY", api_key)
|
||||
requests = []
|
||||
|
||||
def answer(request):
|
||||
|
|
@ -114,6 +117,58 @@ async def test_malformed_local_answer_falls_back_instead_of_crashing(monkeypatch
|
|||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_error_does_not_expose_provider_body(monkeypatch):
|
||||
monkeypatch.setenv("OFFLINE", "true")
|
||||
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||
client = LlmClient(transport=httpx.MockTransport(
|
||||
lambda _: httpx.Response(500, text="private incident address: 17 Example Street")
|
||||
))
|
||||
try:
|
||||
with pytest.raises(LlmUnavailable) as raised:
|
||||
await client.complete(
|
||||
LlmRequest(messages=[{"role": "user", "content": "redacted prompt"}],
|
||||
model="Qwen3-1.7B"),
|
||||
use_cache=False,
|
||||
)
|
||||
assert "HTTP 500" in str(raised.value)
|
||||
assert "Example Street" not in str(raised.value)
|
||||
assert "redacted prompt" not in str(raised.value)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_cache_write_failure_does_not_log_prompt_or_response(caplog):
|
||||
class FakeDb:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
def add(self, _row):
|
||||
return None
|
||||
|
||||
async def commit(self):
|
||||
raise RuntimeError("sensitive prompt echoed by database driver")
|
||||
|
||||
request = LlmRequest(
|
||||
messages=[{"role": "user", "content": "private caller address"}],
|
||||
model="Qwen3-1.7B",
|
||||
)
|
||||
client = LlmClient(sessionmaker=FakeDb)
|
||||
try:
|
||||
await client._to_cache("hash", request, "private caller response")
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
assert "sensitive prompt" not in caplog.text
|
||||
assert "private caller address" not in caplog.text
|
||||
assert "private caller response" not in caplog.text
|
||||
assert "RuntimeError" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qwen_disabled_thinking_closing_marker_is_not_spoken(monkeypatch):
|
||||
monkeypatch.setenv("OFFLINE", "true")
|
||||
|
|
@ -243,6 +298,37 @@ async def test_rejected_qwen_turn_does_not_poison_next_turn():
|
|||
assert all("99" not in message["content"] for message in client.requests[1].messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_address_correction_discards_old_value_from_qwen_context():
|
||||
old_address = "улица Станционная, дом 28"
|
||||
new_address = "Королёв, улица Станционная, дом 28"
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.requests = []
|
||||
self.answers = iter([old_address, new_address])
|
||||
|
||||
async def complete(self, request):
|
||||
self.requests.append(request)
|
||||
return next(self.answers)
|
||||
|
||||
client = FakeClient()
|
||||
caller = LlmCaller(client, "Qwen3-1.7B")
|
||||
slots = SlotMachine(REFINED_SCENARIO, StemEmbedder(), floor=0.5)
|
||||
persona = PersonaState(REFINED_SCENARIO.persona)
|
||||
|
||||
first = await caller.reply(slots.hear("Назовите адрес"), persona, slots)
|
||||
assert first.source == "local_llm"
|
||||
refined_turn = slots.hear("Это точно Москва город?")
|
||||
second = await caller.reply(refined_turn, persona, slots)
|
||||
|
||||
assert refined_turn.refined == ["f_address"]
|
||||
assert second.source == "local_llm"
|
||||
assert second.text == new_address
|
||||
assert len(client.requests[1].messages) == 2 # system + current user turn; no stale dialogue history
|
||||
assert client.requests[1].messages[-1]["content"] == "Это точно Москва город?"
|
||||
|
||||
|
||||
def test_whisper_cpp_uses_loopback_wav_only():
|
||||
requests = []
|
||||
|
||||
|
|
@ -274,3 +360,44 @@ def test_whisper_cpp_command_is_local_and_uses_downloaded_weight(tmp_path, monke
|
|||
assert "127.0.0.1" in argv
|
||||
assert "18082" in argv
|
||||
assert "ggml-small-q5_1.bin" in " ".join(argv)
|
||||
|
||||
|
||||
def test_windows_llama_runner_uses_explicit_exe_and_ignores_bundled_macos(
|
||||
tmp_path, monkeypatch,
|
||||
):
|
||||
mac_binary = tmp_path / "models" / "bin" / "llama-b10934" / "llama-server"
|
||||
mac_binary.parent.mkdir(parents=True)
|
||||
mac_binary.write_bytes(b"Mach-O test fixture")
|
||||
windows_binary = tmp_path / "llama-server.exe"
|
||||
windows_binary.write_bytes(b"Windows test fixture")
|
||||
monkeypatch.setattr(local_llms, "ROOT", tmp_path)
|
||||
monkeypatch.setattr(local_llms.sys, "platform", "win32")
|
||||
monkeypatch.setattr(local_llms.shutil, "which", lambda _name: None)
|
||||
monkeypatch.setenv("LLAMA_SERVER_BIN", str(windows_binary))
|
||||
assert local_llms.binary_path() == str(windows_binary)
|
||||
|
||||
monkeypatch.delenv("LLAMA_SERVER_BIN")
|
||||
with pytest.raises(RuntimeError, match="llama-server"):
|
||||
local_llms.binary_path()
|
||||
|
||||
|
||||
def test_windows_whisper_runner_uses_explicit_exe_and_ignores_bundled_macos(
|
||||
tmp_path, monkeypatch,
|
||||
):
|
||||
mac_binary = (
|
||||
tmp_path / "models" / "bin" / "whisper.cpp-1.9.4" / "build" / "bin"
|
||||
/ "whisper-server"
|
||||
)
|
||||
mac_binary.parent.mkdir(parents=True)
|
||||
mac_binary.write_bytes(b"Mach-O test fixture")
|
||||
windows_binary = tmp_path / "whisper-server.exe"
|
||||
windows_binary.write_bytes(b"Windows test fixture")
|
||||
monkeypatch.setattr(local_stt, "ROOT", tmp_path)
|
||||
monkeypatch.setattr(local_stt.sys, "platform", "win32")
|
||||
monkeypatch.setattr(local_stt.shutil, "which", lambda _name: None)
|
||||
monkeypatch.setenv("WHISPER_SERVER_BIN", str(windows_binary))
|
||||
assert local_stt.binary_path() == str(windows_binary)
|
||||
|
||||
monkeypatch.delenv("WHISPER_SERVER_BIN")
|
||||
with pytest.raises(RuntimeError, match="whisper-server"):
|
||||
local_stt.binary_path()
|
||||
|
|
|
|||
|
|
@ -141,16 +141,24 @@ def test_unassigned_trainee_cannot_download_resource(client):
|
|||
|
||||
def test_archive_hides_material_from_trainee_but_keeps_record(client):
|
||||
_instructor(client)
|
||||
seeded = client.get("/api/materials").json()[0]
|
||||
archived = client.delete(f"/api/materials/{seeded['id']}")
|
||||
created = client.post("/api/materials", json={
|
||||
"title": "Архивируемая памятка",
|
||||
"kind": "text",
|
||||
"body": "Уникальный тестовый материал для проверки архивации.",
|
||||
})
|
||||
assert created.status_code == 201, created.text
|
||||
material_id = created.json()["id"]
|
||||
|
||||
archived = client.delete(f"/api/materials/{material_id}")
|
||||
assert archived.status_code == 200
|
||||
assert archived.json()["active"] is False
|
||||
assert client.get("/api/materials").json() == []
|
||||
assert all(item["id"] != material_id for item in client.get("/api/materials").json())
|
||||
archived_list = client.get("/api/materials?include_archived=true").json()
|
||||
assert len(archived_list) == 1 and archived_list[0]["active"] is False
|
||||
archived_item = next(item for item in archived_list if item["id"] == material_id)
|
||||
assert archived_item["active"] is False
|
||||
|
||||
_trainee(client)
|
||||
assert client.get("/api/materials").json() == []
|
||||
assert all(item["id"] != material_id for item in client.get("/api/materials").json())
|
||||
|
||||
|
||||
def test_trainee_starts_assigned_practice_in_self_mode(client):
|
||||
|
|
|
|||
55
backend/tests/test_production_security_config.py
Normal file
55
backend/tests/test_production_security_config.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import main
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def prod_settings(**overrides) -> Settings:
|
||||
values = {
|
||||
"app_env": "production",
|
||||
"database_url": "postgresql+asyncpg://lct:postgres-secret-with-more-than-32-characters@localhost:5432/lct",
|
||||
"session_secret": "a-unique-secret-that-is-at-least-32-characters-long",
|
||||
"secure_cookies": True,
|
||||
"dev_auth_bypass": False,
|
||||
"offline": True,
|
||||
"llm_provider": "local",
|
||||
**overrides,
|
||||
}
|
||||
return Settings(_env_file=None, **values)
|
||||
|
||||
|
||||
def test_production_accepts_unique_secret_https_cookie_and_password_auth():
|
||||
prod_settings().validate_deployment_security()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("overrides", "message"),
|
||||
[
|
||||
({"session_secret": "dev-secret-поменять-на-стенде"}, "SESSION_SECRET"),
|
||||
({"session_secret": "short"}, "SESSION_SECRET"),
|
||||
({"database_url": "postgresql+asyncpg://lct:short@localhost:5432/lct"}, "PostgreSQL password"),
|
||||
({"database_url": "postgresql+asyncpg://lct:has%40unsafe%40characters-over-32@localhost:5432/lct"}, "PostgreSQL password"),
|
||||
({"secure_cookies": False}, "SECURE_COOKIES"),
|
||||
({"dev_auth_bypass": True}, "DEV_AUTH_BYPASS"),
|
||||
({"demo_no_db": True}, "DEMO_NO_DB"),
|
||||
({"offline": False}, "OFFLINE"),
|
||||
({"llm_provider": "openai"}, "LLM_PROVIDER"),
|
||||
],
|
||||
)
|
||||
def test_production_rejects_insecure_authentication_defaults(overrides, message):
|
||||
with pytest.raises(ValueError, match=message):
|
||||
prod_settings(**overrides).validate_deployment_security()
|
||||
|
||||
|
||||
def test_development_keeps_local_http_and_dev_token_available():
|
||||
settings = Settings(_env_file=None, app_env="development")
|
||||
settings.validate_deployment_security()
|
||||
|
||||
|
||||
def test_production_app_startup_fails_before_serving_with_default_secret(monkeypatch):
|
||||
settings = prod_settings(session_secret="dev-secret-поменять-на-стенде")
|
||||
monkeypatch.setattr(main, "get_settings", lambda: settings)
|
||||
with pytest.raises(RuntimeError, match="SESSION_SECRET"):
|
||||
with TestClient(main.app):
|
||||
pass
|
||||
|
|
@ -4,12 +4,18 @@
|
|||
профиль читается по HTTP. Без Postgres пропускается.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from app.config import get_settings
|
||||
from app.api.auth import Principal
|
||||
from app.api.http import trainees as trainees_api
|
||||
from app.domain.roles import Role
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
|
||||
|
|
@ -25,7 +31,7 @@ def client():
|
|||
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 dev`")
|
||||
pytest.skip(f"Postgres недоступен ({exc}) — запусти `make test-db`")
|
||||
with TestClient(app) as test_client:
|
||||
# Сокеты закрыты ролями (lct-23): тесты входят так же,
|
||||
# как `make lesson`, — через dev-token за флагом.
|
||||
|
|
@ -86,6 +92,102 @@ def test_profile_shows_attempts_and_delta(client):
|
|||
if delta["facts_got"] is not None:
|
||||
assert delta["facts_got"] >= 0, "во второй попытке фактов добыто не меньше"
|
||||
|
||||
# Личный совет должен отражать последнюю оценённую попытку, а не копить
|
||||
# нарушения за всю историю и не раскрывать неизвестные коды таксономии.
|
||||
latest_scored = next(item for item in reversed(profile["attempts"]) if item["score"] is not None)
|
||||
latest_codes = latest_scored["codes"]
|
||||
from app.scoring.group import RECOMMENDATIONS
|
||||
|
||||
recommendations = profile["recommendations"]
|
||||
assert len(recommendations) <= 5
|
||||
expected_codes = {
|
||||
code for code, count in latest_codes.items()
|
||||
if code in RECOMMENDATIONS and isinstance(count, int) and count > 0
|
||||
}
|
||||
assert {item["code"] for item in recommendations} == expected_codes
|
||||
for item in recommendations:
|
||||
assert item["occurrences"] == latest_codes[item["code"]]
|
||||
assert item["title"] and item["recommendation"]
|
||||
|
||||
|
||||
def test_profile_of_unknown_trainee_is_404(client):
|
||||
assert client.get(f"/api/trainees/{uuid4()}/profile").status_code == 404
|
||||
|
||||
|
||||
def test_personal_recommendations_are_explainable_and_limited_to_known_codes():
|
||||
from app.api.http.trainees import _personal_recommendations
|
||||
|
||||
result = _personal_recommendations({"D6": 1, "E1": 3, "unknown": 99, "D2": 0})
|
||||
assert [item.code for item in result] == ["E1", "D6"]
|
||||
assert result[0].title == "Пропущенный факт"
|
||||
assert result[0].recommendation
|
||||
assert result[0].occurrences == 3
|
||||
|
||||
|
||||
def test_profile_read_is_audited_without_copying_profile_data(monkeypatch):
|
||||
trainee_id = uuid4()
|
||||
trainee = SimpleNamespace(id=trainee_id, name="Курсант Петров", group_id=None)
|
||||
who = Principal(
|
||||
login="trainee-login", full_name=trainee.name, role=Role.TRAINEE,
|
||||
trainee_id=trainee_id,
|
||||
)
|
||||
|
||||
class Rows:
|
||||
def __iter__(self):
|
||||
return iter(())
|
||||
|
||||
class Database:
|
||||
async def get(self, model, key):
|
||||
assert key == trainee_id
|
||||
return trainee
|
||||
|
||||
async def execute(self, _statement):
|
||||
return Rows()
|
||||
|
||||
events = []
|
||||
|
||||
async def capture(actor, role, action, object_id=None, detail=""):
|
||||
events.append((actor, role, action, object_id, detail))
|
||||
|
||||
monkeypatch.setattr(trainees_api, "require", lambda *_args, **_kwargs: who)
|
||||
monkeypatch.setattr(trainees_api, "audit_required", capture)
|
||||
result = asyncio.run(trainees_api.profile(trainee_id, object(), Database()))
|
||||
|
||||
assert result.trainee.name == "Курсант Петров"
|
||||
assert events == [("trainee-login", "trainee", "trainee.profile.read", str(trainee_id), "")]
|
||||
|
||||
|
||||
def test_certificate_export_is_audited_before_response(monkeypatch):
|
||||
trainee_id = uuid4()
|
||||
trainee = SimpleNamespace(id=trainee_id, name="Курсант Петров", group_id=None)
|
||||
who = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
|
||||
|
||||
class Result:
|
||||
def one(self):
|
||||
return 1, 90.0, datetime(2026, 9, 26, tzinfo=timezone.utc)
|
||||
|
||||
class Database:
|
||||
async def scalar(self, _statement):
|
||||
return uuid4()
|
||||
|
||||
async def get(self, model, key):
|
||||
assert key == trainee_id
|
||||
return trainee
|
||||
|
||||
async def execute(self, _statement):
|
||||
return Result()
|
||||
|
||||
events = []
|
||||
|
||||
async def capture(actor, role, action, object_id=None, detail=""):
|
||||
events.append((actor, role, action, object_id, detail))
|
||||
|
||||
monkeypatch.setattr(trainees_api, "require", lambda *_args, **_kwargs: who)
|
||||
monkeypatch.setattr(trainees_api, "audit_required", capture)
|
||||
monkeypatch.setattr(trainees_api, "certificate_pdf", lambda **_kwargs: b"%PDF-test")
|
||||
response = asyncio.run(trainees_api.certificate(trainee_id, object(), Database()))
|
||||
|
||||
assert response.body == b"%PDF-test"
|
||||
assert events == [
|
||||
("teacher", "instructor", "trainee.certificate.export.pdf", str(trainee_id), "")
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""WAV-запись вызова: формат, микширование и защищённая выдача."""
|
||||
|
||||
import os
|
||||
import wave
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
|
|
@ -25,6 +26,9 @@ def test_recorder_mixes_16_and_24_khz_into_atomic_wav(tmp_path):
|
|||
assert recorder.finalize() == path
|
||||
assert recorder.finalize() == path
|
||||
assert not path.with_suffix(".wav.tmp").exists()
|
||||
assert not path.with_suffix(".wav.journal").exists()
|
||||
if os.name == "posix": # Windows exposes a different permission model.
|
||||
assert os.stat(path).st_mode & 0o777 == 0o600
|
||||
with wave.open(str(path), "rb") as source:
|
||||
assert source.getnchannels() == 1
|
||||
assert source.getsampwidth() == 2
|
||||
|
|
@ -34,6 +38,34 @@ def test_recorder_mixes_16_and_24_khz_into_atomic_wav(tmp_path):
|
|||
assert samples.max() >= 2000
|
||||
|
||||
|
||||
def test_recorder_recovers_audio_journal_after_process_restart(tmp_path):
|
||||
path = tmp_path / "interrupted.wav"
|
||||
clock_value = [10.0]
|
||||
clock = lambda: clock_value[0]
|
||||
first_process = CallRecorder(path, clock=clock)
|
||||
first_process.add_pcm((1000).to_bytes(2, "little", signed=True) * 320, sample_rate=16_000)
|
||||
|
||||
journal = path.with_suffix(".wav.journal")
|
||||
# Simulate power loss halfway through a journal record. The next process
|
||||
# must keep all complete audio and discard only the torn tail.
|
||||
first_process._journal.close()
|
||||
with journal.open("ab") as partial:
|
||||
partial.write(b"\x40\x01\x00\x00\x00\x00\x00\x00\x40\x01\x00\x00\x02\x00")
|
||||
|
||||
clock_value[0] = 50.0 # monotonic origin changed across host restart
|
||||
recovered = CallRecorder(path, clock=clock)
|
||||
recovered.add_pcm((2000).to_bytes(2, "little", signed=True) * 320, sample_rate=16_000)
|
||||
recovered.finalize()
|
||||
|
||||
with wave.open(str(path), "rb") as source:
|
||||
samples = np.frombuffer(source.readframes(source.getnframes()), dtype="<i2")
|
||||
assert source.getframerate() == 16_000
|
||||
assert samples.size == 640
|
||||
assert np.all(samples[:320] == 1000)
|
||||
assert np.all(samples[320:] == 2000)
|
||||
assert not journal.exists()
|
||||
|
||||
|
||||
def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypatch):
|
||||
session_id = uuid4()
|
||||
path = tmp_path / f"{session_id}.wav"
|
||||
|
|
@ -46,11 +78,17 @@ def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypa
|
|||
async def fake_session(db, requested):
|
||||
assert requested == session_id
|
||||
return SimpleNamespace(
|
||||
trainee_id=uuid4(), owner_login="dev", ended_at=datetime.now(timezone.utc),
|
||||
trainee_id=uuid4(), owner_login="dev", ended_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(sessions.repo, "get_session", fake_session)
|
||||
monkeypatch.setattr(sessions, "recording_path", lambda requested: path)
|
||||
audit_events = []
|
||||
|
||||
async def record_access(actor, role, action, object_id=None, detail=""):
|
||||
audit_events.append((actor, role, action, object_id, detail))
|
||||
|
||||
monkeypatch.setattr(sessions, "audit_required", record_access)
|
||||
with TestClient(app) as client:
|
||||
assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 401
|
||||
client.post("/api/auth/dev-token")
|
||||
|
|
@ -58,6 +96,9 @@ def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypa
|
|||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "audio/wav"
|
||||
assert response.content.startswith(b"RIFF")
|
||||
assert audit_events == [
|
||||
("dev", "instructor", "recording.read", str(session_id), "")
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
sessions,
|
||||
|
|
@ -67,3 +108,29 @@ def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypa
|
|||
),
|
||||
)
|
||||
assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 403
|
||||
assert len(audit_events) == 1
|
||||
|
||||
|
||||
def test_recording_is_not_returned_when_access_audit_is_unavailable(tmp_path, monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
|
||||
session_id = uuid4()
|
||||
path = tmp_path / f"{session_id}.wav"
|
||||
path.write_bytes(b"not returned")
|
||||
|
||||
async def fake_session(db, requested):
|
||||
return SimpleNamespace(
|
||||
trainee_id=uuid4(), owner_login="dev", ended_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
async def audit_unavailable(*_args, **_kwargs):
|
||||
raise HTTPException(status_code=503, detail="audit_unavailable")
|
||||
|
||||
monkeypatch.setattr(sessions.repo, "get_session", fake_session)
|
||||
monkeypatch.setattr(sessions, "recording_path", lambda requested: path)
|
||||
monkeypatch.setattr(sessions, "audit_required", audit_unavailable)
|
||||
with TestClient(app) as client:
|
||||
client.post("/api/auth/dev-token")
|
||||
response = client.get(f"/api/sessions/{session_id}/recording.wav")
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"detail": "audit_unavailable"}
|
||||
|
|
|
|||
|
|
@ -3,16 +3,17 @@
|
|||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.auth import Principal
|
||||
from app.api.http import sessions
|
||||
from app.config import get_settings
|
||||
from app.domain.events import SessionReport
|
||||
from app.domain.roles import Role
|
||||
from app.main import app
|
||||
|
|
@ -20,7 +21,7 @@ from app.scoring.export import _cell, certificate_pdf, to_csv, to_pdf
|
|||
|
||||
|
||||
def sample_report(*, long: bool = False) -> SessionReport:
|
||||
at = datetime(2026, 9, 21, 12, 0, tzinfo=timezone.utc)
|
||||
at = datetime(2026, 9, 21, 12, 0, tzinfo=UTC)
|
||||
long_text = "Заявитель сообщает о дыме в учебном помещении. " * (240 if long else 1)
|
||||
return SessionReport.model_validate({
|
||||
"session_id": str(uuid4()),
|
||||
|
|
@ -114,18 +115,32 @@ def test_certificate_pdf_contains_saved_result(tmp_path):
|
|||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
report = sample_report()
|
||||
state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4(), owner_login="dev")
|
||||
owner_login = "demo-instructor" if get_settings().demo_no_db else "dev"
|
||||
state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4(), owner_login=owner_login)
|
||||
audit_events = []
|
||||
|
||||
async def capture_audit(actor, role, action, object_id=None, detail=""):
|
||||
audit_events.append((actor, role, action, object_id))
|
||||
|
||||
state.audit_events = audit_events
|
||||
monkeypatch.setattr(sessions, "_live", lambda session_id: (state, object()))
|
||||
monkeypatch.setattr(sessions, "build_report", lambda session_id, state, scenario: report)
|
||||
monkeypatch.setattr(sessions, "audit_required", capture_audit)
|
||||
with TestClient(app) as test_client:
|
||||
# These endpoint tests exercise the in-memory live-report path. Durable
|
||||
# report readiness is covered by the isolated PostgreSQL integration suite.
|
||||
monkeypatch.setattr(sessions.hub, "journal", None)
|
||||
test_client.post("/api/auth/dev-token")
|
||||
yield test_client, state, report
|
||||
|
||||
|
||||
def test_export_routes_return_downloads_with_json_report_rights(client):
|
||||
browser, state, report = client
|
||||
json_response = browser.get(f"/api/sessions/{report.session_id}/report")
|
||||
assert json_response.status_code == 200, json_response.text
|
||||
|
||||
csv_response = browser.get(f"/api/sessions/{report.session_id}/report.csv")
|
||||
assert csv_response.status_code == 200
|
||||
assert csv_response.status_code == 200, csv_response.text
|
||||
assert csv_response.headers["content-type"].startswith("text/csv")
|
||||
assert csv_response.content.startswith(b"\xef\xbb\xbf")
|
||||
assert "attachment" in csv_response.headers["content-disposition"]
|
||||
|
|
@ -134,6 +149,11 @@ def test_export_routes_return_downloads_with_json_report_rights(client):
|
|||
assert pdf_response.status_code == 200
|
||||
assert pdf_response.headers["content-type"] == "application/pdf"
|
||||
assert pdf_response.content.startswith(b"%PDF-")
|
||||
assert state.audit_events == [
|
||||
("dev", "instructor", "report.read", str(report.session_id)),
|
||||
("dev", "instructor", "report.export.csv", str(report.session_id)),
|
||||
("dev", "instructor", "report.export.pdf", str(report.session_id)),
|
||||
]
|
||||
|
||||
state.score = None
|
||||
assert browser.get(f"/api/sessions/{report.session_id}/report.csv").status_code == 409
|
||||
|
|
@ -141,13 +161,28 @@ def test_export_routes_return_downloads_with_json_report_rights(client):
|
|||
|
||||
|
||||
def test_trainee_cannot_export_another_persons_report(client, monkeypatch):
|
||||
browser, state, report = client
|
||||
browser, _state, report = client
|
||||
monkeypatch.setattr(
|
||||
sessions, "require",
|
||||
lambda request: Principal(login="trainee", full_name="Учебный", role=Role.TRAINEE, trainee_id=uuid4()),
|
||||
)
|
||||
for suffix in ("csv", "pdf"):
|
||||
assert browser.get(f"/api/sessions/{report.session_id}/report.{suffix}").status_code == 403
|
||||
assert not client[1].audit_events
|
||||
|
||||
|
||||
def test_report_export_fails_closed_when_access_audit_is_unavailable(client, monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
|
||||
browser, _state, report = client
|
||||
|
||||
async def unavailable(*_args, **_kwargs):
|
||||
raise HTTPException(status_code=503, detail="audit_unavailable")
|
||||
|
||||
monkeypatch.setattr(sessions, "audit_required", unavailable)
|
||||
response = browser.get(f"/api/sessions/{report.session_id}/report.pdf")
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"detail": "audit_unavailable"}
|
||||
|
||||
|
||||
def test_archived_report_survives_missing_live_session(monkeypatch):
|
||||
|
|
@ -220,6 +255,12 @@ def test_archived_score_override_updates_json_exports_and_audit(monkeypatch):
|
|||
sessions, "require",
|
||||
lambda request, *roles: Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR),
|
||||
)
|
||||
audit_events = []
|
||||
|
||||
async def capture_audit(actor, role, action, object_id=None, detail=""):
|
||||
audit_events.append((actor, role, action, object_id))
|
||||
|
||||
monkeypatch.setattr(sessions, "audit_required", capture_audit)
|
||||
|
||||
corrected = asyncio.run(sessions.override(
|
||||
archived.session_id,
|
||||
|
|
@ -237,7 +278,8 @@ def test_archived_score_override_updates_json_exports_and_audit(monkeypatch):
|
|||
assert corrected.override_comment == "проверена запись переговоров"
|
||||
audit = db.added[0]
|
||||
assert audit.action == "score.override" and audit.actor == "teacher"
|
||||
assert "84.5" in audit.detail and "проверена запись переговоров" in audit.detail
|
||||
assert "84.5" in audit.detail and "comment_chars=" in audit.detail
|
||||
assert "проверена запись переговоров" not in audit.detail
|
||||
|
||||
report = asyncio.run(sessions.report(archived.session_id, object(), db))
|
||||
assert report.score_final == 84.5 and report.score_auto == 70.0
|
||||
|
|
@ -246,3 +288,8 @@ def test_archived_score_override_updates_json_exports_and_audit(monkeypatch):
|
|||
assert "проверена запись переговоров" in csv_response.body.decode("utf-8-sig")
|
||||
pdf_response = asyncio.run(sessions.report_pdf(archived.session_id, object(), db))
|
||||
assert pdf_response.body.startswith(b"%PDF-")
|
||||
assert audit_events == [
|
||||
("teacher", "instructor", "report.read", str(archived.session_id)),
|
||||
("teacher", "instructor", "report.export.csv", str(archived.session_id)),
|
||||
("teacher", "instructor", "report.export.pdf", str(archived.session_id)),
|
||||
]
|
||||
|
|
|
|||
285
backend/tests/test_route_auth_contract.py
Normal file
285
backend/tests/test_route_auth_contract.py
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
"""Fail closed if a new API endpoint forgets its authentication gate.
|
||||
|
||||
This is a structural guard, not a substitute for the per-role and owner-scope
|
||||
HTTP/WebSocket integration tests. Public endpoints are kept in a small explicit
|
||||
allowlist so that adding a route cannot silently make it public.
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
API_ROOT = Path(__file__).parents[1] / "app" / "api"
|
||||
|
||||
# Public by design: credential entry/session bootstrap and the non-sensitive
|
||||
# incident classification dictionary. dev-token has its own fail-closed flag
|
||||
# and remains hidden in production.
|
||||
PUBLIC_HTTP_ROUTES = {
|
||||
("auth.py", "post", "/login"),
|
||||
("auth.py", "post", "/dev-token"),
|
||||
("http/ekp.py", "get", "/groups"),
|
||||
("http/ekp.py", "get", "/signs"),
|
||||
}
|
||||
|
||||
# Routes that centralize ownership + authentication checks in a shared helper.
|
||||
DELEGATED_HTTP_AUTH = {
|
||||
("http/sessions.py", "get", "/{session_id}/report"): "_report_data",
|
||||
("http/sessions.py", "get", "/{session_id}/report.csv"): "_report_data",
|
||||
("http/sessions.py", "get", "/{session_id}/report.pdf"): "_report_data",
|
||||
}
|
||||
|
||||
# Each tuple is the exact positional Role allowlist passed to a route's
|
||||
# require(request, ...). An empty tuple means any authenticated principal, with
|
||||
# resource ownership checked in the handler. The outer tuple preserves routes
|
||||
# that intentionally apply more than one gate (e.g. authentication then role).
|
||||
HTTP_ROLE_GATE_POLICY = {
|
||||
("http/admin.py", "get", "/config.xml"): (("ADMIN",),),
|
||||
("http/admin.py", "get", "/users"): (("ADMIN",),),
|
||||
("http/admin.py", "post", "/users"): (("ADMIN",),),
|
||||
("http/admin.py", "patch", "/users/{user_id}"): (("ADMIN",),),
|
||||
("http/admin.py", "get", "/audit"): (("ADMIN",),),
|
||||
("http/admin.py", "get", "/audit.csv"): (("ADMIN",),),
|
||||
("http/admin.py", "get", "/diagnostics"): (("ADMIN",),),
|
||||
("http/admin.py", "get", "/diagnostics.json"): (("ADMIN",),),
|
||||
("http/admin.py", "get", "/status"): (("ADMIN",),),
|
||||
("http/admin.py", "get", "/backups"): (("ADMIN",),),
|
||||
("http/admin.py", "post", "/backups"): (("ADMIN",),),
|
||||
("http/groups.py", "get", ""): (("ADMIN", "INSTRUCTOR"),),
|
||||
("http/groups.py", "post", ""): (("ADMIN", "INSTRUCTOR"),),
|
||||
("http/groups.py", "patch", "/{group_id}/owner"): (("ADMIN",),),
|
||||
("http/groups.py", "put", "/{group_id}/trainees/{trainee_id}"): (("ADMIN", "INSTRUCTOR"),),
|
||||
("http/groups.py", "get", "/{group_id}/analytics"): (("ADMIN", "INSTRUCTOR"),),
|
||||
("http/groups.py", "post", "/{group_id}/analytics/insight"): (("INSTRUCTOR",),),
|
||||
("http/materials.py", "get", ""): ((), ("ADMIN", "INSTRUCTOR")),
|
||||
("http/materials.py", "post", ""): (("INSTRUCTOR",),),
|
||||
("http/materials.py", "patch", "/{material_id}"): (("INSTRUCTOR",),),
|
||||
("http/materials.py", "delete", "/{material_id}"): (("INSTRUCTOR",),),
|
||||
("http/materials.py", "put", "/{material_id}/assign/{trainee_id}"): (("INSTRUCTOR",),),
|
||||
("http/materials.py", "put", "/{material_id}/assign-group/{group_id}"): (("INSTRUCTOR",),),
|
||||
("http/materials.py", "delete", "/{material_id}/assign/{trainee_id}"): (("INSTRUCTOR",),),
|
||||
("http/materials.py", "post", "/{material_id}/complete"): (("TRAINEE",),),
|
||||
("http/materials.py", "post", "/{material_id}/start"): (("TRAINEE",),),
|
||||
("http/materials.py", "get", "/{material_id}/download"): ((),),
|
||||
("http/scenario_submissions.py", "post", ""): (("TRAINEE",),),
|
||||
("http/scenario_submissions.py", "get", ""): (("ADMIN", "INSTRUCTOR", "TRAINEE"),),
|
||||
("http/scenario_submissions.py", "post", "/{submission_id}/review"): (("ADMIN", "INSTRUCTOR"),),
|
||||
("http/scenarios.py", "post", "/drafts/from-template"): (("INSTRUCTOR",),),
|
||||
("http/scenarios.py", "post", "/drafts/generate"): (("INSTRUCTOR",),),
|
||||
("http/scenarios.py", "post", "/drafts/generate-from-description"): (("INSTRUCTOR",),),
|
||||
("http/scenarios.py", "get", "/drafts/{scenario_id}"): (("INSTRUCTOR",),),
|
||||
("http/scenarios.py", "patch", "/drafts/{scenario_id}"): (("INSTRUCTOR",),),
|
||||
("http/scenarios.py", "post", "/drafts/{scenario_id}/revise"): (("INSTRUCTOR",),),
|
||||
("http/scenarios.py", "post", "/drafts/{scenario_id}/validate"): (("INSTRUCTOR",),),
|
||||
("http/scenarios.py", "post", "/drafts/{scenario_id}/grammar-check"): (("INSTRUCTOR",),),
|
||||
("http/scenarios.py", "post", "/drafts/{scenario_id}/approve"): (("INSTRUCTOR",),),
|
||||
("http/scenarios.py", "get", ""): (("ADMIN", "INSTRUCTOR", "TRAINEE"),),
|
||||
("http/scenarios.py", "delete", "/{scenario_id}"): (("INSTRUCTOR",),),
|
||||
("http/scenarios.py", "post", "/{scenario_id}/restore"): (("INSTRUCTOR",),),
|
||||
("http/scenarios.py", "get", "/{scenario_id}"): (("ADMIN", "INSTRUCTOR", "TRAINEE"),),
|
||||
("http/sessions.py", "get", "/dds-history"): (("INSTRUCTOR", "TRAINEE"),),
|
||||
("http/sessions.py", "get", "/active"): (("INSTRUCTOR",),),
|
||||
("http/sessions.py", "post", ""): (("INSTRUCTOR",),),
|
||||
("http/sessions.py", "get", "/{session_id}"): ((),),
|
||||
("http/sessions.py", "get", "/{session_id}/checklist"): ((),),
|
||||
("http/sessions.py", "get", "/{session_id}/recording.wav"): (("INSTRUCTOR", "TRAINEE"),),
|
||||
("http/sessions.py", "patch", "/{session_id}/report"): (("INSTRUCTOR",),),
|
||||
("http/sessions.py", "get", ""): ((),),
|
||||
("http/trainees.py", "get", "/{trainee_id}/certificate.pdf"): ((),),
|
||||
("http/trainees.py", "get", ""): (("ADMIN", "INSTRUCTOR"),),
|
||||
("http/trainees.py", "get", "/{trainee_id}/profile"): ((),),
|
||||
}
|
||||
|
||||
AUTH_SESSION_HTTP_ROUTES = {
|
||||
("auth.py", "post", "/logout"),
|
||||
("auth.py", "get", "/me"),
|
||||
}
|
||||
|
||||
WEBSOCKET_ROLE_POLICY = {
|
||||
("call.py", "/ws/call/{session_id}"): {"INSTRUCTOR", "TRAINEE"},
|
||||
("control.py", "/ws/control/{session_id}"): {"INSTRUCTOR"},
|
||||
("observe.py", "/ws/observe/{session_id}"): {"ADMIN", "INSTRUCTOR"},
|
||||
("station.py", "/ws/station/{session_id}"): {"INSTRUCTOR", "TRAINEE"},
|
||||
}
|
||||
|
||||
|
||||
def _route_declaration(node: ast.FunctionDef | ast.AsyncFunctionDef):
|
||||
for decorator in node.decorator_list:
|
||||
if not isinstance(decorator, ast.Call) or not isinstance(decorator.func, ast.Attribute):
|
||||
continue
|
||||
method = decorator.func.attr.lower()
|
||||
if method not in {"get", "post", "put", "patch", "delete", "websocket"}:
|
||||
continue
|
||||
path = decorator.args[0] if decorator.args else None
|
||||
if isinstance(path, ast.Constant) and isinstance(path.value, str):
|
||||
return method, path.value
|
||||
return None
|
||||
|
||||
|
||||
def _called_names(node: ast.AST) -> set[str]:
|
||||
return {
|
||||
call.func.id if isinstance(call.func, ast.Name) else call.func.attr
|
||||
for call in ast.walk(node)
|
||||
if isinstance(call, ast.Call)
|
||||
and (isinstance(call.func, ast.Name) or isinstance(call.func, ast.Attribute))
|
||||
}
|
||||
|
||||
|
||||
def _required_role_gates(node: ast.AST) -> tuple[tuple[str, ...], ...]:
|
||||
gates = []
|
||||
for call in ast.walk(node):
|
||||
if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Name) or call.func.id != "require":
|
||||
continue
|
||||
roles = tuple(sorted(
|
||||
argument.attr
|
||||
for argument in call.args[1:]
|
||||
if isinstance(argument, ast.Attribute)
|
||||
and isinstance(argument.value, ast.Name)
|
||||
and argument.value.id == "Role"
|
||||
))
|
||||
gates.append(roles)
|
||||
return tuple(sorted(gates))
|
||||
|
||||
|
||||
def test_every_http_route_has_an_authentication_gate_or_explicit_public_reason():
|
||||
discovered_public: set[tuple[str, str, str]] = set()
|
||||
missing: list[str] = []
|
||||
discovered_delegated: set[tuple[str, str, str]] = set()
|
||||
|
||||
sources = [*API_ROOT.glob("*.py"), *(API_ROOT / "http").glob("*.py")]
|
||||
for source in sources:
|
||||
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
|
||||
relative = source.relative_to(API_ROOT).as_posix()
|
||||
for node in tree.body:
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
route = _route_declaration(node)
|
||||
if route is None:
|
||||
continue
|
||||
method, path = route
|
||||
key = (relative, method, path)
|
||||
calls = _called_names(node)
|
||||
if key in PUBLIC_HTTP_ROUTES:
|
||||
discovered_public.add(key)
|
||||
continue
|
||||
delegated_helper = DELEGATED_HTTP_AUTH.get(key)
|
||||
if delegated_helper and delegated_helper in calls:
|
||||
discovered_delegated.add(key)
|
||||
elif not calls.intersection({"require", "current"}):
|
||||
missing.append(f"{relative}:{node.name} ({method.upper()} {path})")
|
||||
|
||||
assert discovered_public == PUBLIC_HTTP_ROUTES, (
|
||||
"Public endpoint allowlist drifted; review each newly removed/renamed route "
|
||||
f"and keep the allowlist exact. Missing: {PUBLIC_HTTP_ROUTES - discovered_public}; "
|
||||
f"unexpected: {discovered_public - PUBLIC_HTTP_ROUTES}"
|
||||
)
|
||||
assert discovered_delegated == set(DELEGATED_HTTP_AUTH), (
|
||||
"Delegated-auth routes drifted; re-check their shared guard: "
|
||||
f"missing {set(DELEGATED_HTTP_AUTH) - discovered_delegated}"
|
||||
)
|
||||
assert not missing, "HTTP routes without require/current authentication gate: " + "; ".join(missing)
|
||||
|
||||
for (relative, _, _), helper_name in DELEGATED_HTTP_AUTH.items():
|
||||
tree = ast.parse((API_ROOT / relative).read_text(encoding="utf-8"))
|
||||
helper = next(
|
||||
node for node in tree.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == helper_name
|
||||
)
|
||||
assert "require" in _called_names(helper), (
|
||||
f"delegated helper {relative}:{helper_name} must enforce authentication itself"
|
||||
)
|
||||
|
||||
|
||||
def test_every_websocket_route_checks_a_principal_before_serving():
|
||||
missing: list[str] = []
|
||||
for source in (API_ROOT / "ws").glob("*.py"):
|
||||
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
|
||||
for node in tree.body:
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
route = _route_declaration(node)
|
||||
if route is None or route[0] != "websocket":
|
||||
continue
|
||||
if "principal_of" not in _called_names(node):
|
||||
missing.append(f"{source.name}:{node.name} ({route[1]})")
|
||||
|
||||
assert not missing, "WebSocket routes without principal check: " + "; ".join(missing)
|
||||
|
||||
|
||||
def test_http_routes_match_the_reviewed_role_gate_matrix():
|
||||
found: dict[tuple[str, str, str], tuple[tuple[str, ...], ...]] = {}
|
||||
session_guards: set[tuple[str, str, str]] = set()
|
||||
discovered_routes: set[tuple[str, str, str]] = set()
|
||||
for source in [*API_ROOT.glob("*.py"), *(API_ROOT / "http").glob("*.py")]:
|
||||
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
|
||||
relative = source.relative_to(API_ROOT).as_posix()
|
||||
for node in tree.body:
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
route = _route_declaration(node)
|
||||
if route is None:
|
||||
continue
|
||||
method, path = route
|
||||
key = (relative, method, path)
|
||||
discovered_routes.add(key)
|
||||
if key in HTTP_ROLE_GATE_POLICY:
|
||||
found[key] = _required_role_gates(node)
|
||||
elif key in AUTH_SESSION_HTTP_ROUTES:
|
||||
if "current" in _called_names(node):
|
||||
session_guards.add(key)
|
||||
|
||||
reviewed_routes = (
|
||||
set(HTTP_ROLE_GATE_POLICY)
|
||||
| set(DELEGATED_HTTP_AUTH)
|
||||
| set(PUBLIC_HTTP_ROUTES)
|
||||
| AUTH_SESSION_HTTP_ROUTES
|
||||
)
|
||||
assert discovered_routes == reviewed_routes, (
|
||||
"Every HTTP route must be categorized in the reviewed matrix; "
|
||||
f"unreviewed={discovered_routes - reviewed_routes}, stale={reviewed_routes - discovered_routes}"
|
||||
)
|
||||
assert set(found) == set(HTTP_ROLE_GATE_POLICY), (
|
||||
"The HTTP role matrix must enumerate every protected route; "
|
||||
f"missing={set(HTTP_ROLE_GATE_POLICY) - set(found)}, "
|
||||
f"unexpected={set(found) - set(HTTP_ROLE_GATE_POLICY)}"
|
||||
)
|
||||
differences = {
|
||||
key: (HTTP_ROLE_GATE_POLICY[key], found[key])
|
||||
for key in HTTP_ROLE_GATE_POLICY
|
||||
if HTTP_ROLE_GATE_POLICY[key] != found[key]
|
||||
}
|
||||
assert not differences, f"HTTP route role-gate drift: {differences}"
|
||||
assert session_guards == AUTH_SESSION_HTTP_ROUTES
|
||||
|
||||
|
||||
def test_websocket_routes_match_the_reviewed_role_matrix():
|
||||
found: dict[tuple[str, str], set[str]] = {}
|
||||
for source in (API_ROOT / "ws").glob("*.py"):
|
||||
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
|
||||
for node in tree.body:
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
route = _route_declaration(node)
|
||||
if route is None or route[0] != "websocket":
|
||||
continue
|
||||
roles = {
|
||||
item.attr for item in ast.walk(node)
|
||||
if isinstance(item, ast.Attribute)
|
||||
and isinstance(item.value, ast.Name)
|
||||
and item.value.id == "Role"
|
||||
}
|
||||
found[(source.name, route[1])] = roles
|
||||
|
||||
assert found == WEBSOCKET_ROLE_POLICY, f"WebSocket role policy drift: {found}"
|
||||
|
||||
|
||||
def test_dev_token_remains_runtime_gated():
|
||||
source = (API_ROOT / "auth.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
target = next(
|
||||
node for node in tree.body
|
||||
if isinstance(node, ast.AsyncFunctionDef) and node.name == "dev_token"
|
||||
)
|
||||
calls_and_names = {node.id for node in ast.walk(target) if isinstance(node, ast.Name)}
|
||||
attributes = {node.attr for node in ast.walk(target) if isinstance(node, ast.Attribute)}
|
||||
assert "dev_auth_bypass" in calls_and_names | attributes
|
||||
assert "demo_no_db" in calls_and_names | attributes
|
||||
|
|
@ -8,13 +8,19 @@ from fastapi.testclient import TestClient
|
|||
from app.api.http import scenarios as scenarios_api
|
||||
from app.config import get_settings
|
||||
from app.dialog.llm import LlmUnavailable
|
||||
from app.db.models import AuditLog
|
||||
from app.main import app
|
||||
from app.scenarios import store
|
||||
from app.scenarios import generation, store
|
||||
from app.scenarios.editor import merge_patch, template_copy, validate
|
||||
from app.scenarios import generation
|
||||
from app.scenarios.generation import (GenerationError, correction_target,
|
||||
full_proposal_body, parse_full_proposal,
|
||||
parse_proposal, proposal_body, style_fallback)
|
||||
from app.scenarios.generation import (
|
||||
GenerationError,
|
||||
correction_target,
|
||||
full_proposal_body,
|
||||
parse_full_proposal,
|
||||
parse_proposal,
|
||||
proposal_body,
|
||||
style_fallback,
|
||||
)
|
||||
from app.scenarios.loader import ScenarioError, load_file
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
|
@ -25,23 +31,39 @@ class FakeSession:
|
|||
|
||||
def __init__(self):
|
||||
self.rows = {}
|
||||
self.audit_rows = []
|
||||
self.commit_audit_counts = []
|
||||
|
||||
def add(self, row):
|
||||
self.rows[row.id] = row
|
||||
if isinstance(row, AuditLog):
|
||||
self.audit_rows.append(row)
|
||||
else:
|
||||
self.rows[row.id] = row
|
||||
|
||||
async def get(self, model, key):
|
||||
return self.rows.get(key)
|
||||
|
||||
async def commit(self):
|
||||
pass
|
||||
self.commit_audit_counts.append(len(self.audit_rows))
|
||||
|
||||
async def scalars(self, query):
|
||||
expression = query.column_descriptions[0]["expr"]
|
||||
if getattr(expression, "key", None) == "id":
|
||||
owner_login = query.compile().params.get("owner_login_1")
|
||||
owner_filter = next(
|
||||
clause for clause in query.whereclause.clauses
|
||||
if getattr(getattr(clause, "left", None), "key", None) == "owner_login"
|
||||
and getattr(getattr(clause, "right", None), "value", None) is not None
|
||||
)
|
||||
owner_login = owner_filter.right.value
|
||||
owner_operator = owner_filter.operator.__name__
|
||||
return [
|
||||
row.id for row in self.rows.values()
|
||||
if row.status == "published" and row.owner_login == owner_login
|
||||
if row.status == "published"
|
||||
and (
|
||||
row.owner_login == owner_login
|
||||
if owner_operator == "eq"
|
||||
else row.owner_login is not None and row.owner_login != owner_login
|
||||
)
|
||||
]
|
||||
return [row for row in self.rows.values() if row.status == "published"]
|
||||
|
||||
|
|
@ -60,10 +82,18 @@ def client(monkeypatch):
|
|||
monkeypatch.setattr(scenarios_api, "audit", no_audit)
|
||||
monkeypatch.setattr(store, "restore_published", no_restore)
|
||||
with TestClient(app) as test_client:
|
||||
test_client.fake_db = db
|
||||
yield test_client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def assert_atomic_audit(client, action: str, object_id: str) -> None:
|
||||
row = client.fake_db.audit_rows[-1]
|
||||
assert row.action == action
|
||||
assert row.object_id == object_id
|
||||
assert client.fake_db.commit_audit_counts[-1] == len(client.fake_db.audit_rows)
|
||||
|
||||
|
||||
def test_template_copy_is_local_independent_and_valid():
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
body = template_copy(source, "draft-example")
|
||||
|
|
@ -87,6 +117,20 @@ def test_editor_rejects_derived_truth_and_missing_fact():
|
|||
validate(broken)
|
||||
|
||||
|
||||
def test_editor_can_save_explicit_scenario_decline_with_required_reason():
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
body = template_copy(source, "draft-decline")
|
||||
declined = merge_patch(body, {
|
||||
"dds_decision": {
|
||||
"expected": "decline",
|
||||
"reason": "Повторный вызов уже отрабатывается по первой карточке.",
|
||||
},
|
||||
})
|
||||
assert validate(declined).dds_decision.expected == "decline"
|
||||
with pytest.raises(ScenarioError, match="reason обязателен"):
|
||||
validate(merge_patch(body, {"dds_decision": {"expected": "decline"}}))
|
||||
|
||||
|
||||
def test_ai_proposal_changes_only_story_and_keeps_reference():
|
||||
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
proposal = parse_proposal('''```json
|
||||
|
|
@ -191,10 +235,10 @@ async def test_ai_generation_retries_copied_facts_with_strict_schema(monkeypatch
|
|||
class FakeClient:
|
||||
def __init__(self, **kwargs):
|
||||
self.answers = iter([
|
||||
'{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
|
||||
'"facts":{"f_smoke":"дым идёт в подъезд, на площадке ничего не видно"}}',
|
||||
'{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
|
||||
'"facts":{"f_smoke":"лестница уже заполнена густым дымом"}}',
|
||||
('{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
|
||||
'"facts":{"f_smoke":"дым идёт в подъезд, на площадке ничего не видно"}}'),
|
||||
('{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
|
||||
'"facts":{"f_smoke":"лестница уже заполнена густым дымом"}}'),
|
||||
])
|
||||
|
||||
async def complete(self, request, **kwargs):
|
||||
|
|
@ -317,7 +361,13 @@ async def test_description_generation_retries_fact_that_is_a_question(monkeypatc
|
|||
assert proposal["facts"]["f_people"].endswith("Пострадавших: 1")
|
||||
|
||||
|
||||
def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
|
||||
def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client, monkeypatch):
|
||||
from app.scoring.grammar import GrammarAssessment
|
||||
|
||||
async def fake_assess(_text):
|
||||
return GrammarAssessment(True, (), "rules")
|
||||
|
||||
monkeypatch.setattr(scenarios_api, "assess", fake_assess)
|
||||
source = next(s for s in store.all_scenarios() if s.id == "fire-apartment-l2")
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
response = client.post(
|
||||
|
|
@ -329,6 +379,7 @@ def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
|
|||
draft_id = draft["id"]
|
||||
assert draft["generation"] == "template_copy"
|
||||
assert draft["status"] == "draft"
|
||||
assert_atomic_audit(client, "scenario.draft.create", draft_id)
|
||||
assert store.get(draft_id) is None
|
||||
assert client.get(f"/api/scenarios/{draft_id}").status_code == 404
|
||||
|
||||
|
|
@ -338,10 +389,14 @@ def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
|
|||
)
|
||||
assert changed.status_code == 200, changed.text
|
||||
assert changed.json()["body"]["first_line"] == "Соседи! В доме дым!"
|
||||
assert_atomic_audit(client, "scenario.draft.update", draft_id)
|
||||
check = client.post(f"/api/scenarios/drafts/{draft_id}/validate")
|
||||
assert check.status_code == 200 and check.json()["valid"]
|
||||
grammar = client.post(f"/api/scenarios/drafts/{draft_id}/grammar-check")
|
||||
assert grammar.status_code == 200 and grammar.json()["valid"]
|
||||
approved = client.post(f"/api/scenarios/drafts/{draft_id}/approve")
|
||||
assert approved.status_code == 200, approved.text
|
||||
assert_atomic_audit(client, "scenario.approve", draft_id)
|
||||
assert approved.json()["status"] == "published"
|
||||
assert store.get(draft_id).first_line == "Соседи! В доме дым!"
|
||||
assert client.get(f"/api/scenarios/{draft_id}").status_code == 200
|
||||
|
|
@ -365,6 +420,7 @@ def test_ai_draft_requires_instructor_review_before_publication(client, monkeypa
|
|||
draft = response.json()
|
||||
assert draft["generation"] == "ai_variant"
|
||||
assert draft["id"].startswith("ai-")
|
||||
assert_atomic_audit(client, "scenario.draft.ai_generate", draft["id"])
|
||||
assert draft["body"]["first_line"] == "Помогите, у нас горит балкон!"
|
||||
assert store.get(draft["id"]) is None
|
||||
assert client.get(f"/api/scenarios/{draft['id']}").status_code == 404
|
||||
|
|
@ -438,9 +494,73 @@ def test_instructor_revises_same_ai_draft_by_comment(client, monkeypatch):
|
|||
assert body["facts"][0]["value"] == "улица Ленина, 14, квартира 47, 5-й этаж"
|
||||
assert next(item["value"] for item in body["facts"] if item["id"] == "f_smoke").startswith("чёрный")
|
||||
assert comments[-1] == "Сделай дым чёрным и закрой им площадку"
|
||||
assert_atomic_audit(client, "scenario.draft.ai_revise", draft_id)
|
||||
audit_row = client.fake_db.audit_rows[-1]
|
||||
assert audit_row.detail == "instruction_chars=38"
|
||||
assert "чёрным" not in audit_row.detail
|
||||
assert client.post(f"/api/scenarios/drafts/{draft_id}/validate").json()["valid"]
|
||||
|
||||
|
||||
def test_manual_grammar_check_covers_caller_line_and_fact_values(client, monkeypatch):
|
||||
from app.scoring.grammar import GrammarAssessment
|
||||
|
||||
checked = []
|
||||
|
||||
async def fake_assess(text):
|
||||
checked.append(text)
|
||||
return GrammarAssessment(True, (), "rules")
|
||||
|
||||
monkeypatch.setattr(scenarios_api, "assess", fake_assess)
|
||||
client.post("/api/auth/dev-token")
|
||||
created = client.post("/api/scenarios/drafts/from-template", json={
|
||||
"source_id": "fire-apartment-l2",
|
||||
})
|
||||
assert created.status_code == 201, created.text
|
||||
draft_id = created.json()["id"]
|
||||
|
||||
changed = client.patch(
|
||||
f"/api/scenarios/drafts/{draft_id}",
|
||||
json={"first_line": "Помогите! Горит балкон."},
|
||||
)
|
||||
assert changed.status_code == 200, changed.text
|
||||
assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 409
|
||||
|
||||
response = client.post(f"/api/scenarios/drafts/{draft_id}/grammar-check")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
result = response.json()
|
||||
assert result["valid"] is True
|
||||
assert result["checks"][0]["field"] == "first_line"
|
||||
assert len(result["checks"]) == 1 + len(changed.json()["body"]["facts"])
|
||||
assert checked == [changed.json()["body"]["first_line"], *[
|
||||
fact["value"] for fact in changed.json()["body"]["facts"]
|
||||
]]
|
||||
assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 200
|
||||
|
||||
|
||||
def test_failed_grammar_check_does_not_authorize_manual_draft(client, monkeypatch):
|
||||
from app.scoring.grammar import GrammarAssessment
|
||||
|
||||
async def fake_assess(_text):
|
||||
return GrammarAssessment(False, ("тестовая языковая ошибка",), "rules")
|
||||
|
||||
monkeypatch.setattr(scenarios_api, "assess", fake_assess)
|
||||
client.post("/api/auth/dev-token")
|
||||
created = client.post("/api/scenarios/drafts/from-template", json={
|
||||
"source_id": "fire-apartment-l2",
|
||||
})
|
||||
draft_id = created.json()["id"]
|
||||
assert client.patch(f"/api/scenarios/drafts/{draft_id}",
|
||||
json={"first_line": "пожар"}).status_code == 200
|
||||
|
||||
result = client.post(f"/api/scenarios/drafts/{draft_id}/grammar-check")
|
||||
|
||||
assert result.status_code == 200
|
||||
assert result.json()["valid"] is False
|
||||
assert result.json()["checks"][0]["errors"] == ["тестовая языковая ошибка"]
|
||||
assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 409
|
||||
|
||||
|
||||
def test_ai_editor_works_in_demo_lite_without_database(monkeypatch):
|
||||
monkeypatch.setenv("DEMO_NO_DB", "true")
|
||||
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
|
||||
|
|
@ -517,11 +637,13 @@ def test_instructor_archives_and_restores_scenario_without_deleting_history(clie
|
|||
scenario_id = created.json()["id"]
|
||||
approved = client.post(f"/api/scenarios/drafts/{scenario_id}/approve")
|
||||
assert approved.status_code == 200, approved.text
|
||||
assert_atomic_audit(client, "scenario.approve", scenario_id)
|
||||
original = store.get(scenario_id)
|
||||
assert original is not None
|
||||
|
||||
archived = client.delete(f"/api/scenarios/{scenario_id}")
|
||||
assert archived.status_code == 200, archived.text
|
||||
assert_atomic_audit(client, "scenario.archive", scenario_id)
|
||||
assert archived.json()["status"] == "archived"
|
||||
assert store.get(scenario_id) is None
|
||||
assert scenario_id not in {item["id"] for item in client.get("/api/scenarios").json()}
|
||||
|
|
@ -529,6 +651,7 @@ def test_instructor_archives_and_restores_scenario_without_deleting_history(clie
|
|||
|
||||
restored = client.post(f"/api/scenarios/{scenario_id}/restore")
|
||||
assert restored.status_code == 200, restored.text
|
||||
assert_atomic_audit(client, "scenario.restore", scenario_id)
|
||||
assert restored.json()["status"] == "published"
|
||||
assert store.get(scenario_id).title == original.title
|
||||
assert scenario_id in {item["id"] for item in client.get("/api/scenarios").json()}
|
||||
|
|
@ -544,7 +667,6 @@ def test_instructor_cannot_read_or_edit_another_instructors_draft(client, monkey
|
|||
return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
|
||||
|
||||
monkeypatch.setattr(scenarios_api, "require", instructor)
|
||||
monkeypatch.setattr(scenarios_api, "current", instructor)
|
||||
created = client.post(
|
||||
"/api/scenarios/drafts/from-template",
|
||||
json={"source_id": "fire-apartment-l2", "title": "Личный черновик"},
|
||||
|
|
@ -570,7 +692,6 @@ def test_instructor_cannot_archive_another_instructors_published_scenario(client
|
|||
return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
|
||||
|
||||
monkeypatch.setattr(scenarios_api, "require", instructor)
|
||||
monkeypatch.setattr(scenarios_api, "current", instructor)
|
||||
created = client.post(
|
||||
"/api/scenarios/drafts/from-template",
|
||||
json={"source_id": "fire-apartment-l2", "title": "Публикация автора"},
|
||||
|
|
@ -582,8 +703,8 @@ def test_instructor_cannot_archive_another_instructors_published_scenario(client
|
|||
assert scenario["can_manage"] is True
|
||||
|
||||
identity["login"] = "teacher-two"
|
||||
scenario = next(item for item in client.get("/api/scenarios").json() if item["id"] == scenario_id)
|
||||
assert scenario["can_manage"] is False
|
||||
assert scenario_id not in {item["id"] for item in client.get("/api/scenarios").json()}
|
||||
assert client.get(f"/api/scenarios/{scenario_id}").status_code == 404
|
||||
assert client.delete(f"/api/scenarios/{scenario_id}").status_code == 404
|
||||
|
||||
|
||||
|
|
|
|||
448
backend/tests/test_scenario_submissions.py
Normal file
448
backend/tests/test_scenario_submissions.py
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import auth
|
||||
from app.api.http import scenario_submissions
|
||||
from app.api.http.scenario_submissions import reset_demo_submissions
|
||||
from app.config import get_settings
|
||||
from app.db.models import AuditLog, Group, Scenario, ScenarioSubmission, Trainee
|
||||
from app.domain import ekp
|
||||
from app.domain.events import Exercise, SessionMode
|
||||
from app.main import app
|
||||
from app.scenarios import store
|
||||
from app.session.dds import prepare_card
|
||||
from app.session.state import SessionState
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
monkeypatch.setenv("DEMO_NO_DB", "true")
|
||||
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
|
||||
get_settings.cache_clear()
|
||||
store.reset_demo_drafts()
|
||||
reset_demo_submissions()
|
||||
try:
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _login(client: TestClient, role: str) -> None:
|
||||
response = client.post("/api/auth/dev-token", params={"role": role})
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
def _student_kio(
|
||||
description="В мастерской виден дым из повреждённого оборудования.",
|
||||
address="Москва, учебная улица, дом 10",
|
||||
):
|
||||
source = store.get("t01-1-fire-container")
|
||||
assert source is not None
|
||||
return {
|
||||
"caller_name": "Учебный заявитель",
|
||||
"caller_contact": "+7 900 000-00-00",
|
||||
"address": address,
|
||||
"description": description,
|
||||
"incident_group": ekp.incident(source.ground_truth.incident_code).group,
|
||||
"signs": source.signs,
|
||||
"incident_type": "fire",
|
||||
"dds": source.ground_truth.dds.value,
|
||||
"victims_count": 0,
|
||||
"fire": {"object_kind": "оборудование", "fire_nature": "задымление"},
|
||||
}
|
||||
|
||||
|
||||
def test_trainee_scenario_catalog_and_detail_only_expose_safe_self_practice_fields(client):
|
||||
_login(client, "trainee")
|
||||
listed = client.get("/api/scenarios").json()
|
||||
item = next(row for row in listed if row["id"] == "t01-1-fire-container")
|
||||
assert set(item) == {"id", "title", "level", "modes"}
|
||||
assert "self" in item["modes"]
|
||||
detail = client.get("/api/scenarios/t01-1-fire-container")
|
||||
assert detail.status_code == 200
|
||||
assert set(detail.json()) == {"id", "title", "level", "modes"}
|
||||
assert client.get("/api/scenarios/t01-1-fire-container").json().get("ground_truth") is None
|
||||
|
||||
|
||||
def test_student_submission_is_moderated_then_enters_dds_bank(client):
|
||||
_login(client, "trainee")
|
||||
created = client.post(
|
||||
"/api/scenario-submissions",
|
||||
json={
|
||||
"title": "Пожар в мастерской",
|
||||
"level": "L2",
|
||||
"kio": _student_kio(
|
||||
"В мастерской на первом этаже виден дым, люди вышли наружу.",
|
||||
"Москва, улица Примерная, дом 12",
|
||||
),
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
submission_id = created.json()["id"]
|
||||
assert created.json()["status"] == "pending"
|
||||
assert created.json()["scenario_id"] is None
|
||||
assert client.get("/api/scenarios").status_code == 200
|
||||
assert not any("student-created" in item.get("topics", [])
|
||||
for item in client.get("/api/scenarios").json())
|
||||
|
||||
# Курсанту разрешено видеть своё предложение, но не публиковать его.
|
||||
own = client.get("/api/scenario-submissions").json()
|
||||
assert [item["id"] for item in own] == [submission_id]
|
||||
denied = client.post(
|
||||
f"/api/scenario-submissions/{submission_id}/review",
|
||||
json={"decision": "approve"},
|
||||
)
|
||||
assert denied.status_code == 403
|
||||
|
||||
client.post("/api/auth/logout")
|
||||
_login(client, "instructor")
|
||||
pending = client.get("/api/scenario-submissions").json()
|
||||
assert pending[0]["title"] == "Пожар в мастерской"
|
||||
approved = client.post(
|
||||
f"/api/scenario-submissions/{submission_id}/review",
|
||||
json={"decision": "approve", "comment": "Факты проверены."},
|
||||
)
|
||||
assert approved.status_code == 200, approved.text
|
||||
body = approved.json()
|
||||
assert body["status"] == "approved"
|
||||
assert body["scenario_id"].startswith("student-")
|
||||
|
||||
scenario = store.get(body["scenario_id"])
|
||||
assert scenario is not None
|
||||
assert scenario.ground_truth.address == "Москва, улица Примерная, дом 12"
|
||||
assert scenario.ground_truth.victims == 0
|
||||
assert "student-created" in scenario.topics
|
||||
listed = client.get("/api/scenarios").json()
|
||||
published = next(item for item in listed if item["id"] == scenario.id)
|
||||
assert published["source"] == "trainee"
|
||||
assert published["outcome"] == "card"
|
||||
assert published["dds"] == "01"
|
||||
|
||||
repeated = client.post(
|
||||
f"/api/scenario-submissions/{submission_id}/review",
|
||||
json={"decision": "approve"},
|
||||
)
|
||||
assert repeated.status_code == 409
|
||||
|
||||
|
||||
def test_submitted_kio_is_kept_intact_and_becomes_the_dds_card_after_approval(client):
|
||||
source = store.get("t01-1-fire-container")
|
||||
assert source is not None
|
||||
_login(client, "trainee")
|
||||
created = client.post(
|
||||
"/api/scenario-submissions",
|
||||
json={
|
||||
"title": "КИО курсанта: контейнер во дворе",
|
||||
"level": "L2",
|
||||
"kio": {
|
||||
"caller_name": "Учебный заявитель",
|
||||
"caller_contact": "+7 900 000-00-00",
|
||||
"address": "Москва, учебная улица, дом 10",
|
||||
"description": "Во дворе открыто горит мусорный контейнер.",
|
||||
"incident_group": ekp.incident(source.ground_truth.incident_code).group,
|
||||
"signs": source.signs,
|
||||
"incident_type": "fire",
|
||||
"dds": source.ground_truth.dds.value,
|
||||
"victims_count": 0,
|
||||
"fire": {
|
||||
"object_kind": "мусорный контейнер",
|
||||
"fire_nature": "открытое пламя",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
body = created.json()
|
||||
assert body["status"] == "pending"
|
||||
assert body["kio"]["caller_number"] is None
|
||||
assert body["kio"]["caller_name"] == "Учебный заявитель"
|
||||
assert not any("student-created" in item.get("topics", [])
|
||||
for item in client.get("/api/scenarios").json())
|
||||
|
||||
client.post("/api/auth/logout")
|
||||
_login(client, "instructor")
|
||||
approved = client.post(
|
||||
f"/api/scenario-submissions/{body['id']}/review",
|
||||
json={"decision": "approve", "comment": "КИО проверена."},
|
||||
)
|
||||
assert approved.status_code == 200, approved.text
|
||||
scenario = store.get(approved.json()["scenario_id"])
|
||||
assert scenario is not None
|
||||
assert scenario.student_card is not None
|
||||
assert scenario.student_card.address == "Москва, учебная улица, дом 10"
|
||||
assert scenario.student_card.caller_name == "Учебный заявитель"
|
||||
assert scenario.student_card.victims_count == 0
|
||||
assert scenario.student_card.fire.object_kind == "мусорный контейнер"
|
||||
assert scenario.student_card.signs == source.signs
|
||||
assert "moderated-kio" in scenario.topics
|
||||
state = SessionState(
|
||||
session_id=UUID("00000000-0000-4000-8000-000000000701"),
|
||||
scenario_id=scenario.id,
|
||||
scenario_title=scenario.title,
|
||||
level=scenario.level.value,
|
||||
mode=SessionMode.TRAINING,
|
||||
exercise=Exercise.DDS,
|
||||
)
|
||||
prepare_card(state, scenario)
|
||||
assert state.dispatched_card is not None
|
||||
assert state.dispatched_card.address == scenario.student_card.address
|
||||
assert state.dispatched_card.caller_name == "Учебный заявитель"
|
||||
assert state.dispatched_card.fire.object_kind == "мусорный контейнер"
|
||||
assert any(
|
||||
item["id"] == scenario.id and item["source"] == "trainee"
|
||||
for item in client.get("/api/scenarios").json()
|
||||
)
|
||||
|
||||
|
||||
def test_database_submission_path_persists_kio_and_publishes_on_approval(
|
||||
client,
|
||||
monkeypatch,
|
||||
):
|
||||
source = store.get("t01-1-fire-container")
|
||||
assert source is not None
|
||||
trainee_id = UUID("00000000-0000-4000-8000-000000000112")
|
||||
group_id = UUID("00000000-0000-4000-8000-000000000113")
|
||||
|
||||
class FakeDb:
|
||||
submission = None
|
||||
scenario_row = None
|
||||
|
||||
def __init__(self):
|
||||
self.audit_rows = []
|
||||
self.commit_rows = []
|
||||
|
||||
async def get(self, model, _key):
|
||||
if model is Trainee:
|
||||
return SimpleNamespace(id=trainee_id, group_id=group_id)
|
||||
if model is Group:
|
||||
return SimpleNamespace(id=group_id, owner_login="demo-instructor")
|
||||
raise AssertionError(f"unexpected model: {model}")
|
||||
|
||||
def add(self, row):
|
||||
if isinstance(row, ScenarioSubmission):
|
||||
self.submission = row
|
||||
row.status = "pending"
|
||||
row.created_at = datetime.now(UTC)
|
||||
elif isinstance(row, Scenario):
|
||||
self.scenario_row = row
|
||||
elif isinstance(row, AuditLog):
|
||||
self.audit_rows.append(row)
|
||||
else:
|
||||
raise AssertionError(f"unexpected row: {type(row)}")
|
||||
|
||||
async def commit(self):
|
||||
self.commit_rows.append(tuple(self.audit_rows))
|
||||
|
||||
async def scalar(self, _query):
|
||||
return self.submission
|
||||
|
||||
fake_db = FakeDb()
|
||||
|
||||
async def session_override():
|
||||
yield fake_db
|
||||
|
||||
monkeypatch.setitem(
|
||||
app.dependency_overrides,
|
||||
scenario_submissions.submission_session,
|
||||
session_override,
|
||||
)
|
||||
_login(client, "trainee")
|
||||
created = client.post(
|
||||
"/api/scenario-submissions",
|
||||
json={
|
||||
"title": "КИО в DB-пути",
|
||||
"level": "L2",
|
||||
"kio": {
|
||||
"caller_name": "Учебный заявитель",
|
||||
"address": "Москва, тестовая улица, дом 3",
|
||||
"description": "Во дворе открыто горит мусорный контейнер.",
|
||||
"incident_group": ekp.incident(source.ground_truth.incident_code).group,
|
||||
"signs": source.signs,
|
||||
"incident_type": "fire",
|
||||
"dds": source.ground_truth.dds.value,
|
||||
"victims_count": 0,
|
||||
"fire": {"object_kind": "мусорный контейнер"},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
assert fake_db.submission.kio["address"] == "Москва, тестовая улица, дом 3"
|
||||
assert created.json()["status"] == "pending"
|
||||
assert fake_db.commit_rows[0][0].action == "scenario.submission.create"
|
||||
|
||||
client.post("/api/auth/logout")
|
||||
_login(client, "instructor")
|
||||
approved = client.post(
|
||||
f"/api/scenario-submissions/{created.json()['id']}/review",
|
||||
json={"decision": "approve", "comment": "Проверено."},
|
||||
)
|
||||
assert approved.status_code == 200, approved.text
|
||||
assert fake_db.scenario_row.owner_login == "demo-instructor"
|
||||
assert fake_db.commit_rows[1][-1].action == "scenario.submission.approve"
|
||||
persisted_kio = fake_db.scenario_row.body["student_card"]
|
||||
assert persisted_kio["address"] == "Москва, тестовая улица, дом 3"
|
||||
assert approved.json()["status"] == "approved"
|
||||
|
||||
|
||||
def test_rejection_requires_comment_and_returns_proposal_to_student(client, monkeypatch):
|
||||
audit_rows = []
|
||||
|
||||
async def capture_audit(*args):
|
||||
audit_rows.append(args)
|
||||
|
||||
monkeypatch.setattr(scenario_submissions, "audit", capture_audit)
|
||||
_login(client, "trainee")
|
||||
response = client.post(
|
||||
"/api/scenario-submissions",
|
||||
json={
|
||||
"title": "Обстановка на объекте",
|
||||
"level": "L1",
|
||||
"kio": _student_kio(
|
||||
"В помещении обнаружено повреждение инженерного оборудования."
|
||||
),
|
||||
},
|
||||
)
|
||||
submission_id = response.json()["id"]
|
||||
client.post("/api/auth/logout")
|
||||
_login(client, "instructor")
|
||||
|
||||
missing_reason = client.post(
|
||||
f"/api/scenario-submissions/{submission_id}/review", json={"decision": "reject"}
|
||||
)
|
||||
assert missing_reason.status_code == 422
|
||||
rejected = client.post(
|
||||
f"/api/scenario-submissions/{submission_id}/review",
|
||||
json={
|
||||
"decision": "reject",
|
||||
"comment": "Уточните место и наблюдаемые признаки.",
|
||||
},
|
||||
)
|
||||
assert rejected.status_code == 200
|
||||
assert rejected.json()["status"] == "rejected"
|
||||
assert audit_rows[-1][-1] == "comment_chars=38"
|
||||
assert "Уточните место" not in audit_rows[-1][-1]
|
||||
|
||||
client.post("/api/auth/logout")
|
||||
_login(client, "trainee")
|
||||
own = client.get("/api/scenario-submissions").json()
|
||||
assert own[0]["status"] == "rejected"
|
||||
assert own[0]["review_comment"] == "Уточните место и наблюдаемые признаки."
|
||||
assert not any("student-created" in item.get("topics", [])
|
||||
for item in client.get("/api/scenarios").json())
|
||||
|
||||
|
||||
def test_submission_validation_rejects_short_description(client):
|
||||
_login(client, "trainee")
|
||||
response = client.post(
|
||||
"/api/scenario-submissions",
|
||||
json={
|
||||
"title": "Короткая заявка",
|
||||
"level": "L1",
|
||||
"kio": _student_kio("дым"),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_submission_requires_structured_kio_not_legacy_free_text(client):
|
||||
_login(client, "trainee")
|
||||
response = client.post(
|
||||
"/api/scenario-submissions",
|
||||
json={
|
||||
"title": "Только текст",
|
||||
"level": "L1",
|
||||
"incident_type": "fire",
|
||||
"description": "В мастерской обнаружены дым и повреждение оборудования.",
|
||||
"address": "Москва, учебная улица, дом 10",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_submission_rejects_whitespace_only_title(client):
|
||||
_login(client, "trainee")
|
||||
response = client.post(
|
||||
"/api/scenario-submissions",
|
||||
json={"title": " ", "level": "L1", "kio": _student_kio()},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_submission_uses_street_and_building_when_address_is_blank(client):
|
||||
_login(client, "trainee")
|
||||
kio = _student_kio()
|
||||
kio.update({"address": " ", "street": "Учебная улица", "building": "12"})
|
||||
response = client.post(
|
||||
"/api/scenario-submissions",
|
||||
json={"title": "Проверка адреса", "level": "L1", "kio": kio},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
assert response.json()["address"] == "Учебная улица 12"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("group_id", "group_owner", "detail"),
|
||||
[
|
||||
(None, None, "trainee_group_required_for_review"),
|
||||
(
|
||||
UUID("00000000-0000-4000-8000-000000000001"),
|
||||
None,
|
||||
"instructor_group_required_for_review",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_submission_requires_a_group_with_a_moderating_instructor(
|
||||
client,
|
||||
monkeypatch,
|
||||
group_id,
|
||||
group_owner,
|
||||
detail,
|
||||
):
|
||||
trainee_id = UUID("00000000-0000-4000-8000-000000000112")
|
||||
monkeypatch.setattr(
|
||||
scenario_submissions,
|
||||
"require",
|
||||
lambda *_args, **_kwargs: auth.Principal(
|
||||
login="student",
|
||||
full_name="Курсант",
|
||||
role=auth.Role.TRAINEE,
|
||||
trainee_id=trainee_id,
|
||||
),
|
||||
)
|
||||
|
||||
class FakeDb:
|
||||
async def get(self, model, _key):
|
||||
if model.__name__ == "Trainee":
|
||||
return SimpleNamespace(group_id=group_id)
|
||||
return SimpleNamespace(owner_login=group_owner)
|
||||
|
||||
def add(self, _row):
|
||||
raise AssertionError("proposal must not be stored without a moderator")
|
||||
|
||||
async def session_override():
|
||||
yield FakeDb()
|
||||
|
||||
app.dependency_overrides[scenario_submissions.submission_session] = session_override
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/scenario-submissions",
|
||||
json={
|
||||
"title": "Пожар в мастерской",
|
||||
"level": "L1",
|
||||
"kio": _student_kio(
|
||||
"В мастерской обнаружены дым и повреждение оборудования."
|
||||
),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(scenario_submissions.submission_session, None)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == detail
|
||||
|
|
@ -68,12 +68,19 @@ def test_broken_yaml_names_the_file(tmp_path):
|
|||
load_file(path, tmp_path)
|
||||
|
||||
|
||||
def test_hidden_fact_without_approach_is_rejected(tmp_path):
|
||||
body = VALID.replace(
|
||||
@pytest.mark.parametrize("replace_text", [
|
||||
(
|
||||
' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr } }',
|
||||
' - { id: f_addr, value: "Ленина, 1", hidden: true, reveal_on: { question: q_addr } }',
|
||||
)
|
||||
with pytest.raises(ScenarioError, match="hidden требует"):
|
||||
),
|
||||
(
|
||||
' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr } }',
|
||||
' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr, approach: "проявил эмпатию" } }',
|
||||
),
|
||||
])
|
||||
def test_llm_controlled_fact_disclosure_is_rejected(tmp_path, replace_text):
|
||||
body = VALID.replace(*replace_text)
|
||||
with pytest.raises(ScenarioError, match="Extra inputs are not permitted"):
|
||||
load_file(write(tmp_path, body), tmp_path)
|
||||
|
||||
|
||||
|
|
@ -83,6 +90,38 @@ def test_checklist_pointing_at_missing_fact_is_rejected(tmp_path):
|
|||
load_file(write(tmp_path, body), tmp_path)
|
||||
|
||||
|
||||
def test_duplicate_fact_ids_are_rejected_before_they_can_change_ground_truth(tmp_path):
|
||||
fact = ' - { id: f_addr, value: "Ленина, 1", reveal_on: { question: q_addr } }'
|
||||
body = VALID.replace(fact, fact + '\n - { id: f_addr, value: "Ленина, 2", reveal_on: { question: q_addr } }')
|
||||
with pytest.raises(ScenarioError, match="id фактов должны быть уникальны"):
|
||||
load_file(write(tmp_path, body), tmp_path)
|
||||
|
||||
|
||||
def test_duplicate_local_checklist_ids_are_not_silently_merged(tmp_path):
|
||||
item = ' - { id: q_addr, question: "Адрес?", fact: f_addr }'
|
||||
body = VALID.replace("checklist:\n" + item, "checklist:\n" + item + "\n" + item)
|
||||
with pytest.raises(ScenarioError, match="повторяются id пунктов чек-листа"):
|
||||
load_file(write(tmp_path, body), tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("fields", "message"), [
|
||||
('[address, coordinates]', "отсутствуют в форме КИО"),
|
||||
('[card_id]', "заполняются системой"),
|
||||
('[registered_at]', "заполняются системой"),
|
||||
('[address, address]', "повторяются поля"),
|
||||
])
|
||||
def test_required_fields_must_be_unique_and_fillable_in_kio_form(tmp_path, fields, message):
|
||||
body = VALID + f"\nrequired_fields: {fields}\n"
|
||||
with pytest.raises(ScenarioError, match=message):
|
||||
load_file(write(tmp_path, body), tmp_path)
|
||||
|
||||
|
||||
def test_non_card_outcomes_cannot_require_kio_fields(tmp_path):
|
||||
body = VALID + "\noutcome: consultation\nrequired_fields: [address]\n"
|
||||
with pytest.raises(ScenarioError, match="required_fields должны быть пустыми"):
|
||||
load_file(write(tmp_path, body), tmp_path)
|
||||
|
||||
|
||||
def test_typo_in_field_name_is_rejected(tmp_path):
|
||||
"""Схема строгая: опечатка должна падать на старте, а не игнорироваться."""
|
||||
body = VALID.replace("level: L1", "level: L1\nfirst_lines: 'опечатка'")
|
||||
|
|
|
|||
|
|
@ -1,21 +1,52 @@
|
|||
"""HTTP-ссылки на занятие не дают курсанту чужую карточку или чек-лист."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.db import repo
|
||||
from app.db.models import Session, Utterance
|
||||
from app.db.repo import SessionNodeConflict, ensure_session
|
||||
from app.api.auth import Principal
|
||||
from app.api.http import sessions
|
||||
from app.domain.events import Exercise
|
||||
from app.api.ws import call as call_ws
|
||||
from app.api.ws import observe as observe_ws
|
||||
from app.api.ws import station as station_ws
|
||||
from app.domain.events import Exercise, SessionMode
|
||||
from app.domain.roles import Role
|
||||
from app.session.checkpoint import dump_state
|
||||
from app.session.hub import SessionHub
|
||||
from app.session.state import SessionState
|
||||
from app.session.journal import DbJournal
|
||||
|
||||
|
||||
def request() -> Request:
|
||||
return Request({"type": "http", "method": "GET", "path": "/", "headers": []})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_journal_write_failure_does_not_log_user_text(caplog):
|
||||
private_text = "private caller address and medical detail"
|
||||
|
||||
class FakeDb:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def fail_write(_db, text):
|
||||
raise RuntimeError(text)
|
||||
|
||||
journal = DbJournal(lambda: FakeDb())
|
||||
await journal._write(fail_write, private_text)
|
||||
assert private_text not in caplog.text
|
||||
assert "RuntimeError" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trainee_cannot_read_foreign_session(monkeypatch):
|
||||
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
|
||||
|
|
@ -44,6 +75,69 @@ async def test_instructor_cannot_read_foreign_session(monkeypatch):
|
|||
assert error.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("endpoint", [sessions.report, sessions.report_csv, sessions.report_pdf])
|
||||
async def test_instructor_cannot_read_or_export_foreign_archived_report(monkeypatch, endpoint):
|
||||
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
|
||||
monkeypatch.setattr(sessions, "require", lambda _request: who)
|
||||
monkeypatch.setattr(sessions.hub, "get", lambda _session_id: None)
|
||||
|
||||
async def row(_db, _session_id):
|
||||
return SimpleNamespace(owner_login="teacher-b", trainee_id=uuid4())
|
||||
|
||||
monkeypatch.setattr(sessions.repo, "get_session", row)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await endpoint(uuid4(), request(), db=object())
|
||||
assert error.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trainee_cannot_export_foreign_archived_report(monkeypatch):
|
||||
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
|
||||
monkeypatch.setattr(sessions, "require", lambda _request: who)
|
||||
monkeypatch.setattr(sessions.hub, "get", lambda _session_id: None)
|
||||
|
||||
async def row(_db, _session_id):
|
||||
return SimpleNamespace(owner_login="teacher-a", trainee_id=uuid4())
|
||||
|
||||
monkeypatch.setattr(sessions.repo, "get_session", row)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await sessions.report_pdf(uuid4(), request(), db=object())
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructor_cannot_download_foreign_recording(monkeypatch):
|
||||
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
|
||||
monkeypatch.setattr(sessions, "require", lambda _request, *_roles: who)
|
||||
monkeypatch.setattr(sessions, "get_settings", lambda: SimpleNamespace(demo_no_db=False))
|
||||
|
||||
async def row(_db, _session_id):
|
||||
return SimpleNamespace(
|
||||
owner_login="teacher-b", trainee_id=uuid4(), ended_at=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(sessions.repo, "get_session", row)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await sessions.recording(uuid4(), request(), db=object())
|
||||
assert error.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructor_cannot_override_foreign_archived_score(monkeypatch):
|
||||
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
|
||||
monkeypatch.setattr(sessions, "require", lambda _request, *_roles: who)
|
||||
|
||||
async def row(_db, _session_id):
|
||||
return SimpleNamespace(owner_login="teacher-b")
|
||||
|
||||
monkeypatch.setattr(sessions.repo, "get_session", row)
|
||||
body = sessions.ScoreOverride(score_final=80, comment="Проверка")
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await sessions.override(uuid4(), body, request(), db=object())
|
||||
assert error.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructor_history_is_scoped_to_owner(monkeypatch):
|
||||
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
|
||||
|
|
@ -59,6 +153,251 @@ async def test_instructor_history_is_scoped_to_owner(monkeypatch):
|
|||
assert seen["owner_login"] == "teacher-a"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_demo_history_lists_only_owned_sessions_and_applies_filters(monkeypatch):
|
||||
owner_trainee = uuid4()
|
||||
other_trainee = uuid4()
|
||||
older = datetime(2026, 9, 20, tzinfo=UTC)
|
||||
newer = datetime(2026, 9, 25, tzinfo=UTC)
|
||||
demo_hub = SessionHub()
|
||||
for session_id, owner, trainee_id, mode, started, ended in [
|
||||
(uuid4(), "teacher-a", owner_trainee, SessionMode.TRAINING, older, newer),
|
||||
(uuid4(), "teacher-b", owner_trainee, SessionMode.TRAINING, newer, newer),
|
||||
(uuid4(), "teacher-a", other_trainee, SessionMode.EXAM, newer, newer),
|
||||
]:
|
||||
demo_hub.register(SimpleNamespace(
|
||||
session_id=session_id, scenario_id="ticket-demo", mode=mode, attempt=1,
|
||||
trainee_id=trainee_id, owner_login=owner, lease_fenced=False,
|
||||
started_at=started, ended_at=ended, end_reason=None,
|
||||
))
|
||||
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
|
||||
monkeypatch.setattr(sessions, "require", lambda _request: who)
|
||||
monkeypatch.setattr(sessions, "hub", demo_hub)
|
||||
monkeypatch.setattr(sessions, "get_settings", lambda: SimpleNamespace(demo_no_db=True))
|
||||
|
||||
rows = await sessions.listing(
|
||||
request(), trainee=owner_trainee, mode=SessionMode.TRAINING,
|
||||
since=None, limit=100, db=None,
|
||||
)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].trainee_id == owner_trainee
|
||||
assert rows[0].mode is SessionMode.TRAINING
|
||||
assert rows[0].ended_at == newer
|
||||
|
||||
# Demo memory has no group membership records and must not ignore a group filter.
|
||||
assert await sessions.listing(request(), group=uuid4(), since=None, limit=100, db=None) == []
|
||||
|
||||
trainee = Principal(
|
||||
login="learner", full_name="Курсант", role=Role.TRAINEE, trainee_id=owner_trainee,
|
||||
)
|
||||
monkeypatch.setattr(sessions, "require", lambda _request: trainee)
|
||||
trainee_rows = await sessions.listing(
|
||||
request(), trainee=other_trainee, since=None, limit=100, db=None,
|
||||
)
|
||||
assert trainee_rows
|
||||
assert {row.trainee_id for row in trainee_rows} == {owner_trainee}
|
||||
|
||||
unlinked = Principal(login="unlinked", full_name="Без профиля", role=Role.TRAINEE)
|
||||
monkeypatch.setattr(sessions, "require", lambda _request: unlinked)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await sessions.listing(request(), since=None, limit=100, db=None)
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dds_history_returns_archived_cards_only_for_trainee(monkeypatch):
|
||||
trainee_id = uuid4()
|
||||
session_id = uuid4()
|
||||
card_id = uuid4()
|
||||
ended_at = datetime.now(UTC)
|
||||
who = Principal(
|
||||
login="trainee-a", full_name="Курсант A", role=Role.TRAINEE,
|
||||
trainee_id=trainee_id,
|
||||
)
|
||||
monkeypatch.setattr(sessions, "require", lambda *_args, **_kwargs: who)
|
||||
audit_events = []
|
||||
|
||||
async def capture_audit(actor, role, action, object_id=None, detail=""):
|
||||
audit_events.append((actor, role, action, object_id, detail))
|
||||
|
||||
monkeypatch.setattr(sessions, "audit_required", capture_audit)
|
||||
|
||||
class Rows:
|
||||
def all(self):
|
||||
return [(
|
||||
SimpleNamespace(id=session_id, ended_at=ended_at),
|
||||
SimpleNamespace(score_final=82.5, report={"full_report": {
|
||||
"exercise": "dds",
|
||||
"card_results": [{
|
||||
"card_id": str(card_id), "scenario_id": "fire-apartment",
|
||||
"score_auto": 80, "reply_text": "Бригада направлена",
|
||||
"title": "Пожар", "address": "улица Лесная, 4",
|
||||
"incident_type": "fire", "victims_count": 1,
|
||||
"managed_service": "01", "recipient_services": ["01", "03"],
|
||||
}],
|
||||
}}),
|
||||
)]
|
||||
|
||||
class Database:
|
||||
statement = None
|
||||
|
||||
async def execute(self, statement):
|
||||
self.statement = statement
|
||||
return Rows()
|
||||
|
||||
db = Database()
|
||||
result = await sessions.dds_history(request(), limit=200, db=db)
|
||||
|
||||
sql = str(db.statement.compile(compile_kwargs={"literal_binds": True}))
|
||||
assert "sessions.trainee_id" in sql
|
||||
assert trainee_id.hex in sql
|
||||
assert len(result) == 1
|
||||
assert result[0].session_id == session_id
|
||||
assert result[0].card_id == card_id
|
||||
assert result[0].address == "улица Лесная, 4"
|
||||
assert result[0].score_final == 82.5
|
||||
assert audit_events == [("trainee-a", "trainee", "dds.history.read", None, "cards=1")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dds_history_rejects_admin_role(monkeypatch):
|
||||
who = Principal(login="admin", full_name="Администратор", role=Role.ADMIN)
|
||||
|
||||
def require(*_args, **_kwargs):
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
|
||||
monkeypatch.setattr(sessions, "require", require)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await sessions.dds_history(request(), limit=200, db=None)
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_registry_is_scoped_to_current_instructor(monkeypatch):
|
||||
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
|
||||
monkeypatch.setattr(sessions, "require", lambda _request, _role: who)
|
||||
seen = {}
|
||||
|
||||
def active_sessions(owner_login):
|
||||
seen["owner_login"] = owner_login
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(sessions.hub, "active_sessions", active_sessions)
|
||||
assert await sessions.active(request(), db=None) == []
|
||||
assert seen["owner_login"] == "teacher-a"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("ws_module", "handler_name"),
|
||||
[
|
||||
(observe_ws, "observe"),
|
||||
(call_ws, "call"),
|
||||
(station_ws, "station"),
|
||||
],
|
||||
)
|
||||
async def test_instructor_cannot_join_foreign_live_session(monkeypatch, ws_module, handler_name):
|
||||
session_id = uuid4()
|
||||
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
|
||||
state = SimpleNamespace(owner_login="teacher-b")
|
||||
monkeypatch.setattr(ws_module, "websocket_origin_allowed", lambda _ws: True)
|
||||
monkeypatch.setattr(ws_module, "principal_of", lambda _ws: who)
|
||||
monkeypatch.setattr(ws_module.hub, "get", lambda _session_id: state)
|
||||
|
||||
class Socket:
|
||||
def __init__(self):
|
||||
self.accepted = False
|
||||
self.closed = False
|
||||
self.messages = []
|
||||
|
||||
async def accept(self):
|
||||
self.accepted = True
|
||||
|
||||
async def send_text(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
socket = Socket()
|
||||
await getattr(ws_module, handler_name)(socket, session_id)
|
||||
|
||||
assert socket.accepted and socket.closed
|
||||
assert len(socket.messages) == 1
|
||||
assert '"code":"session_not_found"' in socket.messages[0]
|
||||
assert "teacher-b" not in socket.messages[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_registry_includes_owned_checkpoints_from_other_nodes(monkeypatch):
|
||||
owner = "teacher-a"
|
||||
who = Principal(login=owner, full_name="Преподаватель A", role=Role.INSTRUCTOR)
|
||||
monkeypatch.setattr(sessions, "require", lambda _request, _role: who)
|
||||
monkeypatch.setattr(sessions.hub, "active_sessions", lambda _owner: [])
|
||||
state = SessionState(
|
||||
session_id=uuid4(),
|
||||
scenario_id="remote-case",
|
||||
scenario_title="Удалённое занятие",
|
||||
level="L2",
|
||||
mode=SessionMode.TRAINING,
|
||||
owner_login=owner,
|
||||
exercise=Exercise.DDS,
|
||||
trainee_name="Курсант",
|
||||
)
|
||||
state.started_at = datetime.now(UTC)
|
||||
row = SimpleNamespace(
|
||||
id=state.session_id,
|
||||
owner_login=owner,
|
||||
ended_at=None,
|
||||
live_state=dump_state(state),
|
||||
checkpoint_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
class Rows:
|
||||
def all(self):
|
||||
return [row]
|
||||
|
||||
class FakeDb:
|
||||
async def scalars(self, _query):
|
||||
return Rows()
|
||||
|
||||
result = await sessions.active(request(), db=FakeDb())
|
||||
assert len(result) == 1
|
||||
assert result[0].session_id == state.session_id
|
||||
assert result[0].trainee_name == "Курсант"
|
||||
assert result[0].scenario_id == "remote-case"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_http_session_is_assigned_to_backend_node_at_creation(monkeypatch):
|
||||
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
|
||||
monkeypatch.setattr(sessions, "require", lambda _request, _role: who)
|
||||
monkeypatch.setattr(sessions, "get_settings", lambda: SimpleNamespace(backend_node_id="node-a"))
|
||||
monkeypatch.setattr(sessions.repo, "ensure_group", lambda *_args, **_kwargs: None)
|
||||
seen = {}
|
||||
|
||||
async def create_session(_db, **kwargs):
|
||||
seen.update(kwargs)
|
||||
now = datetime.now(UTC)
|
||||
return SimpleNamespace(
|
||||
id=uuid4(), scenario_id=kwargs["scenario_id"], mode=kwargs["mode"],
|
||||
attempt=1, trainee_id=None, group_id=None,
|
||||
started_at=None, ended_at=None, end_reason=None, created_at=now,
|
||||
)
|
||||
|
||||
async def audit(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(sessions.repo, "create_session", create_session)
|
||||
monkeypatch.setattr(sessions, "audit", audit)
|
||||
result = await sessions.create(
|
||||
sessions.SessionCreate(scenario_id="case", mode=SessionMode.TRAINING),
|
||||
request(), db=object(),
|
||||
)
|
||||
assert result.scenario_id == "case"
|
||||
assert seen["backend_node_id"] == "node-a"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trainee_cannot_read_foreign_checklist(monkeypatch):
|
||||
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())
|
||||
|
|
@ -91,3 +430,140 @@ async def test_trainee_without_profile_cannot_list_everyones_sessions(monkeypatc
|
|||
with pytest.raises(HTTPException) as error:
|
||||
await sessions.listing(request(), db=object())
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_session_keeps_its_backend_owner():
|
||||
existing = SimpleNamespace(
|
||||
owner_login="teacher-a", backend_node_id="node-a"
|
||||
)
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, _query):
|
||||
return existing
|
||||
|
||||
with pytest.raises(SessionNodeConflict):
|
||||
await ensure_session(
|
||||
FakeDb(),
|
||||
session_id=uuid4(),
|
||||
scenario_id="case",
|
||||
mode="training",
|
||||
owner_login="teacher-a",
|
||||
backend_node_id="node-b",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unassigned_existing_session_is_claimed_once():
|
||||
existing = SimpleNamespace(owner_login="teacher-a", backend_node_id=None)
|
||||
|
||||
class FakeDb:
|
||||
commits = 0
|
||||
|
||||
async def scalar(self, _query):
|
||||
return existing
|
||||
|
||||
async def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
db = FakeDb()
|
||||
result = await ensure_session(
|
||||
db,
|
||||
session_id=uuid4(),
|
||||
scenario_id="case",
|
||||
mode="training",
|
||||
owner_login="teacher-a",
|
||||
backend_node_id="node-a",
|
||||
)
|
||||
assert result.backend_node_id == "node-a"
|
||||
assert db.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_journal_assigns_new_lesson_to_its_backend_node(monkeypatch):
|
||||
seen = {}
|
||||
trainee_id = uuid4()
|
||||
row = SimpleNamespace(attempt=3, trainee_id=trainee_id)
|
||||
|
||||
async def ensure(_db, **kwargs):
|
||||
seen.update(kwargs)
|
||||
return row
|
||||
|
||||
monkeypatch.setattr(repo, "ensure_session", ensure)
|
||||
|
||||
class FakeDb:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def scalar(self, _query):
|
||||
return "01"
|
||||
|
||||
journal = DbJournal(lambda: FakeDb(), node_id="node-a")
|
||||
result = await journal.start_lesson(
|
||||
uuid4(), "case", "training", "Курсант", trainee_id,
|
||||
owner_login="teacher-a",
|
||||
)
|
||||
assert result == (3, trainee_id, "01", 1)
|
||||
assert seen["backend_node_id"] == "node-a"
|
||||
assert callable(seen["before_commit"]), "session creation must carry its audit into commit"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_journal_restores_checkpoint_owned_by_this_node():
|
||||
owner = "teacher-a"
|
||||
state = SessionState(
|
||||
session_id=uuid4(),
|
||||
scenario_id="case",
|
||||
scenario_title="Удалённая сессия",
|
||||
level="L1",
|
||||
mode=SessionMode.TRAINING,
|
||||
owner_login=owner,
|
||||
exercise=Exercise.DDS,
|
||||
)
|
||||
state.started_at = datetime.now(UTC)
|
||||
row = SimpleNamespace(
|
||||
id=state.session_id,
|
||||
owner_login=owner,
|
||||
backend_node_id="node-a",
|
||||
backend_fencing_epoch=0,
|
||||
backend_lease_until=None,
|
||||
live_state=dump_state(state),
|
||||
checkpoint_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
class Rows:
|
||||
def __init__(self, values):
|
||||
self.values = values
|
||||
|
||||
def all(self):
|
||||
return self.values
|
||||
|
||||
class FakeDb:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def execute(self, _statement):
|
||||
return None
|
||||
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
async def scalars(self, statement):
|
||||
entity = statement.column_descriptions[0]["entity"]
|
||||
if entity is Utterance:
|
||||
return Rows([])
|
||||
if "backend_node_id IS NULL" in str(statement):
|
||||
return Rows([])
|
||||
return Rows([row])
|
||||
|
||||
journal = DbJournal(lambda: FakeDb(), node_id="node-a")
|
||||
restored = await journal.restore_active()
|
||||
assert len(restored) == 1
|
||||
assert restored[0].session_id == state.session_id
|
||||
assert restored[0].owner_login == owner
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
"""Промежуточное состояние занятия переживает смену backend-процесса."""
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.events import Exercise, LessonCriteria, SessionMode
|
||||
from app.domain.events import CommandAck, CallStarted, Exercise, LessonCriteria, SessionMode
|
||||
from app.domain.kio import KIO
|
||||
from app.domain.statuses import PhoneCallPending, ServiceStatus
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios.loader import load_file
|
||||
from app.scoring.grammar import basic_check
|
||||
from app.session.checkpoint import dump_state, load_state
|
||||
from app.session.dds import deliver_due_cards, prepare_queue
|
||||
from app.session.dds import deliver_due_cards, prepare_handoff_queue, prepare_queue
|
||||
from app.session.hub import LEASE_FENCED_MESSAGE, SessionHub
|
||||
from app.session.state import SessionState, now_utc
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
|
@ -41,7 +43,7 @@ def dds_state() -> SessionState:
|
|||
state.timers.limits[TimerCode.DDS_ACK] = 45_000
|
||||
prepare_queue(state, state.dds_scenarios)
|
||||
service = state.notified_services()[0]
|
||||
state.set_service_status(service, ServiceStatus.ACCEPTED, author="диспетчер")
|
||||
state.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу", author="диспетчер")
|
||||
state.crew_selected = state.crew_options()[0]
|
||||
state.crew_assignments[service] = state.crew_selected
|
||||
state.phone_pending = PhoneCallPending(
|
||||
|
|
@ -56,6 +58,7 @@ def dds_state() -> SessionState:
|
|||
|
||||
def test_active_dds_session_round_trips_without_losing_work():
|
||||
before = dds_state()
|
||||
before.processed_station_commands = ["2a831a63-dbb0-4d9f-af5b-21a617520001"]
|
||||
payload = dump_state(before)
|
||||
restored = load_state(
|
||||
payload,
|
||||
|
|
@ -72,6 +75,7 @@ def test_active_dds_session_round_trips_without_losing_work():
|
|||
assert restored.phone_pending == before.phone_pending
|
||||
assert restored.reply_text == before.reply_text
|
||||
assert restored.reply_grammar == before.reply_grammar
|
||||
assert restored.processed_station_commands == before.processed_station_commands
|
||||
assert restored.dds_scenarios[0].id == before.scenario_id
|
||||
# Время простоя backend входит в норматив, а не обнуляет таймер.
|
||||
timer = next(item for item in restored.timers.snapshot() if item.code is TimerCode.DDS_ACK)
|
||||
|
|
@ -104,7 +108,7 @@ def test_concurrent_dds_queue_round_trips_with_each_timer_and_status():
|
|||
prepare_queue(state, state.dds_scenarios)
|
||||
first_id = state.dispatched_card.card_id
|
||||
first_service = state.managed_services()[0]
|
||||
state.set_service_status(first_service, ServiceStatus.ACCEPTED)
|
||||
state.set_service_status(first_service, ServiceStatus.ACCEPTED, "Принято в работу")
|
||||
state.on_event("card.ack")
|
||||
second_id = state.dds_live_cards[1].card_id
|
||||
assert state.activate_dds_card(second_id)
|
||||
|
|
@ -149,7 +153,7 @@ def test_delivering_next_dds_card_does_not_clear_previous_card_state():
|
|||
prepare_queue(state, scenarios, arrival_interval_seconds=60, max_waiting=1)
|
||||
first_id = state.dds_live_cards[0].card_id
|
||||
service = state.managed_services()[0]
|
||||
state.set_service_status(service, ServiceStatus.ACCEPTED)
|
||||
state.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу")
|
||||
state.capture_active_dds()
|
||||
|
||||
assert deliver_due_cards(state, now_utc() + timedelta(seconds=61)) == 1
|
||||
|
|
@ -165,3 +169,92 @@ def test_delivering_next_dds_card_does_not_clear_previous_card_state():
|
|||
assert len(restored.dds_live_cards) == 2
|
||||
assert restored.dds_next_scenario_index == 2
|
||||
assert restored.dds_next_arrival_at is not None
|
||||
|
||||
|
||||
def test_mixed_handoff_checkpoint_preserves_operator_card_and_generated_queue():
|
||||
first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
second = load_file(LIBRARY / "tickets" / "t01-1-fire-container.yaml", LIBRARY)
|
||||
state = SessionState(
|
||||
session_id=uuid4(), scenario_id=first.id, scenario_title=first.title,
|
||||
level=first.level.value, mode=SessionMode.TRAINING,
|
||||
exercise=Exercise.CARD, handoff_to_dds=True, scenario=first,
|
||||
pending_dds_scenarios=[second],
|
||||
)
|
||||
state.kio = KIO(address="улица Ленина, 14", description="горит балкон")
|
||||
state.dispatch()
|
||||
prepare_handoff_queue(state, state.pending_dds_scenarios)
|
||||
|
||||
restored = load_state(dump_state(state), now_utc())
|
||||
|
||||
assert restored.operator_kio.address == "улица Ленина, 14"
|
||||
assert restored.operator_scenario.id == first.id
|
||||
assert restored.dds_scenarios[0].id == first.id
|
||||
assert restored.dds_scenarios[1].id == second.id
|
||||
assert len(restored.dds_live_cards) == 2
|
||||
assert restored.dds_active_card_id == state.dds_active_card_id
|
||||
|
||||
|
||||
def test_checkpoint_storage_failure_fences_and_notifies_all_data_channels():
|
||||
class BrokenJournal:
|
||||
async def checkpoint(self, _state):
|
||||
raise OSError("simulated database partition")
|
||||
|
||||
local_hub = SessionHub(journal=BrokenJournal())
|
||||
state = dds_state()
|
||||
local_hub.register(state)
|
||||
|
||||
with local_hub.observer(state.session_id) as observers, \
|
||||
local_hub.trainee(state.session_id) as trainee, \
|
||||
local_hub.station(state.session_id) as station:
|
||||
async def failing_transition():
|
||||
async with local_hub.durable_transition(state.session_id):
|
||||
local_hub.to_trainee(
|
||||
state.session_id, CallStarted(started_at=now_utc())
|
||||
)
|
||||
assert trainee.empty(), "success event escaped before durable checkpoint"
|
||||
|
||||
with pytest.raises(OSError, match="partition"):
|
||||
asyncio.run(failing_transition())
|
||||
|
||||
assert state.lease_fenced
|
||||
assert local_hub.get(state.session_id) is None
|
||||
for queue in (observers, trainee, station):
|
||||
event = queue.get_nowait()
|
||||
assert event.message == LEASE_FENCED_MESSAGE
|
||||
assert queue.empty(), "uncommitted success event leaked during fencing"
|
||||
|
||||
|
||||
def test_durable_transition_publishes_event_only_after_checkpoint_commit():
|
||||
class CommitJournal:
|
||||
committed = False
|
||||
|
||||
async def checkpoint(self, _state):
|
||||
await asyncio.sleep(0)
|
||||
self.committed = True
|
||||
|
||||
journal = CommitJournal()
|
||||
local_hub = SessionHub(journal=journal)
|
||||
state = dds_state()
|
||||
local_hub.register(state)
|
||||
|
||||
command_id = uuid4()
|
||||
with local_hub.trainee(state.session_id) as trainee, \
|
||||
local_hub.station(state.session_id) as station:
|
||||
async def transition():
|
||||
async with local_hub.durable_transition(state.session_id):
|
||||
local_hub.to_trainee(
|
||||
state.session_id, CallStarted(started_at=now_utc())
|
||||
)
|
||||
local_hub.to_station(
|
||||
state.session_id, CommandAck(command_id=command_id)
|
||||
)
|
||||
assert trainee.empty()
|
||||
assert station.empty()
|
||||
assert journal.committed
|
||||
|
||||
asyncio.run(transition())
|
||||
event = trainee.get_nowait()
|
||||
assert isinstance(event, CallStarted)
|
||||
ack = station.get_nowait()
|
||||
assert isinstance(ack, CommandAck)
|
||||
assert ack.command_id == command_id
|
||||
|
|
|
|||
59
backend/tests/test_sip_recording_cleanup.py
Normal file
59
backend/tests/test_sip_recording_cleanup.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[2] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from sip_recording_cleanup import remove_smoke_recordings # noqa: E402
|
||||
|
||||
|
||||
def test_cleanup_removes_only_new_wav_basenames_from_compose_volume(tmp_path):
|
||||
calls = []
|
||||
|
||||
def run(command, **kwargs):
|
||||
calls.append((command, kwargs))
|
||||
return subprocess.CompletedProcess(command, 0, stdout="")
|
||||
|
||||
assert remove_smoke_recordings(
|
||||
tmp_path,
|
||||
{"20260926-120000-6101-6102-abc.wav", "../keep.wav", "old.txt"},
|
||||
runner=run,
|
||||
)
|
||||
|
||||
command, options = calls[0]
|
||||
assert command[-4:] == [
|
||||
"rm", "-f", "--", "/recordings/20260926-120000-6101-6102-abc.wav",
|
||||
]
|
||||
assert "../keep.wav" not in command
|
||||
assert "old.txt" not in command
|
||||
assert options["cwd"] == tmp_path
|
||||
assert len(calls) == 2
|
||||
assert calls[1][0][-8:] == [
|
||||
"find", "/recordings", "-maxdepth", "1", "-type", "f", "-name", "*.wav",
|
||||
]
|
||||
|
||||
|
||||
def test_cleanup_does_not_run_compose_for_empty_or_unsafe_names(tmp_path):
|
||||
def unexpected_run(*_args, **_kwargs):
|
||||
raise AssertionError("no deletion command should be needed")
|
||||
|
||||
assert remove_smoke_recordings(tmp_path, {"../outside.wav", "not-a-recording.txt"}, runner=unexpected_run)
|
||||
|
||||
|
||||
def test_cleanup_reports_compose_failure(tmp_path):
|
||||
def fail(command, **_kwargs):
|
||||
return subprocess.CompletedProcess(command, 1)
|
||||
|
||||
assert not remove_smoke_recordings(tmp_path, {"new.wav"}, runner=fail)
|
||||
|
||||
|
||||
def test_cleanup_fails_if_exact_smoke_file_still_exists(tmp_path):
|
||||
calls = []
|
||||
|
||||
def run(command, **_kwargs):
|
||||
calls.append(command)
|
||||
output = "/recordings/new.wav\n" if len(calls) == 2 else ""
|
||||
return subprocess.CompletedProcess(command, 0, stdout=output)
|
||||
|
||||
assert not remove_smoke_recordings(tmp_path, {"new.wav"}, runner=run)
|
||||
|
|
@ -56,8 +56,7 @@ SCENARIO = Scenario.model_validate(
|
|||
{
|
||||
"id": "f_secret",
|
||||
"value": "муж курил на балконе",
|
||||
"hidden": True,
|
||||
"reveal_on": {"approach": "объяснил, что вину никто не ищет"},
|
||||
"reveal_on": {"question": "q_cause"},
|
||||
},
|
||||
],
|
||||
"checklist": [
|
||||
|
|
@ -100,14 +99,13 @@ def test_two_questions_in_one_line_both_count(slots):
|
|||
assert set(turn.revealed) == {"f_address", "f_people"}
|
||||
|
||||
|
||||
def test_hidden_fact_is_not_given_for_a_direct_question(slots):
|
||||
"""Скрывающий звонящий уклоняется от прямого вопроса: факт раскрывается
|
||||
только подходом, иначе механика L3 превращается в обычный чек-лист."""
|
||||
turn = slots.hear("Из-за чего начался пожар?")
|
||||
assert "f_secret" not in turn.revealed
|
||||
assert "q_cause" in slots.asked, "вопрос задан — это должно быть видно в разборе"
|
||||
def test_fact_is_revealed_only_by_its_matching_question(slots):
|
||||
unrelated = slots.hear("Где находится квартира?")
|
||||
assert "f_secret" not in unrelated.revealed
|
||||
|
||||
assert slots.reveal_by_approach("f_secret")
|
||||
cause = slots.hear("Из-за чего начался пожар?")
|
||||
assert cause.revealed == ["f_secret"]
|
||||
assert "q_cause" in slots.asked
|
||||
assert "f_secret" in [fact.id for fact in slots.revealed_facts()]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,17 @@ from uuid import uuid4
|
|||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.http import sessions as sessions_http
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
def client(monkeypatch):
|
||||
async def audit_in_memory(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(sessions_http, "audit_required", audit_in_memory)
|
||||
with TestClient(app) as test_client:
|
||||
# Сокеты закрыты ролями (lct-23): тесты входят так же,
|
||||
# как `make lesson`, — через dev-token за флагом.
|
||||
|
|
@ -97,7 +102,7 @@ def test_acknowledgement_stops_the_four_second_norm(client):
|
|||
trainee.send_json({"type": "call.answer"})
|
||||
trainee.send_json({"type": "dds.dispatch", "service": "01"})
|
||||
read_until(station, "card.received")
|
||||
station.send_json({"type": "card.ack"})
|
||||
station.send_json({"type": "card.ack", "comment": "Подтверждение приёма зафиксировано по докладу старшего группы."})
|
||||
measured = wait_for(lambda: state.timers.measured_ms(TimerCode.DDS_ACK) is not None)
|
||||
assert measured
|
||||
finally:
|
||||
|
|
@ -173,8 +178,25 @@ def test_dispatcher_sets_a_status_and_the_card_follows(client):
|
|||
try:
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.status", "service": "Служба 101", "status": "accepted"})
|
||||
error = read_until(station, "error")
|
||||
assert "комментар" in error["message"]
|
||||
assert hub.get(session_id).status_log == []
|
||||
command_id = str(uuid4())
|
||||
accepted = {
|
||||
"type": "card.status", "service": "Служба 101", "status": "accepted",
|
||||
"comment": "Старший группы подтвердил приём карточки.",
|
||||
"_command_id": command_id,
|
||||
}
|
||||
station.send_json(accepted)
|
||||
state = read_until(station, "station.state")
|
||||
assert state["snapshot"]["statuses"]["Служба 101"] == "accepted"
|
||||
assert read_until(station, "command.ack")["command_id"] == command_id
|
||||
# Simulate a retry after the client lost the ACK: the committed ID
|
||||
# returns another ACK but does not append a second status action.
|
||||
station.send_json(accepted)
|
||||
assert read_until(station, "command.ack")["command_id"] == command_id
|
||||
runtime = hub.get(session_id)
|
||||
assert sum(item.service == "Служба 101" for item in runtime.status_log) == 1
|
||||
assert "responding" in state["snapshot"]["available"]["Служба 101"]
|
||||
assert "accepted" not in state["snapshot"]["available"]["Служба 101"]
|
||||
finally:
|
||||
|
|
@ -186,7 +208,8 @@ def test_out_of_order_status_is_rejected_with_a_reason(client):
|
|||
session_id, station, contexts = dispatched(client)
|
||||
try:
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.status", "service": "Служба 101", "status": "arrived"})
|
||||
station.send_json({"type": "card.status", "service": "Служба 101", "status": "arrived",
|
||||
"comment": "Бригада доложила старшему о прибытии."})
|
||||
error = read_until(station, "error")
|
||||
assert "Принята" in error["message"]
|
||||
assert hub.get(session_id).status_log == []
|
||||
|
|
@ -211,7 +234,7 @@ def test_ack_button_still_works_and_counts_as_accepted(client):
|
|||
session_id, station, contexts = dispatched(client)
|
||||
try:
|
||||
read_until(station, "station.state")
|
||||
station.send_json({"type": "card.ack"})
|
||||
station.send_json({"type": "card.ack", "comment": "Старший группы подтвердил приём карточки."})
|
||||
state = read_until(station, "station.state")
|
||||
assert state["snapshot"]["statuses"]["Служба 101"] == "accepted"
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -34,22 +34,22 @@ def mark(service: str, status: ServiceStatus, comment: str = "", minute: int = 0
|
|||
|
||||
|
||||
def test_only_primary_statuses_are_available_at_first():
|
||||
check([], "Служба 101", ServiceStatus.ACCEPTED, "")
|
||||
check([], "Служба 101", ServiceStatus.ACCEPTED, "Принято в работу")
|
||||
check([], "Служба 101", ServiceStatus.DECLINED, "не обслуживаем, передано в УК")
|
||||
with pytest.raises(StatusError):
|
||||
check([], "Служба 101", ServiceStatus.ARRIVED, "")
|
||||
|
||||
|
||||
def test_accepted_opens_the_rest():
|
||||
log = [mark("Служба 101", ServiceStatus.ACCEPTED)]
|
||||
check(log, "Служба 101", ServiceStatus.RESPONDING, "")
|
||||
check(log, "Служба 101", ServiceStatus.COMPLETED, "")
|
||||
log = [mark("Служба 101", ServiceStatus.ACCEPTED, "Принято в работу")]
|
||||
check(log, "Служба 101", ServiceStatus.RESPONDING, "Бригада выехала")
|
||||
check(log, "Служба 101", ServiceStatus.COMPLETED, "Работы завершены")
|
||||
|
||||
|
||||
def test_declined_leads_only_back_to_accepted():
|
||||
"""Служба может передумать, но не может отказаться дважды по-разному."""
|
||||
log = [mark("Служба 101", ServiceStatus.DECLINED, "не наш адрес")]
|
||||
check(log, "Служба 101", ServiceStatus.ACCEPTED, "")
|
||||
check(log, "Служба 101", ServiceStatus.ACCEPTED, "Повторно принято")
|
||||
with pytest.raises(StatusError):
|
||||
check(log, "Служба 101", ServiceStatus.RESPONDING, "")
|
||||
|
||||
|
|
@ -113,9 +113,10 @@ def test_alarming_statuses_are_the_three_from_the_memo():
|
|||
# ── ошибки диспетчера ──
|
||||
|
||||
|
||||
def _codes(entries, services=SERVICES, elapsed=45_000):
|
||||
def _codes(entries, services=SERVICES, elapsed=45_000, crew_assignments=None):
|
||||
findings = evaluate_dispatcher(
|
||||
entries=entries, services=services, deadline_ms=30_000, elapsed_ms=elapsed
|
||||
entries=entries, services=services, crew_assignments=crew_assignments,
|
||||
deadline_ms=30_000, elapsed_ms=elapsed
|
||||
)
|
||||
return [finding.code for finding in findings]
|
||||
|
||||
|
|
@ -156,13 +157,17 @@ def test_clean_work_has_no_findings():
|
|||
log = []
|
||||
for service in SERVICES:
|
||||
log += [
|
||||
mark(service, ServiceStatus.ACCEPTED),
|
||||
mark(service, ServiceStatus.RESPONDING, minute=1),
|
||||
mark(service, ServiceStatus.ARRIVED, minute=4),
|
||||
mark(service, ServiceStatus.WORKING, minute=5),
|
||||
mark(service, ServiceStatus.COMPLETED, minute=20),
|
||||
mark(service, ServiceStatus.ACCEPTED, "Основание: доклад бригады.\nСведения: принято."),
|
||||
mark(service, ServiceStatus.RESPONDING, "Основание: доклад бригады.\nСведения: выезд.", minute=1),
|
||||
mark(service, ServiceStatus.ARRIVED, "Основание: доклад бригады.\nСведения: прибытие.", minute=4),
|
||||
mark(service, ServiceStatus.WORKING, "Основание: доклад бригады.\nСведения: начало работ.", minute=5),
|
||||
mark(service, ServiceStatus.COMPLETED, "Основание: доклад бригады.\nСведения: завершение работ.", minute=20),
|
||||
]
|
||||
assert _codes(log) == []
|
||||
assert _codes(
|
||||
log,
|
||||
elapsed=10_000,
|
||||
crew_assignments={service: "дежурная бригада" for service in SERVICES},
|
||||
) == []
|
||||
|
||||
|
||||
def test_every_finding_carries_its_reason():
|
||||
|
|
|
|||
25
backend/tests/test_timing_score.py
Normal file
25
backend/tests/test_timing_score.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import pytest
|
||||
|
||||
from app.domain.events import Metric
|
||||
from app.scoring.competency import radar
|
||||
from app.scoring.gost import GostResult
|
||||
from app.scoring.timing import time_credit
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("elapsed_ms", "expected"),
|
||||
[(0, 1.0), (90_000, 0.75), (180_000, 0.5), (360_000, 0.0), (400_000, 0.0),
|
||||
(None, 0.0)],
|
||||
)
|
||||
def test_time_credit_reduces_linearly_against_the_norm(elapsed_ms, expected):
|
||||
assert time_credit(elapsed_ms, 180_000) == expected
|
||||
|
||||
|
||||
def test_fractional_timing_credit_affects_total_and_competency_scores():
|
||||
metric = Metric(
|
||||
key="card_fill_time", title="Время заполнения карточки", fact="90 с",
|
||||
norm="180 с", passed=True, weight=2.0, credit=0.75,
|
||||
)
|
||||
result = GostResult(metrics=[metric])
|
||||
assert result.score == 75.0
|
||||
assert radar([metric])[0].value == 0.75
|
||||
|
|
@ -4,21 +4,28 @@
|
|||
Проверяются пункты приёмки карточки lct-05, а не отдельные функции.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import time
|
||||
import wave
|
||||
from uuid import uuid4
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.http import sessions as sessions_http
|
||||
from app.main import app
|
||||
from app.scenarios import store
|
||||
from app.session.hub import hub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
def client(monkeypatch):
|
||||
async def audit_in_memory(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(sessions_http, "audit_required", audit_in_memory)
|
||||
with TestClient(app) as test_client:
|
||||
# Сокеты закрыты ролями (lct-23): тесты входят так же,
|
||||
# как `make lesson`, — через dev-token за флагом.
|
||||
|
|
@ -225,6 +232,37 @@ def test_instructor_cannot_touch_the_card(client):
|
|||
assert "fields" not in fields and "kio" not in fields
|
||||
|
||||
|
||||
def test_control_contract_contains_only_handled_commands():
|
||||
"""Не рекламировать команду WebSocket, у которой нет ветки обработчика."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
from app.domain.events import InstructorToServer
|
||||
from typing import get_args
|
||||
|
||||
union = get_args(get_args(InstructorToServer)[0])
|
||||
commands = {model.model_fields["type"].default for model in union}
|
||||
source = Path(__file__).parents[1] / "app" / "api" / "ws" / "control.py"
|
||||
tree = ast.parse(source.read_text(encoding="utf-8"))
|
||||
control_matches = [
|
||||
node for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Match)
|
||||
and isinstance(node.subject, ast.Attribute)
|
||||
and isinstance(node.subject.value, ast.Name)
|
||||
and node.subject.value.id == "event"
|
||||
and node.subject.attr == "type"
|
||||
]
|
||||
assert len(control_matches) == 1, "не удалось однозначно найти dispatch control-событий"
|
||||
handled = {
|
||||
case.pattern.value.value
|
||||
for case in control_matches[0].cases
|
||||
if isinstance(case.pattern, ast.MatchValue)
|
||||
and isinstance(case.pattern.value, ast.Constant)
|
||||
and isinstance(case.pattern.value.value, str)
|
||||
}
|
||||
assert commands == handled
|
||||
|
||||
|
||||
def test_call_socket_refuses_session_that_was_not_started(client):
|
||||
with client.websocket_connect(f"/ws/call/{uuid4()}") as trainee:
|
||||
message = trainee.receive_json()
|
||||
|
|
@ -261,10 +299,31 @@ def test_answered_call_is_saved_as_wav_on_hangup(client, monkeypatch, tmp_path):
|
|||
from app.api.ws import call as call_api
|
||||
from app.voice.recording import CallRecorder
|
||||
|
||||
# Exercise the production audio callback without loading models: caller
|
||||
# audio uses the same send_audio path whether it came from TTS or a test.
|
||||
caller_pcm = (2000).to_bytes(2, "little", signed=True) * 480
|
||||
|
||||
class FakeVoice:
|
||||
def __init__(self, *, send_audio, **_kwargs):
|
||||
self.send_audio = send_audio
|
||||
self.speak_count = 0
|
||||
|
||||
def speak(self, *_args):
|
||||
self.speak_count += 1
|
||||
asyncio.get_running_loop().call_later(0.01, self.send_audio, caller_pcm)
|
||||
|
||||
def feed(self, _pcm):
|
||||
return None
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
call_api, "start_recording",
|
||||
lambda session_id: CallRecorder(tmp_path / f"{session_id}.wav"),
|
||||
)
|
||||
monkeypatch.setattr(call_api, "get_voice_models", lambda: object())
|
||||
monkeypatch.setattr(call_api, "VoiceSession", FakeVoice)
|
||||
with lesson(client) as (session_id, _):
|
||||
state = hub.get(session_id)
|
||||
path = tmp_path / f"{session_id}.wav"
|
||||
|
|
@ -272,7 +331,34 @@ def test_answered_call_is_saved_as_wav_on_hangup(client, monkeypatch, tmp_path):
|
|||
read_until(trainee, "call.incoming")
|
||||
trainee.send_json({"type": "call.answer"})
|
||||
read_until(trainee, "call.started")
|
||||
wait_for(lambda: state.recorder and len(state.recorder._segments) >= 1)
|
||||
for _ in range(10):
|
||||
outgoing = trainee.receive()
|
||||
if outgoing.get("bytes") is not None:
|
||||
assert outgoing["bytes"] == caller_pcm
|
||||
break
|
||||
else:
|
||||
raise AssertionError("TTS-реплика звонящего не пришла по WebSocket")
|
||||
first_started_at = state.started_at
|
||||
trainee.send_json({"type": "call.answer"})
|
||||
read_until(trainee, "call.started")
|
||||
assert state.started_at == first_started_at, "повторное подключение перезапустило таймер"
|
||||
assert state.voice.speak_count == 1, "повторное подключение заново проиграло вводную"
|
||||
trainee.send_bytes((1000).to_bytes(2, "little", signed=True) * 320)
|
||||
wait_for(lambda: len(state.recorder._segments) >= 2)
|
||||
|
||||
# Drop process-local runtime objects but leave the durable call journal,
|
||||
# as if the backend process had been killed and restored from PostgreSQL.
|
||||
state.recorder._journal.close()
|
||||
state.recorder = None
|
||||
state.voice = None
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
read_until(trainee, "call.incoming")
|
||||
read_until(trainee, "call.started")
|
||||
assert len(state.recorder._segments) == 2
|
||||
assert state.voice.speak_count == 0, "после восстановления повторилась первая реплика"
|
||||
trainee.send_bytes((3000).to_bytes(2, "little", signed=True) * 320)
|
||||
wait_for(lambda: len(state.recorder._segments) >= 3)
|
||||
trainee.send_json({"type": "call.hangup"})
|
||||
read_until(trainee, "call.ended")
|
||||
wait_for(path.is_file)
|
||||
|
|
@ -280,7 +366,12 @@ def test_answered_call_is_saved_as_wav_on_hangup(client, monkeypatch, tmp_path):
|
|||
assert state.recording_path == str(path)
|
||||
with wave.open(str(path), "rb") as source:
|
||||
assert source.getframerate() == 16_000
|
||||
assert source.getnframes() >= 320
|
||||
samples = source.readframes(source.getnframes())
|
||||
assert source.getnframes() >= 640
|
||||
decoded = np.frombuffer(samples, dtype="<i2")
|
||||
assert 1000 in decoded, "в записи потерян микрофонный звук оператора"
|
||||
assert 2000 in decoded, "в записи потерян звук звонящего"
|
||||
assert 3000 in decoded, "после восстановления потерян микрофонный звук оператора"
|
||||
|
||||
|
||||
def test_score_waits_for_self_assessment(client):
|
||||
|
|
@ -378,18 +469,42 @@ def test_report_shows_missed_questions_and_self_assessment_gap(client):
|
|||
assert report["hints_used"], "использованные подсказки попадают в разбор"
|
||||
|
||||
|
||||
def test_instructor_correction_keeps_the_automatic_score(client):
|
||||
with lesson(client) as (session_id, _):
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
trainee.send_json({"type": "call.answer"})
|
||||
trainee.send_json({"type": "call.hangup"})
|
||||
wait_for(lambda: hub.get(session_id).score is not None)
|
||||
def test_instructor_correction_keeps_the_automatic_score(client, postgres_access):
|
||||
from app.db.base import get_sessionmaker
|
||||
from app.session.journal import DbJournal
|
||||
|
||||
auto = client.get(f"/api/sessions/{session_id}/report").json()["score_auto"]
|
||||
corrected = client.patch(
|
||||
f"/api/sessions/{session_id}/report",
|
||||
json={"score_final": 80.0, "comment": "связь рвалась не по вине курсанта"},
|
||||
).json()
|
||||
# Unlike the websocket-only cases above, this regression exercises the
|
||||
# durable HTTP correction endpoint against a real PostgreSQL score row.
|
||||
journal = DbJournal(get_sessionmaker())
|
||||
try:
|
||||
with lesson(client) as (session_id, _):
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
trainee.send_json({"type": "call.answer"})
|
||||
trainee.send_json({"type": "call.hangup"})
|
||||
wait_for(lambda: hub.get(session_id).score is not None)
|
||||
# The websocket-only fixture deliberately disables journalling;
|
||||
# persist its computed result before testing the HTTP override.
|
||||
state = hub.get(session_id)
|
||||
client.portal.call(
|
||||
journal.start_lesson,
|
||||
session_id,
|
||||
state.scenario_id,
|
||||
state.mode.value,
|
||||
state.trainee_name,
|
||||
state.trainee_id,
|
||||
"dev",
|
||||
)
|
||||
client.portal.call(journal.score, session_id, state.score["score_auto"], state.score)
|
||||
|
||||
auto = client.get(f"/api/sessions/{session_id}/report").json()["score_auto"]
|
||||
response = client.patch(
|
||||
f"/api/sessions/{session_id}/report",
|
||||
json={"score_final": 80.0, "comment": "связь рвалась не по вине курсанта"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
corrected = response.json()
|
||||
finally:
|
||||
hub.journal = None
|
||||
|
||||
assert corrected["score_final"] == 80.0
|
||||
assert corrected["score_auto"] == auto, "автооценка должна сохраниться рядом"
|
||||
|
|
@ -423,6 +538,32 @@ def test_ws_score_override_rejects_other_session_and_invalid_value(client):
|
|||
assert state.score["overridden_by"] == "dev"
|
||||
|
||||
|
||||
def test_ws_score_override_is_not_applied_when_atomic_persistence_fails(client):
|
||||
class FailedJournal:
|
||||
async def score_override(self, *_args):
|
||||
return False
|
||||
|
||||
async def checkpoint(self, *_args):
|
||||
return None
|
||||
|
||||
with lesson(client) as (session_id, control):
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
trainee.send_json({"type": "call.answer"})
|
||||
trainee.send_json({"type": "call.hangup"})
|
||||
wait_for(lambda: state.score is not None)
|
||||
|
||||
auto = state.score["score_auto"]
|
||||
hub.journal = FailedJournal()
|
||||
control.send_json({
|
||||
"type": "score.override", "session_id": str(session_id),
|
||||
"verdict": "85", "comment": "manual review",
|
||||
})
|
||||
time.sleep(0.1)
|
||||
assert state.score["score_auto"] == auto
|
||||
assert "score_final" not in state.score
|
||||
|
||||
|
||||
def test_soft_directive_changes_how_the_caller_sounds(client):
|
||||
from app.domain.events import Mood
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""Знание URL занятия не даёт курсанту доступ к чужому АРМ."""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import json
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
|
|
@ -10,7 +12,9 @@ from fastapi.testclient import TestClient
|
|||
from app.api.auth import Principal
|
||||
from app.domain.roles import Role
|
||||
from app.main import app
|
||||
from app.scenarios import store
|
||||
from app.session.hub import hub
|
||||
from app.session.state import SessionState
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -61,3 +65,133 @@ def test_trainee_cannot_open_foreign_or_unassigned_call_or_station(client, monke
|
|||
break
|
||||
else:
|
||||
raise AssertionError("владелец занятия не допущен")
|
||||
|
||||
|
||||
def test_instructor_cannot_start_or_queue_another_instructors_scenario(client, monkeypatch):
|
||||
source = store.get("fire-apartment-l2")
|
||||
assert source is not None
|
||||
private = source.model_copy(update={"id": "private-ws-owner-test"}, deep=True)
|
||||
store.register_owned_scenario(private, "another-instructor")
|
||||
emitted = []
|
||||
monkeypatch.setattr(hub, "to_observers", lambda _session_id, event: emitted.append(event))
|
||||
public_id = "fire-apartment-l2"
|
||||
cases = [
|
||||
{"scenario_id": private.id},
|
||||
{"scenario_id": public_id, "exercise": "dds",
|
||||
"scenario_ids": [public_id, private.id]},
|
||||
{"scenario_id": public_id, "exercise": "dds",
|
||||
"random_scenario_ids": [public_id, private.id]},
|
||||
]
|
||||
sessions = []
|
||||
try:
|
||||
for fields in cases:
|
||||
session_id = uuid4()
|
||||
sessions.append(session_id)
|
||||
with client.websocket_connect(f"/ws/control/{session_id}") as control:
|
||||
control.send_json({
|
||||
"type": "scenario.start", "trainee": "Курсант", "mode": "training",
|
||||
**fields,
|
||||
})
|
||||
deadline = time.monotonic() + 3
|
||||
while len(emitted) < len(sessions) and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
assert emitted[-1].code.value == "scenario_invalid"
|
||||
assert hub.get(session_id) is None
|
||||
finally:
|
||||
for session_id in sessions:
|
||||
hub.drop(session_id)
|
||||
store._library.pop(private.id, None)
|
||||
store._demo_scenario_owners.pop(private.id, None)
|
||||
|
||||
|
||||
def test_idle_control_socket_closes_when_backend_lease_becomes_uncertain(monkeypatch):
|
||||
control_module = importlib.import_module("app.api.ws.control")
|
||||
session_id = uuid4()
|
||||
state = SessionState(
|
||||
session_id=session_id, scenario_id="test", scenario_title="Тест", level="L1",
|
||||
mode="training",
|
||||
)
|
||||
hub.register(state)
|
||||
who = Principal(login="lease-owner", full_name="Преподаватель", role=Role.INSTRUCTOR)
|
||||
monkeypatch.setattr(control_module, "websocket_origin_allowed", lambda _ws: True)
|
||||
monkeypatch.setattr(control_module, "principal_of", lambda _ws: who)
|
||||
|
||||
class IdleSocket:
|
||||
accepted = False
|
||||
closed = None
|
||||
sent = []
|
||||
|
||||
async def accept(self):
|
||||
self.accepted = True
|
||||
|
||||
async def receive_json(self):
|
||||
await asyncio.sleep(0.02)
|
||||
await hub.fence(state)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def send_text(self, message):
|
||||
self.sent.append(json.loads(message))
|
||||
|
||||
async def close(self, code=None):
|
||||
self.closed = code
|
||||
|
||||
socket = IdleSocket()
|
||||
try:
|
||||
asyncio.run(control_module.control(socket, session_id))
|
||||
assert socket.accepted
|
||||
assert socket.closed == 1012
|
||||
assert socket.sent[-1]["message"].endswith("переподключитесь.")
|
||||
finally:
|
||||
hub.drop(session_id)
|
||||
|
||||
|
||||
def test_control_command_checkpoint_failure_returns_fencing_error(monkeypatch):
|
||||
control_module = importlib.import_module("app.api.ws.control")
|
||||
session_id = uuid4()
|
||||
state = SessionState(
|
||||
session_id=session_id, scenario_id="test", scenario_title="Тест", level="L1",
|
||||
mode="training", owner_login="lease-owner",
|
||||
)
|
||||
|
||||
class BrokenJournal:
|
||||
async def checkpoint(self, _state):
|
||||
raise OSError("simulated database partition")
|
||||
|
||||
class OneCommandSocket:
|
||||
accepted = False
|
||||
closed = None
|
||||
sent = []
|
||||
|
||||
async def accept(self):
|
||||
self.accepted = True
|
||||
|
||||
async def receive_json(self):
|
||||
return {"type": "reference.play"}
|
||||
|
||||
async def send_text(self, message):
|
||||
self.sent.append(json.loads(message))
|
||||
|
||||
async def close(self, code=None):
|
||||
self.closed = code
|
||||
|
||||
who = Principal(login="lease-owner", full_name="Преподаватель", role=Role.INSTRUCTOR)
|
||||
socket = OneCommandSocket()
|
||||
old_journal = hub.journal
|
||||
hub.journal = BrokenJournal()
|
||||
hub.register(state)
|
||||
monkeypatch.setattr(control_module, "websocket_origin_allowed", lambda _ws: True)
|
||||
monkeypatch.setattr(control_module, "principal_of", lambda _ws: who)
|
||||
try:
|
||||
with hub.observer(session_id) as observer_queue:
|
||||
asyncio.run(control_module.control(socket, session_id))
|
||||
assert socket.accepted
|
||||
assert state.lease_fenced
|
||||
assert socket.closed == 1012
|
||||
assert socket.sent[-1]["type"] == "error"
|
||||
assert "переподключитесь" in socket.sent[-1]["message"]
|
||||
event = observer_queue.get_nowait()
|
||||
assert "переподключитесь" in event.message
|
||||
assert observer_queue.empty(), "uncommitted controller event leaked to observers"
|
||||
finally:
|
||||
hub.journal = old_journal
|
||||
hub.drop(session_id)
|
||||
|
|
|
|||
Loading…
Reference in a new issue