solidus_backtracs 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (79) 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 +14 -0
  10. data/.rubocop_todo.yml +40 -0
  11. data/CHANGELOG.md +2 -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_backtracs.js +2 -0
  17. data/app/assets/javascripts/spree/frontend/solidus_backtracs.js +2 -0
  18. data/app/assets/stylesheets/spree/backend/solidus_backtracs.css +4 -0
  19. data/app/assets/stylesheets/spree/frontend/solidus_backtracs.css +4 -0
  20. data/app/controllers/spree/backtracs_controller.rb +46 -0
  21. data/app/decorators/models/solidus_backtracs/spree/shipment_decorator.rb +33 -0
  22. data/app/helpers/solidus_backtracs/export_helper.rb +52 -0
  23. data/app/jobs/solidus_backtracs/api/schedule_shipment_syncs_job.rb +28 -0
  24. data/app/jobs/solidus_backtracs/api/sync_shipment_job.rb +17 -0
  25. data/app/jobs/solidus_backtracs/api/sync_shipments_job.rb +41 -0
  26. data/app/queries/solidus_backtracs/shipment/between_query.rb +14 -0
  27. data/app/queries/solidus_backtracs/shipment/exportable_query.rb +24 -0
  28. data/app/queries/solidus_backtracs/shipment/pending_api_sync_query.rb +51 -0
  29. data/app/views/spree/backtracs/export.xml.builder +58 -0
  30. data/bin/console +17 -0
  31. data/bin/rails +7 -0
  32. data/bin/rails-engine +13 -0
  33. data/bin/rails-sandbox +16 -0
  34. data/bin/rake +7 -0
  35. data/bin/sandbox +86 -0
  36. data/bin/setup +8 -0
  37. data/config/locales/en.yml +5 -0
  38. data/config/routes.rb +6 -0
  39. data/db/migrate/20210220093010_add_backtracs_api_sync_fields.rb +8 -0
  40. data/lib/generators/solidus_backtracs/install/install_generator.rb +27 -0
  41. data/lib/generators/solidus_backtracs/install/templates/initializer.rb +91 -0
  42. data/lib/solidus_backtracs/api/batch_syncer.rb +45 -0
  43. data/lib/solidus_backtracs/api/client.rb +36 -0
  44. data/lib/solidus_backtracs/api/rate_limited_error.rb +23 -0
  45. data/lib/solidus_backtracs/api/request_error.rb +33 -0
  46. data/lib/solidus_backtracs/api/request_runner.rb +87 -0
  47. data/lib/solidus_backtracs/api/shipment_serializer.rb +103 -0
  48. data/lib/solidus_backtracs/api/threshold_verifier.rb +28 -0
  49. data/lib/solidus_backtracs/configuration.rb +62 -0
  50. data/lib/solidus_backtracs/engine.rb +19 -0
  51. data/lib/solidus_backtracs/errors.rb +23 -0
  52. data/lib/solidus_backtracs/shipment_notice.rb +58 -0
  53. data/lib/solidus_backtracs/testing_support/factories.rb +4 -0
  54. data/lib/solidus_backtracs/version.rb +5 -0
  55. data/lib/solidus_backtracs.rb +16 -0
  56. data/solidus_shipstation.gemspec +39 -0
  57. data/spec/controllers/spree/backtracs_controller_spec.rb +103 -0
  58. data/spec/fixtures/backtracs_xml_schema.xsd +171 -0
  59. data/spec/jobs/solidus_backtracs/api/schedule_shipment_syncs_job_spec.rb +32 -0
  60. data/spec/jobs/solidus_backtracs/api/sync_shipments_job_spec.rb +102 -0
  61. data/spec/lib/solidus_backtracs/api/batch_syncer_spec.rb +228 -0
  62. data/spec/lib/solidus_backtracs/api/client_spec.rb +120 -0
  63. data/spec/lib/solidus_backtracs/api/rate_limited_error_spec.rb +21 -0
  64. data/spec/lib/solidus_backtracs/api/request_error_spec.rb +20 -0
  65. data/spec/lib/solidus_backtracs/api/request_runner_spec.rb +65 -0
  66. data/spec/lib/solidus_backtracs/api/shipment_serializer_spec.rb +25 -0
  67. data/spec/lib/solidus_backtracs/api/threshold_verifier_spec.rb +61 -0
  68. data/spec/lib/solidus_backtracs/shipment_notice_spec.rb +111 -0
  69. data/spec/lib/solidus_backtracs_spec.rb +9 -0
  70. data/spec/models/spree/shipment_spec.rb +49 -0
  71. data/spec/queries/solidus_backtracs/shipment/between_query_spec.rb +53 -0
  72. data/spec/queries/solidus_backtracs/shipment/exportable_query_spec.rb +53 -0
  73. data/spec/queries/solidus_backtracs/shipment/pending_api_sync_query_spec.rb +37 -0
  74. data/spec/spec_helper.rb +31 -0
  75. data/spec/support/configuration_helper.rb +13 -0
  76. data/spec/support/controllers.rb +1 -0
  77. data/spec/support/webmock.rb +3 -0
  78. data/spec/support/xsd.rb +5 -0
  79. metadata +248 -0
