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