solidus_shipstation 1.0.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 (78) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.circleci/config.yml +41 -0
  4. data/.gem_release.yml +5 -0
  5. data/.github/stale.yml +17 -0
  6. data/.github_changelog_generator +2 -0
  7. data/.gitignore +20 -0
  8. data/.rspec +2 -0
  9. data/.rubocop.yml +13 -0
  10. data/.rubocop_todo.yml +40 -0
  11. data/CHANGELOG.md +36 -0
  12. data/Gemfile +33 -0
  13. data/LICENSE +26 -0
  14. data/README.md +208 -0
  15. data/Rakefile +6 -0
  16. data/app/assets/javascripts/spree/backend/solidus_shipstation.js +2 -0
  17. data/app/assets/javascripts/spree/frontend/solidus_shipstation.js +2 -0
  18. data/app/assets/stylesheets/spree/backend/solidus_shipstation.css +4 -0
  19. data/app/assets/stylesheets/spree/frontend/solidus_shipstation.css +4 -0
  20. data/app/controllers/spree/shipstation_controller.rb +45 -0
  21. data/app/decorators/models/solidus_shipstation/spree/shipment_decorator.rb +33 -0
  22. data/app/helpers/solidus_shipstation/export_helper.rb +32 -0
  23. data/app/jobs/solidus_shipstation/api/schedule_shipment_syncs_job.rb +19 -0
  24. data/app/jobs/solidus_shipstation/api/sync_shipments_job.rb +41 -0
  25. data/app/queries/solidus_shipstation/shipment/between_query.rb +14 -0
  26. data/app/queries/solidus_shipstation/shipment/exportable_query.rb +24 -0
  27. data/app/queries/solidus_shipstation/shipment/pending_api_sync_query.rb +63 -0
  28. data/app/views/spree/shipstation/export.xml.builder +58 -0
  29. data/bin/console +17 -0
  30. data/bin/rails +7 -0
  31. data/bin/rails-engine +13 -0
  32. data/bin/rails-sandbox +16 -0
  33. data/bin/rake +7 -0
  34. data/bin/sandbox +86 -0
  35. data/bin/setup +8 -0
  36. data/config/locales/en.yml +5 -0
  37. data/config/routes.rb +6 -0
  38. data/db/migrate/20210220093010_add_shipstation_api_sync_fields.rb +9 -0
  39. data/lib/generators/solidus_shipstation/install/install_generator.rb +27 -0
  40. data/lib/generators/solidus_shipstation/install/templates/initializer.rb +62 -0
  41. data/lib/solidus_shipstation.rb +16 -0
  42. data/lib/solidus_shipstation/api/batch_syncer.rb +70 -0
  43. data/lib/solidus_shipstation/api/client.rb +38 -0
  44. data/lib/solidus_shipstation/api/rate_limited_error.rb +23 -0
  45. data/lib/solidus_shipstation/api/request_error.rb +33 -0
  46. data/lib/solidus_shipstation/api/request_runner.rb +50 -0
  47. data/lib/solidus_shipstation/api/shipment_serializer.rb +84 -0
  48. data/lib/solidus_shipstation/api/threshold_verifier.rb +28 -0
  49. data/lib/solidus_shipstation/configuration.rb +44 -0
  50. data/lib/solidus_shipstation/engine.rb +19 -0
  51. data/lib/solidus_shipstation/errors.rb +23 -0
  52. data/lib/solidus_shipstation/shipment_notice.rb +58 -0
  53. data/lib/solidus_shipstation/testing_support/factories.rb +4 -0
  54. data/lib/solidus_shipstation/version.rb +5 -0
  55. data/solidus_shipstation.gemspec +40 -0
  56. data/spec/controllers/spree/shipstation_controller_spec.rb +103 -0
  57. data/spec/fixtures/shipstation_xml_schema.xsd +171 -0
  58. data/spec/jobs/solidus_shipstation/api/schedule_shipment_syncs_job_spec.rb +32 -0
  59. data/spec/jobs/solidus_shipstation/api/sync_shipments_job_spec.rb +102 -0
  60. data/spec/lib/solidus_shipstation/api/batch_syncer_spec.rb +229 -0
  61. data/spec/lib/solidus_shipstation/api/client_spec.rb +120 -0
  62. data/spec/lib/solidus_shipstation/api/rate_limited_error_spec.rb +21 -0
  63. data/spec/lib/solidus_shipstation/api/request_error_spec.rb +20 -0
  64. data/spec/lib/solidus_shipstation/api/request_runner_spec.rb +64 -0
  65. data/spec/lib/solidus_shipstation/api/shipment_serializer_spec.rb +12 -0
  66. data/spec/lib/solidus_shipstation/api/threshold_verifier_spec.rb +61 -0
  67. data/spec/lib/solidus_shipstation/shipment_notice_spec.rb +111 -0
  68. data/spec/lib/solidus_shipstation_spec.rb +9 -0
  69. data/spec/models/spree/shipment_spec.rb +49 -0
  70. data/spec/queries/solidus_shipstation/shipment/between_query_spec.rb +53 -0
  71. data/spec/queries/solidus_shipstation/shipment/exportable_query_spec.rb +53 -0
  72. data/spec/queries/solidus_shipstation/shipment/pending_api_sync_query_spec.rb +37 -0
  73. data/spec/spec_helper.rb +31 -0
  74. data/spec/support/configuration_helper.rb +13 -0
  75. data/spec/support/controllers.rb +1 -0
  76. data/spec/support/webmock.rb +3 -0
  77. data/spec/support/xsd.rb +5 -0
  78. metadata +248 -0
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe SolidusShipstation::Api::ScheduleShipmentSyncsJob do
4
+ it 'schedules the shipment sync in batches' do
5
+ stub_configuration(api_batch_size: 2)
6
+ shipments = create_list(:shipment, 3)
7
+ relation = instance_double('ActiveRecord::Relation').tap do |r|
8
+ allow(r).to receive(:find_in_batches)
9
+ .with(batch_size: 2)
10
+ .and_yield(shipments[0..1])
11
+ .and_yield([shipments.last])
12
+ end
13
+ allow(SolidusShipstation::Shipment::PendingApiSyncQuery).to receive(:apply)
14
+ .and_return(relation)
15
+
16
+ described_class.perform_now
17
+
18
+ expect(SolidusShipstation::Api::SyncShipmentsJob).to have_been_enqueued.with(shipments[0..1])
19
+ expect(SolidusShipstation::Api::SyncShipmentsJob).to have_been_enqueued.with([shipments.last])
20
+ end
21
+
22
+ it 'reports any errors to the handler' do
23
+ error_handler = instance_spy('Proc')
24
+ stub_configuration(error_handler: error_handler)
25
+ error = RuntimeError.new('Something went wrong')
26
+ allow(SolidusShipstation::Shipment::PendingApiSyncQuery).to receive(:apply).and_raise(error)
27
+
28
+ described_class.perform_now
29
+
30
+ expect(error_handler).to have_received(:call).with(error, {})
31
+ end
32
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe SolidusShipstation::Api::SyncShipmentsJob do
4
+ include ActiveSupport::Testing::TimeHelpers
5
+
6
+ context 'when a shipment is syncable' do
7
+ context 'when the sync can be completed successfully' do
8
+ it 'syncs the provided shipments in batch' do
9
+ shipment = build_stubbed(:shipment) { |s| stub_syncability(s, true) }
10
+ batch_syncer = stub_successful_batch_syncer
11
+
12
+ described_class.perform_now([shipment])
13
+
14
+ expect(batch_syncer).to have_received(:call).with([shipment])
15
+ end
16
+ end
17
+
18
+ context 'when the sync cannot be completed' do
19
+ context 'when the error is a rate limit' do
20
+ it 'retries intelligently when hitting a rate limit' do
21
+ freeze_time do
22
+ shipment = build_stubbed(:shipment) { |s| stub_syncability(s, true) }
23
+ stub_failing_batch_syncer(SolidusShipstation::Api::RateLimitedError.new(
24
+ response_code: 429,
25
+ response_headers: { 'X-Rate-Limit-Reset' => 30 },
26
+ response_body: '{"message":"Too Many Requests"}',
27
+ retry_in: 30.seconds,
28
+ ))
29
+
30
+ described_class.perform_now([shipment])
31
+
32
+ expect(described_class).to have_been_enqueued.at(30.seconds.from_now)
33
+ end
34
+ end
35
+ end
36
+
37
+ context 'when the error is a server error' do
38
+ it 'calls the error handler' do
39
+ error_handler = instance_spy('Proc')
40
+ stub_configuration(error_handler: error_handler)
41
+ shipment = build_stubbed(:shipment) { |s| stub_syncability(s, true) }
42
+ error = SolidusShipstation::Api::RequestError.new(
43
+ response_code: 500,
44
+ response_headers: {},
45
+ response_body: '{"message":"Internal Server Error"}',
46
+ )
47
+ stub_failing_batch_syncer(error)
48
+
49
+ described_class.perform_now([shipment])
50
+
51
+ expect(error_handler).to have_received(:call).with(error, {})
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ context 'when a shipment is not syncable' do
58
+ it 'skips shipments that are not pending sync' do
59
+ shipment = build_stubbed(:shipment) { |s| stub_syncability(s, false) }
60
+ batch_syncer = stub_successful_batch_syncer
61
+
62
+ described_class.perform_now([shipment])
63
+
64
+ expect(batch_syncer).not_to have_received(:call)
65
+ end
66
+
67
+ it 'fires a solidus_shipstation.api.sync_skipped event' do
68
+ stub_const('Spree::Event', class_spy(Spree::Event))
69
+ shipment = build_stubbed(:shipment) { |s| stub_syncability(s, false) }
70
+ stub_successful_batch_syncer
71
+
72
+ described_class.perform_now([shipment])
73
+
74
+ expect(Spree::Event).to have_received(:fire).with(
75
+ 'solidus_shipstation.api.sync_skipped',
76
+ shipment: shipment,
77
+ )
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def stub_successful_batch_syncer
84
+ instance_spy(SolidusShipstation::Api::BatchSyncer).tap do |batch_syncer|
85
+ allow(SolidusShipstation::Api::BatchSyncer).to receive(:from_config).and_return(batch_syncer)
86
+ end
87
+ end
88
+
89
+ def stub_failing_batch_syncer(error)
90
+ instance_double(SolidusShipstation::Api::BatchSyncer).tap do |batch_syncer|
91
+ allow(SolidusShipstation::Api::BatchSyncer).to receive(:from_config).and_return(batch_syncer)
92
+
93
+ allow(batch_syncer).to receive(:call).and_raise(error)
94
+ end
95
+ end
96
+
97
+ def stub_syncability(shipment, result)
98
+ allow(SolidusShipstation::Api::ThresholdVerifier).to receive(:call)
99
+ .with(shipment)
100
+ .and_return(result)
101
+ end
102
+ end
@@ -0,0 +1,229 @@
1
+ RSpec.describe SolidusShipstation::Api::BatchSyncer do
2
+ include ActiveSupport::Testing::TimeHelpers
3
+
4
+ describe '.from_config' do
5
+ it 'creates a syncer with the configured API client' do
6
+ client = instance_double(SolidusShipstation::Api::Client)
7
+ allow(SolidusShipstation::Api::Client).to receive(:from_config).and_return(client)
8
+ shipment_matcher = -> {}
9
+ stub_configuration(api_shipment_matcher: shipment_matcher)
10
+
11
+ batch_syncer = described_class.from_config
12
+
13
+ expect(batch_syncer).to have_attributes(
14
+ client: client,
15
+ shipment_matcher: shipment_matcher,
16
+ )
17
+ end
18
+ end
19
+
20
+ describe '#call' do
21
+ context 'when the API call is successful' do
22
+ context 'when the sync operation succeeded' do
23
+ it 'updates the ShipStation data on the shipment' do
24
+ freeze_time do
25
+ shipment = instance_spy('Spree::Shipment', number: 'H123456')
26
+ api_client = instance_double(SolidusShipstation::Api::Client).tap do |client|
27
+ allow(client).to receive(:bulk_create_orders).with([shipment]).and_return(
28
+ {
29
+ 'results' => [
30
+ {
31
+ 'orderNumber' => shipment.number,
32
+ 'success' => true,
33
+ 'orderId' => '123456',
34
+ }
35
+ ]
36
+ }
37
+ )
38
+ end
39
+
40
+ build_batch_syncer(client: api_client).call([shipment])
41
+
42
+ expect(shipment).to have_received(:update_columns).with(
43
+ shipstation_order_id: '123456',
44
+ shipstation_synced_at: Time.zone.now,
45
+ )
46
+ end
47
+ end
48
+
49
+ it 'emits a solidus_shipstation.api.sync_completed event' do
50
+ stub_const('Spree::Event', class_spy(Spree::Event))
51
+ shipment = instance_spy('Spree::Shipment', number: 'H123456')
52
+ api_client = instance_double(SolidusShipstation::Api::Client).tap do |client|
53
+ allow(client).to receive(:bulk_create_orders).with([shipment]).and_return(
54
+ {
55
+ 'results' => [
56
+ {
57
+ 'orderNumber' => shipment.number,
58
+ 'success' => true,
59
+ 'orderId' => '123456',
60
+ }
61
+ ]
62
+ }
63
+ )
64
+ end
65
+
66
+ build_batch_syncer(client: api_client).call([shipment])
67
+
68
+ expect(Spree::Event).to have_received(:fire).with(
69
+ 'solidus_shipstation.api.sync_completed',
70
+ shipment: shipment,
71
+ payload: {
72
+ 'orderNumber' => shipment.number,
73
+ 'success' => true,
74
+ 'orderId' => '123456',
75
+ },
76
+ )
77
+ end
78
+ end
79
+
80
+ context 'when the sync operation failed' do
81
+ it 'does not update the ShipStation data on the shipment' do
82
+ shipment = instance_spy('Spree::Shipment', number: 'H123456')
83
+ api_client = instance_double(SolidusShipstation::Api::Client).tap do |client|
84
+ allow(client).to receive(:bulk_create_orders).with([shipment]).and_return(
85
+ {
86
+ 'results' => [
87
+ {
88
+ 'orderNumber' => shipment.number,
89
+ 'success' => false,
90
+ 'orderId' => '123456',
91
+ }
92
+ ]
93
+ }
94
+ )
95
+ end
96
+
97
+ build_batch_syncer(client: api_client).call([shipment])
98
+
99
+ expect(shipment).not_to have_received(:update_columns)
100
+ end
101
+
102
+ it 'emits a solidus_shipstation.api.sync_failed event' do
103
+ stub_const('Spree::Event', class_spy(Spree::Event))
104
+ shipment = instance_spy('Spree::Shipment', number: 'H123456')
105
+ api_client = instance_double(SolidusShipstation::Api::Client).tap do |client|
106
+ allow(client).to receive(:bulk_create_orders).with([shipment]).and_return(
107
+ {
108
+ 'results' => [
109
+ {
110
+ 'orderNumber' => shipment.number,
111
+ 'success' => false,
112
+ 'orderId' => '123456',
113
+ }
114
+ ]
115
+ }
116
+ )
117
+ end
118
+
119
+ build_batch_syncer(client: api_client).call([shipment])
120
+
121
+ expect(Spree::Event).to have_received(:fire).with(
122
+ 'solidus_shipstation.api.sync_failed',
123
+ shipment: shipment,
124
+ payload: {
125
+ 'orderNumber' => shipment.number,
126
+ 'success' => false,
127
+ 'orderId' => '123456',
128
+ },
129
+ )
130
+ end
131
+ end
132
+ end
133
+
134
+ context 'when the API call hits a rate limit' do
135
+ it 'emits a solidus_shipstation.api.rate_limited event' do
136
+ stub_const('Spree::Event', class_spy(Spree::Event))
137
+ shipment = instance_double('Spree::Shipment')
138
+ error = SolidusShipstation::Api::RateLimitedError.new(
139
+ response_headers: { 'X-Rate-Limit-Reset' => 20 },
140
+ response_body: '{"message":"Too Many Requests"}',
141
+ response_code: 429,
142
+ retry_in: 20.seconds,
143
+ )
144
+ api_client = instance_double(SolidusShipstation::Api::Client).tap do |client|
145
+ allow(client).to receive(:bulk_create_orders).with([shipment]).and_raise(error)
146
+ end
147
+
148
+ begin
149
+ build_batch_syncer(client: api_client).call([shipment])
150
+ rescue SolidusShipstation::Api::RateLimitedError
151
+ # We want to ignore the error here, since we're testing for the event.
152
+ end
153
+
154
+ expect(Spree::Event).to have_received(:fire).with(
155
+ 'solidus_shipstation.api.rate_limited',
156
+ shipments: [shipment],
157
+ error: error,
158
+ )
159
+ end
160
+
161
+ it 're-raises the error' do
162
+ shipment = instance_double('Spree::Shipment')
163
+ error = SolidusShipstation::Api::RateLimitedError.new(
164
+ response_headers: { 'X-Rate-Limit-Reset' => 20 },
165
+ response_body: '{"message":"Too Many Requests"}',
166
+ response_code: 429,
167
+ retry_in: 20.seconds,
168
+ )
169
+ api_client = instance_double(SolidusShipstation::Api::Client).tap do |client|
170
+ allow(client).to receive(:bulk_create_orders).with([shipment]).and_raise(error)
171
+ end
172
+
173
+ expect {
174
+ build_batch_syncer(client: api_client).call([shipment])
175
+ }.to raise_error(error)
176
+ end
177
+ end
178
+
179
+ context 'when the API call results in a server error' do
180
+ it 'emits a solidus_shipstation.api.sync_errored event' do
181
+ stub_const('Spree::Event', class_spy(Spree::Event))
182
+ shipment = instance_double('Spree::Shipment')
183
+ error = SolidusShipstation::Api::RequestError.new(
184
+ response_headers: {},
185
+ response_body: '{"message":"Internal Server Error"}',
186
+ response_code: 500,
187
+ )
188
+ api_client = instance_double(SolidusShipstation::Api::Client).tap do |client|
189
+ allow(client).to receive(:bulk_create_orders).with([shipment]).and_raise(error)
190
+ end
191
+
192
+ begin
193
+ build_batch_syncer(client: api_client).call([shipment])
194
+ rescue SolidusShipstation::Api::RequestError
195
+ # We want to ignore the error here, since we're testing for the event.
196
+ end
197
+
198
+ expect(Spree::Event).to have_received(:fire).with(
199
+ 'solidus_shipstation.api.sync_errored',
200
+ shipments: [shipment],
201
+ error: error,
202
+ )
203
+ end
204
+
205
+ it 're-raises the error' do
206
+ stub_const('Spree::Event', class_spy(Spree::Event))
207
+ shipment = instance_double('Spree::Shipment')
208
+ error = SolidusShipstation::Api::RequestError.new(
209
+ response_headers: {},
210
+ response_body: '{"message":"Internal Server Error"}',
211
+ response_code: 500,
212
+ )
213
+ api_client = instance_double(SolidusShipstation::Api::Client).tap do |client|
214
+ allow(client).to receive(:bulk_create_orders).with([shipment]).and_raise(error)
215
+ end
216
+
217
+ expect {
218
+ build_batch_syncer(client: api_client).call([shipment])
219
+ }.to raise_error(error)
220
+ end
221
+ end
222
+ end
223
+
224
+ private
225
+
226
+ def build_batch_syncer(client:, shipment_matcher: ->(_, shipments) { shipments.first })
227
+ described_class.new(client: client, shipment_matcher: shipment_matcher)
228
+ end
229
+ end
@@ -0,0 +1,120 @@
1
+ RSpec.describe SolidusShipstation::Api::Client do
2
+ describe '.from_config' do
3
+ it 'generates a client from the configuration' do
4
+ request_runner = instance_double('SolidusShipstation::Api::RequestRunner')
5
+ error_handler = instance_spy('Proc')
6
+ shipment_serializer = instance_spy('SolidusShipstation::Api::Serializer')
7
+ allow(SolidusShipstation::Api::RequestRunner).to receive(:from_config).and_return(request_runner)
8
+ allow(SolidusShipstation.config).to receive_messages(
9
+ error_handler: error_handler,
10
+ api_shipment_serializer: shipment_serializer,
11
+ )
12
+
13
+ client = described_class.from_config
14
+
15
+ expect(client).to have_attributes(
16
+ request_runner: request_runner,
17
+ error_handler: error_handler,
18
+ shipment_serializer: shipment_serializer,
19
+ )
20
+ end
21
+ end
22
+
23
+ describe '#bulk_create_orders' do
24
+ it 'calls the bulk order creation endpoint' do
25
+ request_runner = instance_spy('SolidusShipstation::Api::RequestRunner')
26
+ shipment = build_stubbed(:shipment)
27
+ serialized_shipment = { 'key' => 'value' }
28
+
29
+ client = build_client(
30
+ request_runner: request_runner,
31
+ shipment_serializer: stub_shipment_serializer(shipment => serialized_shipment),
32
+ )
33
+ client.bulk_create_orders([shipment])
34
+
35
+ expect(request_runner).to have_received(:call).with(
36
+ :post,
37
+ '/orders/createorders',
38
+ [serialized_shipment],
39
+ )
40
+ end
41
+
42
+ it 'does not fail for serialization errors' do
43
+ request_runner = instance_spy('SolidusShipstation::Api::RequestRunner')
44
+ successful_shipment = build_stubbed(:shipment)
45
+ serialized_shipment = { 'key' => 'value' }
46
+ failing_shipment = build_stubbed(:shipment)
47
+ error = RuntimeError.new('Failed to serialize shipment!')
48
+
49
+ client = build_client(
50
+ request_runner: request_runner,
51
+ shipment_serializer: stub_shipment_serializer(
52
+ successful_shipment => serialized_shipment,
53
+ failing_shipment => error,
54
+ )
55
+ )
56
+ client.bulk_create_orders([failing_shipment, successful_shipment])
57
+
58
+ expect(request_runner).to have_received(:call).with(
59
+ :post,
60
+ '/orders/createorders',
61
+ [serialized_shipment],
62
+ )
63
+ end
64
+
65
+ it 'reports any serialization errors to the error handler' do
66
+ error_handler = instance_spy('Proc')
67
+ shipment = build_stubbed(:shipment)
68
+ error = RuntimeError.new('Failed to serialize shipment!')
69
+
70
+ client = build_client(
71
+ shipment_serializer: stub_shipment_serializer(shipment => error),
72
+ error_handler: error_handler,
73
+ )
74
+ client.bulk_create_orders([shipment])
75
+
76
+ expect(error_handler).to have_received(:call).with(error, shipment: shipment)
77
+ end
78
+
79
+ it 'skips the API call if all shipments failed serialization' do
80
+ request_runner = instance_spy('SolidusShipstation::Api::RequestRunner')
81
+ failing_shipment = build_stubbed(:shipment)
82
+
83
+ client = build_client(
84
+ shipment_serializer: stub_shipment_serializer(
85
+ failing_shipment => RuntimeError.new('Failed to serialize shipment!'),
86
+ ),
87
+ request_runner: request_runner,
88
+ )
89
+ client.bulk_create_orders([failing_shipment])
90
+
91
+ expect(request_runner).not_to have_received(:call)
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def build_client(options = {})
98
+ described_class.new({
99
+ request_runner: instance_spy('SolidusShipstation::Api::RequestRunner'),
100
+ error_handler: instance_spy('Proc'),
101
+ shipment_serializer: stub_shipment_serializer,
102
+ }.merge(options))
103
+ end
104
+
105
+ def stub_shipment_serializer(results_map = {})
106
+ serializer = class_spy('SolidusShipstation::Api::Serializer')
107
+
108
+ results_map.each_pair do |shipment, result_or_error|
109
+ stub = allow(serializer).to receive(:call).with(shipment)
110
+
111
+ if result_or_error.is_a?(Hash)
112
+ stub.and_return(result_or_error)
113
+ else
114
+ stub.and_raise(result_or_error)
115
+ end
116
+ end
117
+
118
+ serializer
119
+ end
120
+ end