test(capabilities): cover REST door, meter-gate, and batch bounds

This commit is contained in:
CREDO23 2026-07-01 17:53:42 +02:00
parent b1e49f21f2
commit b1bd35c082
5 changed files with 291 additions and 4 deletions

View file

@ -0,0 +1,37 @@
"""Per-workspace rate limit: a secondary guard behind the credit meter-gate (05)."""
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from app.capabilities.access import rate_limit
def _request(workspace_id: int) -> Request:
return Request({"type": "http", "path_params": {"workspace_id": workspace_id}})
@pytest.mark.asyncio
async def test_passes_at_the_limit(monkeypatch):
monkeypatch.setattr(
rate_limit, "_incr", lambda *a, **k: rate_limit.CAPABILITY_RATE_LIMIT_PER_MINUTE
)
await rate_limit.enforce_capability_rate_limit(_request(1))
@pytest.mark.asyncio
async def test_blocks_over_the_limit(monkeypatch):
monkeypatch.setattr(
rate_limit,
"_incr",
lambda *a, **k: rate_limit.CAPABILITY_RATE_LIMIT_PER_MINUTE + 1,
)
with pytest.raises(HTTPException) as exc:
await rate_limit.enforce_capability_rate_limit(_request(1))
assert exc.value.status_code == 429
def test_memory_fallback_counts_within_window():
rate_limit._memory.clear()
assert rate_limit._incr_memory("k", window_seconds=60) == 1
assert rate_limit._incr_memory("k", window_seconds=60) == 2

View file

@ -0,0 +1,171 @@
"""The REST door generator turns registry verbs into typed POST routes (05)."""
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from pydantic import BaseModel
from app.capabilities.types import Capability
from app.db import get_async_session
from app.users import get_auth_context
class _EchoInput(BaseModel):
value: str
class _EchoOutput(BaseModel):
echo: str
async def _echo_executor(payload: _EchoInput) -> _EchoOutput:
return _EchoOutput(echo=payload.value)
_ECHO = Capability(
name="test.echo",
input_schema=_EchoInput,
output_schema=_EchoOutput,
executor=_echo_executor,
billing_unit=None,
)
def _build_app(capabilities, monkeypatch) -> FastAPI:
"""Mount the generated door with auth/workspace/session/rate-limit stubbed."""
from app.capabilities.access import rest
from app.capabilities.access.rate_limit import enforce_capability_rate_limit
monkeypatch.setattr(rest, "check_workspace_access", _noop_async, raising=True)
app = FastAPI()
app.include_router(rest.build_capabilities_router(capabilities), prefix="/api/v1")
app.dependency_overrides[get_auth_context] = lambda: SimpleNamespace(user=None)
app.dependency_overrides[enforce_capability_rate_limit] = _allow
async def _session():
yield SimpleNamespace()
app.dependency_overrides[get_async_session] = _session
return app
async def _noop_async(*args, **kwargs) -> None:
return None
async def _allow() -> None:
return None
def _client(app: FastAPI) -> AsyncClient:
return AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
@pytest.mark.asyncio
async def test_verb_is_exposed_as_typed_post_route(monkeypatch):
app = _build_app([_ECHO], monkeypatch)
async with _client(app) as client:
resp = await client.post(
"/api/v1/workspaces/7/capabilities/test.echo",
json={"value": "hi"},
)
assert resp.status_code == 200
assert resp.json() == {"echo": "hi"}
@pytest.mark.asyncio
async def test_input_is_validated_against_the_verb_schema(monkeypatch):
app = _build_app([_ECHO], monkeypatch)
async with _client(app) as client:
resp = await client.post(
"/api/v1/workspaces/7/capabilities/test.echo",
json={"wrong": "field"},
)
assert resp.status_code == 422
def test_registered_verbs_appear_on_rest():
"""A verb in the registry shows up as a route with no per-verb wiring."""
import app.capabilities.web # noqa: F401 (registers web.* at import)
from app.capabilities.access import rest
router = rest.build_capabilities_router()
paths = {route.path for route in router.routes}
assert "/workspaces/{workspace_id}/capabilities/web.scrape" in paths
assert "/workspaces/{workspace_id}/capabilities/web.discover" in paths
@pytest.mark.asyncio
async def test_over_budget_is_blocked_before_the_executor(monkeypatch):
from app.capabilities.access import rest
from app.services.web_crawl_credit_service import InsufficientCreditsError
async def _raise(*args, **kwargs):
raise InsufficientCreditsError(
message="over budget", balance_micros=0, required_micros=1000
)
monkeypatch.setattr(rest, "gate_capability", _raise, raising=True)
app = _build_app([_ECHO], monkeypatch)
async with _client(app) as client:
resp = await client.post(
"/api/v1/workspaces/7/capabilities/test.echo",
json={"value": "hi"},
)
assert resp.status_code == 402
@pytest.mark.asyncio
async def test_rate_limit_blocks_the_workspace(monkeypatch):
"""The generated route enforces the per-workspace limit (429)."""
from app.capabilities.access import rate_limit, rest
monkeypatch.setattr(rest, "check_workspace_access", _noop_async, raising=True)
monkeypatch.setattr(
rate_limit,
"_incr",
lambda *a, **k: rate_limit.CAPABILITY_RATE_LIMIT_PER_MINUTE + 1,
)
app = FastAPI()
app.include_router(rest.build_capabilities_router([_ECHO]), prefix="/api/v1")
app.dependency_overrides[get_auth_context] = lambda: SimpleNamespace(user=None)
async def _session():
yield SimpleNamespace()
app.dependency_overrides[get_async_session] = _session
async with _client(app) as client:
resp = await client.post(
"/api/v1/workspaces/7/capabilities/test.echo",
json={"value": "hi"},
)
assert resp.status_code == 429
@pytest.mark.asyncio
async def test_success_charges_once(monkeypatch):
from unittest.mock import AsyncMock
from app.capabilities.access import rest
charge = AsyncMock()
monkeypatch.setattr(rest, "charge_capability", charge, raising=True)
app = _build_app([_ECHO], monkeypatch)
async with _client(app) as client:
resp = await client.post(
"/api/v1/workspaces/7/capabilities/test.echo",
json={"value": "hi"},
)
assert resp.status_code == 200
charge.assert_awaited_once()
(output, unit, ctx), _ = charge.call_args
assert isinstance(output, _EchoOutput)
assert unit is None
assert ctx.workspace_id == 7

