- 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
206 lines
7.9 KiB
Python
206 lines
7.9 KiB
Python
"""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"]
|