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:
mjalmoudy94
2026-10-14 23:19:05 +03:30
parent fef5a9deab
commit d532929189
117 changed files with 13119 additions and 640 deletions
+29
View File
@@ -0,0 +1,29 @@
"""Test helpers for the prepaid credit ledger.
Prepaid billing means an organization needs credit before engine work runs, so
tests that exercise that work fund the organization first. ``fund_org`` writes
the credit through the same repository the platform owner's endpoints use, so
the wallet and its history are written the real way.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.domain.billing.models import CreditKind, CreditTransaction
from app.infrastructure.repositories.credit import CreditLedgerRepository
async def fund_org(
session_factory: async_sessionmaker, organization_id: str, amount: float = 100.0
) -> CreditTransaction:
"""Credit an organization's wallet by ``amount`` as a labelled adjustment."""
async with session_factory() as session:
transaction = await CreditLedgerRepository(session).apply(
organization_id=organization_id,
amount=amount,
kind=CreditKind.ADJUSTMENT,
reason="test funding",
)
await session.commit()
return transaction
+36
View File
@@ -0,0 +1,36 @@
"""A pre-v2 task: a plain ``tasks`` row with no ``task_pipeline_state``.
Every task made since the engine owns direct tasks gets a pipeline state, so HTTP
``POST /tasks`` and MCP ``tasks_create`` no longer create one of these (decision D-D).
Tasks created before that have no state and still run through the legacy one-shot path
(``WorkforceService.start_run``). Tests of that path create their task with this helper.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.infrastructure.db.models import TaskRow
async def legacy_task(
session_factory: async_sessionmaker,
organization_id: str,
created_by_user_id: str,
*,
title: str = "کار قدیمی",
project_id: str | None = None,
assigned_employee_id: str | None = None,
) -> str:
"""Insert one task with no pipeline state and return its id."""
async with session_factory() as session:
row = TaskRow(
organization_id=organization_id,
project_id=project_id,
title=title,
assigned_employee_id=assigned_employee_id,
created_by_user_id=created_by_user_id,
)
session.add(row)
await session.commit()
return row.id
+205
View File
@@ -0,0 +1,205 @@
"""The owner's credit console: top-ups, an organization's history and the balances page.
What these tests defend:
* a credit from the platform owner moves the wallet, writes one history row, one audit row
that names the reason, and one live ``billing.wallet`` event with the new balance
* the history and the balances page show the credit under the right organization
* only the platform owner reaches the three endpoints: an organization owner and a plain user
get 403 and change nothing, an anonymous caller gets 401
* an unknown organization is a 404, and a zero or negative amount or a blank reason is refused
with a Persian message
"""
from __future__ import annotations
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy import select
from app.application.ports.realtime import RealtimeEvent, get_publisher, set_publisher
from app.domain.audit.models import AuditAction
from app.domain.base import new_id
from app.infrastructure.db.models import AuditEventRow, UserRow
from tests.conftest import auth_header
CREDITS = "/api/admin/credits"
ORGS = "/api/admin/orgs"
OWNER_EMAIL = "platform-owner@example.com"
REASON = "شارژ اولیهٔ سازمان"
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)
@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)
def _payload(name: str, slug: str) -> dict:
return {
"org_name": name,
"slug": slug,
"app_name": f"app-{slug}",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
async def _create_org(client: AsyncClient, token: str, name: str, slug: str) -> str:
"""An organization the manager created through the wizard, as a customer would."""
response = await client.post(
"/api/setup/complete", json=_payload(name, slug), headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()["organization"]["id"]
async def test_the_owner_credit_moves_the_wallet_and_is_audited_and_pushed(
client, manager_token, platform_owner_token, seeded, session_factory, recorder
):
org_id = await _create_org(client, manager_token, "سازمان الف", "credit-alpha")
owner = auth_header(platform_owner_token)
credited = await client.post(
f"{ORGS}/{org_id}/credit", json={"amount": 25.0, "reason": REASON}, headers=owner
)
assert credited.status_code == 200, credited.text
body = credited.json()
assert body["kind"] == "owner_credit"
assert body["amount"] == 25.0
assert body["balance_after"] == 25.0
assert body["reason"] == REASON
# The response carries only the history fields: no model, provider or secret.
assert set(body) == {"id", "kind", "amount", "balance_after", "reason", "created_at"}
balances = await client.get(CREDITS, headers=owner)
assert balances.status_code == 200, balances.text
row = next(b for b in balances.json() if b["organization_id"] == org_id)
assert row["balance"] == 25.0
assert row["currency"] == "USD"
assert row["paused_tasks"] == 0
assert row["last_credit_at"] is not None
history = await client.get(f"{ORGS}/{org_id}/credit-history", headers=owner)
assert history.status_code == 200, history.text
assert [h["reason"] for h in history.json()] == [REASON]
async with session_factory() as session:
owner_id = (
await session.execute(select(UserRow.id).where(UserRow.email == OWNER_EMAIL))
).scalar_one()
audits = (
await session.execute(
select(AuditEventRow).where(
AuditEventRow.action == AuditAction.BILLING_CREDIT,
AuditEventRow.organization_id == org_id,
)
)
).scalars().all()
assert len(audits) == 1
assert audits[0].actor_user_id == owner_id
assert audits[0].metadata_json["reason"] == REASON
assert audits[0].metadata_json["balance"] == 25.0
pushed = [e for e in recorder.events if e.type == "billing.wallet"]
assert pushed, "the owner's credit must push the new balance to the organization"
assert pushed[-1].data == {"organization_id": org_id, "balance": 25.0}
async def test_only_the_platform_owner_reaches_the_credit_console(
client, manager_token, plain_user_token, platform_owner_token, seeded
):
org_id = await _create_org(client, manager_token, "سازمان ب", "credit-beta")
calls = [
("get", CREDITS, None),
("post", f"{ORGS}/{org_id}/credit", {"amount": 5.0, "reason": REASON}),
("get", f"{ORGS}/{org_id}/credit-history", None),
]
# The manager is the organization's owner; the plain user is a customer with no seat.
for token in (manager_token, plain_user_token):
for method, path, body in calls:
kwargs: dict = {"headers": auth_header(token)}
if body is not None:
kwargs["json"] = body
response = await getattr(client, method)(path, **kwargs)
assert response.status_code == 403, response.text
anonymous = await client.get(CREDITS)
assert anonymous.status_code == 401
# The refused credits changed nothing.
balances = await client.get(CREDITS, headers=auth_header(platform_owner_token))
row = next(b for b in balances.json() if b["organization_id"] == org_id)
assert row["balance"] == 0.0
assert row["last_credit_at"] is None
async def test_an_unknown_organization_is_not_found(client, platform_owner_token, seeded):
owner = auth_header(platform_owner_token)
missing = new_id()
credited = await client.post(
f"{ORGS}/{missing}/credit", json={"amount": 5.0, "reason": REASON}, headers=owner
)
assert credited.status_code == 404, credited.text
history = await client.get(f"{ORGS}/{missing}/credit-history", headers=owner)
assert history.status_code == 404, history.text
async def test_the_history_limit_is_checked_and_the_list_is_scoped(
client, manager_token, platform_owner_token, seeded
):
org_a = await _create_org(client, manager_token, "سازمان الف", "credit-hist-a")
owner = auth_header(platform_owner_token)
for reason in ("شارژ نخست", "شارژ دوم"):
response = await client.post(
f"{ORGS}/{org_a}/credit", json={"amount": 10.0, "reason": reason}, headers=owner
)
assert response.status_code == 200, response.text
capped = await client.get(
f"{ORGS}/{org_a}/credit-history?limit=1", headers=owner
)
assert capped.status_code == 200, capped.text
assert [h["reason"] for h in capped.json()] == ["شارژ دوم"]
too_many = await client.get(f"{ORGS}/{org_a}/credit-history?limit=500", headers=owner)
assert too_many.status_code == 422, too_many.text
async def test_a_refused_credit_is_a_4xx_with_a_persian_message(
client, manager_token, platform_owner_token, seeded
):
org_id = await _create_org(client, manager_token, "سازمان پ", "credit-gamma")
owner = auth_header(platform_owner_token)
for amount, reason, message in (
(0.0, REASON, "مقدار اعتبار باید بیشتر از صفر"),
(-3.0, REASON, "مقدار اعتبار باید بیشتر از صفر"),
(10.0, "", "دلیل افزودن اعتبار لازم است."),
):
response = await client.post(
f"{ORGS}/{org_id}/credit", json={"amount": amount, "reason": reason}, headers=owner
)
assert response.status_code == 422, response.text
assert message in response.json()["error"]["message"]
+161
View File
@@ -0,0 +1,161 @@
"""Prepaid billing (D-B): only the platform owner moves an organization's credit.
What these tests defend:
* an organization owner and an organization admin (who holds ``billing.manage``) get 403 from
``add-credit`` and from ``settle``, and the wallet does not move
* a member keeps reading the billing summary (``billing.view``) and gets 403 on ``add-credit``
* the platform owner's ``add-credit`` moves the wallet and writes one ``adjustment`` history row
with the reason ``billing/add-credit``; a clawback that would leave the wallet below zero is
refused and changes nothing
* the platform owner can settle a pending payment, and a succeeded payment credits the wallet
"""
from __future__ import annotations
from httpx import AsyncClient
from app.domain.identity.models import Role
from app.infrastructure.db.models import MembershipRow
from tests.conftest import auth_header, signup
OWNER_ONLY = "اعتبار سازمان را فقط مالک سامانه افزایش می‌دهد."
def _payload(name: str, slug: str) -> dict:
return {
"org_name": name,
"slug": slug,
"app_name": f"app-{slug}",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
async def _create_org(client: AsyncClient, token: str, name: str, slug: str) -> str:
response = await client.post(
"/api/setup/complete", json=_payload(name, slug), headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()["organization"]["id"]
async def _join(session_factory, organization_id: str, user_id: str, role: Role) -> None:
async with session_factory() as session:
session.add(
MembershipRow(organization_id=organization_id, user_id=user_id, role=role.value)
)
await session.commit()
async def _pending_payment(client: AsyncClient, token: str, organization_id: str) -> str:
response = await client.post(
f"/api/organizations/{organization_id}/billing/checkout",
json={"amount": 30.0},
headers=auth_header(token),
)
assert response.status_code == 201, response.text
return response.json()["payment_id"]
async def test_an_organization_owner_and_admin_cannot_add_credit_or_settle(
client, manager_token, session_factory, seeded
):
org_id = await _create_org(client, manager_token, "سازمان الف", "bill-owner")
admin = await signup(client, "billing-admin@example.com")
await _join(session_factory, org_id, admin["user"]["id"], Role.ADMIN)
payment_id = await _pending_payment(client, manager_token, org_id)
billing = f"/api/organizations/{org_id}/billing"
for token in (manager_token, admin["access_token"]):
headers = auth_header(token)
credit = await client.post(f"{billing}/add-credit", json={"amount": 50.0}, headers=headers)
assert credit.status_code == 403, credit.text
assert credit.json()["error"]["message"] == OWNER_ONLY
settle = await client.post(
f"{billing}/payments/{payment_id}/settle",
json={"succeeded": True},
headers=headers,
)
assert settle.status_code == 403, settle.text
assert settle.json()["error"]["message"] == OWNER_ONLY
summary = await client.get(f"{billing}/summary", headers=auth_header(manager_token))
assert summary.status_code == 200, summary.text
assert summary.json()["balance"] == 0.0
async def test_a_member_reads_the_summary_but_cannot_add_credit(
client, manager_token, session_factory, seeded
):
org_id = await _create_org(client, manager_token, "سازمان ب", "bill-member")
member = await signup(client, "billing-member@example.com")
await _join(session_factory, org_id, member["user"]["id"], Role.MEMBER)
billing = f"/api/organizations/{org_id}/billing"
headers = auth_header(member["access_token"])
summary = await client.get(f"{billing}/summary", headers=headers)
assert summary.status_code == 200, summary.text
assert summary.json()["balance"] == 0.0
credit = await client.post(f"{billing}/add-credit", json={"amount": 5.0}, headers=headers)
assert credit.status_code == 403, credit.text
assert credit.json()["error"]["message"] == OWNER_ONLY
async def test_the_platform_owner_adds_and_claws_back_credit_as_adjustments(
client, platform_owner_token, seeded
):
org_id = await _create_org(client, platform_owner_token, "سازمان پ", "bill-platform")
owner = auth_header(platform_owner_token)
billing = f"/api/organizations/{org_id}/billing"
credit = await client.post(f"{billing}/add-credit", json={"amount": 25.0}, headers=owner)
assert credit.status_code == 200, credit.text
assert credit.json()["balance"] == 25.0
assert credit.json()["low_balance"] is False
clawback = await client.post(f"{billing}/add-credit", json={"amount": -10.0}, headers=owner)
assert clawback.status_code == 200, clawback.text
assert clawback.json()["balance"] == 15.0
refused = await client.post(f"{billing}/add-credit", json={"amount": -50.0}, headers=owner)
assert refused.status_code == 422, refused.text
assert refused.json()["error"]["message"] == "اعتبار کافی برای این کسر وجود ندارد."
zero = await client.post(f"{billing}/add-credit", json={"amount": 0.0}, headers=owner)
assert zero.status_code == 422, zero.text
# The refused clawback rolled back: the balance is still 15.
summary = await client.get(f"{billing}/summary", headers=owner)
assert summary.status_code == 200, summary.text
assert summary.json()["balance"] == 15.0
history = await client.get(f"/api/admin/orgs/{org_id}/credit-history", headers=owner)
assert history.status_code == 200, history.text
assert [(h["kind"], h["amount"], h["reason"]) for h in history.json()] == [
("adjustment", -10.0, "billing/add-credit"),
("adjustment", 25.0, "billing/add-credit"),
]
async def test_the_platform_owner_settles_a_payment_and_the_wallet_is_credited(
client, platform_owner_token, seeded
):
org_id = await _create_org(client, platform_owner_token, "سازمان ت", "bill-settle")
owner = auth_header(platform_owner_token)
billing = f"/api/organizations/{org_id}/billing"
payment_id = await _pending_payment(client, platform_owner_token, org_id)
settled = await client.post(
f"{billing}/payments/{payment_id}/settle", json={"succeeded": True}, headers=owner
)
assert settled.status_code == 200, settled.text
assert settled.json()["status"] == "succeeded"
summary = await client.get(f"{billing}/summary", headers=owner)
assert summary.status_code == 200, summary.text
assert summary.json()["balance"] == 30.0
@@ -0,0 +1,112 @@
"""A settled payment is credited through the credit ledger, so the owner's history shows it.
What these tests defend:
* settling a pending payment as succeeded writes exactly one ``adjustment`` history row with the
reason ``billing/settle-payment`` and the payment's amount, the wallet goes up by that amount
once, and the organization's ``last_credit_at`` is set
* settling the same payment again changes nothing: the payment is already final, so neither the
wallet nor the history moves
* a failed payment credits nothing and writes no history row
"""
from __future__ import annotations
from app.application.container import build_billing_service
from app.domain.base import new_id
from app.domain.billing.models import CreditKind, Payment, PaymentStatus
from app.domain.identity.models import PlatformRole
from app.infrastructure.db.models import OrganizationRow, UserRow
from app.infrastructure.repositories.billing import CreditWalletRepository, PaymentRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
SETTLE_REASON = "billing/settle-payment"
async def _org_with_pending_payment(
session_factory, slug: str, amount: float
) -> tuple[str, str]:
"""Create an organization and one pending payment; return (organization id, payment id)."""
async with session_factory() as session:
user = UserRow(
id=new_id(),
email=f"{slug}@example.com",
password_hash="x",
name="t",
platform_role=PlatformRole.MANAGER.value,
)
session.add(user)
await session.flush()
org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id)
session.add(org)
await session.flush()
payment = await PaymentRepository(session).create(
Payment(organization_id=org.id, amount=amount)
)
await session.commit()
return org.id, payment.id
async def _settle(
session_factory, organization_id: str, payment_id: str, *, succeeded: bool
) -> Payment:
"""Settle through the service the owner's endpoint calls, in its own transaction."""
async with session_factory() as session:
payment = await build_billing_service(session).settle_payment(
organization_id=organization_id, payment_id=payment_id, succeeded=succeeded
)
await session.commit()
return payment
async def _balance_and_history(session_factory, organization_id: str):
"""The wallet balance and every credit history row of one organization."""
async with session_factory() as session:
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
history = await CreditLedgerRepository(session).history(organization_id)
return (wallet.balance if wallet else 0.0), history
async def test_a_settled_payment_writes_one_adjustment_row_and_credits_once(session_factory):
organization_id, payment_id = await _org_with_pending_payment(
session_factory, "settle-once", 30.0
)
payment = await _settle(session_factory, organization_id, payment_id, succeeded=True)
assert payment.status == PaymentStatus.SUCCEEDED
balance, history = await _balance_and_history(session_factory, organization_id)
assert balance == 30.0
assert [(t.kind, t.amount, t.balance_after, t.reason) for t in history] == [
(CreditKind.ADJUSTMENT, 30.0, 30.0, SETTLE_REASON)
]
async with session_factory() as session:
last_credit_at = await CreditLedgerRepository(session).last_credit_at_by_org()
assert organization_id in last_credit_at
async def test_settling_the_same_payment_twice_does_not_credit_twice(session_factory):
organization_id, payment_id = await _org_with_pending_payment(
session_factory, "settle-twice", 30.0
)
await _settle(session_factory, organization_id, payment_id, succeeded=True)
again = await _settle(session_factory, organization_id, payment_id, succeeded=True)
assert again.status == PaymentStatus.SUCCEEDED
balance, history = await _balance_and_history(session_factory, organization_id)
assert balance == 30.0
assert len(history) == 1
async def test_a_failed_payment_credits_nothing(session_factory):
organization_id, payment_id = await _org_with_pending_payment(
session_factory, "settle-failed", 30.0
)
failed = await _settle(session_factory, organization_id, payment_id, succeeded=False)
assert failed.status == PaymentStatus.FAILED
balance, history = await _balance_and_history(session_factory, organization_id)
assert balance == 0.0
assert history == []
@@ -0,0 +1,96 @@
"""The optional starting credit for new organizations (``BILLING_STARTING_CREDIT``, D-B).
What these tests defend:
* with the setting at 0, an organization created over HTTP or by the wizard starts with an
empty wallet and no ``starting_credit`` history row, so the platform owner credits it by hand
* with a positive setting, both paths give the new organization that credit and exactly one
``starting_credit`` history row, and no other organization is credited
"""
from __future__ import annotations
from httpx import AsyncClient
from app.core.config import settings
from app.domain.billing.models import CreditKind
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from tests.conftest import auth_header, signup
STARTING_REASON = "اعتبار آغازین"
def _payload(name: str, slug: str) -> dict:
return {
"org_name": name,
"slug": slug,
"app_name": f"app-{slug}",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
async def _http_org(client: AsyncClient, token: str, name: str) -> str:
response = await client.post(
"/api/organizations", json={"name": name}, headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()["id"]
async def _wizard_org(client: AsyncClient, token: str, name: str, slug: str) -> str:
response = await client.post(
"/api/setup/complete", json=_payload(name, slug), headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()["organization"]["id"]
async def _credit_state(session_factory, organization_id: str):
"""The wallet balance and the starting-credit history of one organization."""
async with session_factory() as session:
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
starting = await CreditLedgerRepository(session).history(
organization_id, kinds=(CreditKind.STARTING_CREDIT,)
)
return (wallet.balance if wallet else 0.0), starting
async def test_with_the_default_setting_a_new_organization_starts_empty(
client, session_factory, seeded, monkeypatch
):
monkeypatch.setattr(settings, "billing_starting_credit", 0.0)
http_manager = await signup(client, "start-http@example.com")
wizard_manager = await signup(client, "start-wizard@example.com")
http_org = await _http_org(client, http_manager["access_token"], "سازمان HTTP")
wizard_org = await _wizard_org(
client, wizard_manager["access_token"], "سازمان ویزارد", "start-wizard-org"
)
for organization_id in (http_org, wizard_org):
balance, starting = await _credit_state(session_factory, organization_id)
assert balance == 0.0
assert starting == []
async def test_a_positive_setting_credits_both_paths_once(
client, session_factory, seeded, monkeypatch
):
monkeypatch.setattr(settings, "billing_starting_credit", 3.0)
http_manager = await signup(client, "credit-http@example.com")
wizard_manager = await signup(client, "credit-wizard@example.com")
http_org = await _http_org(client, http_manager["access_token"], "سازمان الف")
wizard_org = await _wizard_org(
client, wizard_manager["access_token"], "سازمان ب", "credit-wizard-org"
)
for organization_id in (http_org, wizard_org):
balance, starting = await _credit_state(session_factory, organization_id)
assert balance == 3.0
assert len(starting) == 1
assert starting[0].amount == 3.0
assert starting[0].balance_after == 3.0
assert starting[0].reason == STARTING_REASON
+171
View File
@@ -0,0 +1,171 @@
"""The manager chat does not call a model for an organization with no credit.
What these tests defend:
* a zero-credit organization gets the note as the manager's reply: the model is
never called and the tags in the order are not executed (prepaid, decision D-B)
* a missing wallet is no credit either
* once the owner credits the organization, the same chat reaches the model
* a manager built without a wallet (the direct construction the other manager
tests use) keeps its old behaviour
The organization fixture and the manager's model profile come from
``tests.test_engine_manager``.
"""
from __future__ import annotations
from app.application.container_org import build_memory
from app.application.engine.manager import CHAT_NO_CREDIT_MESSAGE, ManagerTurns
from app.domain.workforce.models import MessageRole, RoleKey
from app.infrastructure.db.models import CreditWalletRow, ThreadRow
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
)
from app.infrastructure.repositories.workforce import (
EmployeeRepository,
MessageRepository,
TaskRepository,
ThreadRepository,
)
from tests.billing_helpers import fund_org
from tests.engine_fakes import FakeGrants, ScriptedModel, ScriptedReply
from tests.test_engine_manager import _with_model_profile
from tests.test_engine_manager import world as manager_world
#: The engine manager tests' organization fixture. Binding it under its own name
#: registers it for this module's tests (a bare import would be flagged as unused).
world = manager_world
_ORDER = '[[CREATE_TASK title="ورود" kind="feature"]]ساخت فرم ورود[[/CREATE_TASK]]'
_PLAIN_REPLY = "آماده‌ام."
async def _thread_with_manager(world, session) -> str:
"""A manager with a strong model profile, and a thread to talk in."""
manager = await world.employee(session, "مدیر", RoleKey.MANAGER)
await _with_model_profile(world, session, manager.id, world.strong_id)
thread = await ThreadRepository(session).create(
ThreadRow(organization_id=world.organization_id, title="گفتگو")
)
await session.commit()
return thread.id
async def _manager(session, *, scripted, grants, wired: bool) -> ManagerTurns:
"""The engine manager on real repositories; ``wired`` adds the prepaid gate."""
return ManagerTurns(
provider=scripted,
employees=EmployeeRepository(session),
messages=MessageRepository(session),
tasks=TaskRepository(session),
threads=ThreadRepository(session),
models=AiModelRepository(session),
credentials=ModelCredentialRepository(session),
rate_cards=RateCardRepository(session),
ledger=UsageLedgerRepository(session),
grants=grants,
memory=build_memory(session),
profiles=EmployeeProfileRepository(session),
states=PipelineStateRepository(session),
runner=None,
wallets=CreditWalletRepository(session) if wired else None,
)
async def test_a_zero_credit_org_gets_the_note_and_no_model_call(world, session_factory):
async with session_factory() as session:
session.add(
CreditWalletRow(organization_id=world.organization_id, balance=0.0, currency="USD")
)
thread_id = await _thread_with_manager(world, session)
scripted = ScriptedModel([ScriptedReply(content=_ORDER)])
grants = FakeGrants({world.strong_id: "strong"})
async with session_factory() as session:
manager = await _manager(session, scripted=scripted, grants=grants, wired=True)
user_message, assistant = await manager.chat(
organization_id=world.organization_id,
thread_id=thread_id,
content="یک فرم ورود بساز.",
user_id=world.user_id,
)
await session.commit()
tasks = await TaskRepository(session).list_for_org(world.organization_id)
assert user_message.content == "یک فرم ورود بساز."
assert assistant.role is MessageRole.ASSISTANT
assert assistant.content == CHAT_NO_CREDIT_MESSAGE
assert scripted.calls == 0
# The order's CREATE_TASK tag was not executed.
assert tasks == []
async def test_a_missing_wallet_counts_as_no_credit(world, session_factory):
async with session_factory() as session:
thread_id = await _thread_with_manager(world, session)
scripted = ScriptedModel([ScriptedReply(content=_PLAIN_REPLY)])
grants = FakeGrants({world.strong_id: "strong"})
async with session_factory() as session:
manager = await _manager(session, scripted=scripted, grants=grants, wired=True)
_, assistant = await manager.chat(
organization_id=world.organization_id,
thread_id=thread_id,
content="سلام",
user_id=world.user_id,
)
await session.commit()
assert assistant.content == CHAT_NO_CREDIT_MESSAGE
assert scripted.calls == 0
async def test_after_the_owner_credits_the_org_the_model_answers(world, session_factory):
async with session_factory() as session:
thread_id = await _thread_with_manager(world, session)
await fund_org(session_factory, world.organization_id, 50.0)
scripted = ScriptedModel([ScriptedReply(content=_PLAIN_REPLY)])
grants = FakeGrants({world.strong_id: "strong"})
async with session_factory() as session:
manager = await _manager(session, scripted=scripted, grants=grants, wired=True)
_, assistant = await manager.chat(
organization_id=world.organization_id,
thread_id=thread_id,
content="سلام",
user_id=world.user_id,
)
await session.commit()
assert assistant.content == _PLAIN_REPLY
assert scripted.calls == 1
async def test_a_manager_built_without_a_wallet_keeps_the_old_behaviour(world, session_factory):
"""No ``wallets`` means no gate: the direct constructions in other tests rely on it."""
async with session_factory() as session:
thread_id = await _thread_with_manager(world, session)
scripted = ScriptedModel([ScriptedReply(content=_PLAIN_REPLY)])
grants = FakeGrants({world.strong_id: "strong"})
async with session_factory() as session:
manager = await _manager(session, scripted=scripted, grants=grants, wired=False)
_, assistant = await manager.chat(
organization_id=world.organization_id,
thread_id=thread_id,
content="سلام",
user_id=world.user_id,
)
await session.commit()
assert assistant.content == _PLAIN_REPLY
assert scripted.calls == 1
+218
View File
@@ -0,0 +1,218 @@
"""Every usage row debits its organization's prepaid wallet once, in the same transaction.
What these tests defend:
* a model call debits its price: the balance falls by that price and exactly one
``usage`` history row names the usage row it paid for
* a zero-priced usage row is still recorded, but debits nothing
* a sandbox usage debits the wallet too (every unit goes through the one ledger)
* another organization's wallet is never touched
* a rolled-back session leaves neither the usage row nor the debit
* the live wallet event carries the new balance, so the sidebar badge follows it
"""
from __future__ import annotations
import pytest_asyncio
from sqlalchemy import select
from app.application.ports.audit import UsageRecord
from app.application.ports.realtime import RealtimeEvent, get_publisher, set_publisher
from app.application.services.workforce_service import record_model_usage
from app.domain.ai_control.models import RateCard, RateUnit
from app.domain.base import new_id
from app.domain.billing.models import CreditKind
from app.infrastructure.db.models import OrganizationRow, UsageEventRow, UserRow
from app.infrastructure.repositories.ai_control import RateCardRepository
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from tests.billing_helpers import fund_org
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)
@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 two_orgs(session_factory):
"""Two organizations created directly, so neither starts with a wallet."""
org_ids: list[str] = []
async with session_factory() as session:
for label in ("الف", "ب"):
user = UserRow(email=f"debit-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name=f"سازمان {label}", slug=f"debit-{new_id()[:8]}", owner_user_id=user.id
)
session.add(org)
await session.flush()
org_ids.append(org.id)
await session.commit()
return session_factory, org_ids
async def _balance(session_factory, organization_id: str) -> float:
async with session_factory() as session:
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
assert wallet is not None
return wallet.balance
async def _usage_ids(session_factory, organization_id: str) -> list[str]:
async with session_factory() as session:
result = await session.execute(
select(UsageEventRow.id).where(UsageEventRow.organization_id == organization_id)
)
return list(result.scalars())
async def test_a_model_call_debits_its_price_and_names_its_usage_row(two_orgs, recorder):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
async with session_factory() as session:
await RateCardRepository(session).upsert(
RateCard(
name="توکن ورودی",
unit=RateUnit.MODEL_INPUT_TOKENS,
customer_price=2.5,
internal_cost_estimate=1.0,
)
)
await session.commit()
async with session_factory() as session:
await record_model_usage(
ledger=UsageLedgerRepository(session),
rate_cards=RateCardRepository(session),
organization_id=org_a,
unit=RateUnit.MODEL_INPUT_TOKENS,
quantity=4.0,
)
await session.commit()
# Four units at 2.5 each: the wallet falls by 10.
assert await _balance(session_factory, org_a) == 90.0
usage_ids = await _usage_ids(session_factory, org_a)
assert len(usage_ids) == 1
async with session_factory() as session:
debits = await CreditLedgerRepository(session).history(
org_a, kinds=(CreditKind.USAGE,)
)
assert len(debits) == 1
assert debits[0].amount == -10.0
assert debits[0].balance_after == 90.0
assert debits[0].usage_event_id == usage_ids[0]
wallet_events = [e for e in recorder.events if e.type == "billing.wallet"]
assert len(wallet_events) == 1
assert wallet_events[0].data["balance"] == 90.0
async def test_a_zero_priced_usage_row_is_recorded_but_debits_nothing(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
async with session_factory() as session:
balance_after = await UsageLedgerRepository(session).record(
usage=UsageRecord(
organization_id=org_a,
unit=RateUnit.TOOL_CALL,
quantity=1.0,
total_price=0.0,
)
)
await session.commit()
assert balance_after is None
assert len(await _usage_ids(session_factory, org_a)) == 1
assert await _balance(session_factory, org_a) == 100.0
async with session_factory() as session:
debits = await CreditLedgerRepository(session).history(
org_a, kinds=(CreditKind.USAGE,)
)
assert debits == []
async def test_a_sandbox_usage_debits_the_wallet_too(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 20.0)
async with session_factory() as session:
balance_after = await UsageLedgerRepository(session).record(
usage=UsageRecord(
organization_id=org_a,
unit=RateUnit.SANDBOX_CPU_SECOND,
quantity=10.0,
unit_price=0.1,
total_price=1.0,
)
)
await session.commit()
assert balance_after == 19.0
assert await _balance(session_factory, org_a) == 19.0
async def test_another_organizations_wallet_is_untouched(two_orgs):
session_factory, (org_a, org_b) = two_orgs
await fund_org(session_factory, org_a, 50.0)
await fund_org(session_factory, org_b, 50.0)
async with session_factory() as session:
await UsageLedgerRepository(session).record(
usage=UsageRecord(
organization_id=org_a,
unit=RateUnit.TOOL_CALL,
quantity=1.0,
total_price=5.0,
)
)
await session.commit()
assert await _balance(session_factory, org_a) == 45.0
assert await _balance(session_factory, org_b) == 50.0
async with session_factory() as session:
b_debits = await CreditLedgerRepository(session).history(
org_b, kinds=(CreditKind.USAGE,)
)
assert b_debits == []
async def test_a_rolled_back_session_leaves_neither_the_usage_row_nor_the_debit(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
async with session_factory() as session:
await UsageLedgerRepository(session).record(
usage=UsageRecord(
organization_id=org_a,
unit=RateUnit.TOOL_CALL,
quantity=1.0,
total_price=7.0,
)
)
await session.rollback()
assert await _usage_ids(session_factory, org_a) == []
assert await _balance(session_factory, org_a) == 100.0
async with session_factory() as session:
debits = await CreditLedgerRepository(session).history(
org_a, kinds=(CreditKind.USAGE,)
)
assert debits == []
+163
View File
@@ -0,0 +1,163 @@
"""The prepaid credit ledger: each balance change is one atomic update and one history row.
What these tests defend:
* a credit then a debit leave the balance their history rows record
* the same usage row debits a wallet once, however many times it is written
* debiting one organization never moves another organization's wallet
* a missing wallet is created at zero, then credited
* the history is newest first and always scoped to one organization
"""
from __future__ import annotations
import pytest_asyncio
from app.domain.base import new_id
from app.domain.billing.models import CreditKind
from app.infrastructure.db.models import OrganizationRow, UserRow
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from tests.billing_helpers import fund_org
@pytest_asyncio.fixture
async def two_orgs(session_factory):
"""Two organizations created directly, so neither starts with a wallet."""
org_ids: list[str] = []
async with session_factory() as session:
for label in ("الف", "ب"):
user = UserRow(email=f"ledger-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name=f"سازمان {label}", slug=f"ledger-{new_id()[:8]}", owner_user_id=user.id
)
session.add(org)
await session.flush()
org_ids.append(org.id)
await session.commit()
return session_factory, org_ids
async def _balance(session_factory, organization_id: str) -> float:
async with session_factory() as session:
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
assert wallet is not None
return wallet.balance
async def test_a_credit_then_a_debit_leave_the_balance_their_history_records(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
async with session_factory() as session:
debit = await CreditLedgerRepository(session).debit_for_usage(
organization_id=org_a, usage_event_id=new_id(), amount=30.0
)
await session.commit()
assert debit is not None
assert debit.kind is CreditKind.USAGE
assert debit.amount == -30.0
assert debit.balance_after == 70.0
assert await _balance(session_factory, org_a) == 70.0
async with session_factory() as session:
history = await CreditLedgerRepository(session).history(org_a)
assert [t.kind for t in history] == [CreditKind.USAGE, CreditKind.ADJUSTMENT]
assert [t.balance_after for t in history] == [70.0, 100.0]
async def test_the_same_usage_row_debits_the_wallet_once(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
usage_id = new_id()
async with session_factory() as session:
repo = CreditLedgerRepository(session)
first = await repo.debit_for_usage(
organization_id=org_a, usage_event_id=usage_id, amount=25.0
)
second = await repo.debit_for_usage(
organization_id=org_a, usage_event_id=usage_id, amount=25.0
)
free = await repo.debit_for_usage(
organization_id=org_a, usage_event_id=new_id(), amount=0.0
)
await session.commit()
assert first is not None and first.balance_after == 75.0
assert second is None
assert free is None
assert await _balance(session_factory, org_a) == 75.0
async with session_factory() as session:
usage_rows = await CreditLedgerRepository(session).history(
org_a, kinds=(CreditKind.USAGE,)
)
assert len(usage_rows) == 1
assert usage_rows[0].usage_event_id == usage_id
async def test_debiting_one_organization_never_moves_another(two_orgs):
session_factory, (org_a, org_b) = two_orgs
await fund_org(session_factory, org_a, 50.0)
await fund_org(session_factory, org_b, 50.0)
async with session_factory() as session:
await CreditLedgerRepository(session).debit_for_usage(
organization_id=org_a, usage_event_id=new_id(), amount=20.0
)
await session.commit()
assert await _balance(session_factory, org_a) == 30.0
assert await _balance(session_factory, org_b) == 50.0
async with session_factory() as session:
b_history = await CreditLedgerRepository(session).history(org_b)
assert [t.kind for t in b_history] == [CreditKind.ADJUSTMENT]
async def test_a_missing_wallet_is_created_at_zero_then_credited(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
async with session_factory() as session:
assert await CreditWalletRepository(session).get_for_org(org_a) is None
async with session_factory() as session:
credit = await CreditLedgerRepository(session).apply(
organization_id=org_a,
amount=25.0,
kind=CreditKind.OWNER_CREDIT,
reason="first credit",
)
await session.commit()
assert credit.balance_after == 25.0
assert await _balance(session_factory, org_a) == 25.0
async def test_history_is_newest_first_scoped_and_capped(two_orgs):
session_factory, (org_a, org_b) = two_orgs
async with session_factory() as session:
repo = CreditLedgerRepository(session)
await repo.apply(
organization_id=org_a, amount=10.0, kind=CreditKind.STARTING_CREDIT, reason="start"
)
await repo.apply(
organization_id=org_a, amount=40.0, kind=CreditKind.OWNER_CREDIT, reason="owner"
)
# Org B never received credit: a usage debit alone is not a credit.
await repo.debit_for_usage(organization_id=org_b, usage_event_id=new_id(), amount=1.0)
await session.commit()
async with session_factory() as session:
repo = CreditLedgerRepository(session)
everything = await repo.history(org_a)
credits_only = await repo.history(org_a, kinds=(CreditKind.OWNER_CREDIT,))
newest_one = await repo.history(org_a, limit=1)
org_b_history = await repo.history(org_b)
last_credit = await repo.last_credit_at_by_org()
assert [t.reason for t in everything] == ["owner", "start"]
assert [t.reason for t in credits_only] == ["owner"]
assert [t.reason for t in newest_one] == ["owner"]
assert [t.kind for t in org_b_history] == [CreditKind.USAGE]
assert set(last_credit) == {org_a}
assert last_credit[org_a] is not None
+236
View File
@@ -0,0 +1,236 @@
"""The platform owner's credit service: top-ups, the manual history and the balances page.
What these tests defend:
* an owner credit moves the wallet by the amount and writes one history row that names
the owner who made it and the reason
* a zero, negative or oversized amount, and a blank or too-short reason, are refused before
any wallet or history row is written
* an unknown organization is a 404, never a silently created wallet
* the history shows manual credit only (no usage debits) and only one organization's rows
* the balances page lists every organization with its own balance, paused task count and
last credit date; a usage debit is not a credit
* the starting credit is granted only when the setting is positive
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import pytest_asyncio
from app.application.services.credit_service import MAX_OWNER_CREDIT, CreditService
from app.core.errors import NotFoundError, ValidationError
from app.domain.base import new_id
from app.domain.billing.models import CreditKind
from app.domain.workforce.pipeline import Stage, TaskPipelineState
from app.infrastructure.db.models import OrganizationRow, TaskRow, UserRow
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from app.infrastructure.repositories.engine import PipelineStateRepository
from tests.billing_helpers import fund_org
VALID_REASON = "شارژ دستی مالک"
async def _new_org(session, label: str) -> str:
"""An organization with its own owner and no wallet yet."""
user = UserRow(email=f"credit-svc-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name=f"سازمان {label}", slug=f"credit-svc-{new_id()[:8]}", owner_user_id=user.id
)
session.add(org)
await session.flush()
return org.id
@pytest_asyncio.fixture
async def two_orgs(session_factory):
async with session_factory() as session:
org_a = await _new_org(session, "الف")
org_b = await _new_org(session, "ب")
await session.commit()
return session_factory, (org_a, org_b)
async def _balance(session_factory, organization_id: str) -> float:
async with session_factory() as session:
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
return wallet.balance if wallet else 0.0
async def _add_task_at(session_factory, organization_id: str, stage: Stage) -> None:
async with session_factory() as session:
task = TaskRow(organization_id=organization_id, title="کار آزمایشی")
session.add(task)
await session.flush()
await PipelineStateRepository(session).create(
TaskPipelineState(task_id=task.id, organization_id=organization_id, stage=stage)
)
await session.commit()
async def test_an_owner_credit_moves_the_balance_and_writes_one_row(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
actor = new_id()
async with session_factory() as session:
credit = await CreditService(session).owner_credit(
organization_id=org_a, amount=40.0, reason=VALID_REASON, actor_user_id=actor
)
await session.commit()
assert credit.kind is CreditKind.OWNER_CREDIT
assert credit.amount == 40.0
assert credit.balance_after == 40.0
assert await _balance(session_factory, org_a) == 40.0
async with session_factory() as session:
rows = await CreditLedgerRepository(session).history(org_a)
assert len(rows) == 1
assert rows[0].reason == VALID_REASON
assert rows[0].actor_user_id == actor
@pytest.mark.parametrize("amount", [0.0, -5.0, MAX_OWNER_CREDIT + 1])
async def test_a_zero_negative_or_oversized_amount_is_refused(two_orgs, amount):
session_factory, (org_a, _org_b) = two_orgs
async with session_factory() as session:
with pytest.raises(ValidationError):
await CreditService(session).owner_credit(
organization_id=org_a, amount=amount, reason=VALID_REASON, actor_user_id=new_id()
)
async with session_factory() as session:
assert await CreditLedgerRepository(session).history(org_a) == []
assert await _balance(session_factory, org_a) == 0.0
async def test_exactly_the_maximum_amount_is_accepted(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
async with session_factory() as session:
credit = await CreditService(session).owner_credit(
organization_id=org_a,
amount=MAX_OWNER_CREDIT,
reason=VALID_REASON,
actor_user_id=new_id(),
)
await session.commit()
assert credit.balance_after == MAX_OWNER_CREDIT
@pytest.mark.parametrize("reason", ["", " ", "ab", "x" * 501])
async def test_a_blank_short_or_long_reason_is_refused(two_orgs, reason):
session_factory, (org_a, _org_b) = two_orgs
async with session_factory() as session:
with pytest.raises(ValidationError) as caught:
await CreditService(session).owner_credit(
organization_id=org_a, amount=10.0, reason=reason, actor_user_id=new_id()
)
assert caught.value.message == "دلیل افزودن اعتبار لازم است."
assert await _balance(session_factory, org_a) == 0.0
async def test_an_unknown_organization_is_not_found(session_factory):
async with session_factory() as session:
with pytest.raises(NotFoundError) as caught:
await CreditService(session).owner_credit(
organization_id=new_id(),
amount=10.0,
reason=VALID_REASON,
actor_user_id=new_id(),
)
assert caught.value.status_code == 404
async def test_the_history_lists_manual_credit_for_one_organization_only(two_orgs):
session_factory, (org_a, org_b) = two_orgs
await fund_org(session_factory, org_a, 50.0)
async with session_factory() as session:
service = CreditService(session)
await service.owner_credit(
organization_id=org_a, amount=10.0, reason=VALID_REASON, actor_user_id=new_id()
)
await service.owner_credit(
organization_id=org_b, amount=7.0, reason="شارژ سازمان ب", actor_user_id=new_id()
)
await CreditLedgerRepository(session).debit_for_usage(
organization_id=org_a, usage_event_id=new_id(), amount=4.0
)
await session.commit()
async with session_factory() as session:
history = await CreditService(session).history(org_a)
newest = await CreditService(session).history(org_a, limit=1)
# The usage debit is not in the history; the other organization's credit is not either.
assert [t.kind for t in history] == [CreditKind.OWNER_CREDIT, CreditKind.ADJUSTMENT]
assert [t.reason for t in history] == [VALID_REASON, "test funding"]
assert [t.kind for t in newest] == [CreditKind.OWNER_CREDIT]
async def test_the_balances_list_each_organization_with_its_own_numbers(two_orgs):
session_factory, (org_a, org_b) = two_orgs
async with session_factory() as session:
org_c = await _new_org(session, "پ")
await session.commit()
async with session_factory() as session:
await CreditService(session).owner_credit(
organization_id=org_a, amount=30.0, reason=VALID_REASON, actor_user_id=new_id()
)
await session.commit()
async with session_factory() as session:
# A usage debit takes the wallet below zero; it is not a credit.
await CreditLedgerRepository(session).debit_for_usage(
organization_id=org_b, usage_event_id=new_id(), amount=2.0
)
await session.commit()
await _add_task_at(session_factory, org_a, Stage.PAUSED)
await _add_task_at(session_factory, org_a, Stage.PAUSED)
await _add_task_at(session_factory, org_b, Stage.PENDING_IMPLEMENTATION)
async with session_factory() as session:
rows = {row.organization_id: row for row in await CreditService(session).balances()}
funded, debited, empty = rows[org_a], rows[org_b], rows[org_c]
assert funded.name == "سازمان الف"
assert funded.balance == 30.0
assert funded.paused_tasks == 2
assert funded.last_credit_at is not None
assert debited.name == "سازمان ب"
assert debited.balance == -2.0
assert debited.paused_tasks == 0
assert debited.last_credit_at is None
# An organization with no wallet is listed at zero, not left out.
assert empty.balance == 0.0
assert empty.currency == "USD"
assert empty.paused_tasks == 0
assert empty.last_credit_at is None
async def test_the_starting_credit_is_granted_only_when_the_setting_is_positive(
two_orgs, monkeypatch
):
session_factory, (org_a, org_b) = two_orgs
monkeypatch.setattr(
"app.application.services.credit_service.settings",
SimpleNamespace(billing_starting_credit=0.0),
)
async with session_factory() as session:
assert await CreditService(session).grant_starting_credit(org_a) is None
await session.commit()
assert await _balance(session_factory, org_a) == 0.0
monkeypatch.setattr(
"app.application.services.credit_service.settings",
SimpleNamespace(billing_starting_credit=3.0),
)
async with session_factory() as session:
granted = await CreditService(session).grant_starting_credit(org_b)
await session.commit()
assert granted is not None
assert granted.kind is CreditKind.STARTING_CREDIT
assert granted.balance_after == 3.0
+169
View File
@@ -0,0 +1,169 @@
"""Direct tasks enter the engine, and the legacy "run" button refuses them (decision D-D).
What these tests defend:
* a task made over HTTP ``POST /tasks`` gets a pipeline state at ``pending_manager_review``
in the organization's project, so the scheduler's manager takes it from there
* ``POST /tasks/{id}/runs`` on such a task is refused with 409: the engine owns it, and
nothing runs outside the engine
* a task made through the MCP gateway (``tasks_create``) gets the same state
* a pre-v2 task with no state still runs through the legacy path
"""
from __future__ import annotations
from httpx import AsyncClient
from app.application.services.mcp_service import McpService, TokenRateLimiter
from app.domain.base import new_id
from app.domain.identity.models import PlatformRole, Role
from app.domain.workforce.pipeline import Stage
from app.infrastructure.db.models import MembershipRow, OrganizationRow, UserRow
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.workforce import TaskRepository
from app.interfaces.mcp.gateway import McpGateway
from tests.conftest import auth_header, signup
from tests.legacy_tasks import legacy_task
#: Part of the Persian refusal shown when the legacy run button is pressed on an engine task.
ENGINE_OWNS = "موتور خودکار"
def _payload(name: str, slug: str) -> dict:
return {
"org_name": name,
"slug": slug,
"app_name": f"app-{slug}",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
async def _wizard_org(client: AsyncClient, token: str, name: str, slug: str) -> dict:
"""The wizard's answer: the organization, its project and its team."""
response = await client.post(
"/api/setup/complete", json=_payload(name, slug), headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()
async def _state(session_factory, task_id: str):
async with session_factory() as session:
return await PipelineStateRepository(session).get(task_id)
async def _org_with_owner(session_factory, slug: str) -> tuple[str, str]:
"""An organization and its owner, made directly (the MCP gateway needs no wizard)."""
async with session_factory() as session:
user = UserRow(
id=new_id(),
email=f"{slug}@example.com",
password_hash="x",
name=slug,
platform_role=PlatformRole.MANAGER.value,
)
session.add(user)
await session.flush()
org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id)
session.add(org)
await session.flush()
session.add(MembershipRow(organization_id=org.id, user_id=user.id, role=Role.OWNER.value))
await session.commit()
return org.id, user.id
async def _mcp_token(session_factory, organization_id: str, user_id: str) -> str:
async with session_factory() as session:
issued = await McpService(session).issue(
organization_id=organization_id,
name="agent",
scopes=["tasks:write", "tasks:read"],
created_by_user_id=user_id,
)
await session.commit()
return issued.token
async def test_a_task_made_over_http_enters_the_engine(client, session_factory, seeded):
manager = await signup(client, "direct-http@example.com")
body = await _wizard_org(client, manager["access_token"], "سازمان الف", "direct-http")
org_id = body["organization"]["id"]
project_id = body["project"]["id"]
created = await client.post(
f"/api/organizations/{org_id}/tasks",
json={"title": "صفحهٔ ورود"},
headers=auth_header(manager["access_token"]),
)
assert created.status_code == 201, created.text
task_id = created.json()["id"]
state = await _state(session_factory, task_id)
assert state is not None, "a direct task must get a pipeline state"
assert state.organization_id == org_id
assert state.project_id == project_id
assert state.stage is Stage.PENDING_MANAGER_REVIEW
async with session_factory() as session:
task = await TaskRepository(session).get(task_id)
assert task is not None and task.project_id == project_id
async def test_running_a_direct_task_is_refused_with_409(client, session_factory, seeded):
manager = await signup(client, "direct-run@example.com")
headers = auth_header(manager["access_token"])
body = await _wizard_org(client, manager["access_token"], "سازمان ب", "direct-run")
org_id = body["organization"]["id"]
created = await client.post(
f"/api/organizations/{org_id}/tasks", json={"title": "کار مستقیم"}, headers=headers
)
assert created.status_code == 201, created.text
task_id = created.json()["id"]
run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs", json={}, headers=headers
)
assert run.status_code == 409, run.text
assert ENGINE_OWNS in run.json()["error"]["message"]
# Nothing ran outside the engine: the task still has no run.
detail = await client.get(f"/api/organizations/{org_id}/tasks/{task_id}", headers=headers)
assert detail.status_code == 200, detail.text
assert detail.json()["runs"] == []
async def test_an_mcp_task_enters_the_engine(session_factory, seeded):
org_id, user_id = await _org_with_owner(session_factory, "direct-mcp")
token = await _mcp_token(session_factory, org_id, user_id)
gateway = McpGateway(
session_factory=session_factory, rate_limiter=TokenRateLimiter(limit_per_minute=None)
)
created = await gateway.call(
tool="tasks_create", arguments={"title": "کار از راه MCP"}, token=token
)
assert created.ok is True, created.error
task_id = created.payload["task"]["id"]
state = await _state(session_factory, task_id)
assert state is not None, "an MCP task must get a pipeline state too"
assert state.organization_id == org_id
assert state.stage is Stage.PENDING_MANAGER_REVIEW
async def test_a_pre_v2_task_still_runs_through_the_legacy_path(client, session_factory, seeded):
manager = await signup(client, "direct-legacy@example.com")
headers = auth_header(manager["access_token"])
body = await _wizard_org(client, manager["access_token"], "سازمان پ", "direct-legacy")
org_id = body["organization"]["id"]
task_id = await legacy_task(session_factory, org_id, manager["user"]["id"], title="کار قدیمی")
assert await _state(session_factory, task_id) is None
run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs", json={}, headers=headers
)
assert run.status_code == 201, run.text
assert run.json()["status"] == "completed", run.text
+32 -2
View File
@@ -24,7 +24,11 @@ import pytest_asyncio
from httpx import AsyncClient
from app.domain.identity.models import PlatformRole
from app.domain.workforce.pipeline import Stage
from app.infrastructure.repositories.engine import PipelineStateRepository
from tests.billing_helpers import fund_org
from tests.conftest import auth_header, signup
from tests.legacy_tasks import legacy_task
@pytest_asyncio.fixture(autouse=True)
@@ -33,7 +37,9 @@ async def _platform_seed(seeded):
return seeded
async def test_end_to_end_smoke_path(client: AsyncClient):
async def test_end_to_end_smoke_path(client: AsyncClient, session_factory):
"""Decision D-D: the task made over HTTP enters the engine and refuses the legacy run, so
the run, its metering and its audit are walked on a pre-v2 task (see step 10)."""
# -- 1. signup -------------------------------------------------------- #
# The platform owner, not a plain manager: step 6 drives the sandbox
# directly, and v2 makes container/command access owner-only
@@ -184,6 +190,9 @@ async def test_end_to_end_smoke_path(client: AsyncClient):
assert thread.status_code == 201, thread.text
thread_id = thread.json()["id"]
# prepaid (D-B): the HTTP org starts at zero credit, and the chat costs model calls.
await fund_org(session_factory, org_id)
message = await client.post(
f"/api/organizations/{org_id}/threads/{thread_id}/messages",
json={"content": "برای ماژول پرداخت تست بنویس.", "employee_id": employee_id},
@@ -212,10 +221,31 @@ async def test_end_to_end_smoke_path(client: AsyncClient):
)
assert task.status_code == 201, task.text
task_id = task.json()["id"]
# Decision D-D: a task made over HTTP is the engine's from manager review, so it has a
# pipeline state and the legacy run button refuses it.
async with session_factory() as session:
state = await PipelineStateRepository(session).get(task_id)
assert state is not None and state.stage is Stage.PENDING_MANAGER_REVIEW
refused = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
json={"employee_id": employee_id},
headers=headers,
)
assert refused.status_code == 409, refused.text
# -- 10. run task ------------------------------------------------------ #
# The legacy run path serves pre-v2 tasks, which have no pipeline state, so the run, its
# metering and its audit are checked on one of those (decision D-D).
run_task_id = await legacy_task(
session_factory,
org_id,
signup_body["user"]["id"],
title="افزودن تست برای ماژول پرداخت",
project_id=project_id,
assigned_employee_id=employee_id,
)
run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
f"/api/organizations/{org_id}/tasks/{run_task_id}/runs",
json={"employee_id": employee_id},
headers=headers,
)
+408
View File
@@ -0,0 +1,408 @@
"""The engine's read API: status, task board, task detail and recent turns.
What these tests defend:
* any member, including a viewer, reads every route; a foreign member gets 404 on every
route, and a task id of another organization is a 404 under the caller's own organization
* ``status`` reports the wallet balance (``credit_ok`` only above zero), the owner's cap, the
headcount and the stage counts of the organization
* the board lists engine and legacy tasks, and ``stage`` narrows it to one stage
* the task detail carries the pipeline stages, the turns, the gate verdicts, the runs, the
last diff and the merged sha; a turn excerpt is capped at 1,000 characters
* no response names a model, a provider or an ``ai_model_id`` (every seeded model and
provider name is scanned in every body)
"""
from __future__ import annotations
from dataclasses import dataclass
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.application.container_org import build_model_grants
from app.domain.base import utcnow
from app.domain.identity.models import Role
from app.domain.workforce.engine import EmployeeProfile, EngineTurn, GateName, GateResult
from app.domain.workforce.models import Employee, RoleKey, Run, RunStatus
from app.domain.workforce.pipeline import (
Mode,
PipelineKey,
Stage,
TaskKind,
TaskPipelineState,
pipeline_stages,
)
from app.infrastructure.db.models import AiModelRow, AiProviderRow, MembershipRow, TaskRow
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
EngineTurnRepository,
GateResultRepository,
PipelineStateRepository,
)
from app.infrastructure.repositories.workforce import EmployeeRepository, RunRepository
from tests.billing_helpers import fund_org
from tests.conftest import auth_header, signup
DIFF = "diff --git a/app/login.py b/app/login.py\n+def login(): return True\n"
SHA = "9f8e7d6c5b4a39281706f5e4d3c2b1a098765432"
TITLE = "صفحهٔ ورود"
EMPLOYEE = "برنامه‌نویس یک"
def _base(org_id: str) -> str:
return f"/api/organizations/{org_id}/engine"
def _payload(name: str, slug: str) -> dict:
return {
"org_name": name,
"slug": slug,
"app_name": f"app-{slug}",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
@dataclass
class _Org:
id: str
project_id: str
owner_id: str
owner_token: str
@dataclass
class _Data:
org: _Org
model_id: str
done_task_id: str
testing_task_id: str
legacy_task_id: str
async def _make_org(client: AsyncClient, slug: str) -> _Org:
owner = await signup(client, f"{slug}-owner@example.com")
response = await client.post(
"/api/setup/complete",
json=_payload(f"سازمان {slug}", slug),
headers=auth_header(owner["access_token"]),
)
assert response.status_code == 201, response.text
body = response.json()
return _Org(
id=body["organization"]["id"],
project_id=body["project"]["id"],
owner_id=owner["user"]["id"],
owner_token=owner["access_token"],
)
async def _join(
client: AsyncClient,
session_factory: async_sessionmaker,
organization_id: str,
role: Role,
email: str,
) -> str:
"""Add a fresh account to an organization with ``role``; returns its token."""
user = await signup(client, email)
async with session_factory() as session:
session.add(
MembershipRow(
organization_id=organization_id, user_id=user["user"]["id"], role=role.value
)
)
await session.commit()
return user["access_token"]
async def _engine_task(session, org: _Org, *, title: str, stage: Stage, **fields) -> TaskRow:
employee_id = fields.pop("employee_id", None)
row = TaskRow(
organization_id=org.id,
title=title,
description="وصف کار",
project_id=org.project_id,
created_by_user_id=org.owner_id,
assigned_employee_id=employee_id,
)
session.add(row)
await session.flush()
await PipelineStateRepository(session).create(
TaskPipelineState(
task_id=row.id,
organization_id=org.id,
project_id=org.project_id,
pipeline=PipelineKey.PLAN_CODE_TEST,
kind=TaskKind.FEATURE,
stage=stage,
**fields,
)
)
return row
async def _seed(session_factory: async_sessionmaker, org: _Org) -> _Data:
"""One finished engine task (turn, gate, run, diff, sha), one at a test stage, one legacy."""
async with session_factory() as session:
model = (await session.execute(select(AiModelRow).limit(1))).scalars().first()
assert model is not None
developer = await EmployeeRepository(session).create(
Employee(organization_id=org.id, name=EMPLOYEE, role_key=RoleKey.DEVELOPER)
)
await EmployeeProfileRepository(session).upsert(
EmployeeProfile(
employee_id=developer.id,
ai_model_id=model.id,
roles=[RoleKey.DEVELOPER.value],
)
)
done = await _engine_task(
session,
org,
title=TITLE,
stage=Stage.COMPLETED,
employee_id=developer.id,
last_diff=DIFF,
merged_sha=SHA,
)
testing = await _engine_task(session, org, title="جدول کاربران", stage=Stage.PENDING_TEST)
legacy = TaskRow(
organization_id=org.id,
title="کار قدیمی",
project_id=org.project_id,
created_by_user_id=org.owner_id,
)
session.add(legacy)
await session.flush()
run = await RunRepository(session).create(
Run(task_id=done.id, employee_id=developer.id, status=RunStatus.COMPLETED)
)
await EngineTurnRepository(session).create(
EngineTurn(
task_id=done.id,
run_id=run.id,
employee_id=developer.id,
role=RoleKey.DEVELOPER,
mode=Mode.WORK,
stage=Stage.PENDING_IMPLEMENTATION,
reply_excerpt="پاسخ " * 400,
ok=True,
finished_at=utcnow(),
)
)
await GateResultRepository(session).create(
GateResult(task_id=done.id, gate=GateName.GREEN, ok=True, detail="آزمون‌ها سبز شد.")
)
await session.commit()
return _Data(
org=org,
model_id=model.id,
done_task_id=done.id,
testing_task_id=testing.id,
legacy_task_id=legacy.id,
)
async def _get(client: AsyncClient, token: str, url: str):
return await client.get(url, headers=auth_header(token))
# ---------------------------------------------------------------------------
# status
# ---------------------------------------------------------------------------
async def test_status_reports_credit_policy_team_and_stage_counts(
client, session_factory, seeded
):
org = await _make_org(client, "eng-status")
await fund_org(session_factory, org.id, 40.0)
await _seed(session_factory, org)
async with session_factory() as session:
cap = (await build_model_grants(session).get_org_policy(org.id)).max_employees
headcount = await EmployeeRepository(session).count_for_org(org.id)
response = await _get(client, org.owner_token, f"{_base(org.id)}/status")
assert response.status_code == 200, response.text
body = response.json()
assert body["credit_ok"] is True
assert body["balance"] == 40.0
assert body["currency"] == "USD"
assert body["max_employees"] == cap
assert body["team_size"] == headcount
assert body["stage_counts"] == {"completed": 1, "pending_test": 1}
async def test_status_is_not_credit_ok_without_credit(client, session_factory, seeded):
org = await _make_org(client, "eng-zero")
response = await _get(client, org.owner_token, f"{_base(org.id)}/status")
assert response.status_code == 200, response.text
assert response.json()["credit_ok"] is False
assert response.json()["balance"] == 0.0
# ---------------------------------------------------------------------------
# board
# ---------------------------------------------------------------------------
async def test_the_board_lists_engine_and_legacy_tasks_and_filters_by_stage(
client, session_factory, seeded
):
org = await _make_org(client, "eng-board")
data = await _seed(session_factory, org)
board = f"{_base(org.id)}/tasks"
everything = await _get(client, org.owner_token, board)
assert everything.status_code == 200, everything.text
rows = {row["id"]: row for row in everything.json()}
assert set(rows) == {data.done_task_id, data.testing_task_id, data.legacy_task_id}
assert rows[data.legacy_task_id]["stage"] is None
assert rows[data.legacy_task_id]["pipeline"] is None
assert rows[data.legacy_task_id]["cycle"] == 0
assert rows[data.testing_task_id]["stage"] == "pending_test"
assert rows[data.done_task_id]["assignee_name"] == EMPLOYEE
testing = await client.get(
board, params={"stage": "pending_test"}, headers=auth_header(org.owner_token)
)
assert [row["id"] for row in testing.json()] == [data.testing_task_id]
limited = await client.get(board, params={"limit": 1}, headers=auth_header(org.owner_token))
assert len(limited.json()) == 1
unknown = await client.get(
board, params={"stage": "nope"}, headers=auth_header(org.owner_token)
)
assert unknown.status_code == 422
# ---------------------------------------------------------------------------
# detail and turns
# ---------------------------------------------------------------------------
async def test_task_detail_returns_stages_turns_gates_runs_diff_and_sha(
client, session_factory, seeded
):
org = await _make_org(client, "eng-detail")
data = await _seed(session_factory, org)
response = await _get(client, org.owner_token, f"{_base(org.id)}/tasks/{data.done_task_id}")
assert response.status_code == 200, response.text
body = response.json()
assert body["stage"] == "completed"
assert body["description"] == "وصف کار"
assert body["stages"] == [str(s) for s in pipeline_stages(PipelineKey.PLAN_CODE_TEST)]
assert body["merged_sha"] == SHA
assert body["diff"] == DIFF
assert body["assignee_name"] == EMPLOYEE
[turn] = body["turns"]
assert turn["employee_name"] == EMPLOYEE
assert turn["role"] == "developer"
assert turn["mode"] == str(Mode.WORK)
assert len(turn["reply_excerpt"]) == 1000
[gate] = body["gates"]
assert gate["gate"] == "green"
assert gate["ok"] is True
[run] = body["runs"]
assert run["status"] == "completed"
async def test_recent_turns_carry_the_task_title_and_the_employee_name(
client, session_factory, seeded
):
org = await _make_org(client, "eng-turns")
data = await _seed(session_factory, org)
response = await _get(client, org.owner_token, f"{_base(org.id)}/turns")
assert response.status_code == 200, response.text
[turn] = response.json()
assert turn["task_id"] == data.done_task_id
assert turn["task_title"] == TITLE
assert turn["employee_name"] == EMPLOYEE
too_many = await _get(client, org.owner_token, f"{_base(org.id)}/turns?limit=0")
assert too_many.status_code == 422
# ---------------------------------------------------------------------------
# who may read
# ---------------------------------------------------------------------------
async def test_a_viewer_reads_every_route(client, session_factory, seeded):
org = await _make_org(client, "eng-viewer")
data = await _seed(session_factory, org)
token = await _join(
client, session_factory, org.id, Role.VIEWER, "eng-viewer-user@example.com"
)
for path in ("/status", "/tasks", f"/tasks/{data.done_task_id}", "/turns"):
response = await _get(client, token, f"{_base(org.id)}{path}")
assert response.status_code == 200, f"{path}: {response.text}"
async def test_a_foreign_member_gets_404_on_every_route(client, session_factory, seeded):
org_a = await _make_org(client, "eng-a")
data = await _seed(session_factory, org_a)
org_b = await _make_org(client, "eng-b")
for path in ("/status", "/tasks", f"/tasks/{data.done_task_id}", "/turns"):
response = await _get(client, org_b.owner_token, f"{_base(org_a.id)}{path}")
assert response.status_code == 404, f"{path}: {response.text}"
# A's task id under B's own organization is not found, and B's board stays empty.
inside_b = await _get(client, org_b.owner_token, f"{_base(org_b.id)}/tasks/{data.done_task_id}")
assert inside_b.status_code == 404
board_b = await _get(client, org_b.owner_token, f"{_base(org_b.id)}/tasks")
assert board_b.json() == []
turns_b = await _get(client, org_b.owner_token, f"{_base(org_b.id)}/turns")
assert turns_b.json() == []
# ---------------------------------------------------------------------------
# no model identity
# ---------------------------------------------------------------------------
async def test_no_response_names_a_model_a_provider_or_an_ai_model_id(
client, session_factory, seeded
):
org = await _make_org(client, "eng-names")
data = await _seed(session_factory, org)
async with session_factory() as session:
models = list((await session.execute(select(AiModelRow))).scalars().all())
providers = list((await session.execute(select(AiProviderRow))).scalars().all())
forbidden = {data.model_id}
for model in models:
forbidden.update({model.internal_name, model.display_name_for_admin})
forbidden.update(provider.name for provider in providers)
# Names shorter than three characters would match ordinary text; the seed has none.
forbidden = {value for value in forbidden if len(value) >= 3}
assert forbidden
base = _base(org.id)
paths = (
"/status",
"/tasks",
f"/tasks/{data.done_task_id}",
f"/tasks/{data.testing_task_id}",
"/turns",
)
for path in paths:
response = await _get(client, org.owner_token, f"{base}{path}")
assert response.status_code == 200, f"{path}: {response.text}"
for value in forbidden:
assert value not in response.text, f"{path} names {value!r}"
+186
View File
@@ -0,0 +1,186 @@
"""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
+255
View File
@@ -0,0 +1,255 @@
"""The engine's team API: the team with tiers, hire by role and tier, and fire.
What these tests defend:
* ``GET /team`` shows each member's neutral tier, ``unknown`` while no granted model backs it;
the tiers the organization has; the owner's cap; and not the archived members
* hire takes a role and a tier, never a model. A tier with no granted model is a 409, and the
cap is the owner's policy: a hire at the cap is a 409
* firing archives the member; the last active manager cannot be fired (409)
* a member without ``employees.manage`` gets 403 on hire and fire; a foreign member gets 404
* no response names a model, a provider or an ``ai_model_id``
"""
from __future__ import annotations
from sqlalchemy import select
from app.application.container_org import build_model_grants
from app.domain.audit.models import AuditAction
from app.domain.identity.models import Role
from app.infrastructure.db.models import AiModelRow, AiProviderRow, AuditEventRow
from tests.conftest import auth_header
from tests.test_engine_api import _base, _join, _make_org
REFUSED_NO_GRANT = "مدلی از این سطح"
REFUSED_CAP = "سقف تعداد کارمندان"
REFUSED_LAST_MANAGER = "آخرین مدیر"
async def _strong_model_id(session_factory) -> str:
async with session_factory() as session:
models = list((await session.execute(select(AiModelRow))).scalars().all())
return next(m.id for m in models if "reasoning" in (m.capabilities or []))
async def _grant_strong(client, owner_token: str, organization_id: str, session_factory) -> str:
"""Give the organization the strong model only, so its tiers are exactly ``strong``."""
model_id = await _strong_model_id(session_factory)
response = await client.put(
f"/api/admin/model-grants/{organization_id}/models/{model_id}",
json={},
headers=auth_header(owner_token),
)
assert response.status_code == 200, response.text
return model_id
async def _set_cap(client, owner_token: str, organization_id: str, cap: int) -> None:
response = await client.put(
f"/api/admin/org-policies/{organization_id}",
json={"max_employees": cap},
headers=auth_header(owner_token),
)
assert response.status_code == 200, response.text
async def _team(client, token: str, organization_id: str) -> dict:
response = await client.get(f"{_base(organization_id)}/team", headers=auth_header(token))
assert response.status_code == 200, response.text
return response.json()
async def _hire(client, token: str, organization_id: str, role: str, tier: str, name: str = ""):
return await client.post(
f"{_base(organization_id)}/team/hire",
json={"role": role, "tier": tier, "name": name},
headers=auth_header(token),
)
async def _forbidden_values(session_factory, model_id: str) -> set[str]:
"""Every model name, admin label, provider name and model id the seed knows, plus this one."""
async with session_factory() as session:
models = list((await session.execute(select(AiModelRow))).scalars().all())
providers = list((await session.execute(select(AiProviderRow))).scalars().all())
values = {model_id}
for model in models:
values.update({model.internal_name, model.display_name_for_admin, model.id})
values.update(provider.name for provider in providers)
# Names shorter than three characters would match ordinary text; the seed has none.
return {value for value in values if len(value) >= 3}
async def test_the_team_view_shows_neutral_tiers_and_the_owners_cap(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-view")
model_id = await _grant_strong(client, platform_owner_token, org.id, session_factory)
async with session_factory() as session:
cap = (await build_model_grants(session).get_org_policy(org.id)).max_employees
body = await _team(client, org.owner_token, org.id)
assert body["max_employees"] == cap
assert body["tiers"] == [{"tier": "strong", "label": "قوی"}]
assert body["count"] == len(body["employees"])
roles = {member["role_key"]: member for member in body["employees"]}
# The team was bootstrapped before the grant, so nobody runs on a granted model yet.
assert roles["manager"]["tier"] == "unknown"
assert roles["manager"]["status"] == "paused"
for value in await _forbidden_values(session_factory, model_id):
assert value not in str(body), f"team names {value!r}"
async def test_hire_takes_a_role_and_a_tier_and_returns_the_member(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-hire")
model_id = await _grant_strong(client, platform_owner_token, org.id, session_factory)
await _set_cap(client, platform_owner_token, org.id, 12)
before = (await _team(client, org.owner_token, org.id))["count"]
hired = await _hire(
client, org.owner_token, org.id, "developer", "strong", "برنامه‌نویس دوم"
)
assert hired.status_code == 201, hired.text
body = hired.json()
assert body["tier"] == "strong"
assert body["role_key"] == "developer"
assert body["name"] == "برنامه‌نویس دوم"
assert body["status"] == "active"
for value in await _forbidden_values(session_factory, model_id):
assert value not in hired.text, f"hire names {value!r}"
team = await _team(client, org.owner_token, org.id)
assert team["count"] == before + 1
assert body["id"] in {member["id"] for member in team["employees"]}
async with session_factory() as session:
events = list(
(
await session.execute(
select(AuditEventRow).where(AuditEventRow.target_id == body["id"])
)
)
.scalars()
.all()
)
assert [e.action for e in events] == [str(AuditAction.EMPLOYEE_CREATE)]
assert events[0].metadata_json == {"role_key": "developer", "tier": "strong"}
async def test_a_tier_with_no_granted_model_is_a_conflict(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-tier")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
await _set_cap(client, platform_owner_token, org.id, 12)
for tier in ("fast", "balanced", "ultra"):
response = await _hire(client, org.owner_token, org.id, "tester", tier)
assert response.status_code == 409, f"{tier}: {response.text}"
assert REFUSED_NO_GRANT in response.json()["error"]["message"]
async def test_the_cap_is_the_owners_policy_and_a_hire_stops_at_it(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-cap")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
count = (await _team(client, org.owner_token, org.id))["count"]
await _set_cap(client, platform_owner_token, org.id, count + 1)
first = await _hire(client, org.owner_token, org.id, "developer", "strong")
assert first.status_code == 201, first.text
second = await _hire(client, org.owner_token, org.id, "developer", "strong")
assert second.status_code == 409, second.text
assert REFUSED_CAP in second.json()["error"]["message"]
team = await _team(client, org.owner_token, org.id)
async with session_factory() as session:
cap = (await build_model_grants(session).get_org_policy(org.id)).max_employees
assert team["max_employees"] == cap == count + 1
assert team["count"] == count + 1
async def test_the_last_manager_cannot_be_fired_and_a_fired_member_is_archived(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-fire")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
await _set_cap(client, platform_owner_token, org.id, 12)
before = await _team(client, org.owner_token, org.id)
manager = next(m for m in before["employees"] if m["role_key"] == "manager")
headers = auth_header(org.owner_token)
refused = await client.post(f"{_base(org.id)}/team/{manager['id']}/fire", headers=headers)
assert refused.status_code == 409, refused.text
assert REFUSED_LAST_MANAGER in refused.json()["error"]["message"]
hired = await _hire(client, org.owner_token, org.id, "developer", "strong")
assert hired.status_code == 201, hired.text
fired = await client.post(
f"{_base(org.id)}/team/{hired.json()['id']}/fire", headers=headers
)
assert fired.status_code == 200, fired.text
assert fired.json()["status"] == "archived"
after = await _team(client, org.owner_token, org.id)
assert hired.json()["id"] not in {m["id"] for m in after["employees"]}
assert after["count"] == before["count"]
async with session_factory() as session:
events = list(
(
await session.execute(
select(AuditEventRow).where(AuditEventRow.target_id == hired.json()["id"])
)
)
.scalars()
.all()
)
actions = [(e.action, e.metadata_json) for e in events]
assert (str(AuditAction.EMPLOYEE_UPDATE), {"action": "fire"}) in actions
async def test_a_member_without_the_capability_is_refused_on_hire_and_fire(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-member")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
member = await _join(
client, session_factory, org.id, Role.MEMBER, "team-member-user@example.com"
)
team = await _team(client, org.owner_token, org.id)
target = team["employees"][0]["id"]
hired = await _hire(client, member, org.id, "developer", "strong")
assert hired.status_code == 403, hired.text
fired = await client.post(f"{_base(org.id)}/team/{target}/fire", headers=auth_header(member))
assert fired.status_code == 403, fired.text
# The member still reads the team.
readable = await client.get(f"{_base(org.id)}/team", headers=auth_header(member))
assert readable.status_code == 200, readable.text
async def test_another_organization_gets_not_found_on_the_team(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-a")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
await _set_cap(client, platform_owner_token, org.id, 12)
team = await _team(client, org.owner_token, org.id)
target = team["employees"][0]["id"]
other = await _make_org(client, "team-b")
headers = auth_header(other.owner_token)
assert (await client.get(f"{_base(org.id)}/team", headers=headers)).status_code == 404
hired = await client.post(
f"{_base(org.id)}/team/hire",
json={"role": "developer", "tier": "strong"},
headers=headers,
)
assert hired.status_code == 404, hired.text
fired = await client.post(f"{_base(org.id)}/team/{target}/fire", headers=headers)
assert fired.status_code == 404, fired.text
+263
View File
@@ -0,0 +1,263 @@
"""The person's controls on an engine task: pause, resume, cancel and answer.
``Scheduler.user_action`` is the one place these moves are made for a person.
What it defends:
* pause moves a running task to ``paused``; resume brings a paused task back to
triage, and is refused while the wallet is empty (the task stays paused)
* cancel ends the task and releases its worktree
* an answer is accepted only from ``pending_user_decision``; it reaches the task
description and the thread, and the task goes back to triage
* a task of another organization is a 404, never a conflict (tenant isolation)
"""
# ruff: noqa: F811 - the `world` fixture is imported from test_engine_flow; the test parameters
# named `world` are that fixture, which ruff would otherwise read as a redefinition.
from __future__ import annotations
import pytest
from app.application.engine.scheduler import BUDGET_PAUSED_MESSAGE
from app.application.ports.delivery_hooks import NullDeliveryHooks
from app.application.ports.repo_workspace import FakeRepoWorkspace
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.domain.base import new_id
from app.domain.workforce.models import MessageRole, TaskStatus, Thread
from app.domain.workforce.pipeline import Stage
from app.infrastructure.db.models import OrganizationRow, TaskRow
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.workforce import (
MessageRepository,
TaskRepository,
ThreadRepository,
)
from tests.engine_fakes import FakeSandbox, RecordingMemory, ScriptedModel
from tests.test_engine_flow import (
_calls,
_FundedWallet,
_scheduler,
_task,
_Wallets,
_ZeroWallet,
world, # noqa: F401 - pytest injects this fixture into the tests below
)
def _scheduler_for(session, org, *, workspace=None, wallets=None):
return _scheduler(
session,
org,
scripted=ScriptedModel([]),
sandbox=FakeSandbox(),
workspace=workspace if workspace is not None else FakeRepoWorkspace(),
memory=RecordingMemory(),
delivery=NullDeliveryHooks(),
wallets=wallets,
)
async def test_pause_moves_a_running_task_to_paused(world, session_factory):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
await session.commit()
scheduler = _scheduler_for(session, world)
state = await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="pause",
user_id=world.user_id,
)
task = await TaskRepository(session).get(row.id)
assert state.stage is Stage.PAUSED
assert task is not None and task.status is TaskStatus.WAITING_APPROVAL
async def test_resume_brings_a_paused_task_back_to_triage_when_funded(world, session_factory):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PAUSED)
await session.commit()
scheduler = _scheduler_for(session, world, wallets=_Wallets(_FundedWallet()))
state = await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="resume",
user_id=world.user_id,
)
assert state.stage is Stage.PENDING_MANAGER_REVIEW
async def test_resume_with_a_zero_wallet_is_refused_and_the_task_stays_paused(
world, session_factory
):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PAUSED)
await session.commit()
scheduler = _scheduler_for(session, world, wallets=_Wallets(_ZeroWallet()))
with pytest.raises(ConflictError) as refused:
await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="resume",
user_id=world.user_id,
)
state = await PipelineStateRepository(session).get(row.id)
assert refused.value.message == BUDGET_PAUSED_MESSAGE
assert state is not None and state.stage is Stage.PAUSED
async def test_resume_is_refused_for_a_task_that_is_not_paused(world, session_factory):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
await session.commit()
scheduler = _scheduler_for(session, world, wallets=_Wallets(_FundedWallet()))
with pytest.raises(ConflictError, match="فقط کار"):
await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="resume",
user_id=world.user_id,
)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PENDING_IMPLEMENTATION
async def test_cancel_ends_the_task_and_releases_its_worktree(world, session_factory):
workspace = FakeRepoWorkspace()
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
await session.commit()
scheduler = _scheduler_for(session, world, workspace=workspace)
state = await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="cancel",
user_id=world.user_id,
)
task = await TaskRepository(session).get(row.id)
assert state.stage is Stage.CANCELLED
assert task is not None and task.status is TaskStatus.CANCELLED
assert _calls(workspace, "remove_worktree")
async def test_an_answer_reaches_the_description_and_the_thread_then_triage(
world, session_factory
):
async with session_factory() as session:
thread = await ThreadRepository(session).create(
Thread(
organization_id=world.organization_id,
title="گفتگو",
project_id=world.project_id,
)
)
row = await _task(session, world, stage=Stage.PENDING_USER_DECISION)
row.thread_id = thread.id
await session.commit()
scheduler = _scheduler_for(session, world)
state = await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="answer",
answer=" نسخهٔ دوم را انجام بده ",
user_id=world.user_id,
)
task = await TaskRepository(session).get(row.id)
messages = await MessageRepository(session).list_for_thread(thread.id)
assert state.stage is Stage.PENDING_MANAGER_REVIEW
assert task is not None
assert task.description.endswith("پاسخ کاربر: نسخهٔ دوم را انجام بده")
assert [(m.role, m.content) for m in messages] == [
(MessageRole.USER, "نسخهٔ دوم را انجام بده")
]
async def test_an_answer_is_refused_unless_the_task_waits_for_a_decision(
world, session_factory
):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
await session.commit()
scheduler = _scheduler_for(session, world)
with pytest.raises(ConflictError):
await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="answer",
answer="پاسخ",
user_id=world.user_id,
)
state = await PipelineStateRepository(session).get(row.id)
task = await TaskRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PENDING_IMPLEMENTATION
assert task is not None and "پاسخ کاربر" not in task.description
async def test_a_blank_answer_is_refused(world, session_factory):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PENDING_USER_DECISION)
await session.commit()
scheduler = _scheduler_for(session, world)
with pytest.raises(ValidationError):
await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="answer",
answer=" ",
user_id=world.user_id,
)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PENDING_USER_DECISION
async def test_a_legacy_task_without_an_engine_state_is_a_conflict(world, session_factory):
async with session_factory() as session:
row = TaskRow(
organization_id=world.organization_id,
title="کار قدیمی",
project_id=world.project_id,
created_by_user_id=world.user_id,
)
session.add(row)
await session.commit()
scheduler = _scheduler_for(session, world)
with pytest.raises(ConflictError, match="موتور خودکار"):
await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="pause",
user_id=world.user_id,
)
async def test_another_organization_gets_not_found_for_every_control(world, session_factory):
async with session_factory() as session:
other = OrganizationRow(
name="سازمان دیگر",
slug=f"other-{new_id()[:6]}",
owner_user_id=world.user_id,
)
session.add(other)
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
await session.commit()
scheduler = _scheduler_for(session, world)
for action in ("pause", "resume", "cancel", "answer"):
with pytest.raises(NotFoundError):
await scheduler.user_action(
organization_id=other.id,
task_id=row.id,
action=action,
answer="پاسخ",
user_id=world.user_id,
)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PENDING_IMPLEMENTATION
+67 -10
View File
@@ -14,7 +14,8 @@ git (in-memory ``FakeRepoWorkspace``) are fakes. What it defends:
* red tests and a forbidden diff send the work *back* with cycle + 1 instead of
merging it
* ``analysis_only`` never creates a worktree
* an overdrawn wallet pauses the task before a single turn is spent
* an empty, overdrawn or missing wallet pauses the task before a single turn is
spent (prepaid billing, decision D-B)
* a transient provider failure is retried, then handed to the manager
"""
@@ -92,13 +93,19 @@ class _OverdrawnWallet:
class _ZeroWallet:
"""A wallet at exactly zero: the normal starting state of a fresh, post-paid org."""
"""A wallet at exactly zero: prepaid, so no credit is left."""
balance = 0.0
class _FundedWallet:
"""A wallet with credit left: the org may spend a turn."""
balance = 5.0
class _Wallets:
def __init__(self, wallet: _OverdrawnWallet | _ZeroWallet | None) -> None:
def __init__(self, wallet: _OverdrawnWallet | _ZeroWallet | _FundedWallet | None) -> None:
self._wallet = wallet
async def get_for_org(self, organization_id: str):
@@ -567,14 +574,12 @@ async def test_an_overdrawn_wallet_pauses_the_task_before_a_turn_is_spent(
assert BUDGET_PAUSED_MESSAGE
async def test_a_zero_wallet_does_not_pause_a_fresh_org(world, session_factory):
"""A 0.0 balance is every org's starting state, so it must not pause the task.
async def test_a_zero_wallet_pauses_the_task_before_a_turn_is_spent(world, session_factory):
"""A zero balance is no credit left: the task pauses before a turn is spent.
This is an intentional behaviour change. The scheduler used to pause on
``balance <= 0``, which froze every new org before its first turn: usage is
post-paid and nothing debits a wallet. Only an overdrawn wallet (below zero)
pauses now. The old test that encoded the previous rule was renamed and
updated, as PLAN-v2 §7.4 requires for a deliberate behaviour change.
Prepaid billing (decision D-B) reverses the post-paid rule of the earlier plan
(its T3), under which a zero balance was every new org's normal state and did
not pause. Now the platform owner credits an org before any work runs.
"""
scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")])
workspace = FakeRepoWorkspace()
@@ -594,6 +599,58 @@ async def test_a_zero_wallet_does_not_pause_a_fresh_org(world, session_factory):
)
await _run(scheduler, session, 1)
state = await PipelineStateRepository(session).get(row.id)
task = await TaskRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PAUSED
assert task is not None and task.status is TaskStatus.WAITING_APPROVAL
assert scripted.calls == 0
async def test_a_missing_wallet_pauses_the_task(world, session_factory):
"""No wallet row at all counts as zero credit (prepaid): the task pauses."""
scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")])
workspace = FakeRepoWorkspace()
async with session_factory() as session:
await _team(session, world)
row = await _task(session, world, stage=Stage.PENDING_MANAGER_REVIEW)
await session.commit()
scheduler = _scheduler(
session,
world,
scripted=scripted,
sandbox=FakeSandbox(),
workspace=workspace,
memory=RecordingMemory(),
delivery=NullDeliveryHooks(),
wallets=_Wallets(None),
)
await _run(scheduler, session, 1)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PAUSED
assert scripted.calls == 0
async def test_a_funded_wallet_does_not_pause(world, session_factory):
"""Credit left means the org may spend a turn: the task is not paused."""
scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")])
workspace = FakeRepoWorkspace()
async with session_factory() as session:
await _team(session, world)
row = await _task(session, world, stage=Stage.PENDING_MANAGER_REVIEW)
await session.commit()
scheduler = _scheduler(
session,
world,
scripted=scripted,
sandbox=FakeSandbox(),
workspace=workspace,
memory=RecordingMemory(),
delivery=NullDeliveryHooks(),
wallets=_Wallets(_FundedWallet()),
)
await _run(scheduler, session, 1)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is not Stage.PAUSED
assert scripted.calls == 1
+171
View File
@@ -0,0 +1,171 @@
"""The engine repositories behind the v2 UI: read and write paths.
What these tests defend:
* ``last_diff`` and ``merged_sha`` are patched under the version lock and read
back unchanged (the task page shows both)
* the organization turn feed never returns another organization's turns, even
when both organizations worked in the same window (turns carry no
organization column, so the tenant filter goes through the task)
* stage counts are per organization: one organization's board does not count
another organization's tasks
"""
from __future__ import annotations
from datetime import datetime, timedelta
import pytest_asyncio
from app.domain.base import new_id, utcnow
from app.domain.workforce.engine import EngineTurn
from app.domain.workforce.models import RoleKey
from app.domain.workforce.pipeline import Mode, Stage, TaskPipelineState
from app.infrastructure.db.models import OrganizationRow, RunRow, TaskRow, UserRow
from app.infrastructure.repositories.engine import (
EngineTurnRepository,
PipelineStateRepository,
)
@pytest_asyncio.fixture
async def two_orgs(session_factory):
"""Two organizations, each with its own owner: the tenant boundary under test."""
org_ids: list[str] = []
async with session_factory() as session:
for label in ("الف", "ب"):
user = UserRow(email=f"repo-ui-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name=f"سازمان {label}", slug=f"repo-ui-{new_id()[:8]}", owner_user_id=user.id
)
session.add(org)
await session.flush()
org_ids.append(org.id)
await session.commit()
return session_factory, org_ids
async def _add_task(session_factory, organization_id: str, title: str) -> str:
async with session_factory() as session:
task = TaskRow(organization_id=organization_id, title=title)
session.add(task)
await session.commit()
return task.id
async def _add_run(session_factory, task_id: str) -> str:
async with session_factory() as session:
run = RunRow(task_id=task_id)
session.add(run)
await session.commit()
return run.id
async def _add_state(
session_factory, organization_id: str, task_id: str, stage: Stage
) -> TaskPipelineState:
async with session_factory() as session:
state = await PipelineStateRepository(session).create(
TaskPipelineState(task_id=task_id, organization_id=organization_id, stage=stage)
)
await session.commit()
return state
async def _add_turn(
session_factory, task_id: str, run_id: str, excerpt: str, started_at: datetime
) -> None:
async with session_factory() as session:
await EngineTurnRepository(session).create(
EngineTurn(
task_id=task_id,
run_id=run_id,
role=RoleKey.DEVELOPER,
mode=Mode.WORK,
stage=Stage.PENDING_IMPLEMENTATION,
reply_excerpt=excerpt,
started_at=started_at,
)
)
await session.commit()
async def test_last_diff_and_merged_sha_are_patched_and_read_back(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
task_id = await _add_task(session_factory, org_a, "کار دیف")
state = await _add_state(session_factory, org_a, task_id, Stage.PENDING_IMPLEMENTATION)
assert state.last_diff == ""
assert state.merged_sha == ""
diff_text = "diff --git a/app.py b/app.py\n+print('سلام')\n"
sha = "ab12cd34" * 5
async with session_factory() as session:
patched = await PipelineStateRepository(session).patch(
task_id=task_id,
expected_version=state.version,
last_diff=diff_text,
merged_sha=sha,
)
await session.commit()
assert patched.last_diff == diff_text
assert patched.merged_sha == sha
assert patched.version == state.version + 1
async with session_factory() as session:
read_back = await PipelineStateRepository(session).get_or_raise(task_id)
assert read_back.last_diff == diff_text
assert read_back.merged_sha == sha
async def test_the_org_turn_feed_never_returns_another_organizations_turns(two_orgs):
session_factory, (org_a, org_b) = two_orgs
base = utcnow()
task_a = await _add_task(session_factory, org_a, "کار الف")
task_b = await _add_task(session_factory, org_b, "کار ب")
run_a = await _add_run(session_factory, task_a)
run_b = await _add_run(session_factory, task_b)
# Interleaved in time, and org B's turn is the newest of all.
await _add_turn(session_factory, task_a, run_a, "الف-یک", base + timedelta(seconds=1))
await _add_turn(session_factory, task_b, run_b, "ب-دو", base + timedelta(seconds=2))
await _add_turn(session_factory, task_a, run_a, "الف-سه", base + timedelta(seconds=3))
await _add_turn(session_factory, task_b, run_b, "ب-ده", base + timedelta(seconds=10))
async with session_factory() as session:
repo = EngineTurnRepository(session)
feed_a = await repo.list_for_org(org_a)
feed_b = await repo.list_for_org(org_b)
newest_a = await repo.list_for_org(org_a, limit=1)
assert [t.reply_excerpt for t in feed_a] == ["الف-سه", "الف-یک"]
assert all(t.task_id == task_a for t in feed_a)
assert [t.reply_excerpt for t in feed_b] == ["ب-ده", "ب-دو"]
assert all(t.task_id == task_b for t in feed_b)
assert [t.reply_excerpt for t in newest_a] == ["الف-سه"]
async def test_stage_counts_are_per_organization(two_orgs):
session_factory, (org_a, org_b) = two_orgs
for title in ("الف یک", "الف دو"):
task_id = await _add_task(session_factory, org_a, title)
await _add_state(session_factory, org_a, task_id, Stage.PENDING_IMPLEMENTATION)
review_id = await _add_task(session_factory, org_a, "الف سه")
await _add_state(session_factory, org_a, review_id, Stage.PENDING_MANAGER_REVIEW)
b_task = await _add_task(session_factory, org_b, "ب یک")
await _add_state(session_factory, org_b, b_task, Stage.PENDING_IMPLEMENTATION)
async with session_factory() as session:
repo = PipelineStateRepository(session)
counts_a = await repo.stage_counts(org_a)
counts_b = await repo.stage_counts(org_b)
at_implementation = await repo.count_at_by_org(Stage.PENDING_IMPLEMENTATION)
at_review = await repo.count_at_by_org(Stage.PENDING_MANAGER_REVIEW)
assert counts_a == {
str(Stage.PENDING_IMPLEMENTATION): 2,
str(Stage.PENDING_MANAGER_REVIEW): 1,
}
assert counts_b == {str(Stage.PENDING_IMPLEMENTATION): 1}
assert at_implementation == {org_a: 2, org_b: 1}
assert at_review == {org_a: 1}
+150
View File
@@ -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
+68
View File
@@ -0,0 +1,68 @@
"""The scheduler stores what the task page shows: the last diff and the merged sha.
What this test defends:
* the diff the diff guard accepted is kept on the state (``last_diff``), so the
task page can show it after the worktree is gone
* the sha the platform pushed to ``dev`` is kept on the state (``merged_sha``),
so the task page can link to it
The flow fixtures and the scripted replies come from ``tests.test_engine_flow``.
"""
from __future__ import annotations
from app.application.ports.delivery_hooks import NullDeliveryHooks
from app.application.ports.repo_workspace import DiffResult, FakeRepoWorkspace
from app.domain.workforce.pipeline import Stage
from app.infrastructure.repositories.engine import PipelineStateRepository
from tests.engine_fakes import FakeSandbox, RecordingMemory, ScriptedModel, ScriptedReply
from tests.test_engine_flow import _run, _scheduler, _task, _team
from tests.test_engine_flow import world as flow_world
#: The flow tests' organization fixture. Binding it under its own name registers it
#: for this module's tests (a bare import would be flagged as unused and shadowed).
world = flow_world
async def test_the_last_diff_and_the_merged_sha_are_stored_on_the_state(world, session_factory):
scripted = ScriptedModel(
[
ScriptedReply(content='[[ASSIGN employee="" pipeline="plan_code_review_test"/]]'),
ScriptedReply(content="[[PLAN_SPEC]]پلان کار[[/PLAN_SPEC]]"),
ScriptedReply(content="[[WORK_REPORT]]پیاده‌سازی شد[[/WORK_REPORT]]"),
ScriptedReply(content="[[REVIEW_PASS]]قابل قبول است[[/REVIEW_PASS]]"),
ScriptedReply(content="[[TEST_PASS]]سبز است[[/TEST_PASS]]"),
ScriptedReply(content="[[COMPLETE/]]"),
]
)
sandbox = FakeSandbox()
workspace = FakeRepoWorkspace()
memory = RecordingMemory()
delivery = NullDeliveryHooks()
diff_text = "--- a/app/main.py\n+++ b/app/main.py\n@@\n+x = 1\n"
async with session_factory() as session:
await _team(session, world)
row = await _task(session, world, stage=Stage.PENDING_MANAGER_REVIEW)
workspace.diffs[row.id] = DiffResult(changed_files=["app/main.py"], text=diff_text)
await session.commit()
scheduler = _scheduler(
session,
world,
scripted=scripted,
sandbox=sandbox,
workspace=workspace,
memory=memory,
delivery=delivery,
)
await _run(scheduler, session, 7)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is Stage.COMPLETED
# The fake workspace pushes the merged commit to ``dev``; that is the sha the
# platform reports, and it must be the one stored on the state.
pushed_sha = workspace.heads[(world.project_id, "dev")]
assert len(pushed_sha) == 40
assert state.last_diff == diff_text
assert state.merged_sha == pushed_sha
+16 -14
View File
@@ -429,25 +429,27 @@ async def test_a_failed_task_run_is_persisted_with_its_tenant(
seeded, client: AsyncClient, session_factory, monkeypatch
):
"""A task that cannot run is a tenant-visible problem, so the row must name
the organization — otherwise nobody can find it in the console."""
the organization — otherwise nobody can find it in the console.
Decision D-D: the legacy drain is the path under test, so the task is a pre-v2 row
(no pipeline state); a task made over HTTP is the engine's now.
"""
from app.application.services.workforce_service import WorkforceService
from app.workers.ai_runtime import AiRuntimeWorker
from tests.test_workers import _make_org_with_employee
from tests.legacy_tasks import legacy_task
from tests.test_workers import _make_org_with_employee, _owner_of
org_id, headers, project_id, employee_id = await _make_org_with_employee(
org_id, _headers, project_id, employee_id = await _make_org_with_employee(
client, "errlog-worker@example.com"
)
task_id = (
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کاری که شکست می‌خورد",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
)
).json()["id"]
task_id = await legacy_task(
session_factory,
org_id,
await _owner_of(session_factory, org_id),
title="کاری که شکست می‌خورد",
project_id=project_id,
assigned_employee_id=employee_id,
)
async def _boom(*args, **kwargs):
raise RuntimeError("provider returned a malformed response")
@@ -22,6 +22,7 @@ from app.domain.workforce.pipeline import Stage
from app.infrastructure.db.models import AiModelRow
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.workforce import TaskRepository
from tests.billing_helpers import fund_org
from tests.conftest import auth_header, signup
from tests.engine_fakes import ScriptedModel, ScriptedReply
from tests.test_org_wizard import _complete
@@ -78,6 +79,9 @@ async def test_a_manager_order_over_http_gets_a_pipeline_state(
assert created.json()["project_id"] is None
thread_id = created.json()["id"]
# prepaid (D-B): the wizard's org starts at zero credit, and an order costs model calls.
await fund_org(session_factory, org_id)
# The order goes through the real message route.
posted = await client.post(
f"/api/organizations/{org_id}/threads/{thread_id}/messages",
@@ -16,6 +16,7 @@ from app.application.container_engine import build_scheduler
from app.infrastructure.db.models import AiModelRow, OrganizationRow
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.workforce import TaskRepository
from tests.billing_helpers import fund_org
from tests.conftest import auth_header
from tests.engine_fakes import ScriptedModel, ScriptedReply
from tests.test_org_wizard import _complete, _manager
@@ -58,6 +59,9 @@ async def test_an_order_in_a_thread_without_a_project_gets_the_orgs_project(
org_id = body["organization"]["id"]
project_id = body["project"]["id"]
# prepaid (D-B): the wizard's org starts at zero credit, and an order costs model calls.
await fund_org(session_factory, org_id)
# The web UI's way: a thread with no project_id.
created = await client.post(
f"/api/organizations/{org_id}/threads", json={"title": "گفتگو"}, headers=headers
@@ -66,8 +70,10 @@ async def test_an_order_in_a_thread_without_a_project_gets_the_orgs_project(
assert created.json()["project_id"] is None
thread_id = created.json()["id"]
# The order goes to the engine's manager. The HTTP message route builds its manager
# without pipeline states (workforce_service._manager_turns), so it cannot show the state.
# The order goes to the engine's manager. The HTTP message route builds its manager with
# the pipeline states too (workforce_service._manager_turns, covered by
# test_integration_order_route.py); this test calls the manager directly only to read the
# reply and the states in the same session.
async with session_factory() as session:
owner_user_id = (await session.get(OrganizationRow, org_id)).owner_user_id
_, assistant = await build_scheduler(session).manager.chat(
+9
View File
@@ -36,6 +36,7 @@ from app.core.config import settings
from app.core.errors import ValidationError
from app.domain.base import new_id, utcnow
from app.domain.identity.models import PlatformRole, Role
from app.domain.workforce.pipeline import Stage
from app.infrastructure.db.models import (
AuditEventRow,
MembershipRow,
@@ -49,6 +50,7 @@ from app.infrastructure.db.models_delivery import (
ProductionDeployRow,
)
from app.infrastructure.db.models_org import McpTokenRow
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.interfaces.mcp.gateway import McpGateway
from tests.conftest import auth_header, signup
@@ -348,6 +350,7 @@ async def test_pipeline_status_reports_the_delivery_state_and_no_host_details(
async def test_a_task_created_through_a_token_is_attributed_to_its_minter(
session_factory, seeded
):
"""The task is attributed to the minter and enters the engine at manager review (D-D)."""
org_id, user_id = await _org(session_factory, "task-org")
gateway = await _gateway(session_factory)
token = await _token(
@@ -374,6 +377,12 @@ async def test_a_task_created_through_a_token_is_attributed_to_its_minter(
assert row is not None
assert row.created_by_user_id == user_id
# Decision D-D: a task created through the gateway enters the engine at manager review.
async with session_factory() as session:
state = await PipelineStateRepository(session).get(task_id)
assert state is not None
assert state.stage is Stage.PENDING_MANAGER_REVIEW
# …and a task from another organization is a 404, not data.
other_org, _ = await _org(session_factory, "task-other")
other_token = await _token(
+9 -2
View File
@@ -16,6 +16,7 @@ from httpx import AsyncClient
from sqlalchemy import select
from app.application.container_org import build_model_grant_service, build_model_grants
from app.application.engine.manager import BOOTSTRAP_TIERS
from app.core.config import settings
from app.infrastructure.db.models import AiModelRow, AuditEventRow, OrgModelGrantRow
from app.infrastructure.db.models_org import DefaultModelGrantRow, OrgPolicyRow
@@ -323,13 +324,18 @@ async def test_policy_defaults_caps_and_audit(
session_factory,
seeded,
):
"""D-A: the default cap is 7 employees per organization (it was 5).
The starting team (``BOOTSTRAP_TIERS``, 4 employees) must fit under that default.
"""
org_id = await _create_org(client, manager_token, "سازمان د", "delta-org")
owner = auth_header(platform_owner_token)
fresh = await client.get(f"{POLICIES}/{org_id}", headers=owner)
assert fresh.status_code == 200, fresh.text
body = fresh.json()
assert body["max_employees"] == 5
assert body["max_employees"] == 7
assert len(BOOTSTRAP_TIERS) <= body["max_employees"]
assert body["automation_enabled"] is True
assert body["mcp_enabled"] is True
assert body["monthly_credit_budget"] is None
@@ -383,6 +389,7 @@ async def test_policy_defaults_caps_and_audit(
async def test_policy_is_isolated_per_organization(
client: AsyncClient, manager_token: str, platform_owner_token: str, seeded
):
"""D-A: a cap written for one organization leaves the other at the default of 7."""
org_a = await _create_org(client, manager_token, "سازمان ه", "epsilon-org")
org_b = await _create_org(client, manager_token, "سازمان و", "zeta-org")
owner = auth_header(platform_owner_token)
@@ -392,4 +399,4 @@ async def test_policy_is_isolated_per_organization(
a = await client.get(f"{POLICIES}/{org_a}", headers=owner)
b = await client.get(f"{POLICIES}/{org_b}", headers=owner)
assert a.json()["max_employees"] == 2
assert b.json()["max_employees"] == 5
assert b.json()["max_employees"] == 7
+16 -13
View File
@@ -127,13 +127,18 @@ async def test_platform_owner_is_not_joined_to_any_organization(
async def test_seeded_install_can_use_the_product(
client: AsyncClient, seeded_install
client: AsyncClient, seeded_install, session_factory
):
"""The seed's platform rows are enough to create a project and run a task.
A seed that satisfies the login test but leaves the catalogue empty would
still fail the moment a developer tried anything real.
Decision D-D: the run is the legacy path's, so the task is a pre-v2 row (no pipeline
state). A task made over HTTP enters the engine and is refused by the run button.
"""
from tests.legacy_tasks import legacy_task
login = await client.post(
"/api/auth/login",
json={
@@ -142,7 +147,8 @@ async def test_seeded_install_can_use_the_product(
},
)
headers = auth_header(login.json()["access_token"])
org_id = (await client.get("/api/auth/me", headers=headers)).json()["organizations"][0]["id"]
me = (await client.get("/api/auth/me", headers=headers)).json()
org_id = me["organizations"][0]["id"]
# The platform catalogue the seed installs must be visible to a tenant.
templates = await client.get(
@@ -177,17 +183,14 @@ async def test_seeded_install_can_use_the_product(
)
).json()["id"]
task_id = (
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کار seed",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
)
).json()["id"]
task_id = await legacy_task(
session_factory,
org_id,
me["user"]["id"],
title="کار seed",
project_id=project_id,
assigned_employee_id=employee_id,
)
run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
+6 -2
View File
@@ -38,6 +38,7 @@ from app.infrastructure.repositories.delivery import PipelineRunRepository
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.workforce import EmployeeRepository, TaskRepository
from app.workers.ai_runtime import AiRuntimeWorker
from tests.billing_helpers import fund_org
from tests.conftest import auth_header
from tests.engine_fakes import ScriptedModel, ScriptedReply
from tests.test_org_wizard import _complete, _manager
@@ -107,6 +108,8 @@ async def test_a_manager_order_ships_through_the_engine(
org_id = body["organization"]["id"]
slug = body["organization"]["slug"]
project_id = body["project"]["id"]
# prepaid: the owner credits a new org before work
await fund_org(session_factory, org_id, 1000.0)
async with session_factory() as session:
roles = {e.role_key for e in await EmployeeRepository(session).list_for_org(org_id)}
@@ -132,8 +135,9 @@ async def test_a_manager_order_ships_through_the_engine(
thread_id = created.json()["id"]
# A manager order in that thread becomes one task with a pipeline state. The order goes to
# the engine's manager directly: the HTTP message route builds its manager without pipeline
# states (see test_integration_thread_project.py).
# the engine's manager directly, only to read the reply and the states in this session: the
# HTTP message route builds its manager with the pipeline states too
# (test_integration_order_route.py).
async with session_factory() as session:
await build_scheduler(session).manager.chat(
organization_id=org_id,
+59 -44
View File
@@ -22,11 +22,13 @@ from httpx import AsyncClient
from app.domain.base import utcnow
from app.domain.identity.models import PlatformRole
from app.infrastructure.db.models import OrganizationRow
from app.workers import base as worker_base
from app.workers.ai_runtime import AiRuntimeWorker
from app.workers.billing import BillingWorker
from app.workers.sandbox_gateway import SandboxGatewayWorker
from tests.conftest import auth_header, signup
from tests.legacy_tasks import legacy_task
@pytest_asyncio.fixture(autouse=True)
@@ -89,25 +91,32 @@ async def _make_org_with_employee(
return org_id, headers, project_id, employee_id
async def _owner_of(session_factory, organization_id: str) -> str:
"""The user who created an organization: the accountable creator of its tasks."""
async with session_factory() as session:
return (await session.get(OrganizationRow, organization_id)).owner_user_id
# -- ai-runtime -------------------------------------------------------------- #
async def test_ai_runtime_runs_a_task_nobody_clicked(client: AsyncClient):
"""A task created without pressing "run" must still be picked up."""
async def test_ai_runtime_runs_a_task_nobody_clicked(client: AsyncClient, session_factory):
"""A task created without pressing "run" must still be picked up.
Decision D-D: the drain picks up pre-v2 tasks (no pipeline state), so the task is one
of those. A task made over HTTP is the engine's, and the scheduler takes it instead.
"""
org_id, headers, project_id, employee_id = await _make_org_with_employee(
client, "worker-runtime@example.com"
)
task_id = (
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کار بی‌صاحب",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
)
).json()["id"]
task_id = await legacy_task(
session_factory,
org_id,
await _owner_of(session_factory, org_id),
title="کار بی‌صاحب",
project_id=project_id,
assigned_employee_id=employee_id,
)
# Nothing has run it yet.
detail = await client.get(
@@ -130,19 +139,21 @@ async def test_ai_runtime_runs_a_task_nobody_clicked(client: AsyncClient):
assert runs[0]["status"] == "completed"
async def test_ai_runtime_does_not_run_a_task_twice(client: AsyncClient):
"""A closed task must not be re-run — re-running would double-bill."""
org_id, headers, project_id, employee_id = await _make_org_with_employee(
async def test_ai_runtime_does_not_run_a_task_twice(client: AsyncClient, session_factory):
"""A closed task must not be re-run — re-running would double-bill.
Decision D-D: a pre-v2 task (no pipeline state), because that is the drain's path.
"""
org_id, _headers, project_id, employee_id = await _make_org_with_employee(
client, "worker-runtime-twice@example.com"
)
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کار یک‌باره",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
await legacy_task(
session_factory,
org_id,
await _owner_of(session_factory, org_id),
title="کار یک‌باره",
project_id=project_id,
assigned_employee_id=employee_id,
)
first = await AiRuntimeWorker().drain()
@@ -156,19 +167,21 @@ async def test_ai_runtime_does_not_run_a_task_twice(client: AsyncClient):
# -- billing-worker ---------------------------------------------------------- #
async def test_billing_roll_up_creates_a_draft_invoice(client: AsyncClient):
"""Usage in the period must become exactly one draft invoice."""
async def test_billing_roll_up_creates_a_draft_invoice(client: AsyncClient, session_factory):
"""Usage in the period must become exactly one draft invoice.
Decision D-D: the usage comes from the drain, so the task is a pre-v2 one.
"""
org_id, headers, project_id, employee_id = await _make_org_with_employee(
client, "worker-billing@example.com"
)
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کار قابل صورتحساب",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
await legacy_task(
session_factory,
org_id,
await _owner_of(session_factory, org_id),
title="کار قابل صورتحساب",
project_id=project_id,
assigned_employee_id=employee_id,
)
assert (await AiRuntimeWorker().drain()).processed == 1, "the run must be metered"
@@ -190,19 +203,21 @@ async def test_billing_roll_up_creates_a_draft_invoice(client: AsyncClient):
assert body[0]["amount"] > 0, "the invoice must carry the ledger's total"
async def test_billing_roll_up_is_idempotent(client: AsyncClient):
"""Two passes over the same period must not bill twice."""
async def test_billing_roll_up_is_idempotent(client: AsyncClient, session_factory):
"""Two passes over the same period must not bill twice.
Decision D-D: the usage comes from the drain, so the task is a pre-v2 one.
"""
org_id, headers, project_id, employee_id = await _make_org_with_employee(
client, "worker-billing-twice@example.com"
)
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کار یک‌بار صورتحساب",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
await legacy_task(
session_factory,
org_id,
await _owner_of(session_factory, org_id),
title="کار یک‌بار صورتحساب",
project_id=project_id,
assigned_employee_id=employee_id,
)
await AiRuntimeWorker().drain()
+48 -35
View File
@@ -17,6 +17,7 @@ import pytest_asyncio
from httpx import AsyncClient
from tests.conftest import auth_header, signup
from tests.legacy_tasks import legacy_task
@pytest_asyncio.fixture(autouse=True)
@@ -207,23 +208,24 @@ async def test_tools_catalog_requires_no_secrets(client: AsyncClient):
# ---------------------------------------------------------------------------
async def test_starting_a_run_produces_a_timeline(client: AsyncClient):
"""Every run must be reconstructable from its steps."""
token = (await signup(client, "wf-run@example.com"))["access_token"]
async def test_starting_a_run_produces_a_timeline(client: AsyncClient, session_factory):
"""Every run must be reconstructable from its steps.
The task is a pre-v2 row with no pipeline state, so it runs through the legacy path.
A task made over HTTP now enters the engine and is refused here (decision D-D).
"""
signed = await signup(client, "wf-run@example.com")
token = signed["access_token"]
org_id = await _org(client, token)
employee = await _employee(client, token, org_id)
task = await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "افزودن تست برای ماژول پرداخت",
"description": "پوشش تست واحد برای محاسبهٔ تخفیف",
"assigned_employee_id": employee["id"],
},
headers=auth_header(token),
task_id = await legacy_task(
session_factory,
org_id,
signed["user"]["id"],
title="افزودن تست برای ماژول پرداخت",
assigned_employee_id=employee["id"],
)
assert task.status_code == 201, task.text
task_id = task.json()["id"]
run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
@@ -241,17 +243,20 @@ async def test_starting_a_run_produces_a_timeline(client: AsyncClient):
)
async def test_run_payload_never_leaks_model_identity(client: AsyncClient):
token = (await signup(client, "wf-runsecret@example.com"))["access_token"]
async def test_run_payload_never_leaks_model_identity(client: AsyncClient, session_factory):
"""The legacy run's payload names no model (a pre-v2 task, decision D-D)."""
signed = await signup(client, "wf-runsecret@example.com")
token = signed["access_token"]
org_id = await _org(client, token)
employee = await _employee(client, token, org_id)
task = await client.post(
f"/api/organizations/{org_id}/tasks",
json={"title": "کار برای بررسی نشت", "assigned_employee_id": employee["id"]},
headers=auth_header(token),
task_id = await legacy_task(
session_factory,
org_id,
signed["user"]["id"],
title="کار برای بررسی نشت",
assigned_employee_id=employee["id"],
)
task_id = task.json()["id"]
run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
json={"employee_id": employee["id"]},
@@ -263,17 +268,20 @@ async def test_run_payload_never_leaks_model_identity(client: AsyncClient):
assert forbidden not in payload, f"{forbidden} leaked in run payload"
async def test_task_detail_lists_its_runs(client: AsyncClient):
token = (await signup(client, "wf-taskdetail@example.com"))["access_token"]
async def test_task_detail_lists_its_runs(client: AsyncClient, session_factory):
"""A pre-v2 task (no pipeline state, decision D-D) keeps its runs on its detail."""
signed = await signup(client, "wf-taskdetail@example.com")
token = signed["access_token"]
org_id = await _org(client, token)
employee = await _employee(client, token, org_id)
task = await client.post(
f"/api/organizations/{org_id}/tasks",
json={"title": "کار با چند اجرا", "assigned_employee_id": employee["id"]},
headers=auth_header(token),
task_id = await legacy_task(
session_factory,
org_id,
signed["user"]["id"],
title="کار با چند اجرا",
assigned_employee_id=employee["id"],
)
task_id = task.json()["id"]
run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
@@ -292,19 +300,24 @@ async def test_task_detail_lists_its_runs(client: AsyncClient):
assert runs[0]["task_id"] == task_id
async def test_a_closed_task_cannot_be_run_again(client: AsyncClient):
async def test_a_closed_task_cannot_be_run_again(client: AsyncClient, session_factory):
"""A successful run closes its task; re-running it must be refused, not
silently create a second run against a task that is already done."""
token = (await signup(client, "wf-closedtask@example.com"))["access_token"]
silently create a second run against a task that is already done.
The task is a pre-v2 row (no pipeline state, decision D-D), so this is the legacy path.
"""
signed = await signup(client, "wf-closedtask@example.com")
token = signed["access_token"]
org_id = await _org(client, token)
employee = await _employee(client, token, org_id)
task = await client.post(
f"/api/organizations/{org_id}/tasks",
json={"title": "کار بسته‌شده", "assigned_employee_id": employee["id"]},
headers=auth_header(token),
task_id = await legacy_task(
session_factory,
org_id,
signed["user"]["id"],
title="کار بسته‌شده",
assigned_employee_id=employee["id"],
)
task_id = task.json()["id"]
first = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs",