refactor: запись хода занятия — один commit SessionStore на операцию вместо журнала и checkpoint
Правка балла с пульта и из отчёта идёт одной доменной операцией: раньше WS-путь не обновлял full_report живой сессии.
This commit is contained in:
parent
72c6a7cfa3
commit
47cb85ee02
30 changed files with 1406 additions and 1161 deletions
|
|
@ -15,12 +15,13 @@ from app.api.auth import Principal
|
|||
from app.domain.roles import Role
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
from app.session.store import MemorySessionStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with TestClient(app) as test_client:
|
||||
hub.journal = None
|
||||
hub.store = MemorySessionStore()
|
||||
yield test_client
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -19,12 +19,13 @@ from app.api.auth import hash_password, verify_password
|
|||
from app.domain.roles import Role
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
from app.session.store import MemorySessionStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with TestClient(app) as test_client:
|
||||
hub.journal = None
|
||||
hub.store = MemorySessionStore()
|
||||
yield test_client
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from app.api.http import admin
|
|||
from app.domain.roles import Role
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
from app.session.store import MemorySessionStore
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -92,7 +93,7 @@ def client():
|
|||
# process' in-memory cache.
|
||||
auth.prime_generations({})
|
||||
with TestClient(app) as test_client:
|
||||
hub.journal = None
|
||||
hub.store = MemorySessionStore()
|
||||
yield test_client
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import pytest
|
|||
from app.api.ws import call
|
||||
from app.dialog.slots import TurnResult
|
||||
from app.domain.events import Exercise, TranscriptEntry
|
||||
from app.session.store import MemorySessionStore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -24,7 +25,7 @@ async def test_text_dialogue_provider_error_does_not_log_prompt_or_provider_body
|
|||
def revealed_facts(self):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(call.hub, "journal", None)
|
||||
monkeypatch.setattr(call.hub, "store", MemorySessionStore())
|
||||
monkeypatch.setattr(call.hub, "to_trainee", lambda *_args: None)
|
||||
monkeypatch.setattr(call.hub, "to_observers", lambda *_args: None)
|
||||
state = SimpleNamespace(
|
||||
|
|
@ -41,7 +42,9 @@ async def test_text_dialogue_provider_error_does_not_log_prompt_or_provider_body
|
|||
),
|
||||
)
|
||||
|
||||
await call._handle(uuid4(), state, SimpleNamespace(type="text.turn", text="where is the incident"))
|
||||
session_id = uuid4()
|
||||
async with call.hub.operation(session_id):
|
||||
await call._handle(session_id, state, SimpleNamespace(type="text.turn", text="where is the incident"))
|
||||
|
||||
assert secret not in caplog.text
|
||||
assert "RuntimeError" in caplog.text
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ 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
|
||||
from app.session.store import MemorySessionStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -23,7 +24,7 @@ def client(monkeypatch):
|
|||
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
|
||||
hub.store = MemorySessionStore()
|
||||
yield test_client
|
||||
|
||||
|
||||
|
|
@ -450,41 +451,25 @@ def test_handoff_report_and_checklist_follow_operator_scenario(client, monkeypat
|
|||
|
||||
state.self_assessed = True
|
||||
state.self_assessment = {"missed": [checklist[0]["id"]], "comment": ""}
|
||||
asyncio.run(refresh_archived_report(session_id, state))
|
||||
async def refresh():
|
||||
async with hub.operation(session_id):
|
||||
await refresh_archived_report(session_id, state)
|
||||
|
||||
asyncio.run(refresh())
|
||||
assert state.score["full_report"]["scenario_id"] == "fire-apartment-l2"
|
||||
assert state.score["full_report"]["metrics"] == state.score["metrics"]
|
||||
|
||||
session_row = SimpleNamespace(id=session_id, owner_login="teacher")
|
||||
score_row = SimpleNamespace(
|
||||
score_auto=state.score["score_auto"], score_final=state.score["score_auto"],
|
||||
overridden_by=None, override_comment=None,
|
||||
report={"full_report": state.score["full_report"]},
|
||||
)
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, statement):
|
||||
return score_row
|
||||
|
||||
def add(self, row):
|
||||
pass
|
||||
|
||||
async def commit(self):
|
||||
pass
|
||||
|
||||
async def fake_session(db, key):
|
||||
return session_row
|
||||
|
||||
monkeypatch.setattr(sessions_http.repo, "get_session", fake_session)
|
||||
monkeypatch.setattr(
|
||||
sessions_http, "require",
|
||||
lambda request, *roles: Principal(login="teacher", full_name="Преподаватель",
|
||||
lambda request, *roles: Principal(login=state.owner_login, full_name="Преподаватель",
|
||||
role=Role.INSTRUCTOR),
|
||||
)
|
||||
corrected = asyncio.run(sessions_http.override(
|
||||
session_id,
|
||||
sessions_http.ScoreOverride(score_final=50, comment="проверено"),
|
||||
object(), FakeDb(),
|
||||
object(), None,
|
||||
))
|
||||
assert corrected.scenario_id == "fire-apartment-l2"
|
||||
assert state.score["full_report"]["score_final"] == 50
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
|
|
|||
|
|
@ -27,18 +27,59 @@ from app.db.models import (
|
|||
Score, SelfAssessment, Session, Trainee, User, Utterance,
|
||||
)
|
||||
from app.db.repo import SessionNodeConflict, ensure_session
|
||||
from app.domain.events import Exercise, SessionMode
|
||||
from app.domain.events import Exercise, SessionMode, Speaker
|
||||
from app.domain.roles import Role
|
||||
from app.main import LIBRARY, app
|
||||
from app.scenarios import store
|
||||
from app.session.checkpoint import load_state
|
||||
from app.session.dds import build_card
|
||||
from app.session.hub import hub
|
||||
from app.session.journal import DbJournal, SessionLeaseLost
|
||||
from app.session.pg_store import PostgresSessionStore
|
||||
from app.session.store import (
|
||||
LessonRequest,
|
||||
ScoreCalculated,
|
||||
ScoreOverridden,
|
||||
SelfAssessed,
|
||||
SessionLeaseLost,
|
||||
UtteranceAppended,
|
||||
)
|
||||
from app.session.state import SessionState
|
||||
from app.session.timers import SessionTimers
|
||||
|
||||
|
||||
def _state(session_id, scenario, **fields) -> SessionState:
|
||||
return SessionState(
|
||||
session_id=session_id, scenario_id=scenario.id, scenario_title=scenario.title,
|
||||
level=scenario.level, mode=SessionMode.TRAINING, **fields,
|
||||
)
|
||||
|
||||
|
||||
async def _open(store, scenario, session_id, trainee_name, trainee_id=None, owner_login=None):
|
||||
"""Открыть занятие и вернуть его идентичность — как делает пульт."""
|
||||
seen = {}
|
||||
|
||||
def build(identity):
|
||||
seen["identity"] = identity
|
||||
return _state(
|
||||
session_id, scenario, owner_login=owner_login, trainee_id=identity.trainee_id,
|
||||
attempt=identity.attempt, backend_fencing_epoch=identity.fencing_epoch,
|
||||
)
|
||||
|
||||
state = await store.open(LessonRequest(
|
||||
session_id=session_id, scenario_id=scenario.id, mode="training",
|
||||
trainee_name=trainee_name, trainee_id=trainee_id, owner_login=owner_login,
|
||||
backend_node_id=None,
|
||||
), build)
|
||||
return state, seen["identity"]
|
||||
|
||||
|
||||
def _entry(text):
|
||||
from app.domain.events import Speaker, TranscriptEntry
|
||||
|
||||
return TranscriptEntry(ref="u1", speaker=Speaker.CALLER, text=text,
|
||||
at=datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db():
|
||||
"""Свой движок на каждый тест: глобальный в app.db.base кэшируется и
|
||||
|
|
@ -340,16 +381,9 @@ async def test_station_command_ids_survive_a_real_postgres_checkpoint(db, scenar
|
|||
db, scenario_id=scenario.id, mode="training", session_id=uuid4(),
|
||||
)
|
||||
command_id = str(uuid4())
|
||||
state = SessionState(
|
||||
session_id=session.id,
|
||||
scenario_id=scenario.id,
|
||||
scenario_title=scenario.title,
|
||||
level=scenario.level,
|
||||
mode=SessionMode.TRAINING,
|
||||
processed_station_commands=[command_id],
|
||||
)
|
||||
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False))
|
||||
await journal.checkpoint(state)
|
||||
state = _state(session.id, scenario, processed_station_commands=[command_id])
|
||||
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
||||
await pg.commit(state)
|
||||
|
||||
observer_engine = create_async_engine(get_settings().database_url)
|
||||
try:
|
||||
|
|
@ -406,24 +440,22 @@ async def test_security_audit_older_than_six_months_remains_queryable(db, monkey
|
|||
|
||||
|
||||
async def test_websocket_score_override_and_audit_commit_atomically(db, scenario):
|
||||
from app.session.journal import DbJournal
|
||||
|
||||
session = await repo.create_session(
|
||||
db, scenario_id=scenario.id, mode="training", owner_login="score-teacher"
|
||||
)
|
||||
score = Score(
|
||||
session_id=session.id, score_auto=70, score_final=70,
|
||||
report={"full_report": {"score_auto": 70, "score_final": 70}},
|
||||
report={"score_auto": 70, "full_report": {"score_auto": 70, "score_final": 70}},
|
||||
)
|
||||
db.add(score)
|
||||
await db.commit()
|
||||
maker = async_sessionmaker(db.bind, expire_on_commit=False)
|
||||
journal = DbJournal(maker)
|
||||
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
||||
state = _state(session.id, scenario, owner_login="score-teacher")
|
||||
|
||||
# Force the audit insert to fail after the score row is staged. PostgreSQL
|
||||
# must roll back both, and the websocket handler can then refuse the change.
|
||||
saved = await journal.score_override(session.id, 85, "x" * 81, "manual review")
|
||||
assert not saved
|
||||
# must roll back both, and the commit must fail the whole operation.
|
||||
with pytest.raises(Exception):
|
||||
await pg.commit(state, [ScoreOverridden(85, "x" * 81, "instructor", "manual review")])
|
||||
await db.refresh(score)
|
||||
assert score.score_final == 70
|
||||
assert score.overridden_by is None
|
||||
|
|
@ -433,14 +465,12 @@ async def test_websocket_score_override_and_audit_commit_atomically(db, scenario
|
|||
)
|
||||
) is None
|
||||
|
||||
saved = await journal.score_override(
|
||||
session.id, 85, "score-teacher", "manual review"
|
||||
)
|
||||
assert saved
|
||||
await pg.commit(state, [ScoreOverridden(85, "score-teacher", "instructor", "manual review")])
|
||||
await db.refresh(score)
|
||||
assert score.score_auto == 70
|
||||
assert score.score_final == 85
|
||||
assert score.overridden_by == "score-teacher"
|
||||
assert score.report["score_final"] == 85
|
||||
assert score.report["full_report"]["score_final"] == 85
|
||||
audit_row = await db.scalar(
|
||||
select(AuditLog).where(
|
||||
|
|
@ -455,16 +485,16 @@ async def test_websocket_score_override_and_audit_commit_atomically(db, scenario
|
|||
|
||||
|
||||
async def test_initial_result_and_calculation_audit_commit_atomically(db, scenario):
|
||||
from app.session.journal import DbJournal
|
||||
|
||||
session = await repo.create_session(
|
||||
db, scenario_id=scenario.id, mode="training", owner_login="score-teacher"
|
||||
)
|
||||
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False))
|
||||
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
||||
state = _state(session.id, scenario, owner_login="score-teacher")
|
||||
|
||||
# Invalid JSON makes PostgreSQL reject the score transaction; no orphaned
|
||||
# calculation-audit row may remain (or vice versa).
|
||||
assert not await journal.score(session.id, 71, {"bad": object()})
|
||||
with pytest.raises(Exception):
|
||||
await pg.commit(state, [ScoreCalculated(71, {"bad": object()})])
|
||||
assert await db.scalar(
|
||||
select(Score.id).where(Score.session_id == session.id)
|
||||
) is None
|
||||
|
|
@ -472,7 +502,7 @@ async def test_initial_result_and_calculation_audit_commit_atomically(db, scenar
|
|||
AuditLog.action == "score.calculate", AuditLog.object_id == str(session.id)
|
||||
)) is None
|
||||
|
||||
assert await journal.score(session.id, 71, {"full_report": {"score_auto": 71}})
|
||||
await pg.commit(state, [ScoreCalculated(71, {"full_report": {"score_auto": 71}})])
|
||||
score = await db.scalar(select(Score).where(Score.session_id == session.id))
|
||||
audit_row = await db.scalar(select(AuditLog).where(
|
||||
AuditLog.action == "score.calculate", AuditLog.object_id == str(session.id)
|
||||
|
|
@ -574,12 +604,9 @@ async def test_websocket_lesson_start_cannot_claim_foreign_group_trainee(db, sce
|
|||
await db.commit()
|
||||
|
||||
session_id = uuid4()
|
||||
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False))
|
||||
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
||||
with pytest.raises(PermissionError, match="другой учебной групп"):
|
||||
await journal.start_lesson(
|
||||
session_id, scenario.id, "training", trainee.name, trainee.id,
|
||||
owner_login=teacher,
|
||||
)
|
||||
await _open(pg, scenario, session_id, trainee.name, trainee.id, owner_login=teacher)
|
||||
assert await db.get(Session, session_id) is None
|
||||
assert await db.scalar(select(AuditLog.id).where(
|
||||
AuditLog.action == "lesson.start", AuditLog.object_id == str(session_id)
|
||||
|
|
@ -592,14 +619,13 @@ async def test_websocket_lesson_start_cannot_claim_foreign_group_trainee(db, sce
|
|||
|
||||
async def test_websocket_lesson_start_audit_is_committed_with_session(db, scenario):
|
||||
session_id = uuid4()
|
||||
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False))
|
||||
result = await journal.start_lesson(
|
||||
session_id, scenario.id, "training", "Курсант", owner_login="lesson-teacher"
|
||||
)
|
||||
assert result is not None and result[0] == 1 and result[1] is not None
|
||||
assert result[2] is None
|
||||
assert result[3] == 1
|
||||
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
||||
_, identity = await _open(pg, scenario, session_id, "Курсант", owner_login="lesson-teacher")
|
||||
assert identity.attempt == 1 and identity.trainee_id is not None
|
||||
assert identity.service is None
|
||||
assert identity.fencing_epoch == 1
|
||||
persisted = await db.get(Session, session_id)
|
||||
assert persisted.live_state is not None, "первый снимок пишется вместе со строкой занятия"
|
||||
audit_row = await db.scalar(select(AuditLog).where(
|
||||
AuditLog.action == "lesson.start", AuditLog.object_id == str(session_id)
|
||||
))
|
||||
|
|
@ -609,25 +635,67 @@ async def test_websocket_lesson_start_audit_is_committed_with_session(db, scenar
|
|||
await db.commit()
|
||||
|
||||
|
||||
async def test_store_commit_is_one_transaction_with_one_fencing_check(db, scenario):
|
||||
"""Реплика, финиш и оценка — одна транзакция и один `(node_id, epoch)`-чек."""
|
||||
from sqlalchemy import event
|
||||
|
||||
from app.session.store import LessonEnded
|
||||
|
||||
session_id = uuid4()
|
||||
maker = async_sessionmaker(db.bind, expire_on_commit=False)
|
||||
opened = []
|
||||
|
||||
def counting_maker():
|
||||
opened.append(True)
|
||||
return maker()
|
||||
|
||||
pg = PostgresSessionStore(counting_maker, node_id="node-a")
|
||||
state, _ = await _open(pg, scenario, session_id, "Курсант", owner_login="lesson-teacher")
|
||||
statements = []
|
||||
|
||||
def seen(_conn, _cursor, statement, *_args):
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(db.bind.sync_engine, "before_cursor_execute", seen)
|
||||
opened.clear()
|
||||
try:
|
||||
entry = state.append(Speaker.CALLER, "адрес: Ленина, 14")
|
||||
state.ended_at = datetime.now(timezone.utc)
|
||||
await pg.commit(state, [
|
||||
UtteranceAppended(entry),
|
||||
LessonEnded(state.ended_at, "complete"),
|
||||
ScoreCalculated(71, {"score_auto": 71}),
|
||||
])
|
||||
finally:
|
||||
event.remove(db.bind.sync_engine, "before_cursor_execute", seen)
|
||||
|
||||
assert len(opened) == 1, "одна операция — одна транзакция"
|
||||
fence_checks = [sql for sql in statements
|
||||
if sql.startswith("UPDATE sessions SET backend_lease_until")]
|
||||
assert len(fence_checks) == 1
|
||||
row = await db.get(Session, session_id)
|
||||
await db.refresh(row)
|
||||
assert row.end_reason == "complete" and row.live_state is None
|
||||
assert await db.scalar(select(Utterance.text).where(Utterance.session_id == session_id)) == (
|
||||
"адрес: Ленина, 14"
|
||||
)
|
||||
await db.execute(delete(AuditLog).where(AuditLog.object_id == str(session_id)))
|
||||
await db.execute(delete(Session).where(Session.id == session_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def test_old_backend_fencing_epoch_cannot_append_transcript(db, scenario):
|
||||
session_id = uuid4()
|
||||
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-a")
|
||||
started = await journal.start_lesson(
|
||||
session_id, scenario.id, "training", "Курсант", owner_login="lesson-teacher"
|
||||
)
|
||||
assert started is not None
|
||||
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-a")
|
||||
state, identity = await _open(pg, scenario, session_id, "Курсант", owner_login="lesson-teacher")
|
||||
await db.execute(
|
||||
update(Session)
|
||||
.where(Session.id == session_id)
|
||||
.values(backend_node_id="node-b", backend_fencing_epoch=started[3] + 1)
|
||||
.values(backend_node_id="node-b", backend_fencing_epoch=identity.fencing_epoch + 1)
|
||||
)
|
||||
await db.commit()
|
||||
entry = SimpleNamespace(
|
||||
ref="u1", speaker=SimpleNamespace(value="caller"), text="проверка fencing",
|
||||
at=datetime.now(timezone.utc), mood=None,
|
||||
)
|
||||
with pytest.raises(SessionLeaseLost):
|
||||
await journal.utterance(session_id, entry)
|
||||
await pg.commit(state, [UtteranceAppended(_entry("проверка fencing"))])
|
||||
assert await db.scalar(select(Utterance.id).where(Utterance.session_id == session_id)) is None
|
||||
await db.execute(delete(AuditLog).where(AuditLog.object_id == str(session_id)))
|
||||
await db.execute(delete(Session).where(Session.id == session_id))
|
||||
|
|
@ -636,44 +704,28 @@ async def test_old_backend_fencing_epoch_cannot_append_transcript(db, scenario):
|
|||
|
||||
async def test_expired_backend_lease_is_atomically_claimed_and_restored(db, scenario):
|
||||
session_id = uuid4()
|
||||
old = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-a")
|
||||
initial = await old.start_lesson(
|
||||
session_id, scenario.id, "training", "Курсант", owner_login="lease-teacher"
|
||||
)
|
||||
assert initial is not None
|
||||
snapshot = SessionState(
|
||||
session_id=session_id,
|
||||
scenario_id=scenario.id,
|
||||
scenario_title=scenario.title,
|
||||
level=scenario.level,
|
||||
mode=SessionMode.TRAINING,
|
||||
owner_login="lease-teacher",
|
||||
exercise=Exercise.DDS,
|
||||
backend_fencing_epoch=initial[3],
|
||||
)
|
||||
await old.checkpoint(snapshot)
|
||||
old = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-a")
|
||||
snapshot, identity = await _open(old, scenario, session_id, "Курсант", owner_login="lease-teacher")
|
||||
snapshot.exercise = Exercise.DDS
|
||||
await old.commit(snapshot)
|
||||
row = await db.get(Session, session_id)
|
||||
assert row is not None
|
||||
row.backend_lease_until = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
await db.commit()
|
||||
|
||||
new = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-b")
|
||||
new = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False), node_id="node-b")
|
||||
restored = await new.claim_expired(session_id)
|
||||
assert len(restored) == 1
|
||||
assert restored[0].session_id == session_id
|
||||
assert restored[0].backend_fencing_epoch == initial[3] + 1
|
||||
assert restored[0].backend_fencing_epoch == identity.fencing_epoch + 1
|
||||
persisted = await db.get(Session, session_id)
|
||||
assert persisted is not None
|
||||
await db.refresh(persisted)
|
||||
assert persisted.backend_node_id == "node-b"
|
||||
assert persisted.backend_fencing_epoch == initial[3] + 1
|
||||
assert persisted.backend_fencing_epoch == identity.fencing_epoch + 1
|
||||
|
||||
entry = SimpleNamespace(
|
||||
ref="u1", speaker=SimpleNamespace(value="caller"), text="stale owner",
|
||||
at=datetime.now(timezone.utc), mood=None,
|
||||
)
|
||||
with pytest.raises(SessionLeaseLost):
|
||||
await old.utterance(session_id, entry)
|
||||
await old.commit(snapshot, [UtteranceAppended(_entry("stale owner"))])
|
||||
assert await db.scalar(select(Utterance.id).where(Utterance.session_id == session_id)) is None
|
||||
await db.execute(delete(AuditLog).where(AuditLog.object_id == str(session_id)))
|
||||
await db.execute(delete(Session).where(Session.id == session_id))
|
||||
|
|
@ -681,23 +733,21 @@ async def test_expired_backend_lease_is_atomically_claimed_and_restored(db, scen
|
|||
|
||||
|
||||
async def test_self_assessment_and_audit_commit_atomically(db, scenario):
|
||||
from app.session.journal import DbJournal
|
||||
|
||||
trainee = await repo.ensure_trainee(db, f"reflection-{uuid4().hex[:12]}")
|
||||
session = await repo.create_session(
|
||||
db, scenario_id=scenario.id, mode="training", trainee_id=trainee.id,
|
||||
owner_login="reflection-teacher",
|
||||
)
|
||||
journal = DbJournal(async_sessionmaker(db.bind, expire_on_commit=False))
|
||||
pg = PostgresSessionStore(async_sessionmaker(db.bind, expire_on_commit=False))
|
||||
state = _state(session.id, scenario, trainee_id=trainee.id)
|
||||
|
||||
class InvalidText:
|
||||
def __len__(self):
|
||||
return 12
|
||||
|
||||
# Let the DB reject the staged assessment, and verify its audit rolls back too.
|
||||
assert not await journal.self_assessment(
|
||||
session.id, ["q_address"], InvalidText(), datetime.now(timezone.utc)
|
||||
)
|
||||
with pytest.raises(Exception):
|
||||
await pg.commit(state, [SelfAssessed(["q_address"], InvalidText(), datetime.now(timezone.utc))])
|
||||
assert await db.scalar(select(SelfAssessment.id).where(
|
||||
SelfAssessment.session_id == session.id
|
||||
)) is None
|
||||
|
|
@ -705,9 +755,9 @@ async def test_self_assessment_and_audit_commit_atomically(db, scenario):
|
|||
AuditLog.action == "self_assessment.submit", AuditLog.object_id == str(session.id)
|
||||
)) is None
|
||||
|
||||
assert await journal.self_assessment(
|
||||
session.id, ["q_address"], "адрес уточнил поздно", datetime.now(timezone.utc)
|
||||
)
|
||||
await pg.commit(state, [SelfAssessed(
|
||||
["q_address"], "адрес уточнил поздно", datetime.now(timezone.utc),
|
||||
)])
|
||||
assessment = await db.scalar(select(SelfAssessment).where(
|
||||
SelfAssessment.session_id == session.id
|
||||
))
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from app.session import timers as timers_module
|
|||
from app.session.checkpoint import dump_state, load_state
|
||||
from app.session.dds import prepare_handoff_queue, prepare_queue
|
||||
from app.session.hub import hub
|
||||
from app.session.store import MemorySessionStore
|
||||
from app.session.state import SessionState
|
||||
from app.session.timers import now_utc
|
||||
|
||||
|
|
@ -30,6 +31,12 @@ LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
|||
_events = TypeAdapter(StationToServer)
|
||||
|
||||
|
||||
async def finish_in_operation(state):
|
||||
"""finish() пишет оценку в commit операции, завершившей занятие."""
|
||||
async with hub.operation(state.session_id):
|
||||
await finish_module.finish(state.session_id, state)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def offline_finish(monkeypatch):
|
||||
class NoCoaching:
|
||||
|
|
@ -40,7 +47,7 @@ def offline_finish(monkeypatch):
|
|||
return NoCoaching()
|
||||
|
||||
monkeypatch.setattr(finish_module, "coach", no_coach)
|
||||
monkeypatch.setattr(hub, "journal", None)
|
||||
monkeypatch.setattr(hub, "store", MemorySessionStore())
|
||||
|
||||
|
||||
def fire():
|
||||
|
|
@ -150,7 +157,7 @@ def test_call_checkpoint_keeps_dispatcher_work_on_shared_timers():
|
|||
assert snapshot.queue_cards == []
|
||||
|
||||
state.ended_at = now_utc()
|
||||
asyncio.run(finish_module.finish(state.session_id, state))
|
||||
asyncio.run(finish_in_operation(state))
|
||||
|
||||
crew = next(item for item in state.score["metrics"] if item["key"] == "dds_crew")
|
||||
assert crew["passed"] and crew["fact"] == card.crew_selected
|
||||
|
|
@ -189,7 +196,7 @@ def test_finish_scores_every_card_without_switching_active(name):
|
|||
unscored = {card.card_id for card in state.desk.cards.values()}
|
||||
|
||||
state.ended_at = now_utc()
|
||||
asyncio.run(finish_module.finish(state.session_id, state))
|
||||
asyncio.run(finish_in_operation(state))
|
||||
|
||||
assert state.desk.active is active
|
||||
assert {record.card_id for record in state.desk.completed} == unscored
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from app.scenarios.loader import load_file
|
|||
from app.session import finish as finish_module
|
||||
from app.session.dds import prepare_handoff_queue, prepare_queue
|
||||
from app.session.hub import hub
|
||||
from app.session.store import MemorySessionStore
|
||||
from app.session.state import SessionState
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
|
@ -32,6 +33,12 @@ COMMENT = "Основание: доклад старшего.\nСведения:
|
|||
_events = TypeAdapter(StationToServer)
|
||||
|
||||
|
||||
async def finish_in_operation(state):
|
||||
"""finish() пишет оценку в commit операции, завершившей занятие."""
|
||||
async with hub.operation(state.session_id):
|
||||
await finish_module.finish(state.session_id, state)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def offline_finish(monkeypatch):
|
||||
class NoCoaching:
|
||||
|
|
@ -42,7 +49,7 @@ def offline_finish(monkeypatch):
|
|||
return NoCoaching()
|
||||
|
||||
monkeypatch.setattr(finish_module, "coach", no_coach)
|
||||
monkeypatch.setattr(hub, "journal", None)
|
||||
monkeypatch.setattr(hub, "store", MemorySessionStore())
|
||||
|
||||
|
||||
def scenario(name: str):
|
||||
|
|
@ -51,7 +58,13 @@ def scenario(name: str):
|
|||
|
||||
def station(state: SessionState, **payload) -> None:
|
||||
hub.register(state)
|
||||
asyncio.run(station_ws._handle(state.session_id, state, _events.validate_python(payload)))
|
||||
event = _events.validate_python(payload)
|
||||
|
||||
async def command():
|
||||
async with hub.operation(state.session_id):
|
||||
await station_ws._handle(state.session_id, state, event)
|
||||
|
||||
asyncio.run(command())
|
||||
|
||||
|
||||
def active_id(state: SessionState):
|
||||
|
|
@ -167,7 +180,7 @@ def call_with_live_dispatcher() -> SessionState:
|
|||
state.dispatch()
|
||||
work_card(state)
|
||||
state.ended_at = state.dispatched_at
|
||||
asyncio.run(finish_module.finish(state.session_id, state))
|
||||
asyncio.run(finish_in_operation(state))
|
||||
return state
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from app.domain.timers import TimerCode
|
|||
from app.main import app
|
||||
from app.scenarios import store
|
||||
from app.session.hub import hub
|
||||
from app.session.store import MemorySessionStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -42,11 +43,10 @@ def client(monkeypatch):
|
|||
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
|
||||
hub.store = MemorySessionStore()
|
||||
yield test_client
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
|
@ -995,3 +995,32 @@ def test_each_dds_card_uses_its_own_scenario_weights():
|
|||
next(item.weight for item in second_record.metrics if item.key == "dds_primary")
|
||||
== 2.0
|
||||
)
|
||||
|
||||
|
||||
def test_dds_station_command_and_finish_are_one_store_commit_each(client):
|
||||
"""Команда пульта ДДС и её подтверждение фиксируются одним commit."""
|
||||
from app.session.store import LessonEnded, ScoreCalculated
|
||||
|
||||
session_id, control = start(client)
|
||||
store = hub.store
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
read_until(station, "card.received")
|
||||
service = read_until(station, "station.state")["snapshot"]["services"][0]
|
||||
before = len(store.commits)
|
||||
command_id = str(uuid4())
|
||||
station.send_json({
|
||||
"type": "card.status", "service": service, "status": "accepted",
|
||||
"comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята.",
|
||||
"_command_id": command_id,
|
||||
})
|
||||
read_until(station, "command.ack")
|
||||
assert len(store.commits) == before + 1
|
||||
assert command_id in store.snapshot(session_id)["processed_station_commands"]
|
||||
|
||||
station.send_json({"type": "station.finish"})
|
||||
read_until(station, "score.ready")
|
||||
assert len(store.commits) == before + 2
|
||||
assert [type(item) for item in store.commits[-1][1]] == [LessonEnded, ScoreCalculated]
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from app.api.auth import DEMO_TRAINEE_ID
|
|||
from app.config import get_settings
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
from app.session.store import MemorySessionStore
|
||||
|
||||
|
||||
def _wait_for(predicate, timeout=3):
|
||||
|
|
@ -39,7 +40,7 @@ def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch)
|
|||
health = client.get("/api/health").json()
|
||||
assert health["status"] == "ok" and health["demo_no_db"] is True
|
||||
assert health["scenarios_loaded"] > 0
|
||||
assert hub.journal is None
|
||||
assert isinstance(hub.store, MemorySessionStore)
|
||||
|
||||
assert client.post("/api/auth/login", json={
|
||||
"login": "demo-instructor", "password": "demo"
|
||||
|
|
|
|||
|
|
@ -12,11 +12,18 @@ from app.scenarios.loader import load_file
|
|||
from app.session import finish as finish_module
|
||||
from app.session.dds import prepare_handoff_queue, prepare_queue
|
||||
from app.session.hub import hub
|
||||
from app.session.store import MemorySessionStore
|
||||
from app.session.state import SessionState
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
||||
|
||||
async def finish_in_operation(state):
|
||||
"""finish() пишет оценку в commit операции, завершившей занятие."""
|
||||
async with hub.operation(state.session_id):
|
||||
await finish_module.finish(state.session_id, state)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def offline_finish(monkeypatch):
|
||||
class NoCoaching:
|
||||
|
|
@ -27,7 +34,7 @@ def offline_finish(monkeypatch):
|
|||
return NoCoaching()
|
||||
|
||||
monkeypatch.setattr(finish_module, "coach", no_coach)
|
||||
monkeypatch.setattr(hub, "journal", None)
|
||||
monkeypatch.setattr(hub, "store", MemorySessionStore())
|
||||
|
||||
|
||||
def accept_active_card(state: SessionState) -> None:
|
||||
|
|
@ -59,7 +66,7 @@ def test_dds_finish_reports_status_of_active_card_not_last_scored():
|
|||
accept_active_card(state)
|
||||
active_status = state.station_snapshot().card
|
||||
|
||||
asyncio.run(finish_module.finish(state.session_id, state))
|
||||
asyncio.run(finish_in_operation(state))
|
||||
|
||||
assert_active_card_kept(state, active_id, active_status)
|
||||
|
||||
|
|
@ -83,6 +90,6 @@ def test_handoff_finish_reports_status_of_active_card_not_last_scored():
|
|||
accept_active_card(state)
|
||||
active_status = state.station_snapshot().card
|
||||
|
||||
asyncio.run(finish_module.finish(state.session_id, state))
|
||||
asyncio.run(finish_in_operation(state))
|
||||
|
||||
assert_active_card_kept(state, active_id, active_status)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ from types import SimpleNamespace
|
|||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from app.session.pg_store import PostgresSessionStore
|
||||
from app.session.store import MemorySessionStore
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
|
@ -129,7 +131,7 @@ def client(monkeypatch):
|
|||
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)
|
||||
monkeypatch.setattr(sessions.hub, "store", MemorySessionStore())
|
||||
test_client.post("/api/auth/dev-token")
|
||||
yield test_client, state, report
|
||||
|
||||
|
|
@ -243,10 +245,23 @@ def test_archived_score_override_updates_json_exports_and_audit(monkeypatch):
|
|||
def add(self, row):
|
||||
self.added.append(row)
|
||||
|
||||
async def flush(self):
|
||||
pass
|
||||
|
||||
async def rollback(self):
|
||||
pass
|
||||
|
||||
async def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
db = FakeDb()
|
||||
monkeypatch.setattr(sessions.hub, "store", PostgresSessionStore(lambda: db))
|
||||
monkeypatch.setattr(sessions.hub, "get", lambda session_id: None)
|
||||
monkeypatch.setattr(sessions, "_live", lambda session_id: (_ for _ in ()).throw(
|
||||
HTTPException(status_code=404, detail="session_not_found")
|
||||
|
|
|
|||
|
|
@ -15,12 +15,13 @@ from app.api.http import sessions
|
|||
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.events import Exercise, SessionMode, Speaker
|
||||
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
|
||||
from app.session.pg_store import PostgresSessionStore
|
||||
from app.session.store import LessonRequest, UtteranceAppended
|
||||
|
||||
|
||||
def request() -> Request:
|
||||
|
|
@ -28,7 +29,7 @@ def request() -> Request:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_journal_write_failure_does_not_log_user_text(caplog):
|
||||
async def test_store_commit_failure_does_not_log_user_text(caplog):
|
||||
private_text = "private caller address and medical detail"
|
||||
|
||||
class FakeDb:
|
||||
|
|
@ -38,11 +39,16 @@ async def test_journal_write_failure_does_not_log_user_text(caplog):
|
|||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def fail_write(_db, text):
|
||||
raise RuntimeError(text)
|
||||
def add(self, _row):
|
||||
raise RuntimeError(private_text)
|
||||
|
||||
journal = DbJournal(lambda: FakeDb())
|
||||
await journal._write(fail_write, private_text)
|
||||
state = SessionState(
|
||||
session_id=uuid4(), scenario_id="case", scenario_title="Сессия", level="L1",
|
||||
mode=SessionMode.TRAINING,
|
||||
)
|
||||
entry = state.append(Speaker.CALLER, private_text)
|
||||
with pytest.raises(RuntimeError):
|
||||
await PostgresSessionStore(lambda: FakeDb()).commit(state, [UtteranceAppended(entry)])
|
||||
assert private_text not in caplog.text
|
||||
assert "RuntimeError" in caplog.text
|
||||
|
||||
|
|
@ -480,10 +486,13 @@ async def test_unassigned_existing_session_is_claimed_once():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_journal_assigns_new_lesson_to_its_backend_node(monkeypatch):
|
||||
async def test_store_opens_new_lesson_on_its_backend_node(monkeypatch):
|
||||
seen = {}
|
||||
trainee_id = uuid4()
|
||||
row = SimpleNamespace(attempt=3, trainee_id=trainee_id)
|
||||
row = SimpleNamespace(
|
||||
id=uuid4(), attempt=3, trainee_id=trainee_id, backend_fencing_epoch=0,
|
||||
backend_lease_until=None, started_at=None, live_state=None, checkpoint_at=None,
|
||||
)
|
||||
|
||||
async def ensure(_db, **kwargs):
|
||||
seen.update(kwargs)
|
||||
|
|
@ -501,18 +510,38 @@ async def test_journal_assigns_new_lesson_to_its_backend_node(monkeypatch):
|
|||
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",
|
||||
def add(self, item):
|
||||
added.append(item)
|
||||
|
||||
async def commit(self):
|
||||
committed.append(True)
|
||||
|
||||
added, committed, identities = [], [], []
|
||||
|
||||
def build(identity):
|
||||
identities.append(identity)
|
||||
return SessionState(
|
||||
session_id=row.id, scenario_id="case", scenario_title="Сессия", level="L1",
|
||||
mode=SessionMode.TRAINING,
|
||||
)
|
||||
|
||||
store = PostgresSessionStore(lambda: FakeDb(), node_id="node-a")
|
||||
await store.open(LessonRequest(
|
||||
session_id=row.id, scenario_id="case", mode="training", trainee_name="Курсант",
|
||||
trainee_id=trainee_id, owner_login="teacher-a", backend_node_id=None,
|
||||
), build)
|
||||
identity = identities[0]
|
||||
assert (identity.attempt, identity.trainee_id, identity.service, identity.fencing_epoch) == (
|
||||
3, trainee_id, "01", 1,
|
||||
)
|
||||
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"
|
||||
assert seen["commit"] is False, "строка, аудит и снимок коммитятся вместе"
|
||||
assert [item.action for item in added] == ["lesson.start"]
|
||||
assert row.live_state is not None and committed == [True]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_journal_restores_checkpoint_owned_by_this_node():
|
||||
async def test_store_restores_checkpoint_owned_by_this_node():
|
||||
owner = "teacher-a"
|
||||
state = SessionState(
|
||||
session_id=uuid4(),
|
||||
|
|
@ -562,8 +591,8 @@ async def test_journal_restores_checkpoint_owned_by_this_node():
|
|||
return Rows([])
|
||||
return Rows([row])
|
||||
|
||||
journal = DbJournal(lambda: FakeDb(), node_id="node-a")
|
||||
restored = await journal.restore_active()
|
||||
store = PostgresSessionStore(lambda: FakeDb(), node_id="node-a")
|
||||
restored = await store.restore_active()
|
||||
assert len(restored) == 1
|
||||
assert restored[0].session_id == state.session_id
|
||||
assert restored[0].owner_login == owner
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
"""Промежуточное состояние занятия переживает смену backend-процесса."""
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from app.domain.events import (
|
||||
CallStarted,
|
||||
CommandAck,
|
||||
Exercise,
|
||||
LessonCriteria,
|
||||
Metric,
|
||||
|
|
@ -21,7 +18,6 @@ from app.domain.timers import TimerCode
|
|||
from app.scenarios.loader import load_file
|
||||
from app.session.checkpoint import dump_state, load_state
|
||||
from app.session.dds import prepare_handoff_queue, prepare_queue
|
||||
from app.session.hub import LEASE_FENCED_MESSAGE, SessionHub
|
||||
from app.session.state import DdsCardRecord, SessionState, now_utc
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
|
@ -228,69 +224,3 @@ def test_mixed_handoff_checkpoint_preserves_operator_card_and_generated_queue():
|
|||
assert restored.desk.scenarios[1].id == second.id
|
||||
assert len(restored.desk.cards) == 2
|
||||
assert restored.desk.active_id == state.desk.active_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
|
||||
|
|
|
|||
70
backend/tests/test_session_store.py
Normal file
70
backend/tests/test_session_store.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Одна операция занятия — один commit хранилища, события только после него."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.events import CallStarted, Speaker
|
||||
from app.session.hub import LEASE_FENCED_MESSAGE, SessionHub
|
||||
from app.session.state import now_utc
|
||||
from app.session.store import MemorySessionStore, UtteranceAppended
|
||||
from tests.test_session_checkpoint import dds_state
|
||||
|
||||
|
||||
class FailingStore(MemorySessionStore):
|
||||
async def commit(self, state, records=()):
|
||||
raise OSError("simulated database partition")
|
||||
|
||||
|
||||
def test_operation_commits_snapshot_and_records_once_then_publishes():
|
||||
store = MemorySessionStore()
|
||||
local_hub = SessionHub(store=store)
|
||||
state = local_hub.register(dds_state())
|
||||
sid = state.session_id
|
||||
|
||||
with local_hub.trainee(sid) as trainee:
|
||||
async def operation():
|
||||
async with local_hub.operation(sid):
|
||||
entry = state.append(Speaker.OPERATOR, "Адрес?")
|
||||
local_hub.record(sid, UtteranceAppended(entry))
|
||||
local_hub.to_trainee(sid, CallStarted(started_at=now_utc()))
|
||||
assert trainee.empty(), "событие ушло до коммита"
|
||||
assert store.commits == []
|
||||
|
||||
asyncio.run(operation())
|
||||
assert isinstance(trainee.get_nowait(), CallStarted)
|
||||
|
||||
assert len(store.commits) == 1
|
||||
committed_id, records = store.commits[0]
|
||||
assert committed_id == sid
|
||||
assert [type(item) for item in records] == [UtteranceAppended]
|
||||
assert store.snapshot(sid)["transcript"][0]["text"] == "Адрес?"
|
||||
|
||||
|
||||
def test_failed_commit_drops_events_and_fences_session():
|
||||
local_hub = SessionHub(store=FailingStore())
|
||||
state = local_hub.register(dds_state())
|
||||
sid = state.session_id
|
||||
|
||||
with local_hub.observer(sid) as observers, \
|
||||
local_hub.trainee(sid) as trainee, \
|
||||
local_hub.station(sid) as station:
|
||||
async def operation():
|
||||
async with local_hub.operation(sid):
|
||||
local_hub.to_trainee(sid, CallStarted(started_at=now_utc()))
|
||||
|
||||
with pytest.raises(OSError, match="partition"):
|
||||
asyncio.run(operation())
|
||||
|
||||
assert state.lease_fenced
|
||||
assert local_hub.get(sid) is None
|
||||
for queue in (observers, trainee, station):
|
||||
assert queue.get_nowait().message == LEASE_FENCED_MESSAGE
|
||||
assert queue.empty(), "событие несостоявшейся операции утекло"
|
||||
|
||||
|
||||
def test_record_outside_operation_is_rejected():
|
||||
local_hub = SessionHub(store=MemorySessionStore())
|
||||
state = local_hub.register(dds_state())
|
||||
with pytest.raises(RuntimeError):
|
||||
local_hub.record(state.session_id, UtteranceAppended(state.append(Speaker.OPERATOR, "x")))
|
||||
|
|
@ -9,6 +9,7 @@ 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
|
||||
from app.session.store import MemorySessionStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -21,7 +22,7 @@ def client(monkeypatch):
|
|||
# Сокеты закрыты ролями (lct-23): тесты входят так же,
|
||||
# как `make lesson`, — через dev-token за флагом.
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.journal = None
|
||||
hub.store = MemorySessionStore()
|
||||
yield test_client
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ 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
|
||||
from app.session.store import MemorySessionStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -30,7 +31,7 @@ def client(monkeypatch):
|
|||
# Сокеты закрыты ролями (lct-23): тесты входят так же,
|
||||
# как `make lesson`, — через dev-token за флагом.
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.journal = None # тесты не пишут в БД: проверяется поведение каналов
|
||||
hub.store = MemorySessionStore() # тесты не пишут в БД: in-memory adapter хранилища
|
||||
yield test_client
|
||||
|
||||
|
||||
|
|
@ -469,46 +470,93 @@ def test_report_shows_missed_questions_and_self_assessment_gap(client):
|
|||
assert report["hints_used"], "использованные подсказки попадают в разбор"
|
||||
|
||||
|
||||
def test_instructor_correction_keeps_the_automatic_score(client, postgres_access):
|
||||
from app.db.base import get_sessionmaker
|
||||
from app.session.journal import DbJournal
|
||||
@pytest.mark.parametrize("path", ["http", "ws"])
|
||||
def test_score_override_is_one_operation_for_http_and_ws(client, postgres_access, path):
|
||||
"""Правка с пульта и из отчёта даёт одну и ту же строку Score и разбор.
|
||||
|
||||
# Unlike the websocket-only cases above, this regression exercises the
|
||||
# durable HTTP correction endpoint against a real PostgreSQL score row.
|
||||
journal = DbJournal(get_sessionmaker())
|
||||
Раньше WS-путь не трогал `full_report` живой сессии: отчёт показывал
|
||||
старую оценку до перезагрузки.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.base import get_sessionmaker
|
||||
from app.db.models import Score
|
||||
from app.session.pg_store import PostgresSessionStore
|
||||
|
||||
comment = "связь рвалась не по вине курсанта"
|
||||
hub.store = PostgresSessionStore(get_sessionmaker())
|
||||
try:
|
||||
with lesson(client) as (session_id, _):
|
||||
with lesson(client) as (session_id, control), \
|
||||
client.websocket_connect(f"/ws/observe/{session_id}") as observer:
|
||||
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: 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)
|
||||
|
||||
# score.ready уходит только после commit оценки.
|
||||
read_until(observer, "score.ready", limit=50)
|
||||
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, "автооценка должна сохраниться рядом"
|
||||
assert corrected["overridden_by"] == "dev"
|
||||
if path == "http":
|
||||
response = client.patch(
|
||||
f"/api/sessions/{session_id}/report",
|
||||
json={"score_final": 80.0, "comment": comment},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
else:
|
||||
control.send_json({
|
||||
"type": "score.override", "session_id": str(session_id),
|
||||
"verdict": "80", "comment": comment,
|
||||
})
|
||||
read_until(observer, "score.ready", limit=50)
|
||||
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||
|
||||
async def score_row():
|
||||
async with get_sessionmaker()() as db:
|
||||
return await db.scalar(select(Score).where(Score.session_id == session_id))
|
||||
|
||||
row = client.portal.call(score_row)
|
||||
finally:
|
||||
hub.store = MemorySessionStore()
|
||||
|
||||
corrected = {"score_final": 80.0, "overridden_by": "dev", "override_comment": comment}
|
||||
assert {key: report[key] for key in corrected} == corrected
|
||||
assert report["score_auto"] == auto, "автооценка должна сохраниться рядом"
|
||||
assert (row.score_auto, row.score_final, row.overridden_by, row.override_comment) == (
|
||||
auto, 80.0, "dev", comment,
|
||||
)
|
||||
assert {key: row.report[key] for key in corrected} == corrected
|
||||
assert {key: row.report["full_report"][key] for key in corrected} == corrected
|
||||
assert row.report["full_report"]["score_auto"] == auto
|
||||
assert {key: state.score["full_report"][key] for key in corrected} == corrected
|
||||
|
||||
|
||||
def test_each_lesson_operation_is_exactly_one_store_commit(client):
|
||||
"""Раньше реплика, подсказка и финиш шли отдельной транзакцией журнала
|
||||
и ещё одним checkpoint — между ними возможен частичный коммит."""
|
||||
from app.session.store import HintRecorded, LessonEnded, LessonStarted, NoteAdded, ScoreCalculated
|
||||
|
||||
store = hub.store
|
||||
with lesson(client) as (session_id, control):
|
||||
assert len(store.commits) == 1, "запуск: строка занятия и снимок вместе"
|
||||
|
||||
def commits_after(action) -> list:
|
||||
before = len(store.commits)
|
||||
action()
|
||||
wait_for(lambda: len(store.commits) > before)
|
||||
time.sleep(0.05)
|
||||
assert len(store.commits) == before + 1
|
||||
return [type(item) for item in store.commits[-1][1]]
|
||||
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
assert commits_after(lambda: trainee.send_json({"type": "call.answer"})) == [LessonStarted]
|
||||
assert commits_after(lambda: trainee.send_json({"type": "hint.request"})) == [HintRecorded]
|
||||
assert commits_after(lambda: trainee.send_json({"type": "call.hangup"})) == [
|
||||
LessonEnded, ScoreCalculated,
|
||||
]
|
||||
assert commits_after(lambda: control.send_json({
|
||||
"type": "instructor_note.add", "transcript_ref": "u1", "text": "поздно спросил адрес",
|
||||
})) == [NoteAdded]
|
||||
assert all(committed_id == session_id for committed_id, _ in store.commits)
|
||||
|
||||
|
||||
def test_ws_score_override_rejects_other_session_and_invalid_value(client):
|
||||
|
|
@ -538,13 +586,10 @@ 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
|
||||
def test_ws_score_override_is_not_confirmed_when_commit_fails(client):
|
||||
class FailedStore(MemorySessionStore):
|
||||
async def commit(self, state, records=()):
|
||||
raise OSError("simulated database partition")
|
||||
|
||||
with lesson(client) as (session_id, control):
|
||||
state = hub.get(session_id)
|
||||
|
|
@ -553,15 +598,18 @@ def test_ws_score_override_is_not_applied_when_atomic_persistence_fails(client):
|
|||
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
|
||||
with client.websocket_connect(f"/ws/observe/{session_id}") as observer:
|
||||
observer.receive_json()
|
||||
hub.store = FailedStore()
|
||||
control.send_json({
|
||||
"type": "score.override", "session_id": str(session_id),
|
||||
"verdict": "85", "comment": "manual review",
|
||||
})
|
||||
message = read_until(observer, "error")
|
||||
|
||||
assert "переподключитесь" in message["message"], "без записи правка не подтверждается"
|
||||
assert state.lease_fenced
|
||||
assert hub.get(session_id) is None
|
||||
|
||||
|
||||
def test_soft_directive_changes_how_the_caller_sounds(client):
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ 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.store import MemorySessionStore
|
||||
from app.session.state import SessionState
|
||||
|
||||
|
||||
|
|
@ -21,7 +22,7 @@ from app.session.state import SessionState
|
|||
def client():
|
||||
with TestClient(app) as test_client:
|
||||
test_client.post("/api/auth/dev-token")
|
||||
hub.journal = None
|
||||
hub.store = MemorySessionStore()
|
||||
yield test_client
|
||||
|
||||
|
||||
|
|
@ -153,8 +154,8 @@ def test_control_command_checkpoint_failure_returns_fencing_error(monkeypatch):
|
|||
mode="training", owner_login="lease-owner",
|
||||
)
|
||||
|
||||
class BrokenJournal:
|
||||
async def checkpoint(self, _state):
|
||||
class BrokenStore(MemorySessionStore):
|
||||
async def commit(self, _state, _records=()):
|
||||
raise OSError("simulated database partition")
|
||||
|
||||
class OneCommandSocket:
|
||||
|
|
@ -176,8 +177,8 @@ def test_control_command_checkpoint_failure_returns_fencing_error(monkeypatch):
|
|||
|
||||
who = Principal(login="lease-owner", full_name="Преподаватель", role=Role.INSTRUCTOR)
|
||||
socket = OneCommandSocket()
|
||||
old_journal = hub.journal
|
||||
hub.journal = BrokenJournal()
|
||||
old_store = hub.store
|
||||
hub.store = BrokenStore()
|
||||
hub.register(state)
|
||||
monkeypatch.setattr(control_module, "websocket_origin_allowed", lambda _ws: True)
|
||||
monkeypatch.setattr(control_module, "principal_of", lambda _ws: who)
|
||||
|
|
@ -193,7 +194,7 @@ def test_control_command_checkpoint_failure_returns_fencing_error(monkeypatch):
|
|||
assert "переподключитесь" in event.message
|
||||
assert observer_queue.empty(), "uncommitted controller event leaked to observers"
|
||||
finally:
|
||||
hub.journal = old_journal
|
||||
hub.store = old_store
|
||||
hub.drop(session_id)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue