Files
bootnode/api/tests/test_billing.py
T
zeekay 4c917f849b Move every caller and route to /v1
Commerce is called at /v1, the indexer at /v1/indexer, and the operator
serves /v1/clusters. The console's unused fleet helpers called a /api
base nothing serves; they are gone. The legacy Quart env files named an
app that no longer exists.

Claude-Session: https://claude.ai/code/session_011i2AG9nwpiKUCVxAdDzYuU
2026-09-29 18:34:58 -07:00

815 lines
31 KiB
Python

"""Billing module tests — compute units, tiers, Commerce client, and webhooks."""
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from bootnode.core.billing.compute_units import (
COMPUTE_UNITS,
DEFAULT_COMPUTE_UNITS,
get_batch_compute_units,
get_compute_units,
)
from bootnode.core.billing.tiers import (
TIER_LIMITS,
PricingTier,
TierLimits,
calculate_monthly_cost,
get_tier_limits,
)
# =============================================================================
# Compute Units
# =============================================================================
class TestComputeUnits:
def test_free_methods_cost_1(self):
assert get_compute_units("eth_chainId") == 1
assert get_compute_units("eth_blockNumber") == 1
assert get_compute_units("net_version") == 1
def test_light_methods_cost_5(self):
assert get_compute_units("eth_getBalance") == 5
assert get_compute_units("eth_getTransactionByHash") == 5
def test_medium_methods_cost_10(self):
assert get_compute_units("eth_call") == 10
assert get_compute_units("eth_estimateGas") == 10
def test_heavy_methods_cost_25(self):
assert get_compute_units("eth_getLogs") == 25
def test_transaction_methods_cost_50(self):
assert get_compute_units("eth_sendRawTransaction") == 50
def test_debug_methods_cost_100(self):
assert get_compute_units("debug_traceTransaction") == 100
def test_unknown_method_returns_default(self):
assert get_compute_units("nonexistent_method") == DEFAULT_COMPUTE_UNITS
def test_batch_compute_units(self):
methods = ["eth_chainId", "eth_getBalance", "eth_call"]
assert get_batch_compute_units(methods) == 1 + 5 + 10
def test_batch_empty(self):
assert get_batch_compute_units([]) == 0
def test_solana_methods(self):
assert get_compute_units("getAccountInfo") == 5
assert get_compute_units("sendTransaction") == 50
assert get_compute_units("simulateTransaction") == 25
def test_erc4337_methods(self):
assert get_compute_units("eth_sendUserOperation") == 75
assert get_compute_units("eth_estimateUserOperationGas") == 25
def test_bootnode_enhanced_apis(self):
assert get_compute_units("tokens_getBalances") == 15
assert get_compute_units("nfts_getOwned") == 20
assert get_compute_units("webhooks_create") == 5
# =============================================================================
# Pricing Tiers
# =============================================================================
class TestPricingTiers:
def test_all_tiers_defined(self):
for tier in PricingTier:
assert tier in TIER_LIMITS
def test_free_tier_limits(self):
limits = get_tier_limits(PricingTier.FREE)
assert limits.monthly_cu == 30_000_000
assert limits.rate_limit_per_second == 25
assert limits.max_apps == 5
assert limits.max_webhooks == 5
assert limits.price_per_million_cu == 0
def test_payg_tier_limits(self):
limits = get_tier_limits(PricingTier.PAY_AS_YOU_GO)
assert limits.monthly_cu == 0 # unlimited
assert limits.rate_limit_per_second == 300
assert limits.max_apps == 30
assert limits.price_per_million_cu == 40 # $0.40
def test_growth_tier_limits(self):
limits = get_tier_limits(PricingTier.GROWTH)
assert limits.monthly_cu == 100_000_000
assert limits.rate_limit_per_second == 500
assert limits.price_per_million_cu == 35 # $0.35
def test_enterprise_tier_limits(self):
limits = get_tier_limits(PricingTier.ENTERPRISE)
assert limits.rate_limit_per_second == 1000
assert limits.max_apps == 0 # unlimited
assert limits.price_per_million_cu == 0 # custom
def test_tier_rate_limits_ascending(self):
"""Higher tiers should have higher rate limits."""
free = get_tier_limits(PricingTier.FREE)
payg = get_tier_limits(PricingTier.PAY_AS_YOU_GO)
growth = get_tier_limits(PricingTier.GROWTH)
enterprise = get_tier_limits(PricingTier.ENTERPRISE)
assert free.rate_limit_per_second < payg.rate_limit_per_second
assert payg.rate_limit_per_second < growth.rate_limit_per_second
assert growth.rate_limit_per_second < enterprise.rate_limit_per_second
# =============================================================================
# Cost Calculation
# =============================================================================
class TestCostCalculation:
def test_free_tier_always_zero(self):
assert calculate_monthly_cost(PricingTier.FREE, 0) == 0
assert calculate_monthly_cost(PricingTier.FREE, 1_000_000) == 0
def test_enterprise_tier_always_zero(self):
assert calculate_monthly_cost(PricingTier.ENTERPRISE, 0) == 0
assert calculate_monthly_cost(PricingTier.ENTERPRISE, 999_000_000) == 0
def test_payg_1_million_cu(self):
# PAYG: $0.40 per million CU, no included CU
cost = calculate_monthly_cost(PricingTier.PAY_AS_YOU_GO, 1_000_000)
assert cost == 40 # 40 cents
def test_payg_10_million_cu(self):
cost = calculate_monthly_cost(PricingTier.PAY_AS_YOU_GO, 10_000_000)
assert cost == 400 # $4.00
def test_payg_zero_usage(self):
cost = calculate_monthly_cost(PricingTier.PAY_AS_YOU_GO, 0)
assert cost == 0
def test_growth_within_included(self):
# Growth includes 100M CU, no overage
cost = calculate_monthly_cost(PricingTier.GROWTH, 50_000_000)
assert cost == 0 # base fee not counted here (Commerce handles it)
def test_growth_at_included(self):
cost = calculate_monthly_cost(PricingTier.GROWTH, 100_000_000)
assert cost == 0
def test_growth_with_overage(self):
# 10M overage at $0.35 per million
cost = calculate_monthly_cost(PricingTier.GROWTH, 110_000_000)
assert cost == 350 # $3.50
# =============================================================================
# Plan Slug Mappings
# =============================================================================
class TestPlanSlugs:
def test_plan_to_tier_mapping(self):
"""Verify the plan-to-tier mapping in webhooks module."""
from bootnode.core.billing.webhooks import PLAN_TO_TIER
assert PLAN_TO_TIER["bootnode-free"] == PricingTier.FREE
assert PLAN_TO_TIER["bootnode-payg"] == PricingTier.PAY_AS_YOU_GO
assert PLAN_TO_TIER["bootnode-growth"] == PricingTier.GROWTH
assert PLAN_TO_TIER["bootnode-enterprise"] == PricingTier.ENTERPRISE
def test_api_tier_to_plan_slug(self):
"""Verify the tier-to-plan slug mapping in API module."""
from bootnode.api.billing import TIER_TO_PLAN_SLUG
assert TIER_TO_PLAN_SLUG[PricingTier.FREE] == "bootnode-free"
assert TIER_TO_PLAN_SLUG[PricingTier.PAY_AS_YOU_GO] == "bootnode-payg"
assert TIER_TO_PLAN_SLUG[PricingTier.GROWTH] == "bootnode-growth"
assert TIER_TO_PLAN_SLUG[PricingTier.ENTERPRISE] == "bootnode-enterprise"
# =============================================================================
# Commerce Client
# =============================================================================
class TestCommerceClient:
@patch("bootnode.core.billing.commerce.get_settings")
def test_client_uses_commerce_config(self, mock_settings):
mock_settings.return_value = MagicMock(
commerce_url="http://test:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import HanzoCommerceClient
client = HanzoCommerceClient()
assert client.base_url == "http://test:8001"
assert client.api_key == "test-key"
@patch("bootnode.core.billing.commerce.get_settings")
def test_client_headers(self, mock_settings):
mock_settings.return_value = MagicMock(
commerce_url="http://test:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import HanzoCommerceClient
client = HanzoCommerceClient()
headers = client._headers()
assert headers["Authorization"] == "Bearer test-key"
assert headers["X-Source"] == "bootnode"
assert headers["Content-Type"] == "application/json"
@patch("bootnode.core.billing.commerce.get_settings")
@pytest.mark.asyncio
async def test_create_customer(self, mock_settings):
mock_settings.return_value = MagicMock(
commerce_url="http://test:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import HanzoCommerceClient
client = HanzoCommerceClient()
# Mock the _request method
client._request = AsyncMock(return_value={"id": "cust_123"})
customer_id = await client.create_customer(
user_id="user_1", email="[email protected]", name="Test User"
)
assert customer_id == "cust_123"
client._request.assert_called_once()
@patch("bootnode.core.billing.commerce.get_settings")
@pytest.mark.asyncio
async def test_create_checkout(self, mock_settings):
mock_settings.return_value = MagicMock(
commerce_url="http://test:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import HanzoCommerceClient
client = HanzoCommerceClient()
client._request = AsyncMock(return_value={
"order_id": "order_123",
"checkout_url": "https://commerce.hanzo.ai/checkout/order_123",
"authorization_id": "auth_456",
})
result = await client.create_checkout(
customer_id="cust_123",
plan_slug="bootnode-payg",
project_id=uuid4(),
success_url="https://example.com/success",
)
assert result["order_id"] == "order_123"
assert result["checkout_url"] is not None
client._request.assert_called_once_with(
"POST",
"/v1/checkout/authorize",
json=pytest.approx(client._request.call_args[1]["json"], abs=0) if False else client._request.call_args[1].get("json", client._request.call_args[0][2] if len(client._request.call_args[0]) > 2 else None),
)
@patch("bootnode.core.billing.commerce.get_settings")
@pytest.mark.asyncio
async def test_capture_checkout(self, mock_settings):
mock_settings.return_value = MagicMock(
commerce_url="http://test:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import HanzoCommerceClient
client = HanzoCommerceClient()
client._request = AsyncMock(return_value={"order_id": "order_123", "status": "completed"})
result = await client.capture_checkout("order_123")
assert result["status"] == "completed"
client._request.assert_called_once_with("POST", "/v1/checkout/capture/order_123")
@patch("bootnode.core.billing.commerce.get_settings")
@pytest.mark.asyncio
async def test_report_usage(self, mock_settings):
mock_settings.return_value = MagicMock(
commerce_url="http://test:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import HanzoCommerceClient
client = HanzoCommerceClient()
client._request = AsyncMock(return_value={})
now = datetime.now(UTC)
await client.report_usage(
subscription_id="sub_123",
compute_units=5_000_000,
timestamp=now,
idempotency_key="test-key-123",
)
client._request.assert_called_once()
call_args = client._request.call_args
assert call_args[0][0] == "POST"
assert "/usage" in call_args[0][1]
@patch("bootnode.core.billing.commerce.get_settings")
@pytest.mark.asyncio
async def test_get_user_orders(self, mock_settings):
mock_settings.return_value = MagicMock(
commerce_url="http://test:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import HanzoCommerceClient
client = HanzoCommerceClient()
client._request = AsyncMock(return_value={
"data": [
{"id": "order_1", "status": "completed"},
{"id": "order_2", "status": "pending"},
]
})
orders = await client.get_user_orders("cust_123")
assert len(orders) == 2
assert orders[0]["id"] == "order_1"
# =============================================================================
# Commerce Webhooks
# =============================================================================
class TestCommerceWebhooks:
@patch("bootnode.core.billing.webhooks.get_settings")
def test_no_webhook_secret_allows_through_dev(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="", is_production=False)
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
# No secret in dev mode = verification skipped (returns True)
assert handler.verify_signature(b"payload", "any-sig") is True
@patch("bootnode.core.billing.webhooks.get_settings")
def test_no_webhook_secret_rejects_in_production(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="", is_production=True)
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
# No secret in production = fail-closed (returns False)
assert handler.verify_signature(b"payload", "any-sig") is False
@patch("bootnode.core.billing.webhooks.get_settings")
def test_valid_signature(self, mock_settings):
import hashlib
import hmac
secret = "test-secret"
mock_settings.return_value = MagicMock(commerce_webhook_secret=secret)
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
payload = b'{"type":"test"}'
expected_sig = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
assert handler.verify_signature(payload, expected_sig) is True
@patch("bootnode.core.billing.webhooks.get_settings")
def test_invalid_signature(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="test-secret")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
assert handler.verify_signature(b'{"type":"test"}', "bad-signature") is False
@patch("bootnode.core.billing.webhooks.get_settings")
@pytest.mark.asyncio
async def test_unhandled_event_type(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="test")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
result = await handler.handle_event({
"type": "some.unknown.event",
"id": "evt_123",
"data": {},
})
assert result["handled"] is False
assert result["reason"] == "unknown_type"
@patch("bootnode.core.billing.webhooks.get_settings")
@pytest.mark.asyncio
async def test_handles_order_completed(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="test")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
result = await handler.handle_event({
"type": "order.completed",
"id": "evt_456",
"data": {
"id": "order_123",
"customer_id": "cust_456",
"metadata": {},
},
})
assert result["handled"] is True
assert result["result"]["order_id"] == "order_123"
@patch("bootnode.core.billing.webhooks.get_settings")
@pytest.mark.asyncio
async def test_handles_order_cancelled(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="test")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
result = await handler.handle_event({
"type": "order.cancelled",
"id": "evt_789",
"data": {
"id": "order_123",
"reason": "customer_request",
},
})
assert result["handled"] is True
assert result["result"]["reason"] == "customer_request"
@patch("bootnode.core.billing.webhooks.get_settings")
@pytest.mark.asyncio
async def test_handles_payment_paid(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="test")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
result = await handler.handle_event({
"type": "payment.paid",
"id": "evt_pay1",
"data": {
"id": "pay_123",
"order_id": "order_456",
"amount": 4900,
},
})
assert result["handled"] is True
assert result["result"]["amount"] == 4900
@patch("bootnode.core.billing.webhooks.get_settings")
@pytest.mark.asyncio
async def test_handles_payment_refunded(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="test")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
result = await handler.handle_event({
"type": "payment.refunded",
"id": "evt_ref1",
"data": {
"id": "pay_123",
"refund_id": "ref_456",
"amount": 2500,
},
})
assert result["handled"] is True
assert result["result"]["refund_id"] == "ref_456"
@patch("bootnode.core.billing.webhooks.get_settings")
@pytest.mark.asyncio
async def test_handles_invoice_paid(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="test")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
result = await handler.handle_event({
"type": "invoice.paid",
"id": "evt_inv1",
"data": {
"id": "inv_123",
"customer_id": "cust_456",
"amount_paid": 4900,
},
})
assert result["handled"] is True
assert result["result"]["amount_paid"] == 4900
# =============================================================================
# Unified Billing Client
# =============================================================================
class TestUnifiedUser:
def _make_iam_user(self, **kwargs):
"""Create a fake IAM user. Can't use MagicMock because 'name' is reserved."""
from types import SimpleNamespace
defaults = {"id": "user_1", "email": "[email protected]", "name": "Test", "org": "hanzo"}
defaults.update(kwargs)
return SimpleNamespace(**defaults)
def test_from_iam_user_no_commerce(self):
from bootnode.core.billing.unified import UnifiedUser
iam_user = self._make_iam_user()
user = UnifiedUser.from_iam_user(iam_user)
assert user.iam_id == "user_1"
assert user.commerce_customer_id is None
assert user.square_customer_id is None
def test_from_iam_user_with_commerce(self):
from bootnode.core.billing.unified import UnifiedUser
iam_user = self._make_iam_user()
commerce_data = {
"id": "cust_123",
"accounts": {"square": "sq_cust_456"},
"has_payment_method": True,
"default_payment_method": "card_789",
}
user = UnifiedUser.from_iam_user(iam_user, commerce_data)
assert user.commerce_customer_id == "cust_123"
assert user.square_customer_id == "sq_cust_456"
assert user.has_payment_method is True
# =============================================================================
# Config Settings AliasChoices (P0 regression test)
# =============================================================================
class TestConfigSettings:
"""Verify commerce settings are accessible and AliasChoices work."""
def test_commerce_settings_accessible(self):
"""P0 regression: config.py must expose commerce_url, not hanzo_commerce_url."""
from bootnode.config import Settings
s = Settings(
commerce_url="https://test.commerce.ai",
commerce_api_key="test-key",
commerce_webhook_secret="test-secret",
)
assert s.commerce_url == "https://test.commerce.ai"
assert s.commerce_api_key == "test-key"
assert s.commerce_webhook_secret == "test-secret"
def test_commerce_settings_backward_compat(self):
"""AliasChoices: HANZO_COMMERCE_URL env vars should still work."""
import os
env = {
"HANZO_COMMERCE_URL": "https://old.commerce.ai",
"HANZO_COMMERCE_API_KEY": "old-key",
"HANZO_COMMERCE_WEBHOOK_SECRET": "old-secret",
}
old_values = {}
for k, v in env.items():
old_values[k] = os.environ.get(k)
os.environ[k] = v
try:
from bootnode.config import Settings
s = Settings()
assert s.commerce_url == "https://old.commerce.ai"
assert s.commerce_api_key == "old-key"
assert s.commerce_webhook_secret == "old-secret"
finally:
for k, v in old_values.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
def test_commerce_defaults(self):
"""Default commerce_url should be https://commerce.hanzo.ai."""
from bootnode.config import Settings
s = Settings()
assert s.commerce_url == "https://commerce.hanzo.ai"
assert s.commerce_api_key == ""
assert s.commerce_webhook_secret == ""
# =============================================================================
# Commerce Client Error Handling
# =============================================================================
class TestCommerceClientErrors:
@patch("bootnode.core.billing.commerce.get_settings")
@pytest.mark.asyncio
async def test_request_raises_on_http_error(self, mock_settings):
"""_request should raise CommerceError on 4xx/5xx."""
import httpx
mock_settings.return_value = MagicMock(
commerce_url="http://test:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import CommerceError, HanzoCommerceClient
client = HanzoCommerceClient()
# Mock httpx to return a 400 error
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.content = b'{"message": "bad request"}'
mock_response.json.return_value = {"message": "bad request"}
with patch("httpx.AsyncClient") as MockAsyncClient:
mock_client = AsyncMock()
mock_client.request.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
MockAsyncClient.return_value = mock_client
with pytest.raises(CommerceError) as exc_info:
await client._request("GET", "/v1/test")
assert exc_info.value.status_code == 400
assert "bad request" in exc_info.value.message
@patch("bootnode.core.billing.commerce.get_settings")
@pytest.mark.asyncio
async def test_request_raises_on_connection_error(self, mock_settings):
"""_request should raise CommerceError with 503 on connection failure."""
import httpx
mock_settings.return_value = MagicMock(
commerce_url="http://test:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import CommerceError, HanzoCommerceClient
client = HanzoCommerceClient()
with patch("httpx.AsyncClient") as MockAsyncClient:
mock_client = AsyncMock()
mock_client.request.side_effect = httpx.ConnectError("Connection refused")
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
MockAsyncClient.return_value = mock_client
with pytest.raises(CommerceError) as exc_info:
await client._request("GET", "/v1/test")
assert exc_info.value.status_code == 503
# =============================================================================
# Unified Billing Client Error Handling
# =============================================================================
class TestUnifiedBillingErrors:
def _make_iam_user(self, **kwargs):
from types import SimpleNamespace
defaults = {
"id": "user_1",
"email": "[email protected]",
"name": "Test User",
"org": "hanzo",
"roles": [],
}
defaults.update(kwargs)
return SimpleNamespace(**defaults)
@patch("bootnode.core.billing.unified.get_settings")
@pytest.mark.asyncio
async def test_create_customer_raises_on_failure(self, mock_settings):
"""_create_customer should raise CommerceError, not return fake data."""
mock_settings.return_value = MagicMock(
iam_url="http://iam:8000",
commerce_url="http://commerce:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import CommerceError
from bootnode.core.billing.unified import UnifiedBillingClient
client = UnifiedBillingClient()
iam_user = self._make_iam_user()
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.content = b'{"message": "internal error"}'
mock_response.json.return_value = {"message": "internal error"}
with patch("httpx.AsyncClient") as MockAsyncClient:
mock_http = AsyncMock()
mock_http.post.return_value = mock_response
mock_http.__aenter__ = AsyncMock(return_value=mock_http)
mock_http.__aexit__ = AsyncMock(return_value=False)
MockAsyncClient.return_value = mock_http
with pytest.raises(CommerceError) as exc_info:
await client._create_customer(iam_user)
assert exc_info.value.status_code == 500
@patch("bootnode.core.billing.unified.get_settings")
@pytest.mark.asyncio
async def test_create_subscription_raises_commerce_error(self, mock_settings):
"""create_subscription should raise CommerceError, not bare Exception."""
mock_settings.return_value = MagicMock(
iam_url="http://iam:8000",
commerce_url="http://commerce:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import CommerceError
from bootnode.core.billing.unified import UnifiedBillingClient
client = UnifiedBillingClient()
iam_user = self._make_iam_user()
# Mock get_or_create_customer to return a user with customer_id
client.get_or_create_customer = AsyncMock()
client.get_or_create_customer.return_value = MagicMock(
commerce_customer_id="cust_123"
)
mock_response = MagicMock()
mock_response.status_code = 422
mock_response.content = b'{"message": "invalid plan"}'
mock_response.json.return_value = {"message": "invalid plan"}
with patch("httpx.AsyncClient") as MockAsyncClient:
mock_http = AsyncMock()
mock_http.post.return_value = mock_response
mock_http.__aenter__ = AsyncMock(return_value=mock_http)
mock_http.__aexit__ = AsyncMock(return_value=False)
MockAsyncClient.return_value = mock_http
with pytest.raises(CommerceError) as exc_info:
await client.create_subscription(iam_user, "bad-plan", uuid4())
assert exc_info.value.status_code == 422
# =============================================================================
# Webhook Handler Error Propagation
# =============================================================================
class TestWebhookHandlerErrors:
@patch("bootnode.core.billing.webhooks.get_settings")
@pytest.mark.asyncio
async def test_handler_exception_returns_error_dict(self, mock_settings):
"""When a handler throws, handle_event should return error info, not crash."""
mock_settings.return_value = MagicMock(commerce_webhook_secret="test")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
# Monkey-patch a handler to throw
handler.handle_customer_created = AsyncMock(
side_effect=ValueError("test explosion")
)
result = await handler.handle_event({
"type": "customer.created",
"id": "evt_1",
"data": {"id": "cust_1"},
})
assert result["handled"] is False
assert "test explosion" in result["error"]
@patch("bootnode.core.billing.webhooks.get_settings")
@pytest.mark.asyncio
async def test_subscription_created_missing_project_id(self, mock_settings):
"""subscription.created without project_id should return error dict."""
mock_settings.return_value = MagicMock(commerce_webhook_secret="test")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
result = await handler.handle_event({
"type": "subscription.created",
"id": "evt_2",
"data": {
"id": "sub_123",
"customer_id": "cust_456",
"metadata": {}, # No project_id
},
})
assert result["handled"] is True
assert result["result"]["error"] == "missing_project_id"
@patch("bootnode.core.billing.webhooks.get_settings")
@pytest.mark.asyncio
async def test_subscription_created_invalid_project_id(self, mock_settings):
"""subscription.created with bad UUID should return error dict."""
mock_settings.return_value = MagicMock(commerce_webhook_secret="test")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
result = await handler.handle_event({
"type": "subscription.created",
"id": "evt_3",
"data": {
"id": "sub_123",
"customer_id": "cust_456",
"metadata": {"project_id": "not-a-uuid"},
},
})
assert result["handled"] is True
assert result["result"]["error"] == "invalid_project_id"