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,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"]
|
||||
Reference in New Issue
Block a user