# ruff: noqa: INP001 """API integration tests for GET /api/v1/gateways/{gateway_id}/runtime-usage. Uses an in-memory SQLite DB and patches `openclaw_call` to avoid real gateway connections. Tests cover: success, gateway 404, org boundary, and graceful degradation when the gateway RPC returns empty/error data. """ from __future__ import annotations from unittest.mock import AsyncMock, patch from uuid import uuid4 import pytest from fastapi import APIRouter, FastAPI from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine from sqlmodel import SQLModel from sqlmodel.ext.asyncio.session import AsyncSession # sqlmodel's AsyncSession has .exec() from app import models as _models # noqa: F401 — registers SQLModel metadata from app.api.gateways import router as gateways_router from app.db.session import get_session from app.models.gateways import Gateway from app.models.organizations import Organization from app.services.organizations import OrganizationContext async def _make_engine() -> AsyncEngine: engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.create_all) return engine def _make_session_maker(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) def _build_app( session_maker: async_sessionmaker[AsyncSession], org_id: UUID, ) -> FastAPI: app = FastAPI() api_v1 = APIRouter(prefix="/api/v1") api_v1.include_router(gateways_router) app.include_router(api_v1) async def _override_session() -> AsyncSession: async with session_maker() as s: yield s async def _override_org_member() -> OrganizationContext: from app.models.organizations import Organization org = Organization(id=org_id, name="test-org") from app.models.organization_members import OrganizationMember member = OrganizationMember(organization_id=org.id, user_id=uuid4(), role="admin") return OrganizationContext(organization=org, member=member) async def _override_org_admin() -> OrganizationContext: from app.models.organizations import Organization org = Organization(id=org_id, name="test-org") from app.models.organization_members import OrganizationMember member = OrganizationMember(organization_id=org.id, user_id=uuid4(), role="admin") return OrganizationContext(organization=org, member=member) from app.api.deps import require_org_admin, require_org_member app.dependency_overrides[get_session] = _override_session app.dependency_overrides[require_org_member] = _override_org_member app.dependency_overrides[require_org_admin] = _override_org_admin return app async def _seed_gateway(session: AsyncSession, org_id: UUID) -> Gateway: gateway = Gateway( id=uuid4(), organization_id=org_id, name="test-gateway", url="ws://localhost:18789", token="test-token", workspace_root="/tmp/test-workspace", allow_insecure_tls=True, disable_device_pairing=True, ) session.add(gateway) await session.commit() return gateway # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_runtime_usage_empty_gateway_response() -> None: """Returns zeroed-out response gracefully when gateway RPC returns nothing.""" engine = await _make_engine() session_maker = _make_session_maker(engine) org_id = uuid4() async with session_maker() as session: gateway = await _seed_gateway(session, org_id) app = _build_app(session_maker, org_id) with patch( "app.services.openclaw.runtime_usage.openclaw_call", new_callable=AsyncMock, return_value={}, ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get(f"/api/v1/gateways/{gateway.id}/runtime-usage") assert response.status_code == 200 data = response.json() assert data["gateway_id"] == str(gateway.id) assert data["current"]["total_cost_usd"] == 0.0 assert data["current"]["total_tokens"] == 0 assert data["per_model"] == {} assert data["top_sessions"] == [] assert data["predictions"]["safe"] is True @pytest.mark.asyncio async def test_runtime_usage_with_session_data() -> None: """Aggregates per-model usage correctly from a sessions list.""" engine = await _make_engine() session_maker = _make_session_maker(engine) org_id = uuid4() async with session_maker() as session: gateway = await _seed_gateway(session, org_id) rpc_cost_response = { "sessions": [ { "sessionId": "sess-1", "provider": "anthropic", "model": "claude-sonnet-4-6", "usage": {"input_tokens": 10000, "output_tokens": 5000}, "cost": 0.105, "calls": 5, "updatedAt": "2026-05-20T09:00:00Z", }, { "sessionId": "sess-2", "provider": "anthropic", "model": "claude-haiku-4-5", "usage": {"input_tokens": 50000, "output_tokens": 20000}, "calls": 10, "updatedAt": "2026-05-20T08:00:00Z", }, ] } app = _build_app(session_maker, org_id) async def _mock_call(method: str, params=None, *, config): # noqa: ANN001 if method == "usage.cost": return rpc_cost_response return {} with patch( "app.services.openclaw.runtime_usage.openclaw_call", side_effect=_mock_call, ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get(f"/api/v1/gateways/{gateway.id}/runtime-usage") assert response.status_code == 200 data = response.json() per_model = data["per_model"] assert "anthropic/claude-sonnet-4-6" in per_model assert "anthropic/claude-haiku-4-5" in per_model assert per_model["anthropic/claude-sonnet-4-6"]["input_tokens"] == 10000 assert per_model["anthropic/claude-sonnet-4-6"]["calls"] == 5 assert data["current"]["total_calls"] == 15 # At least one top session assert len(data["top_sessions"]) >= 1 @pytest.mark.asyncio async def test_runtime_usage_gateway_not_found() -> None: """Returns 404 when gateway_id does not belong to the org.""" engine = await _make_engine() session_maker = _make_session_maker(engine) org_id = uuid4() other_gateway_id = str(uuid4()) app = _build_app(session_maker, org_id) with patch( "app.services.openclaw.runtime_usage.openclaw_call", new_callable=AsyncMock, return_value={}, ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get(f"/api/v1/gateways/{other_gateway_id}/runtime-usage") assert response.status_code == 404 @pytest.mark.asyncio async def test_runtime_usage_org_boundary() -> None: """A gateway created in a different org is not visible to another org.""" engine = await _make_engine() session_maker = _make_session_maker(engine) org_a = uuid4() org_b = uuid4() # Seed gateway under org_a, but build app with org_b credentials async with session_maker() as session: gateway = await _seed_gateway(session, org_a) app = _build_app(session_maker, org_b) # authenticated as org_b with patch( "app.services.openclaw.runtime_usage.openclaw_call", new_callable=AsyncMock, return_value={}, ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get(f"/api/v1/gateways/{gateway.id}/runtime-usage") assert response.status_code == 404 @pytest.mark.asyncio async def test_runtime_usage_rpc_error_degrades_gracefully() -> None: """A gateway RPC failure returns zeroed usage rather than 500.""" from app.services.openclaw.gateway_rpc import OpenClawGatewayError engine = await _make_engine() session_maker = _make_session_maker(engine) org_id = uuid4() async with session_maker() as session: gateway = await _seed_gateway(session, org_id) app = _build_app(session_maker, org_id) with patch( "app.services.openclaw.runtime_usage.openclaw_call", side_effect=OpenClawGatewayError("timeout"), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get(f"/api/v1/gateways/{gateway.id}/runtime-usage") assert response.status_code == 200 data = response.json() assert data["current"]["total_cost_usd"] == 0.0