stockboy 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/.rspec +5 -0
  4. data/.yardopts +7 -0
  5. data/CHANGELOG.md +24 -0
  6. data/Gemfile +12 -0
  7. data/Guardfile +10 -0
  8. data/LICENSE +21 -0
  9. data/README.md +293 -0
  10. data/Rakefile +30 -0
  11. data/lib/stockboy.rb +80 -0
  12. data/lib/stockboy/attribute.rb +11 -0
  13. data/lib/stockboy/attribute_map.rb +74 -0
  14. data/lib/stockboy/candidate_record.rb +130 -0
  15. data/lib/stockboy/configuration.rb +62 -0
  16. data/lib/stockboy/configurator.rb +176 -0
  17. data/lib/stockboy/dsl.rb +68 -0
  18. data/lib/stockboy/exceptions.rb +3 -0
  19. data/lib/stockboy/filter.rb +58 -0
  20. data/lib/stockboy/filter_chain.rb +41 -0
  21. data/lib/stockboy/filters.rb +11 -0
  22. data/lib/stockboy/filters/missing_email.rb +37 -0
  23. data/lib/stockboy/job.rb +241 -0
  24. data/lib/stockboy/mapped_record.rb +59 -0
  25. data/lib/stockboy/provider.rb +238 -0
  26. data/lib/stockboy/providers.rb +11 -0
  27. data/lib/stockboy/providers/file.rb +135 -0
  28. data/lib/stockboy/providers/ftp.rb +205 -0
  29. data/lib/stockboy/providers/http.rb +123 -0
  30. data/lib/stockboy/providers/imap.rb +290 -0
  31. data/lib/stockboy/providers/soap.rb +120 -0
  32. data/lib/stockboy/railtie.rb +28 -0
  33. data/lib/stockboy/reader.rb +59 -0
  34. data/lib/stockboy/readers.rb +11 -0
  35. data/lib/stockboy/readers/csv.rb +115 -0
  36. data/lib/stockboy/readers/fixed_width.rb +121 -0
  37. data/lib/stockboy/readers/spreadsheet.rb +144 -0
  38. data/lib/stockboy/readers/xml.rb +155 -0
  39. data/lib/stockboy/registry.rb +42 -0
  40. data/lib/stockboy/source_record.rb +43 -0
  41. data/lib/stockboy/string_pool.rb +35 -0
  42. data/lib/stockboy/template_file.rb +44 -0
  43. data/lib/stockboy/translations.rb +70 -0
  44. data/lib/stockboy/translations/boolean.rb +58 -0
  45. data/lib/stockboy/translations/date.rb +41 -0
  46. data/lib/stockboy/translations/decimal.rb +33 -0
  47. data/lib/stockboy/translations/default_empty_string.rb +38 -0
  48. data/lib/stockboy/translations/default_false.rb +41 -0
  49. data/lib/stockboy/translations/default_nil.rb +38 -0
  50. data/lib/stockboy/translations/default_true.rb +41 -0
  51. data/lib/stockboy/translations/default_zero.rb +41 -0
  52. data/lib/stockboy/translations/integer.rb +33 -0
  53. data/lib/stockboy/translations/string.rb +33 -0
  54. data/lib/stockboy/translations/time.rb +41 -0
  55. data/lib/stockboy/translations/uk_date.rb +51 -0
  56. data/lib/stockboy/translations/us_date.rb +51 -0
  57. data/lib/stockboy/translator.rb +66 -0
  58. data/lib/stockboy/version.rb +3 -0
  59. data/spec/fixtures/.gitkeep +0 -0
  60. data/spec/fixtures/files/a_garbage.csv +1 -0
  61. data/spec/fixtures/files/test_data-20120101.csv +1 -0
  62. data/spec/fixtures/files/test_data-20120202.csv +1 -0
  63. data/spec/fixtures/files/z_garbage.csv +1 -0
  64. data/spec/fixtures/jobs/test_job.rb +1 -0
  65. data/spec/fixtures/soap/get_list/fault.xml +8 -0
  66. data/spec/fixtures/soap/get_list/success.xml +18 -0
  67. data/spec/fixtures/spreadsheets/test_data.xls +0 -0
  68. data/spec/fixtures/spreadsheets/test_row_options.xls +0 -0
  69. data/spec/fixtures/xml/body.xml +14 -0
  70. data/spec/spec_helper.rb +28 -0
  71. data/spec/stockboy/attribute_map_spec.rb +59 -0
  72. data/spec/stockboy/attribute_spec.rb +11 -0
  73. data/spec/stockboy/candidate_record_spec.rb +150 -0
  74. data/spec/stockboy/configuration_spec.rb +28 -0
  75. data/spec/stockboy/configurator_spec.rb +127 -0
  76. data/spec/stockboy/filter_chain_spec.rb +40 -0
  77. data/spec/stockboy/filter_spec.rb +41 -0
  78. data/spec/stockboy/filters/missing_email_spec.rb +26 -0
  79. data/spec/stockboy/filters_spec.rb +38 -0
  80. data/spec/stockboy/job_spec.rb +238 -0
  81. data/spec/stockboy/mapped_record_spec.rb +30 -0
  82. data/spec/stockboy/provider_spec.rb +34 -0
  83. data/spec/stockboy/providers/file_spec.rb +116 -0
  84. data/spec/stockboy/providers/ftp_spec.rb +143 -0
  85. data/spec/stockboy/providers/http_spec.rb +94 -0
  86. data/spec/stockboy/providers/imap_spec.rb +76 -0
  87. data/spec/stockboy/providers/soap_spec.rb +107 -0
  88. data/spec/stockboy/providers_spec.rb +38 -0
  89. data/spec/stockboy/readers/csv_spec.rb +68 -0
  90. data/spec/stockboy/readers/fixed_width_spec.rb +52 -0
  91. data/spec/stockboy/readers/spreadsheet_spec.rb +121 -0
  92. data/spec/stockboy/readers/xml_spec.rb +94 -0
  93. data/spec/stockboy/readers_spec.rb +30 -0
  94. data/spec/stockboy/source_record_spec.rb +19 -0
  95. data/spec/stockboy/template_file_spec.rb +30 -0
  96. data/spec/stockboy/translations/boolean_spec.rb +48 -0
  97. data/spec/stockboy/translations/date_spec.rb +38 -0
  98. data/spec/stockboy/translations/decimal_spec.rb +23 -0
  99. data/spec/stockboy/translations/default_empty_string_spec.rb +32 -0
  100. data/spec/stockboy/translations/default_false_spec.rb +25 -0
  101. data/spec/stockboy/translations/default_nil_spec.rb +32 -0
  102. data/spec/stockboy/translations/default_true_spec.rb +25 -0
  103. data/spec/stockboy/translations/default_zero_spec.rb +32 -0
  104. data/spec/stockboy/translations/integer_spec.rb +22 -0
  105. data/spec/stockboy/translations/string_spec.rb +22 -0
  106. data/spec/stockboy/translations/time_spec.rb +27 -0
  107. data/spec/stockboy/translations/uk_date_spec.rb +37 -0
  108. data/spec/stockboy/translations/us_date_spec.rb +37 -0
  109. data/spec/stockboy/translations_spec.rb +55 -0
  110. data/spec/stockboy/translator_spec.rb +27 -0
  111. data/stockboy.gemspec +32 -0
  112. metadata +305 -0
