feat(billing): B-2.7 Stripe Checkout 3 plans CAD/TVQ + Apple/Google Pay

Adds the customer-facing checkout flow under /checkout/<plan>:
- src/billing/plans.py — Plan dataclass + 3 plans (DictIA 8 / 16 / Cloud),
  monthly + yearly Price IDs resolved from STRIPE_DICTIA_*_{SETUP,MONTHLY,YEARLY} env.
- src/billing/stripe_client.py — lazy stripe.api_key init, get_or_create_customer
  (persists user.stripe_customer_id), create_checkout_session with mode=subscription,
  currency=cad, automatic_tax=true (TPS 5% + TVQ 9.975%), billing_address_collection,
  metadata on both Session and Subscription for the B-2.8 webhook.
- src/billing/routes.py — GET /checkout/<plan>?period=monthly|yearly returns 303
  redirect to Stripe-hosted Checkout. Friendly French flash + redirect to /tarifs
  on unknown plan, missing STRIPE_SECRET_KEY, missing Price IDs, or Stripe API error.
  GET /checkout/success and /checkout/cancel render brand-tokenized templates that
  extend marketing/base.html.
- templates/billing/{success,cancel}.html — explicit "activé sous quelques minutes"
  note (webhook is async), aucun montant prélevé reassurance on cancel.
- config/env.stripe.example — env vars + Stripe Dashboard setup checklist
  (CAD activation, Stripe Tax registrations, Apple/Google Pay enable, webhook).
- tests/test_stripe_checkout.py — 25 tests covering plans, stripe_client, routes,
  and the _PUBLIC_INDEXABLE_ENDPOINTS integration. Stripe SDK mocked via
  unittest.mock.patch (no network). Windows manual driver included.

Webhook (B-2.8) will be the source of truth for user.subscription_status.
This task only mutates user.stripe_customer_id (identity, not state).
Existing pricing CTAs in templates/marketing/_partials/_pricing_tiers.html
already link to /checkout/<slug> (verified) — no marketing template touched.

Tests: 25/25 new + 89/89 prior pass on Windows manual driver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Allison
2026-04-28 08:26:13 -04:00
parent b8fa321edd
commit f1a5ad565f
9 changed files with 1239 additions and 1 deletions

View File

@@ -0,0 +1,74 @@
"""Windows manual driver for tests/test_stripe_checkout.py.
src/init_db.py imports `fcntl`, which is POSIX-only. On Windows we stub it
before src.app gets imported, then run each test_* function and report.
Run from the repo root:
py -3 tests/_run_stripe_checkout_windows.py
"""
import os
import sys
import types
import traceback
# 1) Stub fcntl BEFORE any import of src.* happens.
if 'fcntl' not in sys.modules:
fcntl_stub = types.ModuleType('fcntl')
fcntl_stub.LOCK_EX = 2
fcntl_stub.LOCK_NB = 4
fcntl_stub.LOCK_UN = 8
fcntl_stub.LOCK_SH = 1
fcntl_stub.flock = lambda *_args, **_kw: None
fcntl_stub.fcntl = lambda *_args, **_kw: 0
sys.modules['fcntl'] = fcntl_stub
# 2) Make repo root importable
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
sys.path.insert(0, REPO)
# 3) Test-friendly env defaults
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key-stripe')
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('REQUIRE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('TRANSCRIPTION_BASE_URL', 'http://test-stub')
os.environ.setdefault('TRANSCRIPTION_API_KEY', 'test-stub')
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
# 4) Import the test module and run every test_* function
import importlib.util # noqa: E402
spec = importlib.util.spec_from_file_location(
'test_stripe_checkout',
os.path.join(HERE, 'test_stripe_checkout.py'),
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
tests = [(name, fn) for name, fn in vars(mod).items()
if name.startswith('test_') and callable(fn)]
passed = 0
failed = []
for name, fn in tests:
try:
fn()
print(f' PASS {name}')
passed += 1
except Exception as e: # noqa: BLE001
print(f' FAIL {name}: {type(e).__name__}: {e}')
failed.append((name, traceback.format_exc()))
total = len(tests)
print()
print(f'Result: {passed}/{total} passed, {len(failed)} failed')
if failed:
print('\n--- Failures ---\n')
for name, tb in failed:
print(f'### {name}\n{tb}\n')
sys.exit(0 if not failed else 1)

View File

@@ -0,0 +1,593 @@
"""Tests for B-2.7 — Stripe Checkout (3 plans CAD + TPS/TVQ + Apple/Google Pay).
Covers:
- plans.py: Plan dataclass, env-resolved Price IDs, helpers, is_configured.
- stripe_client.py: lazy api_key init, get_or_create_customer, create_checkout_session.
- routes.py: GET /checkout/<plan>, /checkout/success, /checkout/cancel.
- Integration: app.py _PUBLIC_INDEXABLE_ENDPOINTS includes 'billing.success'.
Mocks the stripe library functions (stripe.Customer.create, stripe.checkout.Session.create)
via unittest.mock.patch — no real Stripe API calls.
Note: pytest cannot collect this file on Windows native because src/init_db.py
imports `fcntl` (POSIX-only). Use tests/_run_stripe_checkout_windows.py.
"""
import os
import sys
from unittest.mock import patch, MagicMock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key-stripe')
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('REQUIRE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
from src.app import app, db, bcrypt # noqa: E402
from src.models.user import User # noqa: E402
_PRICE_ENV_VARS = (
'STRIPE_SECRET_KEY',
'STRIPE_DICTIA_8_SETUP', 'STRIPE_DICTIA_8_MONTHLY', 'STRIPE_DICTIA_8_YEARLY',
'STRIPE_DICTIA_16_SETUP', 'STRIPE_DICTIA_16_MONTHLY', 'STRIPE_DICTIA_16_YEARLY',
'STRIPE_DICTIA_CLOUD_MONTHLY', 'STRIPE_DICTIA_CLOUD_YEARLY',
)
def _clear_stripe_env():
for k in _PRICE_ENV_VARS:
os.environ.pop(k, None)
# Reset stripe module-level api_key state
import stripe
stripe.api_key = None
def _disable_csrf():
app.config['WTF_CSRF_ENABLED'] = False
def _make_user(email='checkout@example.qc.ca', password='Password!123',
username=None, name='Checkout User',
stripe_customer_id=None):
hashed = bcrypt.generate_password_hash(password).decode('utf-8')
u = User(
username=username or email.split('@', 1)[0][:20],
email=email,
password=hashed,
email_verified=True,
name=name,
stripe_customer_id=stripe_customer_id,
)
db.session.add(u)
db.session.commit()
return u
def _login_session(client, user):
with client.session_transaction() as sess:
sess['_user_id'] = str(user.id)
sess['_fresh'] = True
# ----------------------------------------------------------------------
# 1-2. is_stripe_configured
# ----------------------------------------------------------------------
def test_is_stripe_configured_when_env_set():
_clear_stripe_env()
try:
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
from src.billing.stripe_client import is_stripe_configured
assert is_stripe_configured() is True
finally:
_clear_stripe_env()
def test_is_stripe_configured_when_env_unset():
_clear_stripe_env()
try:
from src.billing.stripe_client import is_stripe_configured
assert is_stripe_configured() is False
finally:
_clear_stripe_env()
# ----------------------------------------------------------------------
# 3-4. get_plan
# ----------------------------------------------------------------------
def test_get_plan_returns_known_plan():
from src.billing.plans import get_plan, Plan
plan = get_plan('dictia-cloud')
assert plan is not None
assert isinstance(plan, Plan)
assert plan.slug == 'dictia-cloud'
assert plan.has_setup_fee is False
def test_get_plan_returns_none_for_unknown():
from src.billing.plans import get_plan
assert get_plan('foo') is None
assert get_plan('') is None
# ----------------------------------------------------------------------
# 5-7. Plan.is_configured
# ----------------------------------------------------------------------
def test_plan_is_configured_when_env_set():
_clear_stripe_env()
try:
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cloud_m'
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cloud_y'
from src.billing.plans import get_plan
assert get_plan('dictia-cloud').is_configured() is True
finally:
_clear_stripe_env()
def test_plan_is_not_configured_when_env_missing():
_clear_stripe_env()
try:
from src.billing.plans import get_plan
assert get_plan('dictia-cloud').is_configured() is False
finally:
_clear_stripe_env()
def test_hardware_plan_requires_setup_env():
_clear_stripe_env()
try:
os.environ['STRIPE_DICTIA_8_MONTHLY'] = 'price_8_m'
os.environ['STRIPE_DICTIA_8_YEARLY'] = 'price_8_y'
# NO STRIPE_DICTIA_8_SETUP
from src.billing.plans import get_plan
assert get_plan('dictia-8').is_configured() is False
os.environ['STRIPE_DICTIA_8_SETUP'] = 'price_8_setup'
assert get_plan('dictia-8').is_configured() is True
finally:
_clear_stripe_env()
# ----------------------------------------------------------------------
# 8-9. get_or_create_customer
# ----------------------------------------------------------------------
def test_get_or_create_customer_creates_when_missing():
with app.app_context():
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
db.create_all()
try:
user = _make_user(email='newcust@example.qc.ca', name='Alice')
assert user.stripe_customer_id is None
with patch('src.billing.stripe_client.stripe.Customer.create') as mock_cust:
mock_cust.return_value = MagicMock(id='cus_fakeNEW')
from src.billing.stripe_client import get_or_create_customer
cust_id = get_or_create_customer(user)
assert cust_id == 'cus_fakeNEW'
mock_cust.assert_called_once()
kwargs = mock_cust.call_args.kwargs
assert kwargs['email'] == 'newcust@example.qc.ca'
assert kwargs['name'] == 'Alice'
assert kwargs['metadata']['dictia_user_id'] == str(user.id)
db.session.refresh(user)
assert user.stripe_customer_id == 'cus_fakeNEW'
finally:
db.session.rollback()
db.drop_all()
_clear_stripe_env()
def test_get_or_create_customer_reuses_existing():
with app.app_context():
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
db.create_all()
try:
user = _make_user(email='oldcust@example.qc.ca',
stripe_customer_id='cus_existing')
with patch('src.billing.stripe_client.stripe.Customer.create') as mock_cust:
from src.billing.stripe_client import get_or_create_customer
cust_id = get_or_create_customer(user)
assert cust_id == 'cus_existing'
mock_cust.assert_not_called()
finally:
db.session.rollback()
db.drop_all()
_clear_stripe_env()
# ----------------------------------------------------------------------
# 10-13. create_checkout_session
# ----------------------------------------------------------------------
def test_create_checkout_session_includes_setup_for_hardware_plan():
with app.app_context():
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
os.environ['STRIPE_DICTIA_8_SETUP'] = 'price_setup'
os.environ['STRIPE_DICTIA_8_MONTHLY'] = 'price_8m'
os.environ['STRIPE_DICTIA_8_YEARLY'] = 'price_8y'
db.create_all()
try:
user = _make_user(email='hwsetup@example.qc.ca', name='Bob')
with patch('src.billing.stripe_client.stripe.Customer.create') as mock_cust, \
patch('src.billing.stripe_client.stripe.checkout.Session.create') as mock_sess:
mock_cust.return_value = MagicMock(id='cus_x')
mock_sess.return_value = MagicMock(url='https://checkout.stripe.test/cs_x')
from src.billing.stripe_client import create_checkout_session
create_checkout_session(
plan_slug='dictia-8', period='monthly', user=user,
success_url='https://x.ca/success', cancel_url='https://x.ca/cancel',
)
kwargs = mock_sess.call_args.kwargs
assert len(kwargs['line_items']) == 2
assert kwargs['line_items'][0]['price'] == 'price_setup'
assert kwargs['line_items'][1]['price'] == 'price_8m'
assert kwargs['mode'] == 'subscription'
assert kwargs['currency'] == 'cad'
assert kwargs['automatic_tax']['enabled'] is True
assert kwargs['allow_promotion_codes'] is True
assert kwargs['billing_address_collection'] == 'required'
# success_url must include CHECKOUT_SESSION_ID placeholder
assert '{CHECKOUT_SESSION_ID}' in kwargs['success_url']
finally:
db.session.rollback()
db.drop_all()
_clear_stripe_env()
def test_create_checkout_session_no_setup_for_cloud_plan():
with app.app_context():
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
db.create_all()
try:
user = _make_user(email='cloudplan@example.qc.ca', name='Carol')
with patch('src.billing.stripe_client.stripe.Customer.create') as mock_cust, \
patch('src.billing.stripe_client.stripe.checkout.Session.create') as mock_sess:
mock_cust.return_value = MagicMock(id='cus_y')
mock_sess.return_value = MagicMock(url='https://x/cs_y')
from src.billing.stripe_client import create_checkout_session
create_checkout_session(
plan_slug='dictia-cloud', period='monthly', user=user,
success_url='https://x.ca/success', cancel_url='https://x.ca/cancel',
)
kwargs = mock_sess.call_args.kwargs
assert len(kwargs['line_items']) == 1
assert kwargs['line_items'][0]['price'] == 'price_cm'
finally:
db.session.rollback()
db.drop_all()
_clear_stripe_env()
def test_create_checkout_session_uses_yearly_price_when_period_yearly():
with app.app_context():
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
db.create_all()
try:
user = _make_user(email='yearly@example.qc.ca', name='Dan')
with patch('src.billing.stripe_client.stripe.Customer.create') as mock_cust, \
patch('src.billing.stripe_client.stripe.checkout.Session.create') as mock_sess:
mock_cust.return_value = MagicMock(id='cus_z')
mock_sess.return_value = MagicMock(url='https://x/cs_z')
from src.billing.stripe_client import create_checkout_session
create_checkout_session(
plan_slug='dictia-cloud', period='yearly', user=user,
success_url='https://x.ca/success', cancel_url='https://x.ca/cancel',
)
kwargs = mock_sess.call_args.kwargs
assert kwargs['line_items'][0]['price'] == 'price_cy'
finally:
db.session.rollback()
db.drop_all()
_clear_stripe_env()
def test_create_checkout_session_includes_metadata():
with app.app_context():
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
db.create_all()
try:
user = _make_user(email='meta@example.qc.ca', name='Eve')
with patch('src.billing.stripe_client.stripe.Customer.create') as mock_cust, \
patch('src.billing.stripe_client.stripe.checkout.Session.create') as mock_sess:
mock_cust.return_value = MagicMock(id='cus_q')
mock_sess.return_value = MagicMock(url='https://x/cs_q')
from src.billing.stripe_client import create_checkout_session
create_checkout_session(
plan_slug='dictia-cloud', period='monthly', user=user,
success_url='https://x.ca/success', cancel_url='https://x.ca/cancel',
)
kwargs = mock_sess.call_args.kwargs
meta = kwargs['metadata']
assert meta['dictia_user_id'] == str(user.id)
assert meta['dictia_plan_slug'] == 'dictia-cloud'
assert meta['dictia_period'] == 'monthly'
# Subscription-level metadata too (used by webhook B-2.8)
sub_meta = kwargs['subscription_data']['metadata']
assert sub_meta['dictia_user_id'] == str(user.id)
assert sub_meta['dictia_plan_slug'] == 'dictia-cloud'
assert sub_meta['dictia_period'] == 'monthly'
finally:
db.session.rollback()
db.drop_all()
_clear_stripe_env()
# ----------------------------------------------------------------------
# 14-17. create_checkout_session error paths
# ----------------------------------------------------------------------
def test_create_checkout_session_raises_on_unknown_plan():
with app.app_context():
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
db.create_all()
try:
user = _make_user(email='unkplan@example.qc.ca')
from src.billing.stripe_client import create_checkout_session
try:
create_checkout_session(
plan_slug='foo', period='monthly', user=user,
success_url='https://x/s', cancel_url='https://x/c',
)
raise AssertionError('Expected ValueError')
except ValueError:
pass
finally:
db.session.rollback()
db.drop_all()
_clear_stripe_env()
def test_create_checkout_session_raises_on_invalid_period():
with app.app_context():
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
db.create_all()
try:
user = _make_user(email='badperiod@example.qc.ca')
from src.billing.stripe_client import create_checkout_session
try:
create_checkout_session(
plan_slug='dictia-cloud', period='quarterly', user=user,
success_url='https://x/s', cancel_url='https://x/c',
)
raise AssertionError('Expected ValueError')
except ValueError:
pass
finally:
db.session.rollback()
db.drop_all()
_clear_stripe_env()
def test_create_checkout_session_raises_when_stripe_not_configured():
with app.app_context():
_clear_stripe_env()
# NO STRIPE_SECRET_KEY
db.create_all()
try:
user = _make_user(email='nokey@example.qc.ca')
from src.billing.stripe_client import (
create_checkout_session, StripeNotConfiguredError,
)
try:
create_checkout_session(
plan_slug='dictia-cloud', period='monthly', user=user,
success_url='https://x/s', cancel_url='https://x/c',
)
raise AssertionError('Expected StripeNotConfiguredError')
except StripeNotConfiguredError:
pass
finally:
db.session.rollback()
db.drop_all()
_clear_stripe_env()
def test_create_checkout_session_raises_when_plan_env_missing():
with app.app_context():
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
# NO price IDs for dictia-cloud
db.create_all()
try:
user = _make_user(email='noprice@example.qc.ca')
from src.billing.stripe_client import (
create_checkout_session, StripeNotConfiguredError,
)
try:
create_checkout_session(
plan_slug='dictia-cloud', period='monthly', user=user,
success_url='https://x/s', cancel_url='https://x/c',
)
raise AssertionError('Expected StripeNotConfiguredError')
except StripeNotConfiguredError:
pass
finally:
db.session.rollback()
db.drop_all()
_clear_stripe_env()
# ----------------------------------------------------------------------
# 18-22. /checkout/<plan> route
# ----------------------------------------------------------------------
def test_checkout_route_redirects_to_stripe_url():
with app.app_context():
_disable_csrf()
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
db.create_all()
try:
user = _make_user(email='rt-redir@example.qc.ca', name='Frank')
with app.test_client() as client:
_login_session(client, user)
with patch('src.billing.routes.create_checkout_session') as mock_create:
mock_create.return_value = MagicMock(
url='https://checkout.stripe.test/cs_redir'
)
resp = client.get('/checkout/dictia-cloud?period=monthly',
follow_redirects=False)
assert resp.status_code == 303
assert resp.headers['Location'] == 'https://checkout.stripe.test/cs_redir'
# Ensure routes called the helper with the right args
mock_create.assert_called_once()
call_kwargs = mock_create.call_args.kwargs
assert call_kwargs['plan_slug'] == 'dictia-cloud'
assert call_kwargs['period'] == 'monthly'
finally:
db.session.rollback()
db.drop_all()
_clear_stripe_env()
def test_checkout_route_unknown_plan_redirects_to_tarifs():
with app.app_context():
_disable_csrf()
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
db.create_all()
try:
user = _make_user(email='rt-unk@example.qc.ca')
with app.test_client() as client:
_login_session(client, user)
resp = client.get('/checkout/foo', follow_redirects=False)
assert resp.status_code == 302
assert '/tarifs' in resp.headers['Location']
finally:
db.session.rollback()
db.drop_all()
_clear_stripe_env()
def test_checkout_route_normalizes_invalid_period_to_monthly():
with app.app_context():
_disable_csrf()
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
db.create_all()
try:
user = _make_user(email='rt-period@example.qc.ca')
with app.test_client() as client:
_login_session(client, user)
with patch('src.billing.routes.create_checkout_session') as mock_create:
mock_create.return_value = MagicMock(
url='https://checkout.stripe.test/cs_norm'
)
resp = client.get('/checkout/dictia-cloud?period=quarterly',
follow_redirects=False)
assert resp.status_code == 303
assert mock_create.call_args.kwargs['period'] == 'monthly'
finally:
db.session.rollback()
db.drop_all()
_clear_stripe_env()
def test_checkout_route_requires_login():
with app.app_context():
_disable_csrf()
_clear_stripe_env()
db.create_all()
try:
with app.test_client() as client:
resp = client.get('/checkout/dictia-cloud',
follow_redirects=False)
assert resp.status_code == 302
assert '/login' in resp.headers['Location']
finally:
db.session.rollback()
db.drop_all()
_clear_stripe_env()
def test_checkout_route_friendly_message_when_stripe_not_configured():
with app.app_context():
_disable_csrf()
_clear_stripe_env()
# NO STRIPE_SECRET_KEY
db.create_all()
try:
user = _make_user(email='rt-noconfig@example.qc.ca')
with app.test_client() as client:
_login_session(client, user)
resp = client.get('/checkout/dictia-cloud',
follow_redirects=False)
assert resp.status_code == 302
assert '/tarifs' in resp.headers['Location']
# Follow the redirect to see the flashed message
resp2 = client.get('/tarifs')
body = resp2.get_data(as_text=True)
assert 'info@dictia.ca' in body
finally:
db.session.rollback()
db.drop_all()
_clear_stripe_env()
# ----------------------------------------------------------------------
# 23-24. /checkout/success and /checkout/cancel
# ----------------------------------------------------------------------
def test_success_route_renders_template():
with app.app_context():
_disable_csrf()
db.create_all()
try:
with app.test_client() as client:
resp = client.get('/checkout/success?session_id=cs_test_abc')
assert resp.status_code == 200
body = resp.get_data(as_text=True)
# Body should mention the async-activation note (per spec)
assert 'minutes' in body.lower() or 'activé' in body.lower() \
or 'activée' in body.lower() or 'confirmé' in body.lower()
finally:
db.session.rollback()
db.drop_all()
def test_cancel_route_renders_template():
with app.app_context():
_disable_csrf()
db.create_all()
try:
with app.test_client() as client:
resp = client.get('/checkout/cancel')
assert resp.status_code == 200
body = resp.get_data(as_text=True)
# "no charge made" reassurance in French
assert 'aucun' in body.lower() or 'annulé' in body.lower()
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 25. Integration with no-crawl headers
# ----------------------------------------------------------------------
def test_success_route_in_public_indexable_endpoints():
"""Defensive: 'billing.success' was added to _PUBLIC_INDEXABLE_ENDPOINTS in B-1.3."""
from src.app import _PUBLIC_INDEXABLE_ENDPOINTS
assert 'billing.success' in _PUBLIC_INDEXABLE_ENDPOINTS