importu 0.1.0 → 0.2.0

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.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/.editorconfig +15 -0
  3. data/.github/workflows/ci.yml +48 -0
  4. data/.gitignore +4 -0
  5. data/.rspec +1 -0
  6. data/.rubocop.yml +311 -0
  7. data/.simplecov +14 -0
  8. data/.yardstick.yml +36 -0
  9. data/Appraisals +22 -0
  10. data/CHANGELOG.md +51 -0
  11. data/CONTRIBUTING.md +86 -0
  12. data/Gemfile +5 -1
  13. data/LICENSE +21 -0
  14. data/README.md +435 -52
  15. data/Rakefile +71 -0
  16. data/UPGRADING.md +188 -0
  17. data/gemfiles/rails_7_2.gemfile +11 -0
  18. data/gemfiles/rails_7_2.gemfile.lock +268 -0
  19. data/gemfiles/rails_8_0.gemfile +11 -0
  20. data/gemfiles/rails_8_0.gemfile.lock +271 -0
  21. data/gemfiles/rails_8_1.gemfile +11 -0
  22. data/gemfiles/rails_8_1.gemfile.lock +269 -0
  23. data/gemfiles/standalone.gemfile +8 -0
  24. data/gemfiles/standalone.gemfile.lock +197 -0
  25. data/importu.gemspec +41 -22
  26. data/lib/importu/backends/active_record.rb +171 -0
  27. data/lib/importu/backends/middleware/duplicate_manager_proxy.rb +41 -0
  28. data/lib/importu/backends/middleware/enforce_allowed_actions.rb +52 -0
  29. data/lib/importu/backends/middleware.rb +11 -0
  30. data/lib/importu/backends.rb +103 -0
  31. data/lib/importu/config_dsl.rb +381 -0
  32. data/lib/importu/converter_context.rb +94 -0
  33. data/lib/importu/converters.rb +119 -64
  34. data/lib/importu/definition.rb +23 -0
  35. data/lib/importu/duplicate_manager.rb +88 -0
  36. data/lib/importu/exceptions.rb +135 -4
  37. data/lib/importu/importer.rb +183 -96
  38. data/lib/importu/record.rb +138 -102
  39. data/lib/importu/sources/csv.rb +122 -0
  40. data/lib/importu/sources/json.rb +106 -0
  41. data/lib/importu/sources/ruby.rb +46 -0
  42. data/lib/importu/sources/xml.rb +133 -0
  43. data/lib/importu/sources.rb +13 -0
  44. data/lib/importu/summary.rb +277 -0
  45. data/lib/importu/version.rb +3 -1
  46. data/lib/importu.rb +45 -9
  47. data/spec/fixtures/books-duplicates/README.md +7 -0
  48. data/spec/fixtures/books-duplicates/infile.csv +7 -0
  49. data/spec/fixtures/books-duplicates/model.json +23 -0
  50. data/spec/fixtures/books-duplicates/summary.json +10 -0
  51. data/spec/fixtures/books-valid/README.md +13 -0
  52. data/spec/fixtures/books-valid/infile.csv +4 -0
  53. data/spec/fixtures/books-valid/infile.json +23 -0
  54. data/spec/fixtures/books-valid/infile.xml +21 -0
  55. data/spec/fixtures/books-valid/model.json +23 -0
  56. data/spec/fixtures/books-valid/record.json +26 -0
  57. data/spec/fixtures/books-valid/summary.json +8 -0
  58. data/spec/fixtures/source-empty-file/infile.csv +0 -0
  59. data/spec/fixtures/source-empty-file/infile.json +0 -0
  60. data/spec/fixtures/source-empty-file/infile.xml +0 -0
  61. data/spec/fixtures/source-empty-records/infile.csv +3 -0
  62. data/spec/fixtures/source-empty-records/infile.json +1 -0
  63. data/spec/fixtures/source-empty-records/infile.xml +6 -0
  64. data/spec/fixtures/source-malformed/infile.csv +1 -0
  65. data/spec/fixtures/source-malformed/infile.json +1 -0
  66. data/spec/fixtures/source-malformed/infile.xml +3 -0
  67. data/spec/fixtures/source-no-records/infile.csv +1 -0
  68. data/spec/fixtures/source-no-records/infile.json +1 -0
  69. data/spec/fixtures/source-no-records/infile.xml +3 -0
  70. data/spec/lib/importu/backends/active_record_spec.rb +150 -0
  71. data/spec/lib/importu/backends/middleware/duplicate_manager_proxy_spec.rb +70 -0
  72. data/spec/lib/importu/backends/middleware/enforce_allowed_actions_spec.rb +70 -0
  73. data/spec/lib/importu/backends_spec.rb +170 -0
  74. data/spec/lib/importu/converters_spec.rb +184 -141
  75. data/spec/lib/importu/definition_spec.rb +248 -0
  76. data/spec/lib/importu/duplicate_manager_spec.rb +92 -0
  77. data/spec/lib/importu/exceptions_spec.rb +69 -16
  78. data/spec/lib/importu/import_context_spec.rb +199 -0
  79. data/spec/lib/importu/importer_spec.rb +95 -0
  80. data/spec/lib/importu/integration_spec.rb +221 -0
  81. data/spec/lib/importu/record_spec.rb +130 -80
  82. data/spec/lib/importu/sources/csv_spec.rb +29 -0
  83. data/spec/lib/importu/sources/importer_source_examples.rb +175 -0
  84. data/spec/lib/importu/sources/json_spec.rb +29 -0
  85. data/spec/lib/importu/sources/ruby_spec.rb +102 -0
  86. data/spec/lib/importu/sources/xml_spec.rb +70 -0
  87. data/spec/lib/importu/summary_spec.rb +186 -0
  88. data/spec/spec_helper.rb +91 -7
  89. data/spec/support/active_record.rb +20 -0
  90. data/spec/support/book_importer.rb +31 -0
  91. data/spec/support/dummy_backend.rb +50 -0
  92. data/spec/support/fixtures_helper.rb +43 -0
  93. data/spec/support/matchers/delegate_matcher.rb +14 -8
  94. metadata +173 -100
  95. data/lib/importu/core_ext/array/deep_freeze.rb +0 -7
  96. data/lib/importu/core_ext/deep_freeze.rb +0 -3
  97. data/lib/importu/core_ext/hash/deep_freeze.rb +0 -7
  98. data/lib/importu/core_ext/object/deep_freeze.rb +0 -6
  99. data/lib/importu/core_ext.rb +0 -3
  100. data/lib/importu/dsl.rb +0 -127
  101. data/lib/importu/importer/csv.rb +0 -52
  102. data/lib/importu/importer/json.rb +0 -45
  103. data/lib/importu/importer/xml.rb +0 -55
  104. data/spec/factories/importer.rb +0 -12
  105. data/spec/factories/importer_record.rb +0 -13
  106. data/spec/factories/json_importer.rb +0 -14
  107. data/spec/factories/xml_importer.rb +0 -12
  108. data/spec/lib/importu/dsl_spec.rb +0 -26
  109. data/spec/lib/importu/importer/json_spec.rb +0 -37
  110. data/spec/lib/importu/importer/xml_spec.rb +0 -14
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+
4
+ require "importu/definition"
5
+
6
+ RSpec.describe Importu::Definition do
7
+ subject(:definition) { Class.new(ancestor) }
8
+ let(:ancestor) { Class.new(Importu::Definition) }
9
+
10
+ describe "#allow_actions" do
11
+ it "updates the [:backend][:allowed_actions] config" do
12
+ expect { definition.allow_actions(:create, :update) }
13
+ .to change { definition.config[:backend][:allowed_actions] }
14
+ .to([:create, :update])
15
+ end
16
+
17
+ it "inherits config from ancestor" do
18
+ ancestor.allow_actions(:update)
19
+ expect(definition.config[:backend][:allowed_actions]).to eq [:update]
20
+ end
21
+
22
+ it "does not affect ancestor config" do
23
+ ancestor.allow_actions(:update)
24
+ expect { definition.allow_actions(:create, :update) }
25
+ .to_not change { ancestor.config }
26
+ end
27
+ end
28
+
29
+ describe "#before_save" do
30
+ let(:foo_block) { Proc.new {} }
31
+
32
+ it "updates the [:backend][:before_save] config" do
33
+ expect { definition.before_save(&foo_block) }
34
+ .to change { definition.config[:backend][:before_save] }
35
+ .to(foo_block)
36
+ end
37
+
38
+ it "inherits config from ancestor" do
39
+ ancestor.before_save(&foo_block)
40
+ expect(definition.config[:backend][:before_save]).to eq foo_block
41
+ end
42
+
43
+ it "does not affect ancestor config" do
44
+ ancestor.before_save(&foo_block)
45
+ expect { definition.before_save {} }
46
+ .to_not change { ancestor.config }
47
+ end
48
+ end
49
+
50
+ describe "#config" do
51
+ it "returns a config hash of the definition" do
52
+ expect(definition.config).to include(:converters, :fields)
53
+ end
54
+
55
+ it "[:backend][:allowed_actions] defaults to only allow :create" do
56
+ expect(definition.config[:backend][:allowed_actions]).to eq [:create]
57
+ end
58
+
59
+ it "[:backend][:finder_fields] defaults to no fields" do
60
+ expect(definition.config[:backend][:finder_fields]).to eq []
61
+ end
62
+ end
63
+
64
+ describe "#convert_to" do
65
+ let!(:converter) do
66
+ definition.converter :foo do |name, **options|
67
+ "foo(#{name}, {#{options.map {|k, v| "#{k}:#{v}"}.join(",")}})"
68
+ end
69
+ end
70
+
71
+ it "returns a converter stub representing the converter" do
72
+ stub = definition.convert_to(:foo)
73
+ expect(stub.type).to eq :foo
74
+ end
75
+
76
+ it "saves converter options from definition" do
77
+ expect(definition.convert_to(:foo, a: 7).options).to eq({a: 7})
78
+ expect(definition.convert_to(:foo).options).to eq({})
79
+ end
80
+
81
+ it "raises an exception if converter cannot be found" do
82
+ expect { definition.convert_to(:bar) }.to raise_error(KeyError)
83
+ end
84
+ end
85
+
86
+ describe "#converter" do
87
+ let(:foo_block) { Proc.new {} }
88
+
89
+ it "updates the :converters config" do
90
+ expect { definition.converter(:foo, &foo_block) }
91
+ .to change { definition.config[:converters][:foo] }
92
+ .to(foo_block)
93
+ end
94
+
95
+ it "inherits config from ancestor" do
96
+ ancestor.converter(:foo, &foo_block)
97
+ expect(definition.config[:converters][:foo]).to eq foo_block
98
+ end
99
+
100
+ it "does not affect ancestor config" do
101
+ ancestor.converter(:foo, &foo_block)
102
+ expect { definition.converter(:foo) {} }
103
+ .to_not change { ancestor.config }
104
+ end
105
+ end
106
+
107
+ describe "#field" do
108
+ let(:converter) { Proc.new {} }
109
+
110
+ it "updates the :fields config and presets defaults" do
111
+ definition.field(:foo, required: false)
112
+ expect(definition.config[:fields][:foo])
113
+ .to eq definition.field_defaults(:foo).merge(required: false)
114
+ end
115
+
116
+ it "inherits config from ancestors" do
117
+ ancestor.field(:foo, required: false, &converter)
118
+ expect(definition.config[:fields][:foo]).to include(
119
+ required: false,
120
+ converter: converter,
121
+ )
122
+ end
123
+
124
+ it "does not affect ancestor config" do
125
+ ancestor.field(:foo, required: false, &converter)
126
+ expect { definition.field(:foo, abstract: true, label: "baaa") }
127
+ .to_not change { ancestor.config }
128
+ expect(definition.config[:fields][:foo]).to include(
129
+ required: false,
130
+ abstract: true,
131
+ label: "baaa"
132
+ )
133
+ end
134
+
135
+ it "allows partially updating the field config" do
136
+ ancestor.field(:foo, required: false, &converter)
137
+ definition.field(:foo, abstract: true, label: "baaa")
138
+ definition.field(:foo, create: false)
139
+ expect(definition.config[:fields][:foo]).to include(
140
+ required: false,
141
+ abstract: true,
142
+ create: false,
143
+ label: "baaa",
144
+ converter: converter,
145
+ )
146
+ end
147
+
148
+ it "defaults to using the :default converter" do
149
+ definition.field(:foo)
150
+ field_definition = definition.config[:fields][:foo]
151
+ expect(field_definition[:converter].type).to eq :default
152
+ end
153
+ end
154
+
155
+ describe "#fields" do
156
+ it "configures each field with the same properties" do
157
+ converter = Proc.new {}
158
+ expect(definition).to receive(:field).with(:foo, required: false, &converter)
159
+ expect(definition).to receive(:field).with(:bar, required: false, &converter)
160
+ expect(definition).to receive(:field).with(:baz, required: false, &converter)
161
+ definition.fields(:foo, :bar, :baz, required: false, &converter)
162
+ end
163
+ end
164
+
165
+ describe "#find_by" do
166
+ it "updates the [:backend][:finder_fields] config" do
167
+ expect { definition.find_by(:foo, [:bar, :baz]) }
168
+ .to change { definition.config[:backend][:finder_fields] }
169
+ .to([[:foo], [:bar, :baz]])
170
+ end
171
+
172
+ it "inherits config from ancestor" do
173
+ ancestor.find_by(:foo, [:bar, :baz])
174
+ expect(definition.config[:backend][:finder_fields])
175
+ .to eq [[:foo], [:bar, :baz]]
176
+ end
177
+
178
+ it "does not affect ancestor config" do
179
+ ancestor.find_by(:foo, [:bar, :baz])
180
+ expect { definition.find_by(:bar) }
181
+ .to_not change { ancestor.config }
182
+ end
183
+
184
+ it "allows setting to a block" do
185
+ foo_block = Proc.new {}
186
+ expect { definition.find_by(&foo_block) }
187
+ .to change { definition.config[:backend][:finder_fields] }
188
+ .to([foo_block])
189
+ end
190
+
191
+ it "allows clearing all finder fields" do
192
+ ancestor.find_by(:foo, [:bar, :baz])
193
+ expect { definition.find_by(nil) }
194
+ .to change { definition.config[:backend][:finder_fields] }
195
+ .to([])
196
+ end
197
+ end
198
+
199
+ describe "#model" do
200
+ it "updates the :backend config" do
201
+ expect { definition.model("Foo") }
202
+ .to change { definition.config[:backend][:model] }
203
+ .to("Foo")
204
+ end
205
+
206
+ it "inherits config from ancestor" do
207
+ ancestor.model("Foo")
208
+ expect(definition.config[:backend][:model]).to eq "Foo"
209
+ end
210
+
211
+ it "does not affect ancestor config" do
212
+ ancestor.model("Foo")
213
+ expect { definition.model("Bar") }
214
+ .to_not change { ancestor.config }
215
+ end
216
+
217
+ it "allows specifying a backend property" do
218
+ expect { definition.model("Foo", backend: :oven) }
219
+ .to change { definition.config[:backend][:name] }
220
+ .to(:oven)
221
+ end
222
+ end
223
+
224
+ describe "#source" do
225
+ let(:converter) { Proc.new {} }
226
+
227
+ it "updates the :sources config" do
228
+ definition.source(:xml, records_xpath: "//people")
229
+ expect(definition.config[:sources][:xml])
230
+ .to include(records_xpath: "//people")
231
+ end
232
+
233
+ it "inherits config from ancestors" do
234
+ ancestor.source(:xml, records_xpath: "//animals")
235
+ expect(definition.config[:sources][:xml])
236
+ .to include(records_xpath: "//animals")
237
+ end
238
+
239
+ it "does not affect ancestor config" do
240
+ ancestor.source(:xml, records_xpath: "//animals")
241
+ expect { definition.source(:xml, records_xpath: "//people") }
242
+ .to_not change { ancestor.config }
243
+ expect(definition.config[:sources][:xml])
244
+ .to include(records_xpath: "//people")
245
+ end
246
+ end
247
+
248
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+
4
+ require "importu/exceptions"
5
+
6
+ RSpec.describe Importu::DuplicateManager do
7
+ subject(:duplicates) { described_class.new(finder_fields: finder_fields) }
8
+
9
+ let(:finder_fields) { [] }
10
+
11
+ describe "#check_object!" do
12
+ it "allows different unique ids to be set without any errors" do
13
+ duplicates.check_object!(9164)
14
+ expect { duplicates.check_object!(9165) }.to_not raise_error
15
+ end
16
+
17
+ it "raises a DuplicateRecord when unique on subsequent encounters" do
18
+ duplicates.check_object!(9164)
19
+ expect { duplicates.check_object!(9164) }
20
+ .to raise_error(Importu::DuplicateRecord)
21
+ end
22
+ end
23
+
24
+ describe "#check_record!" do
25
+ context "when no finder fields are defined" do
26
+ let(:finder_fields) { [] }
27
+
28
+ it "never considers records to be duplicates" do
29
+ duplicates.check_record!(foo: 3, bar: 4)
30
+ expect { duplicates.check_record!(foo: 3, bar: 4) }.to_not raise_error
31
+ end
32
+ end
33
+
34
+ context "when a finder field is defined" do
35
+ let(:finder_fields) { [[:foo]] }
36
+
37
+ it "allows different values to be set without any errors" do
38
+ duplicates.check_record!(foo: 3, bar: 4)
39
+ expect { duplicates.check_record!(foo: 4, bar: 4) }.to_not raise_error
40
+ end
41
+ end
42
+
43
+ context "when multiple finder field groups are defined" do
44
+ let(:finder_fields) { [[:foo, :bar], [:bar, :baz], [:qux]] }
45
+
46
+ it "records and matches against all field groups" do
47
+ duplicates.check_record!(foo: 1, bar: 1, baz: 1, qux: 1)
48
+
49
+ # Each field group has a unique set of values from previous check
50
+ duplicates.check_record!(foo: 1, bar: 2, baz: 1, qux: 2)
51
+
52
+ # The second field group conflicts with values from previous check
53
+ expect { duplicates.check_record!(foo: 2, bar: 2, baz: 1, qux: 3) }
54
+ .to raise_error(Importu::DuplicateRecord)
55
+ end
56
+
57
+ it "adds all non-duplicate field groups, even if exception is raised" do
58
+ duplicates.check_record!(foo: 1, bar: 1, baz: 1, qux: 1)
59
+
60
+ # Only first field group is duplicate, but others should still get added
61
+ expect { duplicates.check_record!(foo: 1, bar: 1, baz: 2, qux: 2) }
62
+ .to raise_error(Importu::DuplicateRecord)
63
+
64
+ expect { duplicates.check_record!(bar: 1, baz: 2) }
65
+ .to raise_error(Importu::DuplicateRecord)
66
+ expect { duplicates.check_record!(qux: 2) }
67
+ .to raise_error(Importu::DuplicateRecord)
68
+ end
69
+ end
70
+
71
+ context "when a proc-based finder field is defined" do
72
+ let(:finder_fields) { [Proc.new { raise :bleep }, [:foo]] }
73
+
74
+ it "ignores proc-based field groups" do
75
+ duplicates.check_record!(foo: 3, bar: 4)
76
+ duplicates.check_record!(foo: 4, bar: 4)
77
+ expect { duplicates.check_record!(foo: 3, bar: 5) }
78
+ .to raise_error(Importu::DuplicateRecord)
79
+ end
80
+ end
81
+
82
+ context "when a field in a finder field group is does not exist on the record" do
83
+ let(:finder_fields) { [[:foo, :baz]] }
84
+
85
+ it "ignores the field group when doing duplicate checking" do
86
+ duplicates.check_record!(foo: 3, bar: 4)
87
+ expect { duplicates.check_record!(foo: 3, bar: 4) }.to_not raise_error
88
+ end
89
+ end
90
+ end
91
+
92
+ end
@@ -1,10 +1,13 @@
1
- require 'spec_helper'
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
2
3
 