@@ -0,0 +1,40 @@
1
+ require 'spec_helper'
2
+ require 'stockboy/filter_chain'
3
+
4
+ module Stockboy
5
+ describe FilterChain do
6
+
7
+ let(:filter1) { double("Filter") }
8
+ let(:filter2) { double("Filter", reset: true) }
9
+
10
+ it "initializes keys and values from a hash" do
11
+ chain = FilterChain.new(no_angels: filter1, no_daleks: filter2)
12
+ chain.keys.should == [:no_angels, :no_daleks]
13
+ chain.values.should == [filter1, filter2]
14
+ end
15
+
16
+ describe "#reset" do
17
+ let(:chain) { FilterChain.new(no_angels: filter1, no_daleks: filter2) }
18
+
19
+ it "calls reset on all members" do
20
+ filter2.should_receive(:reset)
21
+ chain.reset
22
+ end
23
+
24
+ it "returns a hash of filter keys to empty arrays" do
25
+ empty_records = chain.reset
26
+ empty_records.should == {no_angels: [], no_daleks: []}
27
+ end
28
+ end
29
+
30
+ describe "#prepend" do
31
+ it "adds filters to the front of the chain" do
32
+ chain = FilterChain.new(filter1: double)
33
+ chain.prepend(filter0: double)
34
+ chain.keys.should == [:filter0, :filter1]
35
+ end
36
+ end
37
+
38
+ end
39
+ end
40
+
@@ -0,0 +1,41 @@
1
+ require 'spec_helper'
2
+ require 'stockboy/filter'
3
+
4
+ class FishFilter < Stockboy::Filter
5
+ def filter(raw, translated)
6
+ return true if raw.species =~ /ichtus/
7
+ return true if translated.species =~ /fish/
8
+ return nil
9
+ end
10
+ end
11
+
12
+ module Stockboy
13
+ describe Filter do
14
+
15
+ describe "#call" do
16
+ let(:empty_values) { double.as_null_object }
17
+ subject(:filter) { FishFilter.new }
18
+
19
+ context "matching raw value" do
20
+ it "returns true for match" do
21
+ filter.call(double(species:"babylichtus"), empty_values).should be_true
22
+ end
23
+
24
+ it "returns false for no match" do
25
+ filter.call(double(species:"triceratops"), empty_values).should be_false
26
+ end
27
+ end
28
+
29
+ context "matching translated value" do
30
+ it "returns true for match" do
31
+ filter.call(empty_values, double(species:"babelfish")).should be_true
32
+ end
33
+
34
+ it "returns false for no match" do
35
+ filter.call(empty_values, double(species:"rhinoceros")).should be_false
36
+ end
37
+ end
38
+ end
39
+
40
+ end
41
+ end
@@ -0,0 +1,26 @@
1
+ require 'spec_helper'
2
+ require 'stockboy/filters/missing_email'
3
+
4
+ describe Stockboy::Filters::MissingEmail do
5
+ subject(:filter) { described_class.new(:e) }
6
+ it 'allows email addresses' do
7
+ record = OpenStruct.new(e: 'me@example.com')
8
+ filter.call(record, record).should be_false
9
+ end
10
+
11
+ it 'catches empty strings' do
12
+ record = OpenStruct.new(e: '')
13
+ filter.call(record, record).should be_true
14
+ end
15
+
16
+ it 'catches hyphen placeholders' do
17
+ record = OpenStruct.new(e: '-')
18
+ filter.call(record, record).should be_true
19
+ end
20
+
21
+ it 'uses translated output value' do
22
+ input = OpenStruct.new(e: '', other: 'me@example.com')
23
+ output = OpenStruct.new(e: input.other)
24
+ filter.call(input, output).should be_false
25
+ end
26
+ end
@@ -0,0 +1,38 @@
1
+ require 'spec_helper'
2
+ require 'stockboy/filters'
3
+
4
+ module Stockboy
5
+ describe Filters do
6
+
7
+ let(:filter) { double("filter") }
8
+
9
+ describe ".register" do
10
+ it "registers a key and class" do
11
+ Filters.register(:invalid, filter).should === filter
12
+ end
13
+ end
14
+
15
+ describe ".find" do
16
+ it "returns a filter class" do
17
+ Filters.register(:invalid, filter)
18
+ Filters.find(:invalid).should === filter
19
+ end
20
+ end
21
+
22
+ describe ".[]" do
23
+ it "returns a filter class" do
24
+ Filters.register(:invalid, filter)
25
+ Filters[:invalid].should === filter
26
+ end
27
+ end
28
+
29
+ describe ".all" do
30
+ it "returns all registered filters" do
31
+ Filters.register(:invalid, filter)
32
+ Filters.register(:semivalid, filter)
33
+ Filters.all.should include(invalid: filter, semivalid: filter)
34
+ end
35
+ end
36
+
37
+ end
38
+ end
@@ -0,0 +1,238 @@
1
+ require 'spec_helper'
2
+ require 'stockboy/job'
3
+
4
+ module Stockboy
5
+ describe Job do
6
+ let(:jobs_path) { RSpec.configuration.fixture_path.join('jobs') }
7
+ let(:provider_stub) { double(:ftp).as_null_object }
8
+ let(:reader_stub) { double(:csv).as_null_object }
9
+
10
+ let(:job_template) {
11
+ <<-END.gsub(/^ {6}/,'')
12
+ provider :ftp do
13
+ username 'foo'
14
+ password 'bar'
15
+ host 'ftp.example.com'
16
+ end
17
+ format :csv
18
+ filter :blank_name do |r|
19
+ false if r.name.blank?
20
+ end
21
+ attributes do
22
+ name from: 'userName'
23
+ email from: 'email'
24
+ updated_at from: 'statusDate', as: [:date]
25
+ end
26
+ on :cleanup do |job|
27
+ job.provider.delete_data
28
+ end
29
+ on :cleanup do |job|
30
+ "log: " << job.all_records.size
31
+ end
32
+ END
33
+ }
34
+
35
+ before do
36
+ Stockboy.configuration.template_load_paths = [jobs_path]
37
+
38
+ allow(Stockboy::Providers).to receive(:find) { provider_stub }
39
+ allow(Stockboy::Readers).to receive(:find) { reader_stub }
40
+ end
41
+
42
+ its(:filters) { should be_a Hash }
43
+
44
+ describe "#define" do
45
+ before do
46
+ allow(File).to receive(:read)
47
+ .with("#{jobs_path}/test_job.rb")
48
+ .and_return job_template
49
+ end
50
+
51
+ it "returns an instance of Job" do
52
+ Job.define("test_job").should be_a Job
53
+ end
54
+
55
+ it "yields the defined job" do
56
+ yielded = nil
57
+ job = Job.define("test_job") { |j| yielded = j }
58
+ job.should be_a Job
59
+ job.should be yielded
60
+ end
61
+
62
+ it "should read a file from a path" do
63
+ File.should_receive(:read).with("#{jobs_path}/test_job.rb")
64
+ Job.define("test_job")
65
+ end
66
+
67
+ it "assigns a registered provider from a symbol" do
68
+ job = Job.define("test_job")
69
+ job.provider.should == provider_stub
70
+ end
71
+
72
+ it "assigns a registered reader from a symbol" do
73
+ Stockboy::Readers.should_receive(:find)
74
+ .with(:csv)
75
+ .and_return(reader_stub)
76
+ job = Job.define("test_job")
77
+ job.reader.should == reader_stub
78
+ end
79
+
80
+ it "assigns attributes from a block" do
81
+ job = Job.define("test_job")
82
+ job.attributes.map(&:to).should == [:name, :email, :updated_at]
83
+ end
84
+
85
+ it "assigns triggers into their associated array from a block" do
86
+ job = Job.define("test_job")
87
+ job.triggers[:cleanup].size.should == 2
88
+ job.triggers[:cleanup].each { |t| t.should be_a Proc }
89
+ end
90
+ end
91
+
92
+ describe "#process" do
93
+ let(:attribute_map) { AttributeMap.new { name } }
94
+
95
+ subject(:job) do
96
+ Job.new(provider: double(:provider, data:"", errors:[]),
97
+ attributes: attribute_map)
98
+ end
99
+
100
+ it "records total received record count" do
101
+ job.reader = double(parse: [{"name"=>"A"},{"name"=>"B"}])
102
+
103
+ job.process
104
+ job.total_records.should == 2
105
+ end
106
+
107
+ it "partitions records by filter" do
108
+ job.reader = double(parse: [{"name"=>"A"},{"name"=>"B"}])
109
+ job.filters = {alpha: proc{ |r| r.name =~ /A/ }}
110
+
111
+ job.process
112
+ job.records[:alpha].length.should == 1
113
+ end
114
+
115
+ it "keeps unfiltered_records" do
116
+ job.reader = double(parse: [{"name"=>"A"}])
117
+ job.filters = {zeta: proc{ |r| r.name =~ /Z/ }}
118
+
119
+ job.process
120
+ job.unfiltered_records.length.should == 1
121
+ end
122
+
123
+ it "keeps all_records" do
124
+ job.reader = double(parse: [{"name"=>"A"},{"name"=>"Z"}])
125
+ job.filters = {alpha: proc{ |r| r.name =~ /A/ }}
126
+
127
+ job.process
128
+ job.all_records.length.should == 2
129
+ end
130
+
131
+ it "resets filters between runs" do
132
+
133
+ class CountingFilter
134
+ attr_reader :matches
135
+ define_method(:initialize) { |pattern| @pattern, @matches = /A/, 0 }
136
+ define_method(:call) { |_, output| @matches += 1 if output.name =~ @pattern }
137
+ define_method(:reset) { @matches = 0 }
138
+ end
139
+
140
+ job.reader = double(parse: [{"name"=>"A"},{"name"=>"Z"}])
141
+ job.filters = {alpha: counter = CountingFilter.new(/A/)}
142
+
143
+ counter.matches.should == 0
144
+ 2.times { job.process }
145
+ counter.matches.should == 1
146
+ end
147
+
148
+ it "has empty partitions" do
149
+ job.filters = {alpha: proc{ |r| r.name =~ /A/ }, beta: proc{ |r| r.name =~ /B/ }}
150
+ job.records.should == {alpha: [], beta: []}
151
+ end
152
+ end
153
+
154
+ describe "#record_counts" do
155
+ let(:attribute_map) { AttributeMap.new { name } }
156
+
157
+ subject(:job) do
158
+ Job.new(provider: double(:provider, data:"", errors:[]),
159
+ attributes: attribute_map)
160
+ end
161
+
162
+ context "before processing" do
163
+ it "should be empty" do
164
+ job.record_counts.should == {}
165
+ end
166
+ end
167
+
168
+ it "returns a hash of counts by filtered record partition" do
169
+ job.filters = {
170
+ alpha: proc{ |r| r.name =~ /^A/ },
171
+ zeta: proc{ |r| r.name =~ /^Z/ }
172
+ }
173
+
174
+ job.reader = double(parse: [{"name"=>"Arthur"}, {"name"=>"Abc"}, {"name"=>"Zaphod"}])
175
+ job.process
176
+
177
+ job.record_counts.should == {alpha: 2, zeta: 1}
178
+ end
179
+ end
180
+
181
+ describe "#processed?" do
182
+ subject(:job) do
183
+ Job.new(provider: provider_stub,
184
+ reader: reader_stub,
185
+ attributes: AttributeMap.new)
186
+ end
187
+
188
+ it "indicates if the job has been processed" do
189
+ job.processed?.should be_false
190
+ job.process
191
+ job.processed?.should be_true
192
+ end
193
+ end
194
+
195
+ describe "#trigger" do
196
+
197
+ let(:provider_stub) { double(delete_data: true) }
198
+
199
+ subject(:job) do
200
+ Job.new(
201
+ provider: provider_stub,
202
+ triggers: {
203
+ success: [proc { |j| j.provider.delete_data },
204
+ proc { |j, stats| stats[:count] = 1 if stats }]
205
+ }
206
+ )
207
+ end
208
+
209
+ it "should yield itself to each trigger" do
210
+ provider_stub.should_receive(:delete_data).once
211
+ job.trigger(:success)
212
+ end
213
+
214
+ it "should yield args to each trigger" do
215
+ stats = {}
216
+ job.trigger(:success, stats)
217
+ stats[:count].should == 1
218
+ end
219
+
220
+ end
221
+
222
+ describe "#method_missing" do
223
+
224
+ subject(:job) { Job.new(triggers: {cleanup: proc{|_|}})}
225
+
226
+ it "should call a named trigger" do
227
+ expect(job).to receive(:trigger).with(:cleanup, "trash")
228
+ job.cleanup("trash")
229
+ end
230
+
231
+ it "should raise an error for unknown trigger keys" do
232
+ expect { job.wobble }.to raise_error NoMethodError
233
+ end
234
+
235
+ end
236
+
237
+ end
238
+ end
@@ -0,0 +1,30 @@
1
+ require 'spec_helper'
2
+ require 'stockboy/mapped_record'
3
+
4
+ module Stockboy
5
+ describe MappedRecord do
6
+ subject(:record) do
7
+ MappedRecord.new(:full_name => 'Arthur Dent')
8
+ end
9
+
10
+ it "accesses initialized fields from hash" do
11
+ record.full_name.should == 'Arthur Dent'
12
+ end
13
+
14
+ it "does not redefine accessor methods" do
15
+ record1 = MappedRecord.new(:full_name => 'Arthur Dent')
16
+ record2 = MappedRecord.new(:full_name => 'Arthur Dent')
17
+
18
+ record1.method(:full_name).owner.should ==
19
+ record2.method(:full_name).owner
20
+ end
21
+
22
+ it "only has its own accessor methods" do
23
+ record1 = MappedRecord.new(:first_name => 'Arthur')
24
+ record2 = MappedRecord.new(:last_name => 'Dent')
25
+
26
+ record1.should_not respond_to(:last_name)
27
+ record2.should_not respond_to(:first_name)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,34 @@
1
+ require 'spec_helper'
2
+ require 'stockboy/provider'
3
+
4
+ class ProviderSubclass < Stockboy::Provider
5
+ attr_accessor :foo
6
+ def validate
7
+ errors.add_on_empty(:foo, "Foo is empty")
8
+ end
9
+ end
10
+
11
+ module Stockboy
12
+ describe Provider do
13
+
14
+ describe "#errors" do
15
+ its(:errors) { should be_empty }
16
+ end
17
+
18
+ describe "#logger" do
19
+ its(:logger) { should respond_to :error }
20
+ end
21
+
22
+ describe "abstract method" do
23
+ subject { Class.new(Provider).new }
24
+
25
+ it "raises error for unimplemented #validate" do
26
+ expect{ subject.send :validate }.to raise_error(NoMethodError)
27
+ end
28
+
29
+ it "raises error for unimplemented #fetch_data" do
30
+ expect{ subject.send :fetch_data }.to raise_error(NoMethodError)
31
+ end
32
+ end
33
+ end
34
+ end