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,175 @@
1
+ # frozen_string_literal: true
2
+ require "importu/exceptions"
3
+ require "importu/summary"
4
+
5
+ # Implement the following in specs that use this example:
6
+ #
7
+ # subject(:source) { described_class.new(input, **source_config) }
8
+ # let(:definition) { Class.new(Importu::Definition) }
9
+ # let(:source_config) { definition.config[:sources][:csv] }
10
+ #
11
+ RSpec.shared_examples "importer source" do |format, exclude: []|
12
+ describe "#initialize" do
13
+ unless exclude.include?(:empty_file)
14
+ context "when input file is blank" do
15
+ let(:input) { infile("source-empty-file", format) }
16
+
17
+ it "raises an InvalidInput exception" do
18
+ expect { source }.to raise_error Importu::InvalidInput
19
+ end
20
+ end
21
+ end
22
+
23
+ unless exclude.include?(:malformed)
24
+ context "when input file is malformed" do
25
+ let(:input) { infile("source-malformed", format) }
26
+
27
+ it "raises an InvalidInput exception" do
28
+ expect { source }.to raise_error Importu::InvalidInput
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ describe "#rows" do
35
+ unless exclude.include?(:no_records)
36
+ context "when input has no rows" do
37
+ let(:input) { infile("source-no-records", format) }
38
+
39
+ it "treats file as having 0 rows" do
40
+ expect(source.rows.count).to eq 0
41
+ end
42
+ end
43
+ end
44
+
45
+ unless exclude.include?(:empty_records)
46
+ context "when input contains empty rows" do
47
+ let(:input) { infile("source-empty-records", format) }
48
+
49
+ it "treats empty rows as existing (albeit invalid)" do
50
+ expect(source.rows.count).to eq 2
51
+ end
52
+ end
53
+ end
54
+
55
+ context "when input contains multiple valid rows" do
56
+ let(:input) { infile("books-valid", format) }
57
+
58
+ it "returns rows parsed from source data" do
59
+ expect(source.rows.count).to eq 3
60
+ end
61
+ end
62
+ end
63
+
64
+ describe "#write_errors" do
65
+ subject(:source) { described_class.new(infile("books-valid", format), **source_config) }
66
+
67
+ context "when there are no errors during import" do
68
+ let(:summary) { Importu::Summary.new }
69
+
70
+ it "doesn't try to generate a file, but returns nil" do
71
+ expect(source.write_errors(summary)).to be_nil
72
+ end
73
+ end
74
+
75
+ context "when there are errors during import" do
76
+ let(:summary) do
77
+ Importu::Summary.new.tap do |summary|
78
+ summary.record(:invalid, index: 1, errors: ["foo was invalid"])
79
+ summary.record(:invalid, index: 2, errors: ["foo was invalid", "bar was invalid"])
80
+ end
81
+ end
82
+
83
+ it "returns a rewoundfile handle/io" do
84
+ errfile = source.write_errors(summary)
85
+ expect(errfile).to respond_to(:readline)
86
+ expect(errfile.pos).to eq 0
87
+ end
88
+
89
+ it "records errors to '_errors' field with source data" do
90
+ errfile = source.write_errors(summary)
91
+ new_source = described_class.new(errfile, **source_config)
92
+
93
+ expect(new_source.rows.map {|r| r["_errors"] }).to eq [
94
+ nil, # 0
95
+ "foo was invalid", # 1
96
+ "foo was invalid, bar was invalid", # 2
97
+ ]
98
+
99
+ # Everything except "_errors" should match original input
100
+ expect(new_source.rows.map {|row| row.reject {|k, _v| k == "_errors" } })
101
+ .to eq source.rows.to_a
102
+ end
103
+
104
+ it "clears any existing '_errors' values in source data" do
105
+ errfile = source.write_errors(summary)
106
+ source2 = described_class.new(errfile, **source_config)
107
+
108
+ summary2 = Importu::Summary.new.tap do |summary|
109
+ summary.record(:invalid, index: 0, errors: ["baz was invalid"])
110
+ end
111
+
112
+ errfile2 = source2.write_errors(summary2)
113
+ source3 = described_class.new(errfile2, **source_config)
114
+
115
+ expect(source3.rows.map {|r| r["_errors"] }).to eq [
116
+ "baz was invalid", # 0
117
+ nil, # 1
118
+ nil, # 2
119
+ ]
120
+ end
121
+
122
+ it "does not affect original source data" do
123
+ expect { source.write_errors(summary) }
124
+ .to_not change { source.rows.to_a }
125
+ end
126
+
127
+ it "supports only writing records with errors" do
128
+ errfile = source.write_errors(summary, only_errors: true)
129
+ new_source = described_class.new(errfile, **source_config)
130
+ expect(new_source.rows.map {|r| r["_errors"] }).to eq [
131
+ # nil, # 0, excluded
132
+ "foo was invalid", # 1
133
+ "foo was invalid, bar was invalid", # 2
134
+ ]
135
+ end
136
+ end
137
+ end
138
+
139
+ describe "Importer#records (usable as a record source?)" do
140
+ subject(:importer) { BookImporter.new(source) }
141
+
142
+ context "when source data is valid" do
143
+ let(:input) { infile("books-valid", format) }
144
+
145
+ it "returns records parsed from source data" do
146
+ expected_record_json!("books-valid", importer.records)
147
+ end
148
+
149
+ it "returns same records on subsequent invocations (rewinds)" do
150
+ previous_count = importer.records.count
151
+ expect(importer.records.count).to eq previous_count
152
+ end
153
+ end
154
+ end
155
+
156
+ describe "Importer#import! (verifies entire flow)" do
157
+ subject(:importer) { importer_class.new(source) }
158
+ let(:importer_class) do
159
+ stub_const("Book", Class.new)
160
+
161
+ Class.new(BookImporter) do
162
+ model "Book", backend: :dummy
163
+ end
164
+ end
165
+
166
+ context "when source data is valid" do
167
+ let(:input) { infile("books-valid", format) }
168
+
169
+ it "returns a summary with expected results" do
170
+ summary = importer.import!
171
+ expected_summary_json!("books-valid", summary)
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+
4
+ require "importu/definition"
5
+ require "importu/sources/json"
6
+
7
+ require_relative "importer_source_examples"
8
+
9
+ RSpec.describe Importu::Sources::JSON do
10
+ it_behaves_like "importer source", :json do
11
+ subject(:source) { described_class.new(input, **source_config) }
12
+ let(:definition) { Class.new(Importu::Definition) }
13
+ let(:source_config) { definition.config[:sources][:json] }
14
+ end
15
+
16
+ describe "#initialize" do
17
+ subject(:source) { described_class.new(StringIO.new(data), **source_config) }
18
+ let(:definition) { Class.new(Importu::Definition) }
19
+ let(:source_config) { definition.config[:sources][:json] }
20
+
21
+ context "when document is JSON null" do
22
+ let(:data) { "null" }
23
+ it "raises InvalidInput with empty document message" do
24
+ expect { source }.to raise_error(Importu::InvalidInput, "Empty document")
25
+ end
26
+ end
27
+ end
28
+
29
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ require "importu/sources/ruby"
6
+ require "importu/summary"
7
+
8
+ RSpec.describe Importu::Sources::Ruby do
9
+ subject(:source) { described_class.new(data) }
10
+
11
+ describe "#initialize" do
12
+ let(:data) { [] }
13
+
14
+ it "accepts an array" do
15
+ expect { source }.not_to raise_error
16
+ end
17
+
18
+ it "accepts any enumerable" do
19
+ source = described_class.new([1, 2, 3].each)
20
+ expect(source).to be_a(described_class)
21
+ end
22
+ end
23
+
24
+ describe "#rows" do
25
+ context "with an array of hashes" do
26
+ let(:data) { [{ "name" => "Alice" }, { "name" => "Bob" }] }
27
+
28
+ it "returns an enumerator" do
29
+ expect(source.rows).to be_a(Enumerator)
30
+ end
31
+
32
+ it "yields each hash" do
33
+ expect(source.rows.to_a).to eq data
34
+ end
35
+
36
+ it "can be iterated multiple times" do
37
+ first_pass = source.rows.to_a
38
+ second_pass = source.rows.to_a
39
+ expect(second_pass).to eq first_pass
40
+ end
41
+ end
42
+
43
+ context "with objects responding to to_hash" do
44
+ let(:hashable_object) do
45
+ Class.new do
46
+ def initialize(name)
47
+ @name = name
48
+ end
49
+
50
+ def to_hash
51
+ { "name" => @name }
52
+ end
53
+ end
54
+ end
55
+
56
+ let(:data) { [hashable_object.new("Alice"), hashable_object.new("Bob")] }
57
+
58
+ it "converts each object via to_hash" do
59
+ expect(source.rows.to_a).to eq [{ "name" => "Alice" }, { "name" => "Bob" }]
60
+ end
61
+ end
62
+
63
+ context "with empty array" do
64
+ let(:data) { [] }
65
+
66
+ it "returns 0 rows" do
67
+ expect(source.rows.count).to eq 0
68
+ end
69
+ end
70
+
71
+ context "with symbol keys in hashes" do
72
+ let(:data) { [{ name: "Alice" }] }
73
+
74
+ # NOTE: The Ruby source calls to_hash which preserves symbol keys.
75
+ # However, importu field definitions use string keys by default.
76
+ # Users should ensure their hash keys match what fields expect.
77
+ it "preserves symbol keys (caller must ensure key type matches)" do
78
+ expect(source.rows.first.keys).to eq [:name]
79
+ end
80
+ end
81
+ end
82
+
83
+ describe "#write_errors" do
84
+ let(:data) { [{ "name" => "Alice" }] }
85
+
86
+ let(:summary) do
87
+ Importu::Summary.new.tap do |s|
88
+ s.record(:invalid, index: 0, errors: ["name was invalid"])
89
+ end
90
+ end
91
+
92
+ # The Ruby source has a stub implementation that does nothing.
93
+ # This is intentional - there's no sensible file output for in-memory data.
94
+ it "returns nil (stub implementation)" do
95
+ expect(source.write_errors(summary)).to be_nil
96
+ end
97
+
98
+ it "accepts only_errors option without error" do
99
+ expect { source.write_errors(summary, only_errors: true) }.not_to raise_error
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ require "importu/definition"
6
+ require "importu/sources/xml"
7
+
8
+ require_relative "importer_source_examples"
9
+
10
+ RSpec.describe Importu::Sources::XML do
11
+ it_behaves_like "importer source", :xml do
12
+ subject(:source) { described_class.new(input, **source_config) }
13
+ let(:source_config) { definition.config[:sources][:xml] }
14
+
15
+ let(:definition) do
16
+ Class.new(Importu::Definition) { source :xml, records_xpath: "//book" }
17
+ end
18
+ end
19
+
20
+ describe "xpath edge cases" do
21
+ let(:xml_content) do
22
+ <<~XML
23
+ <?xml version="1.0"?>
24
+ <library>
25
+ <book><title>Red Dwarf</title></book>
26
+ <book><title>Better Than Life</title></book>
27
+ </library>
28
+ XML
29
+ end
30
+
31
+ let(:input) { StringIO.new(xml_content) }
32
+
33
+ context "when records_xpath matches nothing" do
34
+ subject(:source) { described_class.new(input, records_xpath: "//nonexistent") }
35
+
36
+ it "returns 0 rows" do
37
+ expect(source.rows.count).to eq 0
38
+ end
39
+ end
40
+
41
+ context "when records_xpath matches the root" do
42
+ subject(:source) { described_class.new(input, records_xpath: "/library") }
43
+
44
+ it "returns the root element as a single row" do
45
+ rows = source.rows.to_a
46
+ expect(rows.count).to eq 1
47
+ end
48
+ end
49
+
50
+ context "when records_xpath uses attributes" do
51
+ let(:xml_content) do
52
+ <<~XML
53
+ <?xml version="1.0"?>
54
+ <library>
55
+ <book isbn="123"><title>Red Dwarf</title></book>
56
+ <book isbn="456"><title>Better Than Life</title></book>
57
+ </library>
58
+ XML
59
+ end
60
+
61
+ subject(:source) { described_class.new(input, records_xpath: "//book") }
62
+
63
+ it "includes attributes in the row hash" do
64
+ rows = source.rows.to_a
65
+ expect(rows.first["isbn"]).to eq "123"
66
+ expect(rows.first["title"]).to eq "Red Dwarf"
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+
4
+ require "importu/exceptions"
5
+ require "importu/summary"
6
+
7
+ RSpec.describe Importu::Summary do
8
+ subject(:summary) { Importu::Summary.new }
9
+
10
+ describe "#record" do
11
+ it "allows incrementing :created count" do
12
+ expect { summary.record(:created) }.to change { summary.created }.by(1)
13
+ expect { summary.record(:created) }.to change { summary.total }.by(1)
14
+ end
15
+
16
+ it "allows incrementing :updated count" do
17
+ expect { summary.record(:updated) }.to change { summary.updated }.by(1)
18
+ expect { summary.record(:updated) }.to change { summary.total }.by(1)
19
+ end
20
+
21
+ it "allows incrementing :unchanged count" do
22
+ expect { summary.record(:unchanged) }.to change { summary.unchanged }.by(1)
23
+ expect { summary.record(:unchanged) }.to change { summary.total }.by(1)
24
+ end
25
+
26
+ it "allows incrementing :invalid count" do
27
+ expect { summary.record(:invalid) }.to change { summary.invalid }.by(1)
28
+ expect { summary.record(:invalid) }.to change { summary.total }.by(1)
29
+ end
30
+
31
+ it "records aggregated errors associated with :invalid results" do
32
+ summary.record(:invalid, errors: ["foo was invalid", "bar was invalid"])
33
+ expect(summary.validation_errors.count).to eq 2
34
+ end
35
+
36
+ it "allows passing a record index to associate with errors" do
37
+ summary.record(:invalid, index: 71, errors: ["foo was invalid"])
38
+ summary.record(:invalid, index: 94, errors: ["bar was invalid"])
39
+ expect(summary.itemized_errors.keys).to eq [71, 94]
40
+ end
41
+ end
42
+
43
+ describe "#result_msg" do
44
+ it "includes a summary of recorded counts" do
45
+ 5.times { summary.record(:created) }
46
+ 4.times { summary.record(:updated) }
47
+ 3.times { summary.record(:unchanged) }
48
+ 2.times { summary.record(:invalid, errors: ["foo was invalid"]) }
49
+ 1.times { summary.record(:invalid, errors: ["bar was invalid"]) }
50
+
51
+ expect(summary.result_msg).to match(/total:\s*15/i)
52
+ expect(summary.result_msg).to match(/created:\s*5/i)
53
+ expect(summary.result_msg).to match(/updated:\s*4/i)
54
+ expect(summary.result_msg).to match(/unchanged:\s*3/i)
55
+ expect(summary.result_msg).to match(/invalid:\s*3/i)
56
+ end
57
+
58
+ context "when there are no validation errors" do
59
+ it "does not include a breakdown of errors" do
60
+ expect(summary.result_msg).to_not match(/validation errors/i)
61
+ end
62
+ end
63
+
64
+ context "when there are validation errors" do
65
+ it "includes a breakdown of errors" do
66
+ 3.times { summary.record(:invalid, errors: ["foo was invalid"]) }
67
+ 2.times { summary.record(:invalid, errors: ["bar was invalid"]) }
68
+
69
+ expect(summary.result_msg).to match(/validation errors/i)
70
+ expect(summary.result_msg).to match(/foo was invalid:\s*3/i)
71
+ expect(summary.result_msg).to match(/bar was invalid:\s*2/i)
72
+ end
73
+ end
74
+ end
75
+
76
+ describe "#to_hash" do
77
+ it "returns summary counts as a hash" do
78
+ 5.times { summary.record(:created) }
79
+ 4.times { summary.record(:updated) }
80
+ 3.times { summary.record(:unchanged) }
81
+ 2.times { summary.record(:invalid, errors: ["foo was invalid"]) }
82
+ 1.times { summary.record(:invalid, errors: ["bar was invalid"]) }
83
+
84
+ expect(summary.to_hash).to include({
85
+ created: 5,
86
+ invalid: 3,
87
+ unchanged: 3,
88
+ updated: 4,
89
+ total: 15,
90
+ })
91
+ end
92
+
93
+ context "when there are no validation errors" do
94
+ it "includes an empty hash of errors" do
95
+ expect(summary.to_hash[:validation_errors]).to eq({})
96
+ end
97
+ end
98
+
99
+ context "when there are validation errors" do
100
+ it "includes a breakdown of errors" do
101
+ 2.times { summary.record(:invalid, errors: ["foo was invalid"]) }
102
+ 1.times { summary.record(:invalid, errors: ["bar was invalid"]) }
103
+
104
+ expect(summary.to_hash[:validation_errors]).to eq({
105
+ "foo was invalid" => 2,
106
+ "bar was invalid" => 1,
107
+ })
108
+ end
109
+ end
110
+ end
111
+
112
+ describe "#to_s" do
113
+ it "returns same summary as #result_msg" do
114
+ expect(summary.to_s).to eq summary.result_msg
115
+ end
116
+ end
117
+
118
+ describe "#validation_errors" do
119
+ context "when no errors" do
120
+ it "returns an empty hash" do
121
+ expect(summary.validation_errors).to eq({})
122
+ end
123
+ end
124
+
125
+ context "when multiple errors" do
126
+ it "counts each occurrence of error message" do
127
+ summary.record(:invalid, errors: ["foo was invalid", "bar was invalid"])
128
+ summary.record(:invalid, errors: ["bar was invalid", "baz was invalid"])
129
+ expect(summary.validation_errors["foo was invalid"]).to eq 1
130
+ expect(summary.validation_errors["bar was invalid"]).to eq 2
131
+ expect(summary.validation_errors["baz was invalid"]).to eq 1
132
+ end
133
+ end
134
+
135
+ context "when errors contain data within parentheses" do
136
+ it "strips out parenthesis and data within if at end of error" do
137
+ summary.record(:invalid, errors: ["lost parrot (polly)"])
138
+ summary.record(:invalid, errors: ["lost parrot (cracker)"])
139
+ expect(summary.validation_errors["lost parrot"]).to eq 2
140
+ end
141
+ end
142
+
143
+ context "when error has a normalized_message" do
144
+ it "uses normalized_message for aggregation" do
145
+ error1 = Importu::InvalidRecord.new(
146
+ "isbn too short (was: abc)", nil, normalized_message: "isbn too short"
147
+ )
148
+ error2 = Importu::InvalidRecord.new(
149
+ "isbn too short (was: xyz)", nil, normalized_message: "isbn too short"
150
+ )
151
+
152
+ summary.record(:invalid, errors: [error1])
153
+ summary.record(:invalid, errors: [error2])
154
+ expect(summary.validation_errors["isbn too short"]).to eq 2
155
+ end
156
+ end
157
+ end
158
+
159
+ describe "#itemized_errors" do
160
+ context "when no errors" do
161
+ it "returns an empty hash" do
162
+ expect(summary.itemized_errors).to eq({})
163
+ end
164
+
165
+ it "ignores errors not associated with a record index" do
166
+ expect { summary.record(:invalid, errors: ["foo was invalid"]) }
167
+ .to_not change { summary.itemized_errors }
168
+ expect { summary.record(:invalid, index: nil, errors: ["foo was invalid"]) }
169
+ .to_not change { summary.itemized_errors }
170
+ end
171
+
172
+ it "groups error messages by record index" do
173
+ summary.record(:invalid, index: 9, errors: ["foo", "bar"])
174
+ summary.record(:invalid, index: 11, errors: ["foo"])
175
+ summary.record(:invalid, index: 11, errors: ["bar", "baz"])
176
+ summary.record(:invalid, index: 17, errors: ["bar"])
177
+ expect(summary.itemized_errors).to eq({
178
+ 9 => ["foo", "bar"],
179
+ 11 => ["foo", "bar", "baz"],
180
+ 17 => ["bar"],
181
+ })
182
+ end
183
+ end
184
+ end
185
+
186
+ end
data/spec/spec_helper.rb CHANGED
@@ -1,14 +1,98 @@
1
- ENV['RAILS_ENV'] ||= 'test'
1
+ # frozen_string_literal: true
2
+ # This file was generated by the `rspec --init` command. Conventionally, all
3
+ # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
4
+ # The generated `.rspec` file contains `--require spec_helper` which will cause
5
+ # this file to always be loaded, without a need to explicitly require it in any
6
+ # files.
7
+ #
8
+ # Given that it is always loaded, you are encouraged to keep this file as
9
+ # light-weight as possible. Requiring heavyweight dependencies from this file
10
+ # will add to the boot time of your test suite on EVERY test run, even for an
11
+ # individual file that may not need all of that loaded. Instead, consider making
12
+ # a separate helper file that requires the additional dependencies and performs
13
+ # the additional setup, and require it from the spec files that actually need
14
+ # it.
15
+ #
16
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
2
17
 