3
- describe Importu::ImportuException do
4
+ require "importu/exceptions"
5
+
6
+ RSpec.describe Importu::ImportuException do
4
7
  subject(:exception) { Importu::ImportuException.new }
5
8
 
6
9
  it "#name should return 'ImportuException" do
7
- exception.name.should eq "ImportuException"
10
+ expect(exception.name).to eq "ImportuException"
8
11
  end
9
12
 
10
13
  describe Importu::InvalidInput do
@@ -15,7 +18,7 @@ describe Importu::ImportuException do
15
18
  end
16
19
 
17
20
  it "#name should return 'InvalidInput'" do
18
- exception.name.should eq "InvalidInput"
21
+ expect(exception.name).to eq "InvalidInput"
19
22
  end
20
23
  end
21
24
 
@@ -27,24 +30,48 @@ describe Importu::ImportuException do
27
30
  end
28
31
 
29
32
  it "#name should return 'InvalidRecord'" do
30
- exception.name.should eq "InvalidRecord"
33
+ expect(exception.name).to eq "InvalidRecord"
34
+ end
35
+
36
+ describe "#normalized_message" do
37
+ context "when not provided" do
38
+ it "returns nil" do
39
+ expect(exception.normalized_message).to be_nil
40
+ end
41
+ end
42
+
43
+ context "when provided" do
44
+ subject(:exception) do
45
+ Importu::InvalidRecord.new(
46
+ "isbn too short (was: abc)", nil, normalized_message: "isbn too short"
47
+ )
48
+ end
49
+
50
+ it "returns the normalized message" do
51
+ expect(exception.normalized_message).to eq "isbn too short"
52
+ end
53
+
54
+ it "preserves the full message" do
55
+ expect(exception.message).to eq "isbn too short (was: abc)"
56
+ end
57
+ end
31
58
  end
