Files
smart-org/apps/api/tests/test_admin_credits.py
T
mjalmoudy94 d532929189 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
2026-10-14 23:19:05 +03:30

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"]