doc_repo 0.1.1 → 1.0.0.pre.beta.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,435 @@
1
+ # frozen_string_literal: true
2
+ require 'support/in_memory_cache'
3
+
4
+ RSpec.describe DocRepo::NetHttpAdapter do
5
+
6
+ subject(:an_adapter) { DocRepo::NetHttpAdapter.new("any.host") }
7
+
8
+ describe "creating an adapter" do
9
+ it "requires a host" do
10
+ expect {
11
+ DocRepo::NetHttpAdapter.new
12
+ }.to raise_error ArgumentError
13
+ end
14
+
15
+ it "creates a defensive frozen copy of the host", :aggregate_failures do
16
+ original_host = String.new("Any Host") # So it's not frozen
17
+ an_adapter = DocRepo::NetHttpAdapter.new(original_host)
18
+ expect(an_adapter.host).to eq(original_host).and be_frozen
19
+ expect(original_host).not_to be_frozen
20
+ expect {
21
+ original_host.upcase!
22
+ }.not_to change {
23
+ an_adapter.host
24
+ }.from("Any Host")
25
+ end
26
+
27
+ it "has default timeouts of 10 seconds" do
28
+ expect(DocRepo::NetHttpAdapter.new("any.host").opts).to include(
29
+ open_timeout: 10,
30
+ read_timeout: 10,
31
+ ssl_timeout: 10,
32
+ )
33
+ end
34
+
35
+ it "allows custom opts overwriting defaults" do
36
+ an_adapter = DocRepo::NetHttpAdapter.new(
37
+ "any.host",
38
+ open_timeout: 100,
39
+ verify_depth: 2,
40
+ )
41
+ expect(an_adapter.opts).to include(
42
+ open_timeout: 100,
43
+ verify_depth: 2,
44
+ )
45
+ end
46
+
47
+ it "freezes the options once set", :aggregate_failures do
48
+ custom_opts = {
49
+ open_timeout: 100,
50
+ verify_depth: 2,
51
+ }
52
+ an_adapter = DocRepo::NetHttpAdapter.new("any.host", custom_opts)
53
+ expect(an_adapter.opts).to be_frozen
54
+ expect(custom_opts).not_to be_frozen
55
+ end
56
+
57
+ it "forces use of SSL", :aggregate_failures do
58
+ default_opts = DocRepo::NetHttpAdapter.new("any.host").opts
59
+ expect(default_opts).to include(use_ssl: true)
60
+
61
+ custom_opts = DocRepo::NetHttpAdapter.new("any.host", use_ssl: false).opts
62
+ expect(custom_opts).to include(use_ssl: true)
63
+ end
64
+
65
+ it "makes a defensive frozen copy of the cache options", :aggregate_failures do
66
+ cache_opts = { any: :opts }
67
+ cache_adapter = DocRepo::NetHttpAdapter.new(
68
+ "any.host",
69
+ cache_options: cache_opts,
70
+ )
71
+ expect(cache_adapter.cache_options).to eq(cache_opts).and be_frozen
72
+ expect(cache_opts).not_to be_frozen
73
+ end
74
+ end
75
+
76
+ it "retrieving a document returns the result" do
77
+ stub_request(:get, "https://any.host/any-document.ext").to_return(
78
+ status: 200,
79
+ body: "Any Document Content",
80
+ )
81
+ expect(an_adapter.retrieve("/any-document.ext")).to be_an_instance_of(
82
+ DocRepo::Doc
83
+ ).and have_attributes(
84
+ code: 200,
85
+ content: "Any Document Content",
86
+ )
87
+ end
88
+
89
+ it "retrieving a moved document returns a redirect result" do
90
+ stub_request(:get, "https://any.host/any-document.ext").to_return(
91
+ status: 302,
92
+ headers: { "Location" => "https://new.host/any-location" },
93
+ )
94
+ expect(an_adapter.retrieve("/any-document.ext")).to be_an_instance_of(
95
+ DocRepo::Redirect
96
+ ).and have_attributes(
97
+ code: 302,
98
+ url: "https://new.host/any-location",
99
+ )
100
+ end
101
+
102
+ it "retrieving a missing document returns a not found result" do
103
+ stub_request(:get, "https://any.host/any-document.ext").to_return(
104
+ status: [404, "Not Found"],
105
+ body: "Any Error Details",
106
+ )
107
+ expect(an_adapter.retrieve("/any-document.ext")).to be_an_instance_of(
108
+ DocRepo::HttpError
109
+ ).and have_attributes(
110
+ code: 404,
111
+ details: "Any Error Details",
112
+ message: '404 "Not Found"',
113
+ )
114
+ end
115
+
116
+ it "retrieving a document causing an HTTP error returns the error" do
117
+ stub_request(:get, "https://any.host/any-document.ext").to_return(
118
+ status: [404, "Not Found"],
119
+ body: "Any Error Details",
120
+ )
121
+ expect(an_adapter.retrieve("/any-document.ext")).to be_an_instance_of(
122
+ DocRepo::HttpError
123
+ ).and have_attributes(
124
+ code: 404,
125
+ details: "Any Error Details",
126
+ message: '404 "Not Found"',
127
+ )
128
+ end
129
+
130
+ it "retrieving a document which times out returns a gateway error" do
131
+ stub_request(:get, "https://any.host/any-document.ext").to_timeout
132
+ expect(an_adapter.retrieve("/any-document.ext")).to be_an_instance_of(
133
+ DocRepo::GatewayError
134
+ ).and have_attributes(
135
+ code: 504,
136
+ message: '504 "Gateway Timeout"',
137
+ details: "execution expired",
138
+ )
139
+ end
140
+
141
+ it "retrieving a document from a problematic server returns a network error" do
142
+ stub_request(:get, "https://any.host/any-document.ext").to_raise(
143
+ Net::ProtocolError.new("Any Protocol Error")
144
+ )
145
+ expect(an_adapter.retrieve("/any-document.ext")).to be_an_instance_of(
146
+ DocRepo::GatewayError
147
+ ).and have_attributes(
148
+ code: 502,
149
+ message: '502 "Bad Gateway"',
150
+ details: "Any Protocol Error",
151
+ )
152
+ end
153
+
154
+ it "converts SSL certificate errors" do
155
+ stub_request(:get, "https://any.host/any-document.ext").to_raise(
156
+ OpenSSL::SSL::SSLError.new(
157
+ "SSL_connect returned=1 errno=0 state=error: certificate verify failed"
158
+ )
159
+ )
160
+ expect(an_adapter.retrieve("/any-document.ext")).to be_an_instance_of(
161
+ DocRepo::GatewayError
162
+ ).and have_attributes(
163
+ code: 502,
164
+ message: '502 "Bad Gateway"',
165
+ details: "SSL_connect returned=1 errno=0 state=error: certificate verify failed",
166
+ )
167
+ end
168
+
169
+ it "retrieving a document over a bad network wraps the network error" do
170
+ stub_request(:get, "https://any.host/any-document.ext").to_raise(
171
+ SocketError.new("Any Socket Error")
172
+ )
173
+ expect(an_adapter.retrieve("/any-document.ext")).to be_an_instance_of(
174
+ DocRepo::GatewayError
175
+ ).and have_attributes(
176
+ code: 520,
177
+ message: '520 "Unknown Error"',
178
+ details: "Any Socket Error",
179
+ )
180
+ end
181
+
182
+ it "with no cache requests are always made over the network", :aggregate_failures do
183
+ no_cache = DocRepo::NetHttpAdapter.new("any.host")
184
+ stub_request(:get, "https://any.host/any.doc")
185
+ .to_return(status: 200)
186
+ .to_return(status: 500)
187
+ expect(no_cache.retrieve("/any.doc")).to be_an_instance_of(DocRepo::Doc)
188
+ expect(no_cache.retrieve("/any.doc")).to be_an_instance_of(DocRepo::HttpError)
189
+ end
190
+
191
+ context "with a cache store" do
192
+ subject(:cached_adapter) {
193
+ DocRepo::NetHttpAdapter.new(
194
+ "any.host",
195
+ cache: mem_cache,
196
+ cache_options: { any: :options },
197
+ )
198
+ }
199
+
200
+ let(:mem_cache) { DocRepo::Spec::InMemoryCache.new }
201
+
202
+ it "provides the cache options on every request", :aggregate_failures do
203
+ stub_request(:get, /.*/).to_return(status: 200)
204
+ expect {
205
+ cached_adapter.retrieve "/uri/1"
206
+ }.to change {
207
+ mem_cache.options
208
+ }.to eq "any.host:/uri/1" => { any: :options }
209
+
210
+ expect {
211
+ cached_adapter.retrieve "/uri/2"
212
+ }.to change {
213
+ mem_cache.options
214
+ }.to eq(
215
+ "any.host:/uri/1" => { any: :options },
216
+ "any.host:/uri/2" => { any: :options },
217
+ )
218
+
219
+ # Clear just the options, leave the cache alone
220
+ mem_cache.options.clear
221
+
222
+ expect {
223
+ cached_adapter.retrieve "/uri/2"
224
+ }.to change {
225
+ mem_cache.options
226
+ }.to eq "any.host:/uri/2" => { any: :options }
227
+ end
228
+
229
+ it "makes an HTTP request when the URI is not cached" do
230
+ http_request = stub_request(:get, /.*/).to_return(status: 200)
231
+ cached_adapter.retrieve "/uri/1"
232
+ expect(http_request).to have_been_requested
233
+ end
234
+
235
+ it "stores the HTTP response in the cache" do
236
+ stub_request(:get, /.*/).to_return(
237
+ status: [200, "Custom Code"],
238
+ body: "Any Content Body",
239
+ )
240
+ expect {
241
+ cached_adapter.retrieve "/uri/1"
242
+ }.to change {
243
+ mem_cache.cache
244
+ }.from(
245
+ {}
246
+ ).to(
247
+ "any.host:/uri/1" => an_instance_of(Net::HTTPOK).and(have_attributes(
248
+ code: "200",
249
+ message: "Custom Code",
250
+ body: "Any Content Body",
251
+ ))
252
+ )
253
+ end
254
+
255
+ it "re-uses the cached HTTP response" do
256
+ # Populate the cache with a response so we don't have to work with sockets
257
+ stub_request(:get, /.*/)
258
+ .to_return(status: 200, body: "Original Body")
259
+ .to_raise("Cache Not Used!")
260
+ cached_adapter.retrieve "/uri/1"
261
+
262
+ # Customize cache to show changes
263
+ cached_response = mem_cache.cache["any.host:/uri/1"]
264
+ cached_response.body = "Cached Body"
265
+ cached_response.content_type = "Cached Content"
266
+
267
+ expect(cached_adapter.retrieve("/uri/1")).to be_an_instance_of(
268
+ DocRepo::Doc
269
+ ).and have_attributes(
270
+ code: 200,
271
+ content: "Cached Body",
272
+ content_type: "Cached Content",
273
+ )
274
+ end
275
+
276
+ it "performs a conditional GET when cache is expired according to the `Expires` header", :aggregate_failures do
277
+ # Populate the current cache via a response to avoid sockets
278
+ stub_request(:get, /.*/)
279
+ .to_return(
280
+ status: 200,
281
+ body: "Original Body",
282
+ headers: {
283
+ "ETag" => "Any ETag",
284
+ "Last-Modified" => "Sat, 01 Jul 2017 18:18:33 GMT",
285
+ "Expires" => (Time.now + 60).httpdate, # 1 minute from now
286
+ },
287
+ )
288
+ .to_raise("Cache Not Used!")
289
+ cached_adapter.retrieve "/uri/1"
290
+
291
+ # Cache is current so this should return it
292
+ expect(cached_adapter.retrieve("/uri/1").content).to eq "Original Body"
293
+
294
+ # Artificially expire cache
295
+ mem_cache.cache["any.host:/uri/1"]["Expires"] = (Time.now - 1).httpdate
296
+
297
+ conditional_get = stub_request(:get, %r(.*/uri/1))
298
+ .with(
299
+ headers: {
300
+ "If-None-Match" => "Any ETag",
301
+ "If-Modified-Since" => "Sat, 01 Jul 2017 18:18:33 GMT",
302
+ }
303
+ )
304
+ .to_return(status: 304)
305
+ cached_adapter.retrieve "/uri/1"
306
+ expect(conditional_get).to have_been_requested
307
+ end
308
+
309
+ it "considers an invalid `Expires` header as expired" do
310
+ # Populate the current cache via a response to avoid sockets
311
+ stub_request(:get, /.*/)
312
+ .to_return(
313
+ status: 200,
314
+ body: "Original Body",
315
+ headers: {
316
+ "ETag" => "Any ETag",
317
+ },
318
+ )
319
+ .to_raise("Cache Not Used!")
320
+ cached_adapter.retrieve "/uri/1"
321
+
322
+ # Artificially expire cache with invalid value
323
+ mem_cache.cache["any.host:/uri/1"]["Expires"] = "0"
324
+
325
+ conditional_get = stub_request(:get, %r(.*/uri/1))
326
+ .with(
327
+ headers: {
328
+ "If-None-Match" => "Any ETag",
329
+ }
330
+ )
331
+ .to_return(status: 200)
332
+ cached_adapter.retrieve "/uri/1"
333
+ expect(conditional_get).to have_been_requested
334
+ end
335
+
336
+ it "refreshes expired cache which hasn't been modified", :aggregate_failures do
337
+ # Populate the current cache via a response to avoid sockets then expire
338
+ stub_request(:get, /.*/)
339
+ .to_return(
340
+ status: 200,
341
+ body: "Original Body",
342
+ headers: {
343
+ "ETag" => "Any ETag",
344
+ "Last-Modified" => "Sat, 01 Jul 2017 18:18:33 GMT",
345
+ "Expires" => (Time.now + 60).httpdate, # 1 minute from now
346
+ "Content-Type" => "Original Content",
347
+ "Custom-A" => "Original Header",
348
+ "Custom-B" => "Original Header",
349
+ },
350
+ )
351
+ .to_raise("Cache Not Used!")
352
+ stub_request(:get, %r(.*/uri/1))
353
+ .with(
354
+ headers: {
355
+ "If-None-Match" => "Any ETag",
356
+ "If-Modified-Since" => "Sat, 01 Jul 2017 18:18:33 GMT",
357
+ }
358
+ )
359
+ .to_return(
360
+ status: 304,
361
+ body: "Updated Body",
362
+ headers: {
363
+ "ETag" => "Updated ETag",
364
+ "Content-Type" => "Updated Content",
365
+ "Custom-B" => "Updated Header",
366
+ },
367
+ )
368
+ cached_adapter.retrieve "/uri/1"
369
+ mem_cache.cache["any.host:/uri/1"]["Expires"] = (Time.now - 1).httpdate
370
+
371
+ expect(cached_adapter.retrieve("/uri/1")).to have_attributes(
372
+ code: 200,
373
+ content: "Original Body",
374
+ content_type: "Updated Content",
375
+ )
376
+ # Working with headers is painful with Net::HTTP responses
377
+ expect(mem_cache.cache["any.host:/uri/1"].to_hash).to include(
378
+ "etag" => ["Updated ETag"],
379
+ "last-modified" => ["Sat, 01 Jul 2017 18:18:33 GMT"],
380
+ "content-type" => ["Updated Content"],
381
+ "custom-a" => ["Original Header"],
382
+ "custom-b" => ["Updated Header"],
383
+ )
384
+ end
385
+
386
+ it "replaces expired cache which has been modified" do
387
+ # Populate the current cache via a response to avoid sockets then expire
388
+ stub_request(:get, /.*/)
389
+ .to_return(
390
+ status: 200,
391
+ body: "Original Body",
392
+ headers: {
393
+ "ETag" => "Any ETag",
394
+ "Last-Modified" => "Sat, 01 Jul 2017 18:18:33 GMT",
395
+ "Expires" => (Time.now + 60).httpdate, # 1 minute from now
396
+ "Content-Type" => "Original Content",
397
+ "Custom-A" => "Original Header",
398
+ "Custom-B" => "Original Header",
399
+ },
400
+ )
401
+ .to_raise("Cache Not Used!")
402
+ stub_request(:get, %r(.*/uri/1))
403
+ .with(
404
+ headers: {
405
+ "If-None-Match" => "Any ETag",
406
+ "If-Modified-Since" => "Sat, 01 Jul 2017 18:18:33 GMT",
407
+ }
408
+ )
409
+ .to_return(
410
+ status: 201,
411
+ body: "Updated Body",
412
+ headers: {
413
+ "ETag" => "Updated ETag",
414
+ "Content-Type" => "Updated Content",
415
+ "Custom-B" => "Updated Header",
416
+ },
417
+ )
418
+ cached_adapter.retrieve "/uri/1"
419
+ mem_cache.cache["any.host:/uri/1"]["Expires"] = (Time.now - 1).httpdate
420
+
421
+ expect(cached_adapter.retrieve("/uri/1")).to have_attributes(
422
+ code: 201,
423
+ content: "Updated Body",
424
+ content_type: "Updated Content",
425
+ )
426
+ # Working with headers is painful with Net::HTTP responses
427
+ expect(mem_cache.cache["any.host:/uri/1"].to_hash).to eq(
428
+ "etag" => ["Updated ETag"],
429
+ "content-type" => ["Updated Content"],
430
+ "custom-b" => ["Updated Header"],
431
+ )
432
+ end
433
+ end
434
+
435
+ end