Remplace l'ancien pricing (DictIA 8 / 16 / Cloud) par la nouvelle structure canonique v7.0 : 4 forfaits + 1 sentinel quote-only. Changements clés : - pricing_card.html : signature étendue (badge, recommended, capacity_audio, capacity_storage, gpu, yearly_renewal, cta_label) + format prix server-side avec NBSP OQLF (5998 -> 5 998 $) - _pricing_tiers.html : 4 cards (Cloud Basic 189$, Cloud Essentiel 349$, Cloud Pro 549$+485$ RECOMMANDÉ, DictIA Local 5998$ An1) + chip Pro+ soumission -> /contact?pro-plus=1 - plans.py : refonte complète avec yearly_renewal_env (DictIA Local An 2+ = 500$/an) + is_quote_only sentinel (Pro+ -> redirect /contact, jamais Stripe) - routes.py : Pro+ intercepté avant le flow Stripe Checkout - env.stripe.example : nouveau naming STRIPE_CLOUD_BASIC|ESSENTIEL|PRO_* + STRIPE_DICTIA_LOCAL_SETUP/RENEWAL_YEARLY - tarifs.html : header "Quatre forfaits", matrice comparative 4 colonnes, FAQ enrichie (7 questions incluant DictIA Local + onboarding Pro + Pro+) - fonctionnalites.html : section Architecture refondue (4 cards v7.0) - landing.html : ROI footnote + cycle "189$" + wave "189$/mois" actualisés - roi_calculator.js : recalibrage sur Cloud ESSENTIEL 349$ × 12 = 4188$/an - routes.py marketing : FAQ "DictIA 8 et 16" -> "DictIA LOCAL" - contact.html : "déploiements DictIA 16" -> "Cloud PRO" + "DictIA LOCAL" Tests : - test_marketing_landing_template.py : assertions prix v7.0 (189/349/549/5998), 4 slugs (cloud-basic, cloud-essentiel, cloud-pro, dictia-local), Pro+ chip, capacity chips, RECOMMANDÉ sur Cloud PRO - test_marketing_secondary_pages.py : 4 cards + Pro+ chip + matrice 4 col + FAQ 7 questions - test_stripe_checkout.py : env vars v7.0, slugs cloud-basic/cloud-pro/ dictia-local + nouveau test pro-plus -> /contact + tests setup pour Cloud PRO et DictIA Local - test_stripe_webhook.py : plan_slug metadata cloud-basic Status : 28/28 Stripe checkout + 17/17 webhook + 93/98 marketing pass (les 5 marketing failures sont pré-existantes, non liées au pricing : test_landing_has_main_nav et test_footer_links_complete = /blog manquant ; test_trust_bar_has_eyebrow_factual_phrasing + 2 tests conformite = casing eyebrow + entité é — vérifié par git stash baseline). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
648 lines
26 KiB
Python
648 lines
26 KiB
Python
"""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',
|
|
# v7.0 — Cloud BASIC / ESSENTIEL (no setup) + Cloud PRO (setup) + DictIA LOCAL (setup + yearly renewal)
|
|
'STRIPE_CLOUD_BASIC_MONTHLY', 'STRIPE_CLOUD_BASIC_YEARLY',
|
|
'STRIPE_CLOUD_ESSENTIEL_MONTHLY', 'STRIPE_CLOUD_ESSENTIEL_YEARLY',
|
|
'STRIPE_CLOUD_PRO_SETUP', 'STRIPE_CLOUD_PRO_MONTHLY', 'STRIPE_CLOUD_PRO_YEARLY',
|
|
'STRIPE_DICTIA_LOCAL_SETUP', 'STRIPE_DICTIA_LOCAL_RENEWAL_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('cloud-basic')
|
|
assert plan is not None
|
|
assert isinstance(plan, Plan)
|
|
assert plan.slug == 'cloud-basic'
|
|
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_CLOUD_BASIC_MONTHLY'] = 'price_basic_m'
|
|
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_basic_y'
|
|
from src.billing.plans import get_plan
|
|
assert get_plan('cloud-basic').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('cloud-basic').is_configured() is False
|
|
finally:
|
|
_clear_stripe_env()
|
|
|
|
|
|
def test_cloud_pro_requires_setup_env():
|
|
"""Cloud PRO needs monthly + yearly + setup (485 $ onboarding) Price IDs."""
|
|
_clear_stripe_env()
|
|
try:
|
|
os.environ['STRIPE_CLOUD_PRO_MONTHLY'] = 'price_pro_m'
|
|
os.environ['STRIPE_CLOUD_PRO_YEARLY'] = 'price_pro_y'
|
|
# NO STRIPE_CLOUD_PRO_SETUP
|
|
from src.billing.plans import get_plan
|
|
assert get_plan('cloud-pro').is_configured() is False
|
|
os.environ['STRIPE_CLOUD_PRO_SETUP'] = 'price_pro_setup'
|
|
assert get_plan('cloud-pro').is_configured() is True
|
|
finally:
|
|
_clear_stripe_env()
|
|
|
|
|
|
def test_dictia_local_requires_setup_and_renewal():
|
|
"""DictIA LOCAL needs setup (An 1) + yearly_renewal (An 2+) Price IDs."""
|
|
_clear_stripe_env()
|
|
try:
|
|
os.environ['STRIPE_DICTIA_LOCAL_SETUP'] = 'price_local_setup'
|
|
from src.billing.plans import get_plan
|
|
assert get_plan('dictia-local').is_configured() is False
|
|
os.environ['STRIPE_DICTIA_LOCAL_RENEWAL_YEARLY'] = 'price_local_renewal'
|
|
assert get_plan('dictia-local').is_configured() is True
|
|
finally:
|
|
_clear_stripe_env()
|
|
|
|
|
|
def test_pro_plus_is_quote_only_never_configured():
|
|
"""Pro+ is a sentinel — no Stripe Price IDs, route redirects to /contact."""
|
|
_clear_stripe_env()
|
|
try:
|
|
from src.billing.plans import get_plan
|
|
plan = get_plan('pro-plus')
|
|
assert plan is not None
|
|
assert plan.is_quote_only is True
|
|
# Even with fake env vars, Pro+ never has Stripe Price IDs to set
|
|
assert plan.is_configured() is False
|
|
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_cloud_pro():
|
|
"""Cloud PRO has 2 line items: 485 $ onboarding setup + recurring monthly."""
|
|
with app.app_context():
|
|
_clear_stripe_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_CLOUD_PRO_SETUP'] = 'price_setup'
|
|
os.environ['STRIPE_CLOUD_PRO_MONTHLY'] = 'price_pro_m'
|
|
os.environ['STRIPE_CLOUD_PRO_YEARLY'] = 'price_pro_y'
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='prosetup@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='cloud-pro', 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_pro_m'
|
|
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_basic():
|
|
"""Cloud BASIC has no setup fee — single recurring line item."""
|
|
with app.app_context():
|
|
_clear_stripe_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_CLOUD_BASIC_MONTHLY'] = 'price_bm'
|
|
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_by'
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='basicplan@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='cloud-basic', 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_bm'
|
|
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_CLOUD_BASIC_MONTHLY'] = 'price_bm'
|
|
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_by'
|
|
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='cloud-basic', 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_by'
|
|
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_CLOUD_BASIC_MONTHLY'] = 'price_bm'
|
|
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_by'
|
|
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='cloud-basic', 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'] == 'cloud-basic'
|
|
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'] == 'cloud-basic'
|
|
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_CLOUD_BASIC_MONTHLY'] = 'price_cm'
|
|
os.environ['STRIPE_CLOUD_BASIC_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='cloud-basic', 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='cloud-basic', 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 cloud-basic
|
|
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='cloud-basic', 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_CLOUD_BASIC_MONTHLY'] = 'price_cm'
|
|
os.environ['STRIPE_CLOUD_BASIC_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/cloud-basic?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'] == 'cloud-basic'
|
|
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_CLOUD_BASIC_MONTHLY'] = 'price_cm'
|
|
os.environ['STRIPE_CLOUD_BASIC_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/cloud-basic?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/cloud-basic',
|
|
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_pro_plus_redirects_to_contact():
|
|
"""Pro+ is quote-only — /checkout/pro-plus must redirect to /contact?pro-plus=1, NOT to Stripe."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_stripe_env()
|
|
# Even with full Stripe config, Pro+ never reaches Stripe
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='rt-proplus@example.qc.ca')
|
|
with app.test_client() as client:
|
|
_login_session(client, user)
|
|
resp = client.get('/checkout/pro-plus', follow_redirects=False)
|
|
assert resp.status_code == 302
|
|
assert '/contact' in resp.headers['Location']
|
|
assert 'pro-plus=1' 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/cloud-basic',
|
|
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
|