mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
test(capabilities): cover REST door, meter-gate, and batch bounds
This commit is contained in:
parent
b1e49f21f2
commit
b1bd35c082
5 changed files with 291 additions and 4 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue