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:
74
tests/_run_stripe_checkout_windows.py
Normal file
74
tests/_run_stripe_checkout_windows.py
Normal 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)
|
||||
593
tests/test_stripe_checkout.py
Normal file
593
tests/test_stripe_checkout.py
Normal 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
|
||||
Reference in New Issue
Block a user