Skip to content

Commit 2bf9988

Browse files
committed
test: add unit and live tests for Stripe payment handler
15 tests covering: - Configuration gating (is_configured, missing env var) - Successful PaymentIntent creation with mocked Stripe - Error handling for all Stripe exception types: CardError (402), InvalidRequestError (400), RateLimitError (429), APIConnectionError (503), generic StripeError (500) - Non-success statuses: requires_action (3DS), unexpected status - Currency normalization (USD -> usd) - Lazy import with clear error when stripe package missing - Live integration tests (skipped without STRIPE_SECRET_KEY): tok_visa succeeds, tok_chargeDeclined fails Run unit tests (no Stripe key needed): uv run pytest payment_handlers/test_stripe_handler.py -v Run all tests including live Stripe API: STRIPE_SECRET_KEY=sk_test_... uv run pytest payment_handlers/test_stripe_handler.py -v
1 parent 1102e0f commit 2bf9988

1 file changed

Lines changed: 246 additions & 0 deletions

File tree

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
# Copyright 2026 UCP Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for the Stripe payment handler.
16+
17+
These tests use mocks by default (no Stripe key needed). To run the
18+
optional live test against the Stripe test API, set STRIPE_SECRET_KEY:
19+
20+
STRIPE_SECRET_KEY=sk_test_... uv run pytest payment_handlers/test_stripe_handler.py -v
21+
22+
The live test creates a real PaymentIntent in Stripe's test environment.
23+
"""
24+
25+
import os
26+
import sys
27+
from types import SimpleNamespace
28+
from unittest.mock import MagicMock, patch
29+
30+
import pytest
31+
32+
# Ensure the server root is importable.
33+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
34+
35+
from exceptions import PaymentFailedError
36+
from payment_handlers.stripe_handler import StripePaymentHandler
37+
38+
39+
# ---------------------------------------------------------------------------
40+
# Helpers — fake Stripe error hierarchy for except clauses
41+
# ---------------------------------------------------------------------------
42+
43+
44+
class FakeStripeError(Exception):
45+
pass
46+
47+
48+
class FakeCardError(FakeStripeError):
49+
def __init__(self, message="", param=None, code=None):
50+
super().__init__(message)
51+
self.user_message = message
52+
53+
54+
class FakeInvalidRequestError(FakeStripeError):
55+
pass
56+
57+
58+
class FakeRateLimitError(FakeStripeError):
59+
pass
60+
61+
62+
class FakeAPIConnectionError(FakeStripeError):
63+
pass
64+
65+
66+
def _make_mock_stripe():
67+
"""Build a mock stripe module with real exception classes."""
68+
mock = MagicMock()
69+
mock.error.StripeError = FakeStripeError
70+
mock.error.CardError = FakeCardError
71+
mock.error.InvalidRequestError = FakeInvalidRequestError
72+
mock.error.RateLimitError = FakeRateLimitError
73+
mock.error.APIConnectionError = FakeAPIConnectionError
74+
return mock
75+
76+
77+
# ---------------------------------------------------------------------------
78+
# Unit tests (no Stripe key or package required)
79+
# ---------------------------------------------------------------------------
80+
81+
82+
class TestStripeHandlerConfiguration:
83+
"""Test handler configuration and gating logic."""
84+
85+
def test_not_configured_without_env_var(self, monkeypatch):
86+
monkeypatch.delenv("STRIPE_SECRET_KEY", raising=False)
87+
handler = StripePaymentHandler()
88+
assert handler.is_configured is False
89+
90+
def test_configured_with_env_var(self, monkeypatch):
91+
monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test_fake")
92+
handler = StripePaymentHandler()
93+
assert handler.is_configured is True
94+
95+
def test_process_token_raises_when_not_configured(self, monkeypatch):
96+
monkeypatch.delenv("STRIPE_SECRET_KEY", raising=False)
97+
handler = StripePaymentHandler()
98+
with pytest.raises(PaymentFailedError, match="not configured"):
99+
handler.process_token("tok_visa", 3500, "USD")
100+
101+
102+
class TestStripeHandlerMocked:
103+
"""Test payment processing with a mocked Stripe module."""
104+
105+
@pytest.fixture(autouse=True)
106+
def _setup(self, monkeypatch):
107+
monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test_mock")
108+
self.mock_stripe = _make_mock_stripe()
109+
self.handler = StripePaymentHandler()
110+
self.handler._stripe = self.mock_stripe
111+
112+
def test_successful_payment(self):
113+
pi = MagicMock()
114+
pi.id = "pi_test_123"
115+
pi.status = "succeeded"
116+
self.mock_stripe.PaymentIntent.create.return_value = pi
117+
118+
result = self.handler.process_token("tok_visa", 3500, "USD")
119+
120+
assert result == "pi_test_123"
121+
self.mock_stripe.PaymentIntent.create.assert_called_once_with(
122+
amount=3500,
123+
currency="usd",
124+
payment_method_data={
125+
"type": "card",
126+
"card": {"token": "tok_visa"},
127+
},
128+
confirm=True,
129+
automatic_payment_methods={
130+
"enabled": True,
131+
"allow_redirects": "never",
132+
},
133+
)
134+
135+
def test_requires_action_raises(self):
136+
pi = MagicMock()
137+
pi.status = "requires_action"
138+
self.mock_stripe.PaymentIntent.create.return_value = pi
139+
140+
with pytest.raises(PaymentFailedError, match="3DS"):
141+
self.handler.process_token("tok_visa", 1000, "USD")
142+
143+
def test_unexpected_status_raises(self):
144+
pi = MagicMock()
145+
pi.status = "requires_capture"
146+
self.mock_stripe.PaymentIntent.create.return_value = pi
147+
148+
with pytest.raises(PaymentFailedError, match="requires_capture"):
149+
self.handler.process_token("tok_visa", 1000, "USD")
150+
151+
def test_card_error_raises(self):
152+
self.mock_stripe.PaymentIntent.create.side_effect = FakeCardError(
153+
"Your card was declined."
154+
)
155+
156+
with pytest.raises(PaymentFailedError, match="declined"):
157+
self.handler.process_token("tok_declined", 1000, "USD")
158+
159+
def test_rate_limit_error_raises(self):
160+
self.mock_stripe.PaymentIntent.create.side_effect = (
161+
FakeRateLimitError("rate limit")
162+
)
163+
164+
with pytest.raises(PaymentFailedError) as exc_info:
165+
self.handler.process_token("tok_visa", 1000, "USD")
166+
assert exc_info.value.status_code == 429
167+
168+
def test_api_connection_error_raises(self):
169+
self.mock_stripe.PaymentIntent.create.side_effect = (
170+
FakeAPIConnectionError("connection failed")
171+
)
172+
173+
with pytest.raises(PaymentFailedError) as exc_info:
174+
self.handler.process_token("tok_visa", 1000, "USD")
175+
assert exc_info.value.status_code == 503
176+
177+
def test_invalid_request_error_raises(self):
178+
self.mock_stripe.PaymentIntent.create.side_effect = (
179+
FakeInvalidRequestError("bad param")
180+
)
181+
182+
with pytest.raises(PaymentFailedError) as exc_info:
183+
self.handler.process_token("tok_visa", 1000, "USD")
184+
assert exc_info.value.status_code == 400
185+
186+
def test_generic_stripe_error_raises(self):
187+
self.mock_stripe.PaymentIntent.create.side_effect = (
188+
FakeStripeError("unknown error")
189+
)
190+
191+
with pytest.raises(PaymentFailedError) as exc_info:
192+
self.handler.process_token("tok_visa", 1000, "USD")
193+
assert exc_info.value.status_code == 500
194+
195+
def test_currency_lowered(self):
196+
pi = MagicMock()
197+
pi.id = "pi_eur"
198+
pi.status = "succeeded"
199+
self.mock_stripe.PaymentIntent.create.return_value = pi
200+
201+
self.handler.process_token("tok_visa", 2000, "EUR")
202+
203+
call_kwargs = self.mock_stripe.PaymentIntent.create.call_args[1]
204+
assert call_kwargs["currency"] == "eur"
205+
206+
207+
class TestStripeHandlerLazyImport:
208+
"""Test that stripe is only imported when needed."""
209+
210+
def test_import_error_gives_clear_message(self, monkeypatch):
211+
monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test_fake")
212+
handler = StripePaymentHandler()
213+
handler._stripe = None
214+
215+
with patch.dict("sys.modules", {"stripe": None}):
216+
with pytest.raises(PaymentFailedError, match="stripe package"):
217+
_ = handler.stripe
218+
219+
220+
# ---------------------------------------------------------------------------
221+
# Live test (only runs when STRIPE_SECRET_KEY is set)
222+
# ---------------------------------------------------------------------------
223+
224+
live = pytest.mark.skipif(
225+
not os.environ.get("STRIPE_SECRET_KEY"),
226+
reason="STRIPE_SECRET_KEY not set — skipping live Stripe test",
227+
)
228+
229+
230+
@live
231+
class TestStripeHandlerLive:
232+
"""Integration tests against the real Stripe test API.
233+
234+
These create actual PaymentIntents visible in your Stripe dashboard.
235+
Only runs when STRIPE_SECRET_KEY=sk_test_... is set.
236+
"""
237+
238+
def test_live_payment_with_tok_visa(self):
239+
handler = StripePaymentHandler()
240+
result = handler.process_token("tok_visa", 100, "USD")
241+
assert result.startswith("pi_")
242+
243+
def test_live_payment_declined(self):
244+
handler = StripePaymentHandler()
245+
with pytest.raises(PaymentFailedError):
246+
handler.process_token("tok_chargeDeclined", 100, "USD")

0 commit comments

Comments
 (0)