"""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/, /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/ 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