dpla-analysand 3.0.2
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.
- checksums.yaml +7 -0
- data/.gitignore +19 -0
- data/.rspec +2 -0
- data/.travis.yml +8 -0
- data/CHANGELOG +67 -0
- data/Gemfile +8 -0
- data/LICENSE +22 -0
- data/README +48 -0
- data/Rakefile +22 -0
- data/analysand.gemspec +33 -0
- data/bin/analysand +27 -0
- data/lib/analysand.rb +3 -0
- data/lib/analysand/bulk_response.rb +14 -0
- data/lib/analysand/change_watcher.rb +280 -0
- data/lib/analysand/config_response.rb +25 -0
- data/lib/analysand/connection_testing.rb +52 -0
- data/lib/analysand/database.rb +322 -0
- data/lib/analysand/errors.rb +60 -0
- data/lib/analysand/http.rb +90 -0
- data/lib/analysand/instance.rb +255 -0
- data/lib/analysand/reading.rb +26 -0
- data/lib/analysand/response.rb +35 -0
- data/lib/analysand/response_headers.rb +18 -0
- data/lib/analysand/session_response.rb +16 -0
- data/lib/analysand/status_code_predicates.rb +25 -0
- data/lib/analysand/streaming_view_response.rb +90 -0
- data/lib/analysand/version.rb +3 -0
- data/lib/analysand/view_response.rb +24 -0
- data/lib/analysand/view_streaming/builder.rb +142 -0
- data/lib/analysand/viewing.rb +95 -0
- data/lib/analysand/writing.rb +71 -0
- data/script/setup_database.rb +45 -0
- data/spec/analysand/a_response.rb +70 -0
- data/spec/analysand/change_watcher_spec.rb +102 -0
- data/spec/analysand/database_spec.rb +243 -0
- data/spec/analysand/database_writing_spec.rb +488 -0
- data/spec/analysand/instance_spec.rb +205 -0
- data/spec/analysand/response_spec.rb +26 -0
- data/spec/analysand/view_response_spec.rb +44 -0
- data/spec/analysand/view_streaming/builder_spec.rb +73 -0
- data/spec/analysand/view_streaming_spec.rb +122 -0
- data/spec/fixtures/vcr_cassettes/get_config.yml +40 -0
- data/spec/fixtures/vcr_cassettes/get_many_config.yml +40 -0
- data/spec/fixtures/vcr_cassettes/head_request_with_etag.yml +40 -0
- data/spec/fixtures/vcr_cassettes/reload_config.yml +114 -0
- data/spec/fixtures/vcr_cassettes/unauthorized_put_config.yml +43 -0
- data/spec/fixtures/vcr_cassettes/view.yml +40 -0
- data/spec/smoke/database_thread_spec.rb +59 -0
- data/spec/spec_helper.rb +30 -0
- data/spec/support/database_access.rb +40 -0
- data/spec/support/example_isolation.rb +86 -0
- data/spec/support/test_parameters.rb +39 -0
- metadata +283 -0
@@ -0,0 +1,205 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
require 'analysand/errors'
|
4
|
+
require 'analysand/instance'
|
5
|
+
require 'json'
|
6
|
+
require 'uri'
|
7
|
+
require 'vcr'
|
8
|
+
|
9
|
+
module Analysand
|
10
|
+
describe Instance do
|
11
|
+
describe '#initialize' do
|
12
|
+
it 'requires an absolute URI' do
|
13
|
+
lambda { Instance.new(URI("/abc")) }.should raise_error(InvalidURIError)
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'accepts URIs as strings' do
|
17
|
+
uri = 'http://localhost:5984/'
|
18
|
+
|
19
|
+
db = Instance.new(uri)
|
20
|
+
|
21
|
+
db.uri.should == URI(uri)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
let(:instance) { Instance.new(instance_uri) }
|
26
|
+
|
27
|
+
describe '#post_session' do
|
28
|
+
describe 'with valid credentials' do
|
29
|
+
let(:resp) { instance.post_session(member1_username, member1_password) }
|
30
|
+
|
31
|
+
it 'returns success' do
|
32
|
+
resp.should be_success
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'returns a session cookie in the response' do
|
36
|
+
resp.session_cookie.should_not be_empty
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
it 'supports hash credentials' do
|
41
|
+
resp = instance.post_session(member1_credentials)
|
42
|
+
|
43
|
+
resp.should be_success
|
44
|
+
end
|
45
|
+
|
46
|
+
describe 'with invalid credentials' do
|
47
|
+
let(:resp) { instance.post_session(member1_username, 'wrong') }
|
48
|
+
|
49
|
+
it 'does not return success' do
|
50
|
+
resp.should_not be_success
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
describe '#test_session' do
|
56
|
+
describe 'with a valid cookie' do
|
57
|
+
let(:resp) { instance.post_session(member1_username, member1_password) }
|
58
|
+
let(:cookie) { resp.session_cookie }
|
59
|
+
|
60
|
+
it 'returns success' do
|
61
|
+
resp = instance.get_session(cookie)
|
62
|
+
|
63
|
+
resp.should be_success
|
64
|
+
end
|
65
|
+
|
66
|
+
it 'returns valid' do
|
67
|
+
resp = instance.get_session(cookie)
|
68
|
+
|
69
|
+
resp.should be_valid
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
describe 'with an invalid cookie' do
|
74
|
+
let(:cookie) { 'AuthSession=YWRtaW46NTBBNDkwRUE6npTfHKz68y5q1FX4pWiB-Lzk5mQ' }
|
75
|
+
|
76
|
+
it 'returns success' do
|
77
|
+
resp = instance.get_session(cookie)
|
78
|
+
|
79
|
+
resp.should be_success
|
80
|
+
end
|
81
|
+
|
82
|
+
it 'does not return valid' do
|
83
|
+
resp = instance.get_session(cookie)
|
84
|
+
|
85
|
+
resp.should_not be_valid
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
describe 'with a malformed cookie' do
|
90
|
+
let(:cookie) { 'AuthSession=wrong' }
|
91
|
+
|
92
|
+
it 'does not return success' do
|
93
|
+
resp = instance.get_session(cookie)
|
94
|
+
|
95
|
+
resp.should_not be_success
|
96
|
+
end
|
97
|
+
|
98
|
+
it 'does not return valid' do
|
99
|
+
resp = instance.get_session(cookie)
|
100
|
+
|
101
|
+
resp.should_not be_valid
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
describe '#get_config' do
|
107
|
+
let(:credentials) { admin_credentials }
|
108
|
+
|
109
|
+
it 'retrieves a configuration option' do
|
110
|
+
VCR.use_cassette('get_config') do
|
111
|
+
instance.get_config('stats/rate', admin_credentials).value.should == '"1000"'
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
it 'retrieves many configuration options' do
|
116
|
+
VCR.use_cassette('get_many_config') do
|
117
|
+
json = instance.get_config('stats', admin_credentials).value
|
118
|
+
|
119
|
+
JSON.parse(json).should == {
|
120
|
+
'rate' => '1000',
|
121
|
+
'samples' => '[0, 60, 300, 900]'
|
122
|
+
}
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
describe '#put_config!' do
|
128
|
+
it 'raises ConfigurationNotSaved on non-success' do
|
129
|
+
VCR.use_cassette('unauthorized_put_config') do
|
130
|
+
lambda { instance.put_config!('stats/rate', '"1000"') }.should raise_error(ConfigurationNotSaved)
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
describe '#put_config' do
|
136
|
+
let(:credentials) { admin_credentials }
|
137
|
+
|
138
|
+
after do
|
139
|
+
instance.delete_config!('foo/bar', admin_credentials)
|
140
|
+
end
|
141
|
+
|
142
|
+
it 'sets a configuration option' do
|
143
|
+
instance.put_config('foo/bar', '"1200"', admin_credentials)
|
144
|
+
instance.get_config('foo/bar', admin_credentials).value.should == '"1200"'
|
145
|
+
end
|
146
|
+
|
147
|
+
it 'roundtrips values from get_config' do
|
148
|
+
instance.put_config!('foo/bar', '"[0, 60, 300, 900]"', admin_credentials)
|
149
|
+
|
150
|
+
from_db = instance.get_config('foo/bar', admin_credentials).value
|
151
|
+
instance.put_config('foo/bar', from_db, admin_credentials)
|
152
|
+
instance.get_config('foo/bar', admin_credentials).value.should == from_db
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
describe '#delete_config' do
|
157
|
+
let(:credentials) { admin_credentials }
|
158
|
+
|
159
|
+
before do
|
160
|
+
instance.put_config!('foo/bar', '"1000"', admin_credentials)
|
161
|
+
end
|
162
|
+
|
163
|
+
it 'deletes configuration options' do
|
164
|
+
instance.delete_config('foo/bar', admin_credentials)
|
165
|
+
|
166
|
+
resp = instance.get_config('foo/bar', admin_credentials)
|
167
|
+
expect(resp.not_found?).to eq(true)
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
describe '#put_admin' do
|
172
|
+
let(:credentials) { admin_credentials }
|
173
|
+
let(:password) { 'password' }
|
174
|
+
let(:username) { 'new_admin' }
|
175
|
+
|
176
|
+
after do
|
177
|
+
instance.delete_admin!(username, credentials)
|
178
|
+
end
|
179
|
+
|
180
|
+
it 'adds an admin to the CouchDB admins list' do
|
181
|
+
instance.put_admin(username, password, admin_credentials)
|
182
|
+
|
183
|
+
resp = instance.post_session(username, password)
|
184
|
+
resp.body['roles'].should include('_admin')
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
describe '#delete_admin' do
|
189
|
+
let(:credentials) { admin_credentials }
|
190
|
+
let(:password) { 'password' }
|
191
|
+
let(:username) { 'new_admin' }
|
192
|
+
|
193
|
+
before do
|
194
|
+
instance.put_admin!(username, password, admin_credentials)
|
195
|
+
end
|
196
|
+
|
197
|
+
it 'deletes an admin from the CouchDB admins list' do
|
198
|
+
instance.delete_admin(username, admin_credentials)
|
199
|
+
|
200
|
+
resp = instance.post_session(username, password)
|
201
|
+
resp.should be_unauthorized
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
require 'analysand/database'
|
4
|
+
require 'analysand/response'
|
5
|
+
|
6
|
+
require File.expand_path('../a_response', __FILE__)
|
7
|
+
|
8
|
+
module Analysand
|
9
|
+
describe Response do
|
10
|
+
let(:db) { Database.new(database_uri) }
|
11
|
+
|
12
|
+
let(:response) do
|
13
|
+
VCR.use_cassette('head_request_with_etag') do
|
14
|
+
db.head('abc123', admin_credentials)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
it_should_behave_like 'a response'
|
19
|
+
|
20
|
+
describe '#etag' do
|
21
|
+
it 'removes quotes from ETags' do
|
22
|
+
response.etag.should == '1-967a00dff5e02add41819138abb3284d'
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
require 'analysand/database'
|
4
|
+
require 'analysand/view_response'
|
5
|
+
|
6
|
+
require File.expand_path('../a_response', __FILE__)
|
7
|
+
|
8
|
+
module Analysand
|
9
|
+
describe ViewResponse do
|
10
|
+
let(:resp_with_docs) do
|
11
|
+
'{"rows": [{"id":"foo","key":"foo","value":{},"doc":{"foo":"bar"}}]}'
|
12
|
+
end
|
13
|
+
|
14
|
+
let(:resp_without_docs) do
|
15
|
+
'{"rows": [{"id":"foo","key":"foo","value":{}}]}'
|
16
|
+
end
|
17
|
+
|
18
|
+
let(:db) { Database.new(database_uri) }
|
19
|
+
|
20
|
+
it_should_behave_like 'a response' do
|
21
|
+
let(:response) do
|
22
|
+
VCR.use_cassette('view') { db.view('doc/a_view') }
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
describe '#docs' do
|
27
|
+
describe 'if the view includes docs' do
|
28
|
+
subject { ViewResponse.new(double(:body => resp_with_docs)) }
|
29
|
+
|
30
|
+
it 'returns the value of the "doc" key in each row' do
|
31
|
+
subject.docs.should == [{'foo' => 'bar'}]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
describe 'if the view does not include docs' do
|
36
|
+
subject { ViewResponse.new(double(:body => resp_without_docs)) }
|
37
|
+
|
38
|
+
it 'returns []' do
|
39
|
+
subject.docs.should == []
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
require 'analysand/view_streaming/builder'
|
4
|
+
|
5
|
+
module Analysand
|
6
|
+
module ViewStreaming
|
7
|
+
describe Builder do
|
8
|
+
let(:builder) { Builder.new }
|
9
|
+
|
10
|
+
it 'recognizes the "total_rows" key' do
|
11
|
+
builder << '{"total_rows":10000}'
|
12
|
+
|
13
|
+
builder.total_rows.should == 10000
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'recognizes the "offset" key' do
|
17
|
+
builder << '{"offset":20}'
|
18
|
+
|
19
|
+
builder.offset.should == 20
|
20
|
+
end
|
21
|
+
|
22
|
+
describe 'for rows' do
|
23
|
+
before do
|
24
|
+
builder << '{"total_rows":10000,"offset":20,"rows":'
|
25
|
+
end
|
26
|
+
|
27
|
+
it 'builds objects' do
|
28
|
+
builder << '[{"id":"foo","key":"bar","value":1}]'
|
29
|
+
|
30
|
+
builder.staged_rows.should == [
|
31
|
+
{ 'id' => 'foo', 'key' => 'bar', 'value' => 1 }
|
32
|
+
]
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'builds objects containing other objects' do
|
36
|
+
builder << '[{"id":"foo","key":"bar","value":{"total_rows":1}}]'
|
37
|
+
|
38
|
+
builder.staged_rows.should == [
|
39
|
+
{ 'id' => 'foo',
|
40
|
+
'key' => 'bar',
|
41
|
+
'value' => {
|
42
|
+
'total_rows' => 1
|
43
|
+
}
|
44
|
+
}
|
45
|
+
]
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'builds objects containing arrays' do
|
49
|
+
builder << '[{"id":"foo","key":"bar","value":{"things":[1,2,3]}}]'
|
50
|
+
|
51
|
+
builder.staged_rows.should == [
|
52
|
+
{ 'id' => 'foo',
|
53
|
+
'key' => 'bar',
|
54
|
+
'value' => {
|
55
|
+
'things' => [1, 2, 3]
|
56
|
+
}
|
57
|
+
}
|
58
|
+
]
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
describe 'given unexpected top-level keys' do
|
63
|
+
before do
|
64
|
+
builder << '{"total_rows":10000,"offset":20,"rows":[],'
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'raises Analysand::UnexpectedViewKey' do
|
68
|
+
lambda { builder << '"abc":"def"}' }.should raise_error(Analysand::UnexpectedViewKey)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
require 'analysand/database'
|
4
|
+
require 'benchmark'
|
5
|
+
|
6
|
+
require File.expand_path('../a_response', __FILE__)
|
7
|
+
|
8
|
+
module Analysand
|
9
|
+
describe Database do
|
10
|
+
let(:db) { Database.new(database_uri) }
|
11
|
+
let(:row_count) { 15000 }
|
12
|
+
|
13
|
+
before do
|
14
|
+
WebMock.disable!
|
15
|
+
end
|
16
|
+
|
17
|
+
after do
|
18
|
+
WebMock.enable!
|
19
|
+
end
|
20
|
+
|
21
|
+
before do
|
22
|
+
clean_databases!
|
23
|
+
|
24
|
+
doc = {
|
25
|
+
'views' => {
|
26
|
+
'a_view' => {
|
27
|
+
'map' => %Q{
|
28
|
+
function (doc) {
|
29
|
+
var i;
|
30
|
+
|
31
|
+
for(i = 0; i < #{row_count}; i++) {
|
32
|
+
emit(doc['_id'], i);
|
33
|
+
}
|
34
|
+
}
|
35
|
+
}
|
36
|
+
}
|
37
|
+
}
|
38
|
+
}
|
39
|
+
|
40
|
+
db.put!('_design/doc', doc, admin_credentials)
|
41
|
+
db.put!('abc123', {}, admin_credentials)
|
42
|
+
end
|
43
|
+
|
44
|
+
shared_examples_for 'a view streamer' do
|
45
|
+
def get_view(options = {})
|
46
|
+
db.send(method, 'doc/a_view', options)
|
47
|
+
end
|
48
|
+
|
49
|
+
describe 'response' do
|
50
|
+
it_should_behave_like 'a response' do
|
51
|
+
let(:response) { get_view(:stream => true) }
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
it 'returns all rows in order' do
|
56
|
+
resp = get_view(:stream => true)
|
57
|
+
|
58
|
+
resp.rows.map { |r| r['value'] }.should == (0...row_count).to_a
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'yields docs' do
|
62
|
+
resp = get_view(:include_docs => true, :stream => true)
|
63
|
+
|
64
|
+
expect(resp.docs.take(10).all? { |d| d.has_key?('_id') }).to eq(true)
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'returns rows as soon as possible' do
|
68
|
+
# first, make sure the view's built
|
69
|
+
db.head('_design/doc/_view/a_view', admin_credentials)
|
70
|
+
|
71
|
+
streamed = Benchmark.realtime do
|
72
|
+
resp = get_view(:stream => true)
|
73
|
+
resp.rows.take(10)
|
74
|
+
end
|
75
|
+
|
76
|
+
read_everything = Benchmark.realtime do
|
77
|
+
resp = get_view
|
78
|
+
resp.rows.take(10)
|
79
|
+
end
|
80
|
+
|
81
|
+
streamed.should_not be_within(0.5).of(read_everything)
|
82
|
+
end
|
83
|
+
|
84
|
+
it 'returns view metadata' do
|
85
|
+
resp = get_view(:stream => true)
|
86
|
+
|
87
|
+
resp.total_rows.should == row_count
|
88
|
+
resp.offset.should == 0
|
89
|
+
end
|
90
|
+
|
91
|
+
describe '#each' do
|
92
|
+
it 'returns an Enumerator if no block is given' do
|
93
|
+
resp = get_view(:stream => true)
|
94
|
+
|
95
|
+
resp.rows.each.should be_instance_of(Enumerator)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
describe '#view in streaming mode' do
|
101
|
+
it_should_behave_like 'a view streamer' do
|
102
|
+
let(:method) { :view }
|
103
|
+
end
|
104
|
+
|
105
|
+
it 'returns error codes from failures' do
|
106
|
+
resp = db.view('doc/nonexistent', :stream => true)
|
107
|
+
|
108
|
+
resp.code.should == '404'
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
describe '#view! in streaming mode' do
|
113
|
+
it_should_behave_like 'a view streamer' do
|
114
|
+
let(:method) { :view! }
|
115
|
+
end
|
116
|
+
|
117
|
+
it 'raises CannotAccessView on failure' do
|
118
|
+
lambda { db.view!('doc/nonexistent', :stream => true) }.should raise_error(CannotAccessView)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|