- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console - Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages - Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review - Pause/resume/cancel/answer actions in engine API and UI - Employee cap (7 per org default), review pipeline guard (active reviewer required) - Scheduler: wide window prevents org starvation; archived employees excluded from cap - Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox - Order route fix: manager built with pipeline states + employee profiles - Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy - Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed) - api-engine.ts typed client; api-credit.ts client - Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables - 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
151 lines
4.9 KiB
Python
151 lines
4.9 KiB
Python
"""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:<id>`` and ``org:<id>``
|
|
* 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
|