32
59
  end
33
60
 
34
61
  describe Importu::FieldParseError do
35
- subject(:exception) { Importu::FieldParseError.new }
62
+ subject(:exception) { Importu::FieldParseError.new(:foo, "is invalid") }
36
63
 
37
64
  it "should be a subclass of Importu::InvalidRecord" do
38
65
  exception.class.ancestors.include?(Importu::InvalidRecord)
39
66
  end
40
67
 
41
68
  it "#name should return 'FieldParseError'" do
42
- exception.name.should eq "FieldParseError"
69
+ expect(exception.name).to eq "FieldParseError"
43
70
  end
44
71
  end
45
72
 
46
73
  describe Importu::MissingField do
47
- let(:definition) { { :name => "foo_field_1", :label => "Field 1" } }
74
+ let(:definition) { { name: "foo_field_1", label: "Field 1" } }
48
75
  subject(:exception) { Importu::MissingField.new(definition) }
49
76
 
50
77
  it "should be a subclass of Importu::InvalidRecord" do
@@ -52,30 +79,56 @@ describe Importu::ImportuException do
52
79
  end
53
80
 
54
81
  it "#name should return 'MissingField'" do
55
- exception.name.should eq "MissingField"
82
+ expect(exception.name).to eq "MissingField"
56
83
  end
