- 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
187 lines
7.5 KiB
Python
187 lines
7.5 KiB
Python
"""Pause, resume, cancel and answer on an engine task, through the HTTP routes.
|
|
|
|
What these tests defend:
|
|
|
|
* a member with ``tasks.run`` pauses and resumes a funded task; resume is refused with the
|
|
Persian budget reason while the organization has no credit, and the task stays paused
|
|
* cancel ends the task and writes one ``task.control`` audit row that names the action
|
|
* an answer is accepted only from ``pending_user_decision``; its text never reaches the audit
|
|
trail, and a blank answer is a 422
|
|
* a viewer gets 403, and a member of another organization gets 404, on every action
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.application.engine.scheduler import BUDGET_PAUSED_MESSAGE
|
|
from app.domain.identity.models import Role
|
|
from app.domain.workforce.pipeline import Stage
|
|
from app.infrastructure.db.models import AuditEventRow
|
|
from app.infrastructure.repositories.engine import PipelineStateRepository
|
|
from tests.billing_helpers import fund_org
|
|
from tests.conftest import auth_header
|
|
from tests.test_engine_api import _base, _engine_task, _join, _make_org
|
|
|
|
ANSWER = "نسخهٔ دوم را انجام بده"
|
|
|
|
|
|
async def _post(client, token: str, organization_id: str, task_id: str, action: str, json=None):
|
|
return await client.post(
|
|
f"{_base(organization_id)}/tasks/{task_id}/{action}",
|
|
json=json,
|
|
headers=auth_header(token),
|
|
)
|
|
|
|
|
|
async def _stage_of(session_factory, task_id: str) -> Stage | None:
|
|
async with session_factory() as session:
|
|
state = await PipelineStateRepository(session).get(task_id)
|
|
return state.stage if state is not None else None
|
|
|
|
|
|
async def _task_at(session_factory, org, stage: Stage, title: str = "صفحهٔ ورود") -> str:
|
|
async with session_factory() as session:
|
|
row = await _engine_task(session, org, title=title, stage=stage)
|
|
await session.commit()
|
|
return row.id
|
|
|
|
|
|
async def test_a_member_pauses_then_resumes_a_funded_task(client, session_factory, seeded):
|
|
org = await _make_org(client, "act-pause")
|
|
await fund_org(session_factory, org.id, 40.0)
|
|
member = await _join(client, session_factory, org.id, Role.MEMBER, "act-member@example.com")
|
|
task_id = await _task_at(session_factory, org, Stage.PENDING_IMPLEMENTATION)
|
|
|
|
paused = await _post(client, member, org.id, task_id, "pause")
|
|
|
|
assert paused.status_code == 200, paused.text
|
|
assert paused.json()["stage"] == "paused"
|
|
assert paused.json()["status"] == "waiting_approval"
|
|
|
|
resumed = await _post(client, member, org.id, task_id, "resume")
|
|
|
|
assert resumed.status_code == 200, resumed.text
|
|
assert resumed.json()["stage"] == "pending_manager_review"
|
|
|
|
|
|
async def test_resume_is_refused_without_credit_and_the_task_stays_paused(
|
|
client, session_factory, seeded
|
|
):
|
|
org = await _make_org(client, "act-zero")
|
|
member = await _join(
|
|
client, session_factory, org.id, Role.MEMBER, "act-zero-member@example.com"
|
|
)
|
|
task_id = await _task_at(session_factory, org, Stage.PAUSED)
|
|
|
|
refused = await _post(client, member, org.id, task_id, "resume")
|
|
|
|
assert refused.status_code == 409, refused.text
|
|
assert refused.json()["error"]["message"] == BUDGET_PAUSED_MESSAGE
|
|
assert await _stage_of(session_factory, task_id) is Stage.PAUSED
|
|
|
|
|
|
async def test_cancel_ends_the_task_and_writes_one_audit_row(client, session_factory, seeded):
|
|
org = await _make_org(client, "act-cancel")
|
|
member = await _join(
|
|
client, session_factory, org.id, Role.MEMBER, "act-cancel-member@example.com"
|
|
)
|
|
task_id = await _task_at(session_factory, org, Stage.PENDING_IMPLEMENTATION)
|
|
|
|
cancelled = await _post(client, member, org.id, task_id, "cancel")
|
|
|
|
assert cancelled.status_code == 200, cancelled.text
|
|
assert cancelled.json()["stage"] == "cancelled"
|
|
assert cancelled.json()["status"] == "cancelled"
|
|
async with session_factory() as session:
|
|
events = list(
|
|
(
|
|
await session.execute(
|
|
select(AuditEventRow).where(
|
|
AuditEventRow.action == "task.control",
|
|
AuditEventRow.target_id == task_id,
|
|
)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assert len(events) == 1
|
|
assert events[0].organization_id == org.id
|
|
assert events[0].target_type == "task"
|
|
assert events[0].metadata_json == {"action": "cancel"}
|
|
|
|
|
|
async def test_an_answer_is_accepted_only_from_a_decision_and_stays_out_of_the_audit(
|
|
client, session_factory, seeded
|
|
):
|
|
org = await _make_org(client, "act-answer")
|
|
member = await _join(
|
|
client, session_factory, org.id, Role.MEMBER, "act-answer-member@example.com"
|
|
)
|
|
working = await _task_at(session_factory, org, Stage.PENDING_IMPLEMENTATION, "کار یک")
|
|
deciding = await _task_at(session_factory, org, Stage.PENDING_USER_DECISION, "کار دو")
|
|
|
|
refused = await _post(
|
|
client, member, org.id, working, "answer", json={"content": ANSWER}
|
|
)
|
|
assert refused.status_code == 409, refused.text
|
|
assert await _stage_of(session_factory, working) is Stage.PENDING_IMPLEMENTATION
|
|
|
|
blank = await _post(client, member, org.id, deciding, "answer", json={"content": ""})
|
|
assert blank.status_code == 422, blank.text
|
|
assert await _stage_of(session_factory, deciding) is Stage.PENDING_USER_DECISION
|
|
|
|
accepted = await _post(
|
|
client, member, org.id, deciding, "answer", json={"content": ANSWER}
|
|
)
|
|
assert accepted.status_code == 200, accepted.text
|
|
assert accepted.json()["stage"] == "pending_manager_review"
|
|
|
|
async with session_factory() as session:
|
|
events = list(
|
|
(
|
|
await session.execute(
|
|
select(AuditEventRow).where(
|
|
AuditEventRow.action == "task.control",
|
|
AuditEventRow.target_id == deciding,
|
|
)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assert [event.metadata_json for event in events] == [{"action": "answer"}]
|
|
assert ANSWER not in repr(events[0].metadata_json)
|
|
|
|
|
|
async def test_a_viewer_is_refused_and_a_foreign_member_gets_not_found(
|
|
client, session_factory, seeded
|
|
):
|
|
org = await _make_org(client, "act-viewer")
|
|
viewer = await _join(
|
|
client, session_factory, org.id, Role.VIEWER, "act-viewer-user@example.com"
|
|
)
|
|
other = await _make_org(client, "act-other")
|
|
task_id = await _task_at(session_factory, org, Stage.PENDING_USER_DECISION)
|
|
|
|
for action in ("pause", "resume", "cancel"):
|
|
response = await _post(client, viewer, org.id, task_id, action)
|
|
assert response.status_code == 403, f"{action}: {response.text}"
|
|
answer = await _post(client, viewer, org.id, task_id, "answer", json={"content": ANSWER})
|
|
assert answer.status_code == 403, answer.text
|
|
assert await _stage_of(session_factory, task_id) is Stage.PENDING_USER_DECISION
|
|
|
|
for action in ("pause", "resume", "cancel"):
|
|
response = await _post(client, other.owner_token, org.id, task_id, action)
|
|
assert response.status_code == 404, f"{action}: {response.text}"
|
|
answer = await _post(
|
|
client, other.owner_token, org.id, task_id, "answer", json={"content": ANSWER}
|
|
)
|
|
assert answer.status_code == 404, answer.text
|
|
|
|
# The other organization's own routes do not reach this task either.
|
|
inside = await _post(client, other.owner_token, other.id, task_id, "pause")
|
|
assert inside.status_code == 404, inside.text
|
|
assert await _stage_of(session_factory, task_id) is Stage.PENDING_USER_DECISION
|