- Create channels.py: HMAC-signed OAuth state generation/verification, Slack OAuth install/callback, WhatsApp manual connect, test message endpoint - Create billing.py: Stripe Checkout session, billing portal session, webhook handler with idempotency (StripeEvent table), subscription lifecycle management - Update usage.py: add _aggregate_rows_by_agent and _aggregate_rows_by_provider helpers (unit-testable without DB), complete usage endpoints - Fix audit.py: rename 'metadata' attribute to 'event_metadata' (SQLAlchemy 2.0 DeclarativeBase reserves 'metadata') - Enhance runner.py: audit log now includes prompt_tokens, completion_tokens, total_tokens, cost_usd, provider in LLM call metadata - Update api/__init__.py to export all new routers - All 27 unit tests passing
178 lines
5.8 KiB
Python
178 lines
5.8 KiB
Python
"""
|
|
Unit tests for Stripe webhook handler logic.
|
|
|
|
Tests:
|
|
- process_stripe_event idempotency: same event_id processed twice returns
|
|
"already_processed" on the second call
|
|
- customer.subscription.updated: updates tenant subscription_status
|
|
- customer.subscription.deleted: sets status=canceled and deactivates agents
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from shared.api.billing import process_stripe_event
|
|
|
|
|
|
@pytest.fixture()
|
|
def mock_session() -> AsyncMock:
|
|
"""Mock AsyncSession that tracks execute calls."""
|
|
session = AsyncMock()
|
|
# Default: no existing StripeEvent found (not yet processed)
|
|
session.execute.return_value = MagicMock(scalar_one_or_none=MagicMock(return_value=None))
|
|
return session
|
|
|
|
|
|
@pytest.fixture()
|
|
def tenant_id() -> str:
|
|
return str(uuid.uuid4())
|
|
|
|
|
|
@pytest.fixture()
|
|
def agent_id() -> str:
|
|
return str(uuid.uuid4())
|
|
|
|
|
|
class _MockTenant:
|
|
def __init__(self, tenant_id: str) -> None:
|
|
self.id = uuid.UUID(tenant_id)
|
|
self.stripe_customer_id: str | None = None
|
|
self.stripe_subscription_id: str | None = None
|
|
self.stripe_subscription_item_id: str | None = None
|
|
self.subscription_status: str = "trialing"
|
|
self.trial_ends_at = None
|
|
self.agent_quota: int = 0
|
|
|
|
|
|
class _MockAgent:
|
|
def __init__(self, agent_id: str, tenant_id: str) -> None:
|
|
self.id = uuid.UUID(agent_id)
|
|
self.tenant_id = uuid.UUID(tenant_id)
|
|
self.is_active: bool = True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stripe_webhook_idempotency(mock_session: AsyncMock, tenant_id: str) -> None:
|
|
"""
|
|
Processing the same event_id twice returns 'already_processed' on second call.
|
|
The second call should detect the existing StripeEvent and skip processing.
|
|
"""
|
|
event_id = "evt_test_idempotent_001"
|
|
|
|
event_data = {
|
|
"id": event_id,
|
|
"type": "customer.subscription.updated",
|
|
"data": {
|
|
"object": {
|
|
"id": "sub_test123",
|
|
"status": "active",
|
|
"customer": "cus_test123",
|
|
"trial_end": None,
|
|
"items": {"data": [{"id": "si_test123", "quantity": 2}]},
|
|
}
|
|
},
|
|
}
|
|
|
|
# First call: no existing event in DB
|
|
first_session = AsyncMock()
|
|
first_session.execute.return_value = MagicMock(
|
|
scalar_one_or_none=MagicMock(return_value=None),
|
|
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
|
|
)
|
|
|
|
mock_tenant = _MockTenant(tenant_id)
|
|
with patch("shared.api.billing._get_tenant_by_stripe_customer", new_callable=AsyncMock) as mock_get:
|
|
mock_get.return_value = mock_tenant
|
|
result1 = await process_stripe_event(event_data, first_session)
|
|
|
|
assert result1 != "already_processed"
|
|
|
|
# Second call: event already in DB (simulating idempotent duplicate)
|
|
existing_event = MagicMock() # Non-None means already processed
|
|
second_session = AsyncMock()
|
|
second_session.execute.return_value = MagicMock(
|
|
scalar_one_or_none=MagicMock(return_value=existing_event)
|
|
)
|
|
|
|
result2 = await process_stripe_event(event_data, second_session)
|
|
assert result2 == "already_processed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stripe_subscription_updated(mock_session: AsyncMock, tenant_id: str) -> None:
|
|
"""
|
|
customer.subscription.updated event updates tenant subscription_status.
|
|
"""
|
|
event_data = {
|
|
"id": "evt_test_sub_updated",
|
|
"type": "customer.subscription.updated",
|
|
"data": {
|
|
"object": {
|
|
"id": "sub_updated123",
|
|
"status": "active",
|
|
"customer": "cus_tenant123",
|
|
"trial_end": None,
|
|
"items": {"data": [{"id": "si_updated123", "quantity": 3}]},
|
|
}
|
|
},
|
|
}
|
|
|
|
mock_tenant = _MockTenant(tenant_id)
|
|
assert mock_tenant.subscription_status == "trialing"
|
|
|
|
with patch("shared.api.billing._get_tenant_by_stripe_customer", new_callable=AsyncMock) as mock_get:
|
|
mock_get.return_value = mock_tenant
|
|
result = await process_stripe_event(event_data, mock_session)
|
|
|
|
assert result != "already_processed"
|
|
assert mock_tenant.subscription_status == "active"
|
|
assert mock_tenant.stripe_subscription_id == "sub_updated123"
|
|
assert mock_tenant.agent_quota == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stripe_cancellation(mock_session: AsyncMock, tenant_id: str, agent_id: str) -> None:
|
|
"""
|
|
customer.subscription.deleted sets status=canceled and deactivates all tenant agents.
|
|
"""
|
|
event_data = {
|
|
"id": "evt_test_canceled",
|
|
"type": "customer.subscription.deleted",
|
|
"data": {
|
|
"object": {
|
|
"id": "sub_canceled123",
|
|
"status": "canceled",
|
|
"customer": "cus_tenant_cancel",
|
|
"trial_end": None,
|
|
"items": {"data": []},
|
|
}
|
|
},
|
|
}
|
|
|
|
mock_tenant = _MockTenant(tenant_id)
|
|
mock_tenant.subscription_status = "active"
|
|
|
|
mock_agent = _MockAgent(agent_id, tenant_id)
|
|
assert mock_agent.is_active is True
|
|
|
|
with (
|
|
patch("shared.api.billing._get_tenant_by_stripe_customer", new_callable=AsyncMock) as mock_get_tenant,
|
|
patch("shared.api.billing._deactivate_all_agents", new_callable=AsyncMock) as mock_deactivate,
|
|
):
|
|
mock_get_tenant.return_value = mock_tenant
|
|
# Simulate deactivation side effect
|
|
async def _do_deactivate(session, tenant_id_arg): # type: ignore[no-untyped-def]
|
|
mock_agent.is_active = False
|
|
mock_deactivate.side_effect = _do_deactivate
|
|
|
|
result = await process_stripe_event(event_data, mock_session)
|
|
|
|
assert result != "already_processed"
|
|
assert mock_tenant.subscription_status == "canceled"
|
|
assert mock_agent.is_active is False
|
|
mock_deactivate.assert_called_once()
|