|
18 | 18 | require 'spec_helper'
|
19 | 19 | require 'optimizely/logger'
|
20 | 20 | require 'optimizely/cmab/cmab_client'
|
| 21 | +require 'webmock/rspec' |
21 | 22 |
|
22 | 23 | describe Optimizely::DefaultCmabClient do
|
23 | 24 | let(:spy_logger) { spy('logger') }
|
| 25 | + let(:retry_config) { Optimizely::CmabRetryConfig.new(max_retries: 3, retry_delay: 0.01, max_backoff: 1, backoff_multiplier: 2) } |
24 | 26 | let(:rule_id) { 'test_rule' }
|
25 | 27 | let(:user_id) { 'user123' }
|
26 | 28 | let(:attributes) { {'attr1': 'value1', 'attr2': 'value2'} }
|
27 | 29 | let(:cmab_uuid) { 'uuid-1234' }
|
28 | 30 | let(:expected_url) { "https://prediction.cmab.optimizely.com/predict/#{rule_id}" }
|
29 |
| - let(:expected_body) do |
| 31 | + let(:expected_body_for_webmock) do |
30 | 32 | {
|
31 | 33 | instances: [{
|
32 | 34 | visitorId: user_id,
|
|
37 | 39 | ],
|
38 | 40 | cmabUUID: cmab_uuid
|
39 | 41 | }]
|
40 |
| - } |
| 42 | + }.to_json |
41 | 43 | end
|
42 | 44 | let(:expected_headers) { {'Content-Type' => 'application/json'} }
|
43 | 45 |
|
44 | 46 | before do
|
45 | 47 | allow(Kernel).to receive(:sleep)
|
| 48 | + WebMock.disable_net_connect! |
46 | 49 | end
|
47 | 50 |
|
48 | 51 | after do
|
49 | 52 | RSpec::Mocks.space.proxy_for(spy_logger).reset
|
| 53 | + WebMock.reset! |
| 54 | + WebMock.allow_net_connect! |
50 | 55 | end
|
51 | 56 |
|
52 | 57 | context 'when client is configured without retries' do
|
53 |
| - let(:mock_http_client) { double('http_client') } |
54 |
| - let(:client) { described_class.new(mock_http_client, nil, spy_logger) } |
| 58 | + let(:client) { described_class.new(nil, nil, spy_logger) } |
55 | 59 |
|
56 | 60 | it 'should return the variation id on success' do
|
57 |
| - mock_response = double('response', status_code: 200, json: {'predictions' => [{'variationId' => 'abc123'}]}) |
58 |
| - allow(mock_http_client).to receive(:post).and_return(mock_response) |
| 61 | + WebMock.stub_request(:post, expected_url) |
| 62 | + .with(body: expected_body_for_webmock, headers: expected_headers) |
| 63 | + .to_return(status: 200, body: {'predictions' => [{'variationId' => 'abc123'}]}.to_json, headers: {'Content-Type' => 'application/json'}) |
59 | 64 |
|
60 | 65 | result = client.fetch_decision(rule_id, user_id, attributes, cmab_uuid)
|
61 | 66 |
|
62 | 67 | expect(result).to eq('abc123')
|
63 |
| - expect(mock_http_client).to have_received(:post).with( |
64 |
| - expected_url, |
65 |
| - hash_including( |
66 |
| - json: expected_body, |
67 |
| - headers: expected_headers, |
68 |
| - timeout: 10 |
69 |
| - ) |
70 |
| - ).once |
| 68 | + expect(WebMock).to have_requested(:post, expected_url) |
| 69 | + .with(body: expected_body_for_webmock, headers: expected_headers).once |
71 | 70 | expect(Kernel).not_to have_received(:sleep)
|
72 | 71 | end
|
73 | 72 |
|
74 | 73 | it 'should return HTTP exception' do
|
75 |
| - allow(mock_http_client).to receive(:post).and_raise(StandardError.new('Connection error')) |
| 74 | + WebMock.stub_request(:post, expected_url) |
| 75 | + .with(body: expected_body_for_webmock, headers: expected_headers) |
| 76 | + .to_raise(StandardError.new('Connection error')) |
76 | 77 |
|
77 | 78 | expect do
|
78 | 79 | client.fetch_decision(rule_id, user_id, attributes, cmab_uuid)
|
79 | 80 | end.to raise_error(Optimizely::CmabFetchError, /Connection error/)
|
80 | 81 |
|
81 |
| - expect(mock_http_client).to have_received(:post).once |
| 82 | + expect(WebMock).to have_requested(:post, expected_url) |
| 83 | + .with(body: expected_body_for_webmock, headers: expected_headers).once |
82 | 84 | expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('Connection error'))
|
83 | 85 | expect(Kernel).not_to have_received(:sleep)
|
84 | 86 | end
|
85 | 87 |
|
86 | 88 | it 'should not return 200 status' do
|
87 |
| - mock_response = double('response', status_code: 500, json: nil) |
88 |
| - allow(mock_http_client).to receive(:post).and_return(mock_response) |
| 89 | + WebMock.stub_request(:post, expected_url) |
| 90 | + .with(body: expected_body_for_webmock, headers: expected_headers) |
| 91 | + .to_return(status: 500) |
89 | 92 |
|
90 | 93 | expect do
|
91 | 94 | client.fetch_decision(rule_id, user_id, attributes, cmab_uuid)
|
92 | 95 | end.to raise_error(Optimizely::CmabFetchError, /500/)
|
93 | 96 |
|
94 |
| - expect(mock_http_client).to have_received(:post).with( |
95 |
| - expected_url, |
96 |
| - hash_including( |
97 |
| - json: expected_body, |
98 |
| - headers: expected_headers, |
99 |
| - timeout: 10 |
100 |
| - ) |
101 |
| - ).once |
| 97 | + expect(WebMock).to have_requested(:post, expected_url) |
| 98 | + .with(body: expected_body_for_webmock, headers: expected_headers).once |
102 | 99 | expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('500'))
|
103 | 100 | expect(Kernel).not_to have_received(:sleep)
|
104 | 101 | end
|
105 | 102 |
|
106 | 103 | it 'should return invalid json' do
|
107 |
| - mock_response = double('response', status_code: 200) |
108 |
| - allow(mock_response).to receive(:json).and_raise(JSON::ParserError.new('Expecting value')) |
109 |
| - allow(mock_http_client).to receive(:post).and_return(mock_response) |
| 104 | + WebMock.stub_request(:post, expected_url) |
| 105 | + .with(body: expected_body_for_webmock, headers: expected_headers) |
| 106 | + .to_return(status: 200, body: 'this is not json', headers: {'Content-Type' => 'text/plain'}) |
110 | 107 |
|
111 | 108 | expect do
|
112 | 109 | client.fetch_decision(rule_id, user_id, attributes, cmab_uuid)
|
113 | 110 | end.to raise_error(Optimizely::CmabInvalidResponseError, /Invalid CMAB fetch response/)
|
114 | 111 |
|
115 |
| - expect(mock_http_client).to have_received(:post).with( |
116 |
| - expected_url, |
117 |
| - hash_including( |
118 |
| - json: expected_body, |
119 |
| - headers: expected_headers, |
120 |
| - timeout: 10 |
121 |
| - ) |
122 |
| - ).once |
| 112 | + expect(WebMock).to have_requested(:post, expected_url) |
| 113 | + .with(body: expected_body_for_webmock, headers: expected_headers).once |
123 | 114 | expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('Invalid CMAB fetch response'))
|
124 | 115 | expect(Kernel).not_to have_received(:sleep)
|
125 | 116 | end
|
126 | 117 |
|
127 | 118 | it 'should return invalid response structure' do
|
128 |
| - mock_response = double('response', status_code: 200, json: {'no_predictions' => []}) |
129 |
| - allow(mock_http_client).to receive(:post).and_return(mock_response) |
| 119 | + WebMock.stub_request(:post, expected_url) |
| 120 | + .with(body: expected_body_for_webmock, headers: expected_headers) |
| 121 | + .to_return(status: 200, body: {'no_predictions' => []}.to_json, headers: {'Content-Type' => 'application/json'}) |
130 | 122 |
|
131 | 123 | expect do
|
132 | 124 | client.fetch_decision(rule_id, user_id, attributes, cmab_uuid)
|
133 | 125 | end.to raise_error(Optimizely::CmabInvalidResponseError, /Invalid CMAB fetch response/)
|
134 | 126 |
|
135 |
| - expect(mock_http_client).to have_received(:post).with( |
136 |
| - expected_url, |
137 |
| - hash_including( |
138 |
| - json: expected_body, |
139 |
| - headers: expected_headers, |
140 |
| - timeout: 10 |
141 |
| - ) |
142 |
| - ).once |
| 127 | + expect(WebMock).to have_requested(:post, expected_url) |
| 128 | + .with(body: expected_body_for_webmock, headers: expected_headers).once |
143 | 129 | expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('Invalid CMAB fetch response'))
|
144 | 130 | expect(Kernel).not_to have_received(:sleep)
|
145 | 131 | end
|
146 | 132 | end
|
147 | 133 |
|
148 | 134 | context 'when client is configured with retries' do
|
149 |
| - let(:mock_http_client) { double('http_client') } # Fresh double for this context |
150 |
| - let(:retry_config) { Optimizely::CmabRetryConfig.new(max_retries: 3, retry_delay: 0.01, max_backoff: 1, backoff_multiplier: 2) } |
151 |
| - let(:client_with_retry) { described_class.new(mock_http_client, retry_config, spy_logger) } |
| 135 | + let(:client_with_retry) { described_class.new(nil, retry_config, spy_logger) } |
152 | 136 |
|
153 |
| - it 'should return the variation id on first try with retry config but no retry needed' do |
154 |
| - mock_response = double('response', status_code: 200, json: {'predictions' => [{'variationId' => 'abc123'}]}) |
155 |
| - allow(mock_http_client).to receive(:post).and_return(mock_response) |
| 137 | + it 'should return the variation id on first try' do |
| 138 | + WebMock.stub_request(:post, expected_url) |
| 139 | + .with(body: expected_body_for_webmock, headers: expected_headers) |
| 140 | + .to_return(status: 200, body: {'predictions' => [{'variationId' => 'abc123'}]}.to_json, headers: {'Content-Type' => 'application/json'}) |
156 | 141 |
|
157 | 142 | result = client_with_retry.fetch_decision(rule_id, user_id, attributes, cmab_uuid)
|
158 | 143 |
|
159 | 144 | expect(result).to eq('abc123')
|
160 |
| - expect(mock_http_client).to have_received(:post).with( |
161 |
| - expected_url, |
162 |
| - hash_including( |
163 |
| - json: expected_body, |
164 |
| - headers: expected_headers, |
165 |
| - timeout: 10 |
166 |
| - ) |
167 |
| - ).once |
| 145 | + expect(WebMock).to have_requested(:post, expected_url) |
| 146 | + .with(body: expected_body_for_webmock, headers: expected_headers).once |
168 | 147 | expect(Kernel).not_to have_received(:sleep)
|
169 | 148 | end
|
170 | 149 |
|
171 | 150 | it 'should return the variation id on third try' do
|
172 |
| - failure_response = double('response', status_code: 500) |
173 |
| - success_response = double('response', status_code: 200, json: {'predictions' => [{'variationId' => 'xyz456'}]}) |
174 |
| - |
175 |
| - # Use a sequence to control responses |
176 |
| - allow(mock_http_client).to receive(:post).and_return(failure_response, failure_response, success_response) |
| 151 | + WebMock.stub_request(:post, expected_url) |
| 152 | + .with(body: expected_body_for_webmock, headers: expected_headers) |
| 153 | + .to_return({status: 500}, |
| 154 | + {status: 500}, |
| 155 | + {status: 200, body: {'predictions' => [{'variationId' => 'xyz456'}]}.to_json, headers: {'Content-Type' => 'application/json'}}) |
177 | 156 |
|
178 | 157 | result = client_with_retry.fetch_decision(rule_id, user_id, attributes, cmab_uuid)
|
179 | 158 |
|
180 | 159 | expect(result).to eq('xyz456')
|
181 |
| - expect(mock_http_client).to have_received(:post).exactly(3).times |
| 160 | + expect(WebMock).to have_requested(:post, expected_url) |
| 161 | + .with(body: expected_body_for_webmock, headers: expected_headers).exactly(3).times |
182 | 162 |
|
183 | 163 | expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 1) after 0.01 seconds...').once
|
184 | 164 | expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 2) after 0.02 seconds...').once
|
185 | 165 | expect(spy_logger).not_to have_received(:log).with(Logger::INFO, a_string_including('Retrying CMAB request (attempt 3)'))
|
186 | 166 |
|
187 | 167 | expect(Kernel).to have_received(:sleep).with(0.01).once
|
188 | 168 | expect(Kernel).to have_received(:sleep).with(0.02).once
|
189 |
| - expect(Kernel).not_to have_received(:sleep).with(0.04) |
190 | 169 | expect(Kernel).not_to have_received(:sleep).with(0.08)
|
191 | 170 | end
|
192 | 171 |
|
193 | 172 | it 'should exhaust all retry attempts' do
|
194 |
| - failure_response = double('response', status_code: 500) |
195 |
| - |
196 |
| - # All attempts fail |
197 |
| - allow(mock_http_client).to receive(:post).and_return(failure_response, failure_response, failure_response, failure_response) |
| 173 | + WebMock.stub_request(:post, expected_url) |
| 174 | + .with(body: expected_body_for_webmock, headers: expected_headers) |
| 175 | + .to_return({status: 500}, |
| 176 | + {status: 500}, |
| 177 | + {status: 500}, |
| 178 | + {status: 500}) |
198 | 179 |
|
199 | 180 | expect do
|
200 | 181 | client_with_retry.fetch_decision(rule_id, user_id, attributes, cmab_uuid)
|
201 | 182 | end.to raise_error(Optimizely::CmabFetchError)
|
202 | 183 |
|
203 |
| - expect(mock_http_client).to have_received(:post).exactly(4).times |
| 184 | + expect(WebMock).to have_requested(:post, expected_url) |
| 185 | + .with(body: expected_body_for_webmock, headers: expected_headers).exactly(4).times |
204 | 186 |
|
205 | 187 | expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 1) after 0.01 seconds...').once
|
206 | 188 | expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 2) after 0.02 seconds...').once
|
|
0 commit comments