3
- require 'importu'
4
- require 'factory_girl'
18
+ require "simplecov"
19
+
20
+ require "active_record" if Gem.loaded_specs.has_key?("activerecord")
5
21
 
6
22
  # Requires supporting ruby files with custom matchers and macros, etc,
7
23
  # in spec/support/ and its subdirectories.
8
- Dir[File.expand_path('../support/**/*.rb', __FILE__)].each {|f| require f }
24
+ Dir[File.expand_path("support/**/*.rb", __dir__)].each {|f| require f }
9
25
 
10
26
  RSpec.configure do |config|
11
- config.include FactoryGirl::Syntax::Methods
12
- end
27
+ # rspec-expectations config goes here. You can use an alternate
28
+ # assertion/expectation library such as wrong or the stdlib/minitest
29
+ # assertions if you prefer.
30
+ config.expect_with :rspec do |expectations|
31
+ # This option will default to `true` in RSpec 4. It makes the `description`
32
+ # and `failure_message` of custom matchers include text for helper methods
33
+ # defined using `chain`, e.g.:
34
+ # be_bigger_than(2).and_smaller_than(4).description
35
+ # # => "be bigger than 2 and smaller than 4"
36
+ # ...rather than:
37
+ # # => "be bigger than 2"
38
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
39
+ end
40
+
41
+ # rspec-mocks config goes here. You can use an alternate test double
42
+ # library (such as bogus or mocha) by changing the `mock_with` option here.
43
+ config.mock_with :rspec do |mocks|
44
+ # Prevents you from mocking or stubbing a method that does not exist on
45
+ # a real object. This is generally recommended, and will default to
46
+ # `true` in RSpec 4.
47
+ mocks.verify_partial_doubles = true
48
+ end
49
+
50
+ # This option will default to `:apply_to_host_groups` in RSpec 4 (and will
51
+ # have no way to turn it off -- the option exists only for backwards
52
+ # compatibility in RSpec 3). It causes shared context metadata to be
53
+ # inherited by the metadata hash of host groups and examples, rather than
54
+ # triggering implicit auto-inclusion in groups with matching metadata.
55
+ config.shared_context_metadata_behavior = :apply_to_host_groups
56
+
57
+ # This allows you to limit a spec run to individual examples or groups
58
+ # you care about by tagging them with `:focus` metadata. When nothing
59
+ # is tagged with `:focus`, all examples get run. RSpec also provides
60
+ # aliases for `it`, `describe`, and `context` that include `:focus`
61
+ # metadata: `fit`, `fdescribe` and `fcontext`, respectively.
62
+ config.filter_run_when_matching :focus
63
+
64
+ # Only run certain specs when the relevant gems are available. This is to
65
+ # allow for testing integration with different frameworks/backends in
66
+ # isolation with the appraisal gem. If all gems were loaded simultaneously
67
+ # then we wouldn't know if we were using an ActiveSupport monkeypatched
68
+ # method (i.e. String#present?) that would break w/o ActiveSupport loaded.
69
+ config.filter_run_excluding active_record: !defined?(::ActiveRecord)
13
70
 