57
84
 
58
85
  it "#definition should return the definition passed during construction" do
59
- exception.definition.should eq definition
86
+ expect(exception.definition).to eq definition
60
87
  end
61
88
 
62
89
  describe "#message" do
63
90
  it "should mention a missing field" do
64
- exception.message.should match(/missing field/i)
91
+ expect(exception.message).to match(/missing field/i)
65
92
  end
66
93
 
67
94
  context "field definition has a label" do
68
- let(:definition) { { :label => "Field 2" } }
95
+ let(:definition) { { label: "Field 2" } }
69
96
  it "mentions missing field's label" do
70
- exception.message.should match(/Field 2/)
97
+ expect(exception.message).to match(/Field 2/)
71
98
  end
72
99
  end
73
100
 
74
101
  context "field definition is missing a label" do
75
- let(:definition) { { :name => "foo_field_2" } }
102
+ let(:definition) { { name: "foo_field_2" } }
76
103
 
77
104
  it "mentions missing field's name" do
78
- exception.message.should match(/foo_field_2/)
105
+ expect(exception.message).to match(/foo_field_2/)
106
+ end
107
+ end
108
+
109
+ context "when available_fields is provided" do
110
+ subject(:exception) do
111
+ Importu::MissingField.new(definition, available_fields: ["name", "email", "phone"])
112
+ end
113
+
114
+ it "lists available fields" do
115
+ expect(exception.message).to match(/available fields: name, email, phone/)
116
+ end
117
+ end
118
+
119
+ context "when available_fields is empty" do
120
+ subject(:exception) do
121
+ Importu::MissingField.new(definition, available_fields: [])
122
+ end
123
+
124
+ it "does not include available fields section" do
125
+ expect(exception.message).not_to match(/available fields/)
126
+ end
127
+ end
128
+
129
+ context "when available_fields is not provided" do
130
+ it "does not include available fields section" do
131
+ expect(exception.message).not_to match(/available fields/)
79
132
  end
