feat(integration): prepaid billing, engine UI, credits console, direct tasks, v2 final
- 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
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user