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