80
133
  end
81
134
  end
@@ -89,7 +142,7 @@ describe Importu::ImportuException do
89
142
  end
90
143
 
91
144
  it "#name should return 'DuplicateRecord'" do
92
- exception.name.should eq "DuplicateRecord"
145
+ expect(exception.name).to eq "DuplicateRecord"
93
146
  end
94
147
  end
95
148
 
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+
4
+ require "importu/converter_context"
5
+ require "importu/definition"
6
+ require "importu/exceptions"
7
+
8
+ RSpec.describe Importu::ConverterContext do
9
+ subject(:context) do
10
+ described_class.with_config(**definition.config).new(data)
11
+ end
12
+
13
+ let(:definition) { Class.new(Importu::Definition) }
14
+
15
+ let(:data) { {} }
16
+
17
+ describe "#field_value" do
18
+ let(:definition) do
19
+ Class.new(super()) do
20
+ field :field1, required: true, &convert_to(:integer)
21
+ end
22
+ end
23
+
24
+ let(:data) { { "field1" => "73" } }
25
+
26
+ it "returns the field value after applying the converter" do
27
+ expect(context.field_value(:field1)).to eq 73
28
+ end
29
+
30
+ context "when a definition doesn't exist for the field" do
31
+ it "raises an InvalidDefinition error" do
32
+ expect { context.field_value(:nonexistent_field) }
33
+ .to raise_error(Importu::InvalidDefinition)
34
+ end
35
+ end
36
+
37
+ context "when the converted value is nil" do
38
+ let(:data) { { "field1" => nil } }
39
+
40
+ context "and the field is required" do
41
+ let(:definition) { Class.new(super()) { field :field1, required: true } }
42
+
43
+ it "raises a MissingField error" do
44
+ expect { context.field_value(:field1) }
45
+ .to raise_error(Importu::MissingField)
46
+ end
47
+
48
+ context "and a default is defined on the field" do
49
+ let(:definition) { Class.new(super()) { field :field1, default: :beep } }
50
+
51
+ it "raises a MissingField error" do
52
+ expect { context.field_value(:field1) }
53
+ .to raise_error(Importu::MissingField)
54
+ end
55
+ end
56
+ end
57
+
58
+ context "and the field is not required" do
59
+ let(:definition) { Class.new(super()) { field :field1, required: false } }
60
+
61
+ it "returns nil" do
62
+ expect(context.field_value(:field1)).to be nil
63
+ end
64
+
65
+ context "annd a default is defined on the field" do
66
+ let(:definition) { Class.new(super()) { field :field1, default: :beep } }
67
+
68
+ it "returns the default value" do
69
+ expect(context.field_value(:field1)).to eq :beep
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ context "when converter raises an ArgumentError" do
76
+ let(:definition) do
77
+ Class.new(super()) do
78
+ converter(:ash) {|*| raise ArgumentError, "sawdust" }
79
+ field :field1, &convert_to(:ash)
80
+ end
81
+ end
82
+
83
+ it "raises a FieldParseError error" do
84
+ expect { context.field_value(:field1) }
85
+ .to raise_error(Importu::FieldParseError)
86
+ end
87
+
88
+ it "includes the original error message" do
89
+ expect { context.field_value(:field1) }
90
+ .to raise_error(Importu::FieldParseError)
91
+ .with_message(/sawdust/)
92
+ end
93
+ end
94
+
95
+ context "when converter raises an unexpected error" do
96
+ let(:definition) do
97
+ Class.new(super()) do
98
+ converter(:rubble) {|*| raise StandardError, "you did what?!" }
99
+ field :field1, &convert_to(:rubble)
100
+ end
101
+ end
102
+
103
+ it "raises the unexpected exception" do
104
+ expect { context.field_value(:field1) }
105
+ .to raise_error(StandardError)
106
+ end
107
+ end
108
+ end
109
+
110
+ describe "field cross-references" do
111
+ context "when a field converter references another field" do
112
+ let(:definition) do
113
+ Class.new(super()) do
114
+ field :first_name
115
+ field :last_name
116
+ field :full_name, abstract: true do
117
+ "#{field_value(:first_name)} #{field_value(:last_name)}"
118
+ end
119
+ end
120
+ end
121
+
122
+ let(:data) { { "first_name" => "Arthur", "last_name" => "Dent" } }
123
+
124
+ it "can access other field values" do
125
+ expect(context.field_value(:full_name)).to eq "Arthur Dent"
126
+ end
127
+ end
128
+
129
+ context "when a field references a field with a converter" do
130
+ let(:definition) do
131
+ Class.new(super()) do
132
+ field :price, &convert_to(:decimal)
133
+ field :quantity, &convert_to(:integer)
134
+ field :total, abstract: true do
135
+ field_value(:price) * field_value(:quantity)
136
+ end
137
+ end
138
+ end
139
+
140
+ let(:data) { { "price" => "19.99", "quantity" => "3" } }
141
+
142
+ it "receives the converted value, not raw" do
143
+ expect(context.field_value(:total)).to eq BigDecimal("59.97")
144
+ end
145
+ end
146
+
147
+ context "when a referenced field is missing" do
148
+ let(:definition) do
149
+ Class.new(super()) do
150
+ field :first_name
151
+ field :last_name
152
+ field :full_name, abstract: true do
153
+ "#{field_value(:first_name)} #{field_value(:last_name)}"
154
+ end
155
+ end
156
+ end
157
+
158
+ let(:data) { { "first_name" => "Arthur" } }
159
+
160
+ it "raises MissingField for the referenced field" do
161
+ expect { context.field_value(:full_name) }
162
+ .to raise_error(Importu::MissingField)
163
+ end
164
+ end
165
+ end
166
+
167
+ describe "#raw_value" do
168
+ let(:definition) { Class.new(super()) { field :field1 } }
169
+ let(:data) { { "field1" => "zippy" } }
170
+
171
+ it "returns the data associated with the field" do
172
+ expect(context.raw_value(:field1)).to eq data["field1"]
173
+ end
174
+
175
+ context "when a definition doesn't exist for the field" do
176
+ it "raises an InvalidDefinition error" do
177
+ expect { context.raw_value(:nonexistent_field) }
178
+ .to raise_error(Importu::InvalidDefinition)
179
+ end
180
+ end
181
+
182
+ context "when data is nil for the field" do
183
+ let(:data) { { "field1" => nil } }
184
+
185
+ it "returns nil" do
186
+ expect(context.raw_value(:field1)).to be nil
187
+ end
188
+ end
189
+
190
+ context "when the field does not exist in data hash" do
191
+ let(:data) { {} }
192
+
193
+ it "returns nil" do
194
+ expect(context.raw_value(:field1)).to be nil
195
+ end
196
+ end
197
+ end
198
+
199
+ end