"""Every stage move publishes ``task.step`` once, on the task and the org channel. What these tests defend: * a legal move publishes exactly one ``task.step``: the stage it left, the stage it reached, the pipeline and the coarse status, on ``task:`` and ``org:`` * a refused move (an illegal transition) publishes nothing, so a client is never told that a task moved when it did not * a broken realtime bus cannot undo a move that already happened * the payload names stages and statuses only, never a model or an employee """ from __future__ import annotations import pytest import pytest_asyncio from app.application.engine.state import advance from app.application.ports.realtime import ( RealtimeEvent, get_publisher, org_channel, set_publisher, task_channel, ) from app.core.errors import ConflictError from app.domain.base import new_id from app.domain.workforce.models import TaskStatus from app.domain.workforce.pipeline import Stage, TaskPipelineState from app.infrastructure.db.models import OrganizationRow, TaskRow, UserRow from app.infrastructure.repositories.engine import PipelineStateRepository class _Recorder: """Stands in for the realtime bus and keeps every event it is given.""" def __init__(self) -> None: self.events: list[RealtimeEvent] = [] async def publish(self, event: RealtimeEvent) -> None: self.events.append(event) class _BrokenBus: async def publish(self, event: RealtimeEvent) -> None: raise RuntimeError("bus is down") @pytest_asyncio.fixture async def recorder(): """Install a recording publisher for one test and put the previous one back.""" previous = get_publisher() bus = _Recorder() set_publisher(bus) try: yield bus finally: set_publisher(previous) @pytest_asyncio.fixture async def one_task(session_factory): """One task at ``pending_manager_review`` in a fresh organization.""" async with session_factory() as session: user = UserRow(email=f"step-{new_id()[:8]}@example.com", password_hash="x") session.add(user) await session.flush() org = OrganizationRow( name="سازمان گام", slug=f"step-{new_id()[:8]}", owner_user_id=user.id ) session.add(org) await session.flush() task = TaskRow(organization_id=org.id, title="کار گام") session.add(task) await session.flush() await PipelineStateRepository(session).create( TaskPipelineState( task_id=task.id, organization_id=org.id, stage=Stage.PENDING_MANAGER_REVIEW, ) ) await session.commit() return session_factory, org.id, task.id def _steps(bus: _Recorder) -> list[RealtimeEvent]: return [event for event in bus.events if event.type == "task.step"] async def test_a_legal_move_publishes_one_task_step_on_both_channels(one_task, recorder): session_factory, org_id, task_id = one_task async with session_factory() as session: moved = await advance( PipelineStateRepository(session), task_id=task_id, expected_from=Stage.PENDING_MANAGER_REVIEW, to=Stage.PENDING_ANALYSIS, ) await session.commit() assert moved.stage is Stage.PENDING_ANALYSIS steps = _steps(recorder) assert len(steps) == 1 step = steps[0] assert step.channels == (task_channel(task_id), org_channel(org_id)) assert step.organization_id == org_id assert step.data == { "task_id": task_id, "from": str(Stage.PENDING_MANAGER_REVIEW), "to": str(Stage.PENDING_ANALYSIS), "pipeline": str(moved.pipeline), "status": TaskStatus.RUNNING.value, } async def test_a_refused_move_publishes_nothing(one_task, recorder): session_factory, _org_id, task_id = one_task async with session_factory() as session: with pytest.raises(ConflictError): await advance( PipelineStateRepository(session), task_id=task_id, expected_from=Stage.PENDING_MANAGER_REVIEW, to=Stage.COMPLETED, ) await session.rollback() assert _steps(recorder) == [] async def test_a_broken_bus_does_not_undo_the_move(one_task): session_factory, _org_id, task_id = one_task previous = get_publisher() set_publisher(_BrokenBus()) try: async with session_factory() as session: moved = await advance( PipelineStateRepository(session), task_id=task_id, expected_from=Stage.PENDING_MANAGER_REVIEW, to=Stage.PENDING_ANALYSIS, ) await session.commit() finally: set_publisher(previous) assert moved.stage is Stage.PENDING_ANALYSIS async with session_factory() as session: stored = await PipelineStateRepository(session).get_or_raise(task_id) assert stored.stage is Stage.PENDING_ANALYSIS