Files
sand-boxer/tests/test_payments.py
tegwick 1415e17230 Implement SAND-WP-0006: SaaS payments, routing, and ext.saas-stub
Add credits store, metering on create/destroy, extension routing resolver,
metered SaaS stub extension, burst/saas profiles, credits CLI, docs, and tests.
2026-06-24 07:52:20 +02:00

98 lines
3.5 KiB
Python

"""Payments and metering integration tests."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from sandboxer.core.manager import SandboxManager
from sandboxer.lifecycle.store import SandboxStore
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest, SandboxState
from sandboxer.payments.credits import CreditsStore
class SaaSBackend:
def provision(self, profile, inputs, host):
return {"sandbox_id": "saas1234", "host": "saas-stub", "endpoint": "https://x"}
def wait_ready(self, handle):
return {"endpoint": handle["endpoint"], "host": handle["host"]}
def teardown(self, handle):
return {"provider_removed": "true"}
def estimate_cost(self, profile, inputs, *, duration_s=3600):
from sandboxer.models import MeterQuote
return MeterQuote(
extension_id="ext.saas-stub",
estimated_usd=0.5,
duration_s=duration_s,
)
def meter_actual(self, handle, *, duration_s: float) -> float:
return 0.25
def _set_balance(credits: CreditsStore, amount: float) -> None:
credits._write({"balance_usd": amount, "currency": "USD"})
def test_saas_create_destroy_debits_credits(tmp_path: Path) -> None:
store = SandboxStore(path=tmp_path / "sandboxes.json")
credits = CreditsStore(path=tmp_path / "credits.json")
_set_balance(credits, 5.0)
manager = SandboxManager(store=store, credits=credits)
request = SandboxCreateRequest(
profile="profile.saas-stub",
inputs={},
consumer=Consumer(actor=ActorType.ADM, project="test"),
)
backend = SaaSBackend()
with (
patch("sandboxer.core.manager.resolve_extension") as resolve_ext,
patch("sandboxer.core.manager.resolve_backend", return_value=backend),
patch("sandboxer.payments.metering.resolve_backend", return_value=backend),
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
):
from sandboxer.extensions.registry import load_extension
resolve_ext.return_value = load_extension("ext.saas-stub")
status = manager.create(request)
assert status.state == SandboxState.READY
assert status.meter and status.meter.estimate_usd == 0.5
destroyed = manager.destroy(status.sandbox_id)
assert destroyed.state == SandboxState.DESTROYED
assert destroyed.meter and destroyed.meter.actual_usd == 0.25
assert credits.balance() == 4.75
def test_insufficient_credits_blocks_create(tmp_path: Path) -> None:
store = SandboxStore(path=tmp_path / "sandboxes.json")
credits = CreditsStore(path=tmp_path / "credits.json")
_set_balance(credits, 0.01)
manager = SandboxManager(store=store, credits=credits)
request = SandboxCreateRequest(
profile="profile.saas-stub",
inputs={},
consumer=Consumer(actor=ActorType.ADM, project="test"),
)
with (
patch("sandboxer.core.manager.resolve_extension") as resolve_ext,
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
):
from sandboxer.extensions.registry import load_extension
resolve_ext.return_value = load_extension("ext.saas-stub")
with patch("sandboxer.core.manager.resolve_backend", return_value=SaaSBackend()):
try:
manager.create(request)
raise AssertionError("expected RuntimeError")
except RuntimeError as exc:
assert "Insufficient credits" in str(exc)