14
- FactoryGirl.find_definitions
71
+ config.include FixturesHelper
72
+
73
+ # Allows RSpec to persist some state between runs in order to support
74
+ # the `--only-failures` and `--next-failure` CLI options. We recommend
75
+ # you configure your source control system to ignore this file.
76
+ config.example_status_persistence_file_path = "spec/examples.txt"
77
+
78
+ # Limits the available syntax to the non-monkey patched syntax that is
79
+ # recommended. For more details, see:
80
+ # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/
81
+ # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
82
+ # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode
83
+ config.disable_monkey_patching!
84
+
85
+ # This setting enables warnings. It's recommended, but in some cases may
86
+ # be too noisy due to issues in dependencies.
87
+ config.warnings = true
88
+
89
+ # Many RSpec users commonly either run the entire suite or an individual
90
+ # file, and it's useful to allow more verbose output when running an
91
+ # individual spec file.
92
+ if config.files_to_run.one?
93
+ # Use the documentation formatter for detailed output,
94
+ # unless a formatter has already been configured
95
+ # (e.g. via a command-line flag).
96
+ config.default_formatter = "doc"
97
+ end
98
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+ if defined?(::ActiveRecord)
3
+ ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"
4
+
5
+ ActiveRecord::Schema.define do
6
+ self.verbose = false
7
+
8
+ create_table :books, force: true do |t|
9
+ t.string :title, null: false
10
+ t.string :authors, null: false
11
+ t.string :isbn10, null: false
12
+ t.integer :pages
13
+ t.date :release_date, null: false
14
+ t.timestamps null: false
15
+ end
16
+
17
+ add_index :books, :isbn10, unique: true
18
+ end
19
+
20
+ end