View file

@ -12,10 +12,11 @@ from uuid import UUID
import pytest
import app.capabilities.billing as billing
from app.capabilities.billing import charge_capability
from app.capabilities.billing import charge_capability, gate_capability
from app.capabilities.types import BillingUnit, CapabilityContext
from app.capabilities.web.scrape.schemas import ScrapeOutput, ScrapeRow
from app.capabilities.web.scrape.schemas import ScrapeInput, ScrapeOutput, ScrapeRow
from app.config import config
from app.services.web_crawl_credit_service import InsufficientCreditsError
pytestmark = pytest.mark.unit
@ -125,3 +126,60 @@ async def test_free_verb_without_a_unit_is_noop(monkeypatch, record_usage):
record_usage.assert_not_awaited()
session.execute.assert_not_called()
assert user.credit_micros_balance == 100_000
def _gate_session(owner_id, balance_micros):
"""Mock session serving owner-resolution and the spendable-balance read."""
session = AsyncMock()
def _make_result(*_args, **_kwargs):
result = MagicMock()
result.scalar_one_or_none.return_value = owner_id # owner resolution
result.first.return_value = (balance_micros, 0) # balance, reserved
return result
session.execute = AsyncMock(side_effect=_make_result)
return session
async def test_gate_blocks_when_worst_case_exceeds_balance(monkeypatch):
monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", True)
monkeypatch.setattr(config, "WEB_CRAWL_MICROS_PER_SUCCESS", 1000)
session = _gate_session(_OWNER, balance_micros=1500) # affords 1 crawl, not 2
with pytest.raises(InsufficientCreditsError):
await gate_capability(
ScrapeInput(urls=["https://a.com", "https://b.com"]),
BillingUnit.WEB_CRAWL,
_ctx(session),
)
async def test_gate_passes_when_balance_covers_worst_case(monkeypatch):
monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", True)
monkeypatch.setattr(config, "WEB_CRAWL_MICROS_PER_SUCCESS", 1000)
session = _gate_session(_OWNER, balance_micros=100_000)
await gate_capability(
ScrapeInput(urls=["https://a.com", "https://b.com"]),
BillingUnit.WEB_CRAWL,
_ctx(session),
)
async def test_gate_is_noop_when_disabled(monkeypatch):
monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", False)
session = _gate_session(_OWNER, balance_micros=0)
await gate_capability(
ScrapeInput(urls=["https://a.com"]), BillingUnit.WEB_CRAWL, _ctx(session)
)
async def test_gate_is_noop_for_free_verb(monkeypatch):
monkeypatch.setattr(config, "WEB_CRAWL_CREDIT_BILLING_ENABLED", True)
session = _gate_session(_OWNER, balance_micros=0)
await gate_capability(ScrapeInput(urls=["https://a.com"]), None, _ctx(session))
session.execute.assert_not_called()

View file

@ -1,10 +1,16 @@
"""ScrapeOutput reports its own billable count (a success single-sources it)."""
"""ScrapeOutput reports its own billable count; ScrapeInput bounds its batch size."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.capabilities.web.scrape.schemas import ScrapeOutput, ScrapeRow
from app.capabilities.web.scrape.schemas import (
MAX_SCRAPE_URLS,
ScrapeInput,
ScrapeOutput,
ScrapeRow,
)
pytestmark = pytest.mark.unit
@ -24,3 +30,18 @@ def test_billable_units_counts_successful_rows():
def test_billable_units_is_zero_without_successes():
assert _output("empty", "failed").billable_units == 0
def test_rejects_empty_url_batch():
with pytest.raises(ValidationError):
ScrapeInput(urls=[])
def test_rejects_batch_over_the_cap():
with pytest.raises(ValidationError):
ScrapeInput(urls=[f"https://{i}.com" for i in range(MAX_SCRAPE_URLS + 1)])
def test_accepts_batch_at_the_cap():
payload = ScrapeInput(urls=[f"https://{i}.com" for i in range(MAX_SCRAPE_URLS)])
assert payload.estimated_units == MAX_SCRAPE_URLS