solidus_backtracs 2.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 (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