19
19
require 'optimizely/cmab/cmab_client'
20
20
21
21
describe Optimizely ::CmabClient do
22
- before ( :context ) do
22
+ let ( :mock_http_client ) { double ( 'http_client' ) }
23
+ let ( :mock_logger ) { double ( 'logger' ) }
24
+ let ( :retry_config ) { Optimizely ::CmabRetryConfig . new ( max_retries : 3 , initial_backoff : 0.01 , max_backoff : 1 , backoff_multiplier : 2 ) }
25
+ let ( :client ) { described_class . new ( http_client : mock_http_client , logger : mock_logger , retry_config : nil ) }
26
+ let ( :rule_id ) { 'test_rule' }
27
+ let ( :user_id ) { 'user123' }
28
+ let ( :attributes ) { { 'attr1' : 'value1' , 'attr2' : 'value2' } }
29
+ let ( :cmab_uuid ) { 'uuid-1234' }
30
+ let ( :expected_url ) { "https://prediction.cmab.optimizely.com/predict/#{ rule_id } " }
31
+ let ( :expected_body ) do
32
+ {
33
+ instances : [ {
34
+ visitorId : user_id ,
35
+ experimentId : rule_id ,
36
+ attributes : [
37
+ { id : 'attr1' , value : 'value1' , type : 'custom_attribute' } ,
38
+ { id : 'attr2' , value : 'value2' , type : 'custom_attribute' }
39
+ ] ,
40
+ cmabUUID : cmab_uuid
41
+ } ]
42
+ }
43
+ let ( :expected_headers ) { { 'Content-Type' => 'application/json' } }
44
+
45
+ it 'should return the variation id on success without retrying' do
46
+ mock_response = double ( 'response' , status_code : 200 , json : { 'predictions' => [ { 'variationId' : 'abc123' } ] } )
47
+ allow ( mock_http_client ) . to receive ( :post ) . and_return ( mock_response )
48
+ result = client . fetch_decision ( rule_id , user_id , attributes , cmab_uuid )
49
+ expect ( result ) . to eq ( 'abc123' )
50
+ expect ( mock_http_client ) . to have_received ( :post ) . with (
51
+ expected_url ,
52
+ hash_including (
53
+ json : expected_body ,
54
+ headers : expected_headers ,
55
+ timeout : 10
56
+ )
57
+ )
58
+ end
59
+
60
+ it 'should return HTTP exception without retrying' do
61
+ allow ( mock_http_client ) . to receive ( :post ) . and_raise ( StandardError . new ( 'Connection error' ) )
62
+ allow ( mock_logger ) . to receive ( :error )
63
+ expect {
64
+ client . fetch_decision ( rule_id , user_id , attributes , cmab_uuid )
65
+ } . to raise_error ( Optimizely ::CmabFetchError , /Connection error/ )
66
+ expect ( mock_http_client ) . to have_received ( :post ) . once
67
+ expect ( mock_logger ) . to have_received ( :error ) . with ( a_string_including ( 'Connection error' ) )
68
+ end
69
+
70
+ it 'should not return 200 status without retrying' do
71
+ mock_response = double ( 'response' , status_code : 500 , json : nil )
72
+ allow ( mock_http_client ) . to receive ( :post ) . and_return ( mock_response )
73
+
74
+ expect {
75
+ client . fetch_decision ( rule_id , user_id , attributes , cmab_uuid )
76
+ } . to raise_error ( Optimizely ::CmabFetchError , /500/ )
77
+
78
+ expect ( mock_http_client ) . to have_received ( :post ) . with (
79
+ expected_url ,
80
+ hash_including (
81
+ json : expected_body ,
82
+ headers : expected_headers ,
83
+ timeout : 10
84
+ )
85
+ )
86
+ expect ( mock_logger ) . to have_received ( :error ) . with ( a_string_including ( '500' ) )
87
+ end
88
+
89
+ it 'should return invalid json without retrying' do
90
+ mock_response = double ( 'response' , status_code : 200 )
91
+ allow ( mock_response ) . to receive ( :json ) . and_raise ( JSON ::ParserError . new ( 'Expecting value' ) )
92
+ allow ( mock_http_client ) . to receive ( :post ) . and_return ( mock_response )
93
+
94
+ expect {
95
+ client . fetch_decision ( rule_id , user_id , attributes , cmab_uuid )
96
+ } . to raise_error ( Optimizely ::CmabInvalidResponseError , /Invalid CMAB fetch response/ )
97
+
98
+ expect ( mock_http_client ) . to have_received ( :post ) . with (
99
+ expected_url ,
100
+ hash_including (
101
+ json : expected_body ,
102
+ headers : expected_headers ,
103
+ timeout : 10
104
+ )
105
+ )
106
+ expect ( mock_logger ) . to have_received ( :error ) . with ( a_string_including ( 'Invalid CMAB fetch response' ) )
107
+ end
108
+
109
+ it 'should return invalid response structure without retrying' do
110
+ mock_response = double ( 'response' , status_code : 200 , json : { 'no_predictions' => [ ] } )
111
+ allow ( mock_http_client ) . to receive ( :post ) . and_return ( mock_response )
112
+
113
+ expect {
114
+ client . fetch_decision ( rule_id , user_id , attributes , cmab_uuid )
115
+ } . to raise_error ( Optimizely ::CmabInvalidResponseError , /Invalid CMAB fetch response/ )
116
+
117
+ expect ( mock_http_client ) . to have_received ( :post ) . with (
118
+ expected_url ,
119
+ hash_including (
120
+ json : expected_body ,
121
+ headers : expected_headers ,
122
+ timeout : 10
123
+ )
124
+ )
125
+ expect ( mock_logger ) . to have_received ( :error ) . with ( a_string_including ( 'Invalid CMAB fetch response' ) )
126
+ end
127
+
128
+ it 'should return the variation id on first try with retry config but no retry needed' do
129
+ client_with_retry = described_class . new (
130
+ http_client : mock_http_client ,
131
+ logger : mock_logger ,
132
+ retry_config : retry_config
133
+ )
134
+
135
+ # Mock successful response
136
+ mock_response = double ( 'response' , status_code : 200 , json : { 'predictions' => [ { 'variationId' : 'abc123' } ] } )
137
+ allow ( mock_http_client ) . to receive ( :post ) . and_return ( mock_response )
138
+ allow_any_instance_of ( Object ) . to receive ( :sleep )
139
+
140
+ result = client . fetch_decision ( rule_id , user_id , attributes , cmab_uuid )
141
+
142
+ expect ( result ) . to eq ( 'abc123' )
143
+ expect ( mock_http_client ) . to have_received ( :post ) . with (
144
+ expected_url ,
145
+ hash_including (
146
+ json : expected_body ,
147
+ headers : expected_headers ,
148
+ timeout : 10
149
+ )
150
+ ) . once
151
+ expect_any_instance_of ( Object ) . not_to have_received ( :sleep )
152
+ end
153
+
154
+ it 'should return the variation id on third try with retry config' do
155
+ client_with_retry = described_class . new (
156
+ http_client : mock_http_client ,
157
+ logger : mock_logger ,
158
+ retry_config : retry_config
159
+ )
160
+
161
+ # Create failure and success responses
162
+ failure_response = double ( 'response' , status_code : 500 )
163
+ success_response = double ( 'response' , status_code : 200 , json : { 'predictions' => [ { 'variationId' : 'xyz456' } ] } )
164
+
165
+ # First two calls fail, third succeeds
166
+ call_sequence = [ failure_response , failure_response , success_response ]
167
+ allow ( mock_http_client ) . to receive ( :post ) { call_sequence . shift }
168
+
169
+ allow ( mock_logger ) . to receive ( :info )
170
+ allow_any_instance_of ( Object ) . to receive ( :sleep )
171
+
172
+ result = client_with_retry . fetch_decision ( rule_id , user_id , attributes , cmab_uuid )
173
+
174
+ expect ( result ) . to eq ( 'xyz456' )
175
+ expect ( mock_http_client ) . to have_received ( :post ) . exactly ( 3 ) . times
176
+
177
+ # Verify all HTTP calls used correct parameters
178
+ expect ( mock_http_client ) . to have_received ( :post ) . with (
179
+ expected_url ,
180
+ hash_including (
181
+ json : expected_body ,
182
+ headers : expected_headers ,
183
+ timeout : 10
184
+ )
185
+ )
186
+
187
+ # Verify retry logging
188
+ expect ( mock_logger ) . to have_received ( :info ) . with ( 'Retrying CMAB request (attempt 1) after 0.01 seconds...' )
189
+ expect ( mock_logger ) . to have_received ( :info ) . with ( 'Retrying CMAB request (attempt 2) after 0.02 seconds...' )
190
+
191
+ # Verify sleep was called with correct backoff times
192
+ expect_any_instance_of ( Object ) . to have_received ( :sleep ) . with ( 0.01 )
193
+ expect_any_instance_of ( Object ) . to have_received ( :sleep ) . with ( 0.02 )
194
+ end
195
+
196
+ it 'should exhausts all retry attempts' do
197
+ client_with_retry = described_class . new (
198
+ http_client : mock_http_client ,
199
+ logger : mock_logger ,
200
+ retry_config : retry_config
201
+ )
202
+
203
+ # Create failure response
204
+ failure_response = double ( 'response' , status_code : 500 )
205
+
206
+ # All attempts fail
207
+ allow ( mock_http_client ) . to receive ( :post ) . and_return ( failure_response )
208
+ allow ( mock_logger ) . to receive ( :info )
209
+ allow ( mock_logger ) . to receive ( :error )
210
+ allow_any_instance_of ( Object ) . to receive ( :sleep )
211
+
212
+ expect {
213
+ client_with_retry . fetch_decision ( rule_id , user_id , attributes , cmab_uuid )
214
+ } . to raise_error ( Optimizely ::CmabFetchError )
215
+
216
+ # Verify all attempts were made (1 initial + 3 retries)
217
+ expect ( mock_http_client ) . to have_received ( :post ) . exactly ( 4 ) . times
218
+
219
+ # Verify retry logging
220
+ expect ( mock_logger ) . to have_received ( :info ) . with ( 'Retrying CMAB request (attempt 1) after 0.01 seconds...' )
221
+ expect ( mock_logger ) . to have_received ( :info ) . with ( 'Retrying CMAB request (attempt 2) after 0.02 seconds...' )
222
+ expect ( mock_logger ) . to have_received ( :info ) . with ( 'Retrying CMAB request (attempt 3) after 0.08 seconds...' )
223
+
224
+ # Verify sleep was called for each retry
225
+ expect_any_instance_of ( Object ) . to have_received ( :sleep ) . with ( 0.01 )
226
+ expect_any_instance_of ( Object ) . to have_received ( :sleep ) . with ( 0.02 )
227
+ expect_any_instance_of ( Object ) . to have_received ( :sleep ) . with ( 0.08 )
228
+
229
+ # Verify final error logging
230
+ expect ( mock_logger ) . to have_received ( :error ) . with ( a_string_including ( 'Max retries exceeded for CMAB request' ) )
23
231
end
24
232
end
0 commit comments