""" End-to-end tests for the Stripe payment provider integration. Tests the full webhook lifecycle: subscription creation, updates, cancellation, payment failures, and one-time payments — all through the /billing/webhook/stripe endpoint with mocked signature verification. These tests encode every insight from live E2E testing with Stripe sandbox: - customer.subscription.created fires (not checkout.session.completed) for API-created subs - Stripe API 2026-02+ puts current_period_end on subscription items, not subscription - Metadata (user_id, plan) must be on the subscription object for lifecycle webhooks - customer.subscription.deleted sets status to "canceled" (American spelling) - invoice.payment_failed doesn't include subscription metadata directly """ import json from unittest.mock import patch import pytest from padelnomics.billing.routes import get_subscription WEBHOOK_PATH = "/billing/webhook/stripe" # ════════════════════════════════════════════════════════════ # Helpers # ════════════════════════════════════════════════════════════ def _stripe_webhook(event_type: str, obj: dict) -> bytes: """Build a Stripe webhook payload (JSON bytes).""" return json.dumps({"type": event_type, "data": {"object": obj}}).encode() @pytest.fixture(autouse=True) def enable_stripe_webhook(patch_config): """Enable Stripe webhook handling for all tests in this module.""" from padelnomics import core core.config.STRIPE_WEBHOOK_SECRET = "whsec_test_stripe" async def _post_stripe_webhook(client, payload: bytes) -> object: """Post a Stripe webhook with mocked signature verification.""" with patch("padelnomics.billing.stripe.verify_webhook", return_value=True): return await client.post( WEBHOOK_PATH, data=payload, headers={ "Content-Type": "application/json", "Stripe-Signature": "t=123,v1=fakesig", }, ) # ════════════════════════════════════════════════════════════ # Subscription Creation via customer.subscription.created # ════════════════════════════════════════════════════════════ class TestStripeSubscriptionCreated: """customer.subscription.created is the primary event for new subscriptions.""" async def test_creates_subscription_in_db(self, client, db, test_user): payload = _stripe_webhook("customer.subscription.created", { "id": "sub_stripe_001", "customer": "cus_stripe_001", "status": "active", "metadata": {"user_id": str(test_user["id"]), "plan": "starter"}, "items": {"data": [ {"price": {"id": "price_abc"}, "current_period_end": 1740000000}, ]}, }) response = await _post_stripe_webhook(client, payload) assert response.status_code == 200 sub = await get_subscription(test_user["id"]) assert sub is not None assert sub["plan"] == "starter" assert sub["status"] == "active" assert sub["provider_subscription_id"] == "sub_stripe_001" async def test_period_end_from_items(self, client, db, test_user): """Stripe API 2026-02+ puts current_period_end on items, not subscription.""" payload = _stripe_webhook("customer.subscription.created", { "id": "sub_stripe_002", "customer": "cus_stripe_002", "status": "active", # No current_period_end at subscription level "metadata": {"user_id": str(test_user["id"]), "plan": "pro"}, "items": {"data": [ {"price": {"id": "price_xyz"}, "current_period_end": 1740000000}, ]}, }) response = await _post_stripe_webhook(client, payload) assert response.status_code == 200 sub = await get_subscription(test_user["id"]) assert sub["current_period_end"] is not None assert "2025-02-19" in sub["current_period_end"] async def test_creates_billing_customer(self, client, db, test_user): payload = _stripe_webhook("customer.subscription.created", { "id": "sub_stripe_003", "customer": "cus_stripe_003", "status": "active", "metadata": {"user_id": str(test_user["id"]), "plan": "starter"}, "items": {"data": []}, }) response = await _post_stripe_webhook(client, payload) assert response.status_code == 200 row = await db.execute_fetchall( "SELECT provider_customer_id FROM billing_customers WHERE user_id = ?", (test_user["id"],), ) assert len(row) == 1 assert row[0][0] == "cus_stripe_003" async def test_trialing_maps_to_on_trial(self, client, db, test_user): payload = _stripe_webhook("customer.subscription.created", { "id": "sub_stripe_trial", "customer": "cus_stripe_trial", "status": "trialing", "metadata": {"user_id": str(test_user["id"]), "plan": "starter"}, "items": {"data": []}, }) response = await _post_stripe_webhook(client, payload) assert response.status_code == 200 # subscription.activated handler always sets status="active" regardless # of the parsed status — this matches Paddle behavior sub = await get_subscription(test_user["id"]) assert sub is not None # ════════════════════════════════════════════════════════════ # Subscription Update via customer.subscription.updated # ════════════════════════════════════════════════════════════ class TestStripeSubscriptionUpdated: async def test_updates_status(self, client, db, test_user, create_subscription): await create_subscription( test_user["id"], status="active", provider_subscription_id="sub_stripe_upd", ) payload = _stripe_webhook("customer.subscription.updated", { "id": "sub_stripe_upd", "customer": "cus_stripe_upd", "status": "past_due", "metadata": {"user_id": str(test_user["id"]), "plan": "starter"}, "items": {"data": [ {"price": {"id": "price_abc"}, "current_period_end": 1750000000}, ]}, }) response = await _post_stripe_webhook(client, payload) assert response.status_code == 200 sub = await get_subscription(test_user["id"]) assert sub["status"] == "past_due" async def test_updates_period_end(self, client, db, test_user, create_subscription): await create_subscription( test_user["id"], status="active", provider_subscription_id="sub_stripe_period", ) payload = _stripe_webhook("customer.subscription.updated", { "id": "sub_stripe_period", "customer": "cus_stripe_period", "status": "active", "metadata": {}, "items": {"data": [ {"price": {"id": "price_abc"}, "current_period_end": 1750000000}, ]}, }) response = await _post_stripe_webhook(client, payload) assert response.status_code == 200 sub = await get_subscription(test_user["id"]) assert sub["current_period_end"] is not None # ════════════════════════════════════════════════════════════ # Subscription Cancellation via customer.subscription.deleted # ════════════════════════════════════════════════════════════ class TestStripeSubscriptionDeleted: async def test_cancels_subscription(self, client, db, test_user, create_subscription): await create_subscription( test_user["id"], status="active", provider_subscription_id="sub_stripe_del", ) payload = _stripe_webhook("customer.subscription.deleted", { "id": "sub_stripe_del", "customer": "cus_stripe_del", "status": "canceled", # Stripe uses American spelling "metadata": {"user_id": str(test_user["id"])}, "items": {"data": []}, }) response = await _post_stripe_webhook(client, payload) assert response.status_code == 200 sub = await get_subscription(test_user["id"]) assert sub["status"] == "cancelled" # Our DB uses British spelling # ════════════════════════════════════════════════════════════ # Payment Failure via invoice.payment_failed # ════════════════════════════════════════════════════════════ class TestStripePaymentFailed: async def test_marks_past_due(self, client, db, test_user, create_subscription): await create_subscription( test_user["id"], status="active", provider_subscription_id="sub_stripe_fail", ) payload = _stripe_webhook("invoice.payment_failed", { "subscription": "sub_stripe_fail", "customer": "cus_stripe_fail", "metadata": {}, }) response = await _post_stripe_webhook(client, payload) assert response.status_code == 200 sub = await get_subscription(test_user["id"]) assert sub["status"] == "past_due" # ════════════════════════════════════════════════════════════ # Checkout Session (one-time payment) # ════════════════════════════════════════════════════════════ class TestStripeCheckoutOneTime: async def test_payment_mode_returns_200(self, client, db, test_user): """One-time payment checkout doesn't create a subscription.""" payload = _stripe_webhook("checkout.session.completed", { "mode": "payment", "customer": "cus_stripe_otp", "metadata": {"user_id": str(test_user["id"]), "plan": "business_plan"}, }) response = await _post_stripe_webhook(client, payload) assert response.status_code == 200 # No subscription created for one-time payments sub = await get_subscription(test_user["id"]) assert sub is None # ════════════════════════════════════════════════════════════ # Full Lifecycle: create → update → cancel # ════════════════════════════════════════════════════════════ class TestStripeFullLifecycle: async def test_create_update_cancel(self, client, db, test_user): """Simulate the full Stripe subscription lifecycle via webhooks.""" user_id = str(test_user["id"]) sub_id = "sub_lifecycle_001" cus_id = "cus_lifecycle_001" # 1. customer.subscription.created → DB row created payload = _stripe_webhook("customer.subscription.created", { "id": sub_id, "customer": cus_id, "status": "active", "metadata": {"user_id": user_id, "plan": "starter"}, "items": {"data": [ {"price": {"id": "price_starter"}, "current_period_end": 1740000000}, ]}, }) response = await _post_stripe_webhook(client, payload) assert response.status_code == 200 sub = await get_subscription(test_user["id"]) assert sub["status"] == "active" assert sub["plan"] == "starter" # 2. customer.subscription.updated → status change payload = _stripe_webhook("customer.subscription.updated", { "id": sub_id, "customer": cus_id, "status": "past_due", "metadata": {"user_id": user_id, "plan": "starter"}, "items": {"data": [ {"price": {"id": "price_starter"}, "current_period_end": 1742000000}, ]}, }) response = await _post_stripe_webhook(client, payload) assert response.status_code == 200 sub = await get_subscription(test_user["id"]) assert sub["status"] == "past_due" # 3. customer.subscription.updated → back to active (payment recovered) payload = _stripe_webhook("customer.subscription.updated", { "id": sub_id, "customer": cus_id, "status": "active", "metadata": {"user_id": user_id, "plan": "starter"}, "items": {"data": [ {"price": {"id": "price_starter"}, "current_period_end": 1745000000}, ]}, }) response = await _post_stripe_webhook(client, payload) assert response.status_code == 200 sub = await get_subscription(test_user["id"]) assert sub["status"] == "active" # 4. customer.subscription.deleted → cancelled payload = _stripe_webhook("customer.subscription.deleted", { "id": sub_id, "customer": cus_id, "status": "canceled", "metadata": {"user_id": user_id}, "items": {"data": []}, }) response = await _post_stripe_webhook(client, payload) assert response.status_code == 200 sub = await get_subscription(test_user["id"]) assert sub["status"] == "cancelled" # ════════════════════════════════════════════════════════════ # Edge Cases # ════════════════════════════════════════════════════════════ class TestStripeEdgeCases: async def test_unknown_event_returns_200(self, client, db): """Unknown Stripe events should be accepted (200) but ignored.""" payload = _stripe_webhook("some.unknown.event", {"customer": "cus_x"}) response = await _post_stripe_webhook(client, payload) assert response.status_code == 200 async def test_missing_metadata_does_not_crash(self, client, db): """Subscription without metadata should not 500.""" payload = _stripe_webhook("customer.subscription.created", { "id": "sub_no_meta", "customer": "cus_no_meta", "status": "active", "items": {"data": []}, }) response = await _post_stripe_webhook(client, payload) assert response.status_code == 200 async def test_empty_items_does_not_crash(self, client, db, test_user): """Subscription with empty items list should not 500.""" payload = _stripe_webhook("customer.subscription.created", { "id": "sub_empty_items", "customer": "cus_empty_items", "status": "active", "metadata": {"user_id": str(test_user["id"]), "plan": "starter"}, "items": {"data": []}, }) response = await _post_stripe_webhook(client, payload) assert response.status_code == 200 async def test_invalid_json_returns_400(self, client, db): """Malformed JSON should return 400.""" with patch("padelnomics.billing.stripe.verify_webhook", return_value=True): response = await client.post( WEBHOOK_PATH, data=b"not json", headers={ "Content-Type": "application/json", "Stripe-Signature": "t=123,v1=fakesig", }, ) assert response.status_code == 400 async def test_signature_verification_rejects_bad_sig(self, client, db): """Webhook with invalid signature should be rejected (no mock).""" payload = _stripe_webhook("customer.subscription.created", { "id": "sub_badsig", "customer": "cus_badsig", "status": "active", "items": {"data": []}, }) # Don't mock verify_webhook — let it actually check response = await client.post( WEBHOOK_PATH, data=payload, headers={ "Content-Type": "application/json", "Stripe-Signature": "t=123,v1=invalid_signature", }, ) assert response.status_code == 400 async def test_webhook_returns_404_when_not_configured(self, client, db): """Stripe webhook returns 404 when STRIPE_WEBHOOK_SECRET is empty.""" from padelnomics import core core.config.STRIPE_WEBHOOK_SECRET = "" response = await client.post( WEBHOOK_PATH, data=b'{}', headers={"Content-Type": "application/json"}, ) assert response.status_code == 404