@@ -0,0 +1,171 @@
1
+ <xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified" xmlns:xs="http://www.w3.org/2001/XMLSchema">
2
+ <xs:element name="Orders">
3
+ <xs:complexType>
4
+ <xs:sequence>
5
+ <xs:element name="Order" maxOccurs="unbounded" minOccurs="0">
6
+ <xs:complexType>
7
+ <xs:all>
8
+ <xs:element type="String50" name="OrderID" minOccurs="0"/>
9
+ <xs:element type="String50" name="OrderNumber"/>
10
+ <xs:element type="DateTime" name="OrderDate"/>
11
+ <xs:element type="String50" name="OrderStatus"/>
12
+ <xs:element type="DateTime" name="LastModified"/>
13
+ <xs:element type="String100" name="ShippingMethod" minOccurs="0"/>
14
+ <xs:element type="String50" name="PaymentMethod" minOccurs="0"/>
15
+ <xs:element type="xs:float" name="OrderTotal"/>
16
+ <xs:element type="xs:float" name="TaxAmount" minOccurs="0"/>
17
+ <xs:element type="xs:float" name="ShippingAmount" minOccurs="0"/>
18
+ <xs:element type="String1000" name="CustomerNotes" minOccurs="0"/>
19
+ <xs:element type="String1000" name="InternalNotes" minOccurs="0"/>
20
+ <xs:element type="xs:boolean" name="Gift" minOccurs="0"/>
21
+ <xs:element type="String1000" name="GiftMessage" minOccurs="0"/>
22
+ <xs:element type="String100" name="CustomField1" minOccurs="0"/>
23
+ <xs:element type="String100" name="CustomField2" minOccurs="0"/>
24
+ <xs:element type="String100" name="CustomField3" minOccurs="0"/>
25
+ <xs:element type="String100" name="RequestedWarehouse" minOccurs="0"/>
26
+ <xs:element type="String50" name="Source" minOccurs="0" />
27
+ <xs:element name="Customer">
28
+ <xs:complexType>
29
+ <xs:all>
30
+ <xs:element type="String100" name="CustomerCode"/>
31
+ <xs:element name="BillTo">
32
+ <xs:complexType>
33
+ <xs:all>
34
+ <xs:element type="String100" name="Name"/>
35
+ <xs:element type="String100" name="Company" minOccurs="0"/>
36
+ <xs:element type="String50" name="Phone" minOccurs="0"/>
37
+ <xs:element type="Email" name="Email" minOccurs="0"/>
38
+ <xs:element type="String200" name="Address1" minOccurs="0"/>
39
+ <xs:element type="String200" name="Address2" minOccurs="0"/>
40
+ <xs:element type="String100" name="City" minOccurs="0"/>
41
+ <xs:element type="String100" name="State" minOccurs="0"/>
42
+ <xs:element type="String50" name="PostalCode" minOccurs="0"/>
43
+ <xs:element type="StringExactly2" name="Country" minOccurs="0"/>
44
+ </xs:all>
45
+ </xs:complexType>
46
+ </xs:element>
47
+ <xs:element name="ShipTo">
48
+ <xs:complexType>
49
+ <xs:all>
50
+ <xs:element type="String100" name="Name"/>
51
+ <xs:element type="String100" name="Company" minOccurs="0"/>
52
+ <xs:element type="String200" name="Address1"/>
53
+ <xs:element type="String200" name="Address2" minOccurs="0"/>
54
+ <xs:element type="String100" name="City"/>
55
+ <xs:element type="String100" name="State" minOccurs="0"/>
56
+ <xs:element type="String50" name="PostalCode" minOccurs="1"/>
57
+ <xs:element type="StringExactly2" name="Country"/>
58
+ <xs:element type="String50" name="Phone" minOccurs="0"/>
59
+ </xs:all>
60
+ </xs:complexType>
61
+ </xs:element>
62
+ </xs:all>
63
+ </xs:complexType>
64
+ </xs:element>
65
+ <xs:element name="Items">
66
+ <xs:complexType>
67
+ <xs:sequence>
68
+ <xs:element name="Item" maxOccurs="unbounded" minOccurs="0">
69
+ <xs:complexType>
70
+ <xs:all>
71
+ <xs:element type="String50" name="LineItemID" minOccurs="0"/>
72
+ <xs:element type="String100" name="SKU"/>
73
+ <xs:element type="String200" name="Name"/>
74
+ <xs:element type="xs:boolean" name="Adjustment" minOccurs="0"/>
75
+ <xs:element type="xs:anyURI" name="ImageUrl" minOccurs="0"/>
76
+ <xs:element type="xs:float" name="Weight" minOccurs="0"/>
77
+ <xs:element name="WeightUnits" minOccurs="0">
78
+ <xs:simpleType>
79
+ <xs:restriction base="xs:string">
80
+ <xs:pattern value="|pound|pounds|lb|lbs|gram|grams|gm|oz|ounces|Pound|Pounds|Lb|Lbs|Gram|Grams|Gm|Oz|Ounces|POUND|POUNDS|LB|LBS|GRAM|GRAMS|GM|OZ|OUNCES"/>
81
+ </xs:restriction>
82
+ </xs:simpleType>
83
+ </xs:element>
84
+ <xs:element name="Dimensions" minOccurs="0">
85
+ <xs:complexType>
86
+ <xs:all>
87
+ <xs:element name="DimensionUnits" minOccurs="0">
88
+ <xs:simpleType>
89
+ <xs:restriction base="xs:string">
90
+ <xs:pattern value="|inch|inches|in|centimeter|centimeters|cm|INCH|INCHES|IN|CENTIMETER|CENTIMETERS|CM"/>
91
+ </xs:restriction>
92
+ </xs:simpleType>
93
+ </xs:element>
94
+ <xs:element type="xs:float" name="Length"/>
95
+ <xs:element type="xs:float" name="Width"/>
96
+ <xs:element type="xs:float" name="Height"/>
97
+ </xs:all>
98
+ </xs:complexType>
99
+ </xs:element>
100
+ <xs:element type="xs:int" name="Quantity"/>
101
+ <xs:element type="xs:float" name="UnitPrice"/>
102
+ <xs:element type="String100" name="Location" minOccurs="0"/>
103
+ <xs:element name="Options" minOccurs="0">
104
+ <xs:complexType>
105
+ <xs:sequence>
106
+ <xs:element name="Option" maxOccurs="100" minOccurs="0">
107
+ <xs:complexType>
108
+ <xs:all>
109
+ <xs:element type="String100" name="Name"/>
110
+ <xs:element type="String100" name="Value"/>
111
+ <xs:element type="xs:float" name="Weight" minOccurs="0"/>
112
+ </xs:all>
113
+ </xs:complexType>
114
+ </xs:element>
115
+ </xs:sequence>
116
+ </xs:complexType>
117
+ </xs:element>
118
+ </xs:all>
119
+ </xs:complexType>
120
+ </xs:element>
121
+ </xs:sequence>
122
+ </xs:complexType>
123
+ </xs:element>
124
+ </xs:all>
125
+ </xs:complexType>
126
+ </xs:element>
127
+ </xs:sequence>
128
+ <xs:attribute type="xs:short" name="pages"/>
129
+ </xs:complexType>
130
+ </xs:element>
131
+ <xs:simpleType name="DateTime">
132
+ <xs:restriction base="xs:string">
133
+ <xs:pattern value="[0-9][0-9]?/[0-9][0-9]?/[0-9][0-9][0-9]?[0-9]? [0-9][0-9]?:[0-9][0-9]?:?[0-9]?[0-9]?. ?[aApP]?[mM]?"/>
134
+ </xs:restriction>
135
+ </xs:simpleType>
136
+ <xs:simpleType name="Email">
137
+ <xs:restriction base="xs:string">
138
+ </xs:restriction>
139
+ </xs:simpleType>
140
+ <xs:simpleType name="StringExactly2">
141
+ <xs:restriction base="xs:string">
142
+ <xs:minLength value="2"/>
143
+ <xs:maxLength value="2"/>
144
+ </xs:restriction>
145
+ </xs:simpleType>
146
+ <xs:simpleType name="String30">
147
+ <xs:restriction base="xs:string">
148
+ <xs:maxLength value="30"/>
149
+ </xs:restriction>
150
+ </xs:simpleType>
151
+ <xs:simpleType name="String50">
152
+ <xs:restriction base="xs:string">
153
+ <xs:maxLength value="50"/>
154
+ </xs:restriction>
155
+ </xs:simpleType>
156
+ <xs:simpleType name="String100">
157
+ <xs:restriction base="xs:string">
158
+ <xs:maxLength value="100"/>
159
+ </xs:restriction>
160
+ </xs:simpleType>
161
+ <xs:simpleType name="String200">
162
+ <xs:restriction base="xs:string">
163
+ <xs:maxLength value="200"/>
164
+ </xs:restriction>
165
+ </xs:simpleType>
166
+ <xs:simpleType name="String1000">
167
+ <xs:restriction base="xs:string">
168
+ <xs:maxLength value="1000"/>
169
+ </xs:restriction>
170
+ </xs:simpleType>
171
+ </xs:schema>
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe SolidusBacktracs::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(SolidusBacktracs::Shipment::PendingApiSyncQuery).to receive(:apply)
14
+ .and_return(relation)
15
+
16
+ described_class.perform_now
17
+
18
+ expect(SolidusBacktracs::Api::SyncShipmentsJob).to have_been_enqueued.with(shipments[0..1])
19
+ expect(SolidusBacktracs::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(SolidusBacktracs::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 SolidusBacktracs::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(SolidusBacktracs::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 = SolidusBacktracs::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_backtracs.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_backtracs.api.sync_skipped',
76
+ shipment: shipment,
77
+ )
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def stub_successful_batch_syncer
84
+ instance_spy(SolidusBacktracs::Api::BatchSyncer).tap do |batch_syncer|
85
+ allow(SolidusBacktracs::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(SolidusBacktracs::Api::BatchSyncer).tap do |batch_syncer|
91
+ allow(SolidusBacktracs::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(SolidusBacktracs::Api::ThresholdVerifier).to receive(:call)
99
+ .with(shipment)
100
+ .and_return(result)
101
+ end
102
+ end
@@ -0,0 +1,228 @@
1
+ RSpec.describe SolidusBacktracs::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(SolidusBacktracs::Api::Client)
7
+ allow(SolidusBacktracs::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 backtracs data on the shipment' do
24
+ freeze_time do
25
+ shipment = instance_spy('Spree::Shipment', number: 'H123456')
26
+ api_client = instance_double(SolidusBacktracs::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
+ backtracs_synced_at: Time.zone.now,
44
+ )
45
+ end
46
+ end
47
+
48
+ it 'emits a solidus_backtracs.api.sync_completed event' do
49
+ stub_const('Spree::Event', class_spy(Spree::Event))
50
+ shipment = instance_spy('Spree::Shipment', number: 'H123456')
51
+ api_client = instance_double(SolidusBacktracs::Api::Client).tap do |client|
52
+ allow(client).to receive(:bulk_create_orders).with([shipment]).and_return(
53
+ {
54
+ 'results' => [
55
+ {
56
+ 'orderNumber' => shipment.number,
57
+ 'success' => true,
58
+ 'orderId' => '123456',
59
+ }
60
+ ]
61
+ }
62
+ )
63
+ end
64
+
65
+ build_batch_syncer(client: api_client).call([shipment])
66
+
67
+ expect(Spree::Event).to have_received(:fire).with(
68
+ 'solidus_backtracs.api.sync_completed',
69
+ shipment: shipment,
70
+ payload: {
71
+ 'orderNumber' => shipment.number,
72
+ 'success' => true,
73
+ 'orderId' => '123456',
74
+ },
75
+ )
76
+ end
77
+ end
78
+
79
+ context 'when the sync operation failed' do
80
+ it 'does not update the backtracs data on the shipment' do
81
+ shipment = instance_spy('Spree::Shipment', number: 'H123456')
82
+ api_client = instance_double(SolidusBacktracs::Api::Client).tap do |client|
83
+ allow(client).to receive(:bulk_create_orders).with([shipment]).and_return(
84
+ {
85
+ 'results' => [
86
+ {
87
+ 'orderNumber' => shipment.number,
88
+ 'success' => false,
89
+ 'orderId' => '123456',
90
+ }
91
+ ]
92
+ }
93
+ )
94
+ end
95
+
96
+ build_batch_syncer(client: api_client).call([shipment])
97
+
98
+ expect(shipment).not_to have_received(:update_columns)
99
+ end
100
+
101
+ it 'emits a solidus_backtracs.api.sync_failed event' do
102
+ stub_const('Spree::Event', class_spy(Spree::Event))
103
+ shipment = instance_spy('Spree::Shipment', number: 'H123456')
104
+ api_client = instance_double(SolidusBacktracs::Api::Client).tap do |client|
105
+ allow(client).to receive(:bulk_create_orders).with([shipment]).and_return(
106
+ {
107
+ 'results' => [
108
+ {
109
+ 'orderNumber' => shipment.number,
110
+ 'success' => false,
111
+ 'orderId' => '123456',
112
+ }
113
+ ]
114
+ }
115
+ )
116
+ end
117
+
118
+ build_batch_syncer(client: api_client).call([shipment])
119
+
120
+ expect(Spree::Event).to have_received(:fire).with(
121
+ 'solidus_backtracs.api.sync_failed',
122
+ shipment: shipment,
123
+ payload: {
124
+ 'orderNumber' => shipment.number,
125
+ 'success' => false,
126
+ 'orderId' => '123456',
127
+ },
128
+ )
129
+ end
130
+ end
131
+ end
132
+
133
+ context 'when the API call hits a rate limit' do
134
+ it 'emits a solidus_backtracs.api.rate_limited event' do
135
+ stub_const('Spree::Event', class_spy(Spree::Event))
136
+ shipment = instance_double('Spree::Shipment')
137
+ error = SolidusBacktracs::Api::RateLimitedError.new(
138
+ response_headers: { 'X-Rate-Limit-Reset' => 20 },
139
+ response_body: '{"message":"Too Many Requests"}',
140
+ response_code: 429,
141
+ retry_in: 20.seconds,
142
+ )
143
+ api_client = instance_double(SolidusBacktracs::Api::Client).tap do |client|
144
+ allow(client).to receive(:bulk_create_orders).with([shipment]).and_raise(error)
145
+ end
146
+
147
+ begin
148
+ build_batch_syncer(client: api_client).call([shipment])
149
+ rescue SolidusBacktracs::Api::RateLimitedError
150
+ # We want to ignore the error here, since we're testing for the event.
151
+ end
152
+
153
+ expect(Spree::Event).to have_received(:fire).with(
154
+ 'solidus_backtracs.api.rate_limited',
155
+ shipments: [shipment],
156
+ error: error,
157
+ )
158
+ end
159
+
160
+ it 're-raises the error' do
161
+ shipment = instance_double('Spree::Shipment')
162
+ error = SolidusBacktracs::Api::RateLimitedError.new(
163
+ response_headers: { 'X-Rate-Limit-Reset' => 20 },
164
+ response_body: '{"message":"Too Many Requests"}',
165
+ response_code: 429,
166
+ retry_in: 20.seconds,
167
+ )
168
+ api_client = instance_double(SolidusBacktracs::Api::Client).tap do |client|
169
+ allow(client).to receive(:bulk_create_orders).with([shipment]).and_raise(error)
170
+ end
171
+
172
+ expect {
173
+ build_batch_syncer(client: api_client).call([shipment])
174
+ }.to raise_error(error)
175
+ end
176
+ end
177
+
178
+ context 'when the API call results in a server error' do
179
+ it 'emits a solidus_backtracs.api.sync_errored event' do
180
+ stub_const('Spree::Event', class_spy(Spree::Event))
181
+ shipment = instance_double('Spree::Shipment')
182
+ error = SolidusBacktracs::Api::RequestError.new(
183
+ response_headers: {},
184
+ response_body: '{"message":"Internal Server Error"}',
185
+ response_code: 500,
186
+ )
187
+ api_client = instance_double(SolidusBacktracs::Api::Client).tap do |client|
188
+ allow(client).to receive(:bulk_create_orders).with([shipment]).and_raise(error)
189
+ end
190
+
191
+ begin
192
+ build_batch_syncer(client: api_client).call([shipment])
193
+ rescue SolidusBacktracs::Api::RequestError
194
+ # We want to ignore the error here, since we're testing for the event.
195
+ end
196
+
197
+ expect(Spree::Event).to have_received(:fire).with(
198
+ 'solidus_backtracs.api.sync_errored',
199
+ shipments: [shipment],
200
+ error: error,
201
+ )
202
+ end
203
+
204
+ it 're-raises the error' do
205
+ stub_const('Spree::Event', class_spy(Spree::Event))
206
+ shipment = instance_double('Spree::Shipment')
207
+ error = SolidusBacktracs::Api::RequestError.new(
208
+ response_headers: {},
209
+ response_body: '{"message":"Internal Server Error"}',
210
+ response_code: 500,
211
+ )
212
+ api_client = instance_double(SolidusBacktracs::Api::Client).tap do |client|
213
+ allow(client).to receive(:bulk_create_orders).with([shipment]).and_raise(error)
214
+ end
215
+
216
+ expect {
217
+ build_batch_syncer(client: api_client).call([shipment])
218
+ }.to raise_error(error)
219
+ end
220
+ end
221
+ end
222
+
223
+ private
224
+
225
+ def build_batch_syncer(client:, shipment_matcher: ->(_, shipments) { shipments.first })
226
+ described_class.new(client: client, shipment_matcher: shipment_matcher)
227
+ end
228
+ end
@@ -0,0 +1,120 @@
1
+ RSpec.describe SolidusBacktracs::Api::Client do
2
+ describe '.from_config' do
3
+ it 'generates a client from the configuration' do
4
+ request_runner = instance_double('SolidusBacktracs::Api::RequestRunner')
5
+ error_handler = instance_spy('Proc')
6
+ shipment_serializer = instance_spy('SolidusBacktracs::Api::Serializer')
7
+ allow(SolidusBacktracs::Api::RequestRunner).to receive(:from_config).and_return(request_runner)
8
+ allow(SolidusBacktracs.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('SolidusBacktracs::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('SolidusBacktracs::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('SolidusBacktracs::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('SolidusBacktracs::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('SolidusBacktracs::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
@@ -0,0 +1,21 @@
1
+ RSpec.describe SolidusBacktracs::Api::RateLimitedError do
2
+ describe '.from_response' do
3
+ it 'extracts the status code, body, headers and retry time from the response' do
4
+ response = instance_double(
5
+ 'HTTParty::Response',
6
+ code: 429,
7
+ headers: { 'X-Rate-Limit-Reset' => 20 },
8
+ body: '{ "message": "Too Many Requests" }',
9
+ )
10
+
11
+ error = described_class.from_response(response)
12
+
13
+ expect(error).to have_attributes(
14
+ response_code: 429,
15
+ response_headers: { 'X-Rate-Limit-Reset' => 20 },
16
+ response_body: '{ "message": "Too Many Requests" }',
17
+ retry_in: 20.seconds,
18
+ )
19
+ end
20
+ end
21
+ end