doc_repo 0.1.1 → 1.0.0.pre.beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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