Skip to content

Commit 192c774

Browse files
committed
Update tests after refactoring
1 parent 1d85ded commit 192c774

File tree

4 files changed

+251
-56
lines changed

4 files changed

+251
-56
lines changed

test/integration/finance/stripe_rate_limit_handling_test.rb

Lines changed: 59 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -20,49 +20,46 @@ class Finance::StripeRateLimitHandlingTest < ActionDispatch::IntegrationTest
2020

2121
# ==================== Unit Tests for Rate Limit Detection ====================
2222

23-
test 'PaymentTransaction detects rate limit from HTTP 429 status code' do
24-
transaction = PaymentTransaction.new
23+
test 'StripeChargeService#rate_limit_error? detects rate limit from Stripe error code' do
24+
service = prepare_stripe_charge_service
2525

2626
response = stub(
2727
success?: false,
28-
params: { 'error' => { 'http_code' => 429 } },
29-
message: 'Rate limit exceeded'
28+
params: { 'error' => { 'code' => 'rate_limit' } }
3029
)
3130

32-
assert transaction.send(:rate_limit_error?, response),
33-
'Should detect rate limit from HTTP 429 status code'
31+
assert service.send(:rate_limit_error?, response),
32+
'Should detect rate limit from Stripe rate_limit error code'
3433
end
3534

36-
test 'PaymentTransaction detects rate limit from error message' do
37-
transaction = PaymentTransaction.new
35+
test 'StripeChargeService#rate_limit_error? does not false-positive on other errors' do
36+
service = prepare_stripe_charge_service
3837

3938
response = stub(
4039
success?: false,
41-
params: {},
42-
message: 'Too many requests - please try again later'
40+
params: { 'error' => { 'code' => 'card_declined' } }
4341
)
4442

45-
assert transaction.send(:rate_limit_error?, response),
46-
'Should detect rate limit from error message'
43+
assert_not service.send(:rate_limit_error?, response),
44+
'Should not detect rate limit for other error codes'
4745
end
4846

49-
test 'PaymentTransaction does not false-positive on other errors' do
50-
transaction = PaymentTransaction.new
47+
test 'StripeChargeService#rate_limit_error? does not detect rate limit on successful response' do
48+
service = prepare_stripe_charge_service
5149

5250
response = stub(
53-
success?: false,
54-
params: { 'error' => { 'http_code' => 402 } },
55-
message: 'Card declined'
51+
success?: true,
52+
params: {}
5653
)
5754

58-
assert_not transaction.send(:rate_limit_error?, response),
59-
'Should not detect rate limit for other error codes'
55+
assert_not service.send(:rate_limit_error?, response),
56+
'Should not detect rate limit on successful response'
6057
end
6158

6259
# ==================== Integration Tests for Invoice Charging ====================
6360

64-
test 'Invoice charge re-raises RateLimitError when Stripe returns 429' do
65-
Account.any_instance.expects(:charge!).raises(Finance::Payment::StripeRateLimitError.new)
61+
test 'Invoice charge re-raises RateLimitError when Stripe returns rate limit error' do
62+
Account.any_instance.expects(:charge!).raises(create_rate_limit_error)
6663

6764
invoice = prepare_invoice
6865

@@ -103,23 +100,12 @@ class Finance::StripeRateLimitHandlingTest < ActionDispatch::IntegrationTest
103100

104101
# ==================== BillingWorker Retry Logic Tests ====================
105102

106-
test 'BillingWorker uses exponential backoff for rate limit errors' do
107-
rate_limit_error = Finance::Payment::StripeRateLimitError.new
108-
109-
# First retry: ~15 seconds (3^1 * 5 = 15)
110-
retry_delay_1 = BillingWorker.sidekiq_retry_in_block.call(1, rate_limit_error)
111-
assert retry_delay_1 >= 15, "First retry should be >= 15 seconds, got #{retry_delay_1}"
112-
assert retry_delay_1 <= 25, "First retry should be <= 25 seconds (with jitter), got #{retry_delay_1}"
103+
test 'BillingWorker uses Sidekiq default exponential backoff for rate limit errors' do
104+
rate_limit_error = create_rate_limit_error
113105

114-
# Second retry: ~45 seconds (3^2 * 5 = 45)
115-
retry_delay_2 = BillingWorker.sidekiq_retry_in_block.call(2, rate_limit_error)
116-
assert retry_delay_2 >= 45, "Second retry should be >= 45 seconds, got #{retry_delay_2}"
117-
assert retry_delay_2 <= 55, "Second retry should be <= 55 seconds (with jitter), got #{retry_delay_2}"
118-
119-
# Third retry: ~135 seconds (3^3 * 5 = 135)
120-
retry_delay_3 = BillingWorker.sidekiq_retry_in_block.call(3, rate_limit_error)
121-
assert retry_delay_3 >= 135, "Third retry should be >= 135 seconds, got #{retry_delay_3}"
122-
assert retry_delay_3 <= 145, "Third retry should be <= 145 seconds (with jitter), got #{retry_delay_3}"
106+
# Returns nil to use Sidekiq's default exponential backoff
107+
retry_delay = BillingWorker.sidekiq_retry_in_block.call(1, rate_limit_error)
108+
assert_nil retry_delay, "Should return nil to use Sidekiq's default exponential backoff"
123109
end
124110

125111
test 'BillingWorker uses standard retry delay for non-rate-limit errors' do
@@ -135,7 +121,7 @@ class Finance::StripeRateLimitHandlingTest < ActionDispatch::IntegrationTest
135121
# ==================== Job-Level Integration Tests ====================
136122

137123
test 'BillingService re-raises rate limit error and releases lock' do
138-
Account.any_instance.expects(:charge!).raises(Finance::Payment::StripeRateLimitError.new)
124+
Account.any_instance.expects(:charge!).raises(create_rate_limit_error)
139125

140126
invoice = prepare_invoice
141127

@@ -147,13 +133,15 @@ class Finance::StripeRateLimitHandlingTest < ActionDispatch::IntegrationTest
147133
assert_instance_of Finance::Payment::StripeRateLimitError, error
148134

149135
# Lock should be released - we can acquire it again immediately
136+
lock_service = Synchronization::BillingLockService.new(@buyer.id.to_s)
150137
assert_nothing_raised do
151-
Finance::BillingService.new(@buyer.id, provider_account_id: @provider.id, now: invoice.due_on).send(:acquire_lock)
138+
lock_service.lock
152139
end
140+
lock_service.unlock
153141
end
154142

155143
test 'rate limit error flow through BillingWorker' do
156-
Account.any_instance.expects(:charge!).raises(Finance::Payment::StripeRateLimitError.new)
144+
Account.any_instance.expects(:charge!).raises(create_rate_limit_error)
157145

158146
invoice = prepare_invoice
159147

@@ -182,7 +170,7 @@ class Finance::StripeRateLimitHandlingTest < ActionDispatch::IntegrationTest
182170
# First attempt: Invoice 1 succeeds, Invoice 2 hits rate limit
183171
call_sequence = sequence('charging')
184172
Account.any_instance.expects(:charge!).with(invoice1.cost, invoice: invoice1).returns(true).in_sequence(call_sequence)
185-
Account.any_instance.expects(:charge!).with(invoice2.cost, invoice: invoice2).raises(Finance::Payment::StripeRateLimitError.new).in_sequence(call_sequence)
173+
Account.any_instance.expects(:charge!).with(invoice2.cost, invoice: invoice2).raises(create_rate_limit_error).in_sequence(call_sequence)
186174

187175
# Job fails with rate limit
188176
assert_raises(Finance::Payment::StripeRateLimitError) do
@@ -214,12 +202,12 @@ class Finance::StripeRateLimitHandlingTest < ActionDispatch::IntegrationTest
214202
invoice = prepare_invoice
215203

216204
# First rate limit error
217-
Account.any_instance.expects(:charge!).raises(Finance::Payment::StripeRateLimitError.new)
205+
Account.any_instance.expects(:charge!).raises(create_rate_limit_error)
218206
assert_raises(Finance::Payment::StripeRateLimitError) { invoice.charge! }
219207
assert_equal 0, invoice.reload.charging_retries_count
220208

221209
# Second rate limit error
222-
Account.any_instance.expects(:charge!).raises(Finance::Payment::StripeRateLimitError.new)
210+
Account.any_instance.expects(:charge!).raises(create_rate_limit_error)
223211
assert_raises(Finance::Payment::StripeRateLimitError) { invoice.charge! }
224212
assert_equal 0, invoice.reload.charging_retries_count
225213

@@ -231,7 +219,7 @@ class Finance::StripeRateLimitHandlingTest < ActionDispatch::IntegrationTest
231219
invoice = prepare_invoice
232220

233221
# First attempt: rate limit
234-
Account.any_instance.expects(:charge!).raises(Finance::Payment::StripeRateLimitError.new)
222+
Account.any_instance.expects(:charge!).raises(create_rate_limit_error)
235223
assert_raises(Finance::Payment::StripeRateLimitError) { invoice.charge! }
236224

237225
# Second attempt: success
@@ -246,7 +234,7 @@ class Finance::StripeRateLimitHandlingTest < ActionDispatch::IntegrationTest
246234
invoice = prepare_invoice
247235

248236
# First attempt: rate limit
249-
Account.any_instance.expects(:charge!).raises(Finance::Payment::StripeRateLimitError.new)
237+
Account.any_instance.expects(:charge!).raises(create_rate_limit_error)
250238
assert_raises(Finance::Payment::StripeRateLimitError) { invoice.charge! }
251239
assert_equal 0, invoice.reload.charging_retries_count
252240

@@ -260,6 +248,32 @@ class Finance::StripeRateLimitHandlingTest < ActionDispatch::IntegrationTest
260248

261249
private
262250

251+
def prepare_stripe_charge_service(invoice: nil)
252+
invoice ||= prepare_invoice
253+
gateway = stub('gateway')
254+
Finance::StripeChargeService.new(
255+
gateway,
256+
payment_method_id: 'pm_test',
257+
invoice: invoice,
258+
gateway_options: {}
259+
)
260+
end
261+
262+
def create_rate_limit_error
263+
response = stub(
264+
success?: false,
265+
params: { 'error' => { 'code' => 'rate_limit' } },
266+
message: 'Request rate limit exceeded'
267+
)
268+
payment_metadata = {
269+
invoice_id: 123,
270+
buyer_id: 456,
271+
payment_method_id: 'pm_test',
272+
gateway_options: {}
273+
}
274+
Finance::Payment::StripeRateLimitError.new(response, payment_metadata)
275+
end
276+
263277
def prepare_invoice
264278
prepare_invoice_with_id('00000001')
265279
end
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
class Synchronization::BillingLockServiceTest < ActionDispatch::IntegrationTest
6+
include BillingResultsTestHelpers
7+
8+
setup do
9+
@account_id = SecureRandom.random_number(100000).to_s
10+
end
11+
12+
teardown do
13+
clear_billing_locks
14+
end
15+
16+
test "initializes with account_id and creates billing resource key" do
17+
service = Synchronization::BillingLockService.new(@account_id)
18+
19+
assert_equal @account_id, service.account_id
20+
assert_equal "lock:billing:#{@account_id}", service.send(:lock_key)
21+
end
22+
23+
test "uses default timeout of 1 hour" do
24+
service = Synchronization::BillingLockService.new(@account_id)
25+
26+
assert_equal 1.hour.in_milliseconds, service.send(:timeout)
27+
end
28+
29+
test "allows custom timeout" do
30+
custom_timeout = 30.minutes.in_milliseconds
31+
service = Synchronization::BillingLockService.new(@account_id, timeout: custom_timeout)
32+
33+
assert_equal custom_timeout, service.send(:timeout)
34+
end
35+
36+
test "lock acquires lock and stores lock info" do
37+
service = Synchronization::BillingLockService.new(@account_id)
38+
39+
service.lock
40+
41+
assert_not_nil service.lock_info
42+
assert_instance_of Hash, service.lock_info
43+
assert service.lock_info.key?(:validity)
44+
assert service.lock_info.key?(:resource)
45+
end
46+
47+
test "lock raises LockBillingError when lock is already held" do
48+
service1 = Synchronization::BillingLockService.new(@account_id)
49+
service2 = Synchronization::BillingLockService.new(@account_id)
50+
51+
service1.lock
52+
53+
error = assert_raises(Finance::LockBillingError) do
54+
service2.lock
55+
end
56+
57+
assert_match(/Concurrent billing job already running for account #{@account_id}/, error.message)
58+
end
59+
60+
test "unlock releases the lock" do
61+
service1 = Synchronization::BillingLockService.new(@account_id)
62+
service2 = Synchronization::BillingLockService.new(@account_id)
63+
64+
# First service acquires lock
65+
service1.lock
66+
67+
# Second service cannot acquire lock
68+
assert_raises(Finance::LockBillingError) { service2.lock }
69+
70+
# First service releases lock
71+
service1.unlock
72+
73+
# Now second service can acquire lock
74+
assert_nothing_raised { service2.lock }
75+
76+
service2.unlock
77+
end
78+
79+
test "unlock is idempotent and does not raise if lock is not held" do
80+
service = Synchronization::BillingLockService.new(@account_id)
81+
82+
# Unlock without locking should not raise
83+
assert_nothing_raised { service.unlock }
84+
85+
# Lock and unlock
86+
service.lock
87+
service.unlock
88+
89+
# Unlock again should not raise
90+
assert_nothing_raised { service.unlock }
91+
end
92+
93+
test "unlock sets lock_info to nil" do
94+
service = Synchronization::BillingLockService.new(@account_id)
95+
96+
service.lock
97+
assert_not_nil service.lock_info
98+
99+
service.unlock
100+
assert_nil service.lock_info
101+
end
102+
103+
test "unlock logs warning if unlock fails" do
104+
service = Synchronization::BillingLockService.new(@account_id)
105+
service.lock
106+
107+
# Simulate unlock failure
108+
service.send(:manager).expects(:unlock).raises(StandardError.new("Redis error"))
109+
110+
Rails.logger.expects(:warn).with(
111+
"Failed to release billing lock for account #{@account_id}: Redis error"
112+
)
113+
114+
service.unlock
115+
end
116+
117+
test "works with block when called" do
118+
executed = false
119+
120+
result = Synchronization::BillingLockService.new(@account_id, timeout: 10000) do
121+
executed = true
122+
end.call
123+
124+
assert executed, "Block should have been executed"
125+
assert result, "Should return truthy value when block executes successfully"
126+
end
127+
128+
test "different account IDs have separate locks" do
129+
account_id_1 = "123"
130+
account_id_2 = "456"
131+
132+
service1 = Synchronization::BillingLockService.new(account_id_1)
133+
service2 = Synchronization::BillingLockService.new(account_id_2)
134+
135+
# Both should be able to acquire locks simultaneously
136+
assert_nothing_raised { service1.lock }
137+
assert_nothing_raised { service2.lock }
138+
139+
service1.unlock
140+
service2.unlock
141+
end
142+
143+
test "lock expires after timeout" do
144+
service1 = Synchronization::BillingLockService.new(@account_id, timeout: 100)
145+
service2 = Synchronization::BillingLockService.new(@account_id, timeout: 100)
146+
147+
service1.lock
148+
149+
# Wait for lock to expire
150+
sleep 0.12
151+
152+
# Should be able to acquire lock again
153+
assert_nothing_raised { service2.lock }
154+
155+
service2.unlock
156+
end
157+
158+
test "inherits from NowaitLockService" do
159+
assert Synchronization::BillingLockService < Synchronization::NowaitLockService
160+
end
161+
162+
test "call method works via parent class" do
163+
# The parent NowaitLockService has a call method that should work
164+
result = Synchronization::BillingLockService.new(@account_id, timeout: 10000).call
165+
166+
assert result, "Should successfully acquire lock via call method"
167+
end
168+
end

test/unit/services/synchronization/nowait_lock_service_test.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,17 @@ class Synchronization::NowaitLockServiceTest < ActionDispatch::IntegrationTest
4646
sleep 0.12
4747
assert Synchronization::NowaitLockService.call(lock_key, timeout: 100).result
4848
end
49+
50+
test "lock_key prefixes resource with 'lock:'" do
51+
service = Synchronization::NowaitLockService.new("billing:123", timeout: 10000)
52+
53+
assert_equal "lock:billing:123", service.send(:lock_key)
54+
end
55+
56+
test "accepts timeout as keyword argument" do
57+
timeout_ms = 5000
58+
service = Synchronization::NowaitLockService.new(lock_key, timeout: timeout_ms)
59+
60+
assert_equal timeout_ms, service.send(:timeout)
61+
end
4962
end

0 commit comments

Comments
 (0)