active_fulfillment 2.1.6 → 2.1.7
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data.tar.gz.sig +0 -0
- data/CHANGELOG +5 -0
- data/lib/active_fulfillment/fulfillment/service.rb +7 -12
- data/lib/active_fulfillment/fulfillment/services.rb +1 -0
- data/lib/active_fulfillment/fulfillment/services/shopify_api.rb +123 -0
- data/lib/active_fulfillment/fulfillment/version.rb +1 -1
- data/test/test_helper.rb +8 -7
- data/test/unit/services/shopify_api_test.rb +134 -0
- metadata +33 -2
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 36a68a21c6b752b4ed78e4d11fb5b4da3e05080a
|
4
|
+
data.tar.gz: 755d255c35294881ac9c1a815836f0adce457876
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4023404d90d16da1415227009f16348767890d6ea2f8799738e29cc801731f3cee43c50fd6c29b743672f4b6c7a99473383cd6c022129e4bf1112bd4838ec2f7
|
7
|
+
data.tar.gz: c15a97a53539e0d597aaa08c1760bcee3fbe751920e9e53ee92b121c824e9bd895a8463059c0e1dabb602624e148d5bdcbaf0db46291a5658612ac8b8612fee2
|
checksums.yaml.gz.sig
CHANGED
Binary file
|
data.tar.gz.sig
CHANGED
Binary file
|
data/CHANGELOG
CHANGED
@@ -1,5 +1,10 @@
|
|
1
1
|
= ActiveFulfillment CHANGELOG
|
2
2
|
|
3
|
+
== Version 2.1.7
|
4
|
+
|
5
|
+
* Add shopify_api service
|
6
|
+
* Drop Ruby 1.9.3 support
|
7
|
+
|
3
8
|
== Version 2.1.0
|
4
9
|
|
5
10
|
* Added fetch_tracking_data methods which returns tracking_companies and tracking_urls if available in addition to tracking_numbers for each service
|
@@ -5,6 +5,8 @@ module ActiveMerchant
|
|
5
5
|
include RequiresParameters
|
6
6
|
include PostsData
|
7
7
|
|
8
|
+
attr_accessor :logger
|
9
|
+
|
8
10
|
def initialize(options = {})
|
9
11
|
check_test_mode(options)
|
10
12
|
|
@@ -20,12 +22,12 @@ module ActiveMerchant
|
|
20
22
|
@options[:test] || Base.mode == :test
|
21
23
|
end
|
22
24
|
|
23
|
-
|
24
|
-
|
25
|
-
raise NotImplementedError.new("Subclasses must implement")
|
25
|
+
def valid_credentials?
|
26
|
+
true
|
26
27
|
end
|
27
28
|
|
28
|
-
|
29
|
+
# API Requirements for Implementors
|
30
|
+
def fulfill(order_id, shipping_address, line_items, options = {})
|
29
31
|
raise NotImplementedError.new("Subclasses must implement")
|
30
32
|
end
|
31
33
|
|
@@ -44,15 +46,8 @@ module ActiveMerchant
|
|
44
46
|
raise NotImplementedError.new("Subclasses must implement")
|
45
47
|
end
|
46
48
|
|
47
|
-
def valid_credentials?
|
48
|
-
raise NotImplementedError.new("Subclasses must implement")
|
49
|
-
end
|
50
|
-
|
51
|
-
def test_mode?
|
52
|
-
raise NotImplementedError.new("Subclasses must implement")
|
53
|
-
end
|
54
|
-
|
55
49
|
private
|
50
|
+
|
56
51
|
def check_test_mode(options)
|
57
52
|
if options[:test] and not test_mode?
|
58
53
|
raise ArgumentError, 'Test mode is not supported by this gateway'
|
@@ -0,0 +1,123 @@
|
|
1
|
+
module ActiveMerchant
|
2
|
+
module Fulfillment
|
3
|
+
class ShopifyAPIService < Service
|
4
|
+
|
5
|
+
RESCUABLE_CONNECTION_ERRORS = [
|
6
|
+
Net::ReadTimeout,
|
7
|
+
Net::OpenTimeout,
|
8
|
+
TimeoutError,
|
9
|
+
Errno::ETIMEDOUT,
|
10
|
+
Timeout::Error,
|
11
|
+
IOError,
|
12
|
+
EOFError,
|
13
|
+
SocketError,
|
14
|
+
Errno::ECONNRESET,
|
15
|
+
Errno::ECONNABORTED,
|
16
|
+
Errno::EPIPE,
|
17
|
+
Errno::ECONNREFUSED,
|
18
|
+
Errno::EAGAIN,
|
19
|
+
Errno::EHOSTUNREACH,
|
20
|
+
Errno::ENETUNREACH,
|
21
|
+
Resolv::ResolvError,
|
22
|
+
Net::HTTPBadResponse,
|
23
|
+
Net::HTTPHeaderSyntaxError,
|
24
|
+
Net::ProtocolError,
|
25
|
+
ActiveMerchant::ConnectionError,
|
26
|
+
ActiveMerchant::ResponseError,
|
27
|
+
ActiveMerchant::InvalidResponseError
|
28
|
+
]
|
29
|
+
|
30
|
+
def initialize(options = {})
|
31
|
+
@name = options[:name]
|
32
|
+
@callback_url = options[:callback_url]
|
33
|
+
@format = options[:format]
|
34
|
+
end
|
35
|
+
|
36
|
+
def fulfill(order_id, shipping_address, line_items, options = {})
|
37
|
+
raise NotImplementedError.new("Shopify API Service must listen to fulfillment/create Webhooks")
|
38
|
+
end
|
39
|
+
|
40
|
+
def fetch_stock_levels(options = {})
|
41
|
+
response = send_app_request('fetch_stock', options.delete(:headers), options)
|
42
|
+
if response
|
43
|
+
stock_levels = parse_response(response, 'StockLevels', 'Product', 'Sku', 'Quantity') { |p| p.to_i }
|
44
|
+
Response.new(true, "API stock levels", {:stock_levels => stock_levels})
|
45
|
+
else
|
46
|
+
Response.new(false, "Unable to fetch remote stock levels")
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def fetch_tracking_data(order_ids, options = {})
|
51
|
+
options.merge!({:order_ids => order_ids})
|
52
|
+
response = send_app_request('fetch_tracking_numbers', options.delete(:headers), options)
|
53
|
+
if response
|
54
|
+
tracking_numbers = parse_response(response, 'TrackingNumbers', 'Order', 'ID', 'Tracking') { |o| o }
|
55
|
+
Response.new(true, "API tracking_numbers", {:tracking_numbers => tracking_numbers,
|
56
|
+
:tracking_companies => {},
|
57
|
+
:tracking_urls => {}})
|
58
|
+
else
|
59
|
+
Response.new(false, "Unable to fetch remote tracking numbers #{order_ids.inspect}")
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def request_uri(action, data)
|
66
|
+
URI.parse "#{@callback_url}/#{action}.#{@format}?#{data.to_param}"
|
67
|
+
end
|
68
|
+
|
69
|
+
def send_app_request(action, headers, data)
|
70
|
+
uri = request_uri(action, data)
|
71
|
+
|
72
|
+
logger.info "[" + @name.upcase + " APP] Post #{uri}"
|
73
|
+
|
74
|
+
response = nil
|
75
|
+
realtime = Benchmark.realtime do
|
76
|
+
begin
|
77
|
+
Timeout.timeout(20.seconds) do
|
78
|
+
response = ssl_get(uri, headers)
|
79
|
+
end
|
80
|
+
rescue *(RESCUABLE_CONNECTION_ERRORS) => e
|
81
|
+
logger.warn "[#{self}] Error while contacting fulfillment service error =\"#{e.message}\""
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
line = "[" + @name.upcase + "APP] Response from #{uri} --> "
|
86
|
+
line << "#{response} #{"%.4fs" % realtime}"
|
87
|
+
logger.info line
|
88
|
+
|
89
|
+
response
|
90
|
+
end
|
91
|
+
|
92
|
+
def parse_response(response, root, type, key, value)
|
93
|
+
case @format
|
94
|
+
when 'json'
|
95
|
+
response_data = ActiveSupport::JSON.decode(response)
|
96
|
+
return {} unless response_data.is_a?(Hash)
|
97
|
+
response_data[root.underscore] || response_data
|
98
|
+
when 'xml'
|
99
|
+
response_data = {}
|
100
|
+
document = REXML::Document.new(response)
|
101
|
+
document.elements[root].each do |node|
|
102
|
+
if node.name == type
|
103
|
+
response_data[node.elements[key].text] = node.elements[value].text
|
104
|
+
end
|
105
|
+
end
|
106
|
+
response_data
|
107
|
+
end
|
108
|
+
|
109
|
+
rescue ActiveSupport::JSON.parse_error, REXML::ParseException
|
110
|
+
{}
|
111
|
+
end
|
112
|
+
|
113
|
+
def encode_payload(payload, root)
|
114
|
+
case @format
|
115
|
+
when 'json'
|
116
|
+
{root => payload}.to_json
|
117
|
+
when 'xml'
|
118
|
+
payload.to_xml(:root => root)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
data/test/test_helper.rb
CHANGED
@@ -9,6 +9,7 @@ require 'minitest/autorun'
|
|
9
9
|
require 'digest/md5'
|
10
10
|
require 'active_fulfillment'
|
11
11
|
require 'active_utils'
|
12
|
+
require 'timecop'
|
12
13
|
|
13
14
|
require 'mocha/setup'
|
14
15
|
|
@@ -16,35 +17,35 @@ module Test
|
|
16
17
|
module Unit
|
17
18
|
class TestCase < MiniTest::Unit::TestCase
|
18
19
|
include ActiveMerchant::Fulfillment
|
19
|
-
|
20
|
+
|
20
21
|
LOCAL_CREDENTIALS = ENV['HOME'] + '/.active_merchant/fixtures.yml' unless defined?(LOCAL_CREDENTIALS)
|
21
22
|
DEFAULT_CREDENTIALS = File.dirname(__FILE__) + '/fixtures.yml' unless defined?(DEFAULT_CREDENTIALS)
|
22
23
|
|
23
24
|
def all_fixtures
|
24
25
|
@@fixtures ||= load_fixtures
|
25
26
|
end
|
26
|
-
|
27
|
+
|
27
28
|
def fixtures(key)
|
28
29
|
data = all_fixtures[key] || raise(StandardError, "No fixture data was found for '#{key}'")
|
29
|
-
|
30
|
+
|
30
31
|
data.dup
|
31
32
|
end
|
32
|
-
|
33
|
+
|
33
34
|
def load_fixtures
|
34
35
|
file = File.exists?(LOCAL_CREDENTIALS) ? LOCAL_CREDENTIALS : DEFAULT_CREDENTIALS
|
35
36
|
yaml_data = YAML.load(File.read(file))
|
36
37
|
symbolize_keys(yaml_data)
|
37
|
-
|
38
|
+
|
38
39
|
yaml_data
|
39
40
|
end
|
40
41
|
|
41
42
|
def xml_fixture(path) # where path is like 'usps/beverly_hills_to_ottawa_response'
|
42
43
|
open(File.join(File.dirname(__FILE__),'fixtures','xml',"#{path}.xml")) {|f| f.read}
|
43
44
|
end
|
44
|
-
|
45
|
+
|
45
46
|
def symbolize_keys(hash)
|
46
47
|
return unless hash.is_a?(Hash)
|
47
|
-
|
48
|
+
|
48
49
|
hash.symbolize_keys!
|
49
50
|
hash.each{|k,v| symbolize_keys(v)}
|
50
51
|
end
|
@@ -0,0 +1,134 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class ShopifyAPITest < Test::Unit::TestCase
|
4
|
+
|
5
|
+
def setup
|
6
|
+
@service = build_service()
|
7
|
+
@service.logger = stub(:info => nil, :debug => nil, :warn => nil, :error => nil)
|
8
|
+
end
|
9
|
+
|
10
|
+
def test_request_uri_is_correct_when_no_sku_passed
|
11
|
+
Timecop.freeze do
|
12
|
+
timestamp = Time.now.utc.to_i
|
13
|
+
uri = @service.send(:request_uri, 'fetch_stock', {timestamp: timestamp, shop: 'www.snowdevil.ca'})
|
14
|
+
assert_equal "http://supershopifyapptwin.com/fetch_stock.json?shop=www.snowdevil.ca×tamp=#{timestamp}", uri.to_s
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def test_request_uri_is_correct_when_sku_is_passed
|
19
|
+
Timecop.freeze do
|
20
|
+
timestamp = Time.now.utc.to_i
|
21
|
+
uri = @service.send(:request_uri, 'fetch_stock', {sku: '123', timestamp: timestamp, shop: 'www.snowdevil.ca'})
|
22
|
+
assert_equal "http://supershopifyapptwin.com/fetch_stock.json?shop=www.snowdevil.ca&sku=123×tamp=#{timestamp}", uri.to_s
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def test_response_from_failed_stock_request
|
27
|
+
mock_app_request('fetch_stock', anything, nil)
|
28
|
+
response = @service.fetch_stock_levels()
|
29
|
+
refute response.success?
|
30
|
+
assert_equal "Unable to fetch remote stock levels", response.message
|
31
|
+
end
|
32
|
+
|
33
|
+
def test_response_from_failed_tracking_request
|
34
|
+
mock_app_request('fetch_tracking_numbers', anything, nil)
|
35
|
+
response = @service.fetch_tracking_numbers([1,2])
|
36
|
+
refute response.success?
|
37
|
+
assert_equal "Unable to fetch remote tracking numbers [1, 2]", response.message
|
38
|
+
end
|
39
|
+
|
40
|
+
def test_response_with_invalid_json_is_parsed_to_empty_hash
|
41
|
+
bad_json = '{a: 9, 0}'
|
42
|
+
mock_app_request('fetch_stock', anything, bad_json)
|
43
|
+
assert_equal({}, @service.fetch_stock_levels().stock_levels)
|
44
|
+
end
|
45
|
+
|
46
|
+
def test_response_with_valid_but_incorrect_json_is_parsed_to_empty_hash
|
47
|
+
incorrect_json = '[]'
|
48
|
+
mock_app_request('fetch_stock', anything, incorrect_json)
|
49
|
+
assert_equal({}, @service.fetch_stock_levels().stock_levels)
|
50
|
+
end
|
51
|
+
|
52
|
+
def test_response_with_invalid_xml_is_parsed_to_empty_hash
|
53
|
+
service = build_service(format: 'xml')
|
54
|
+
bad_xml = '<A><B></C></A>'
|
55
|
+
mock_app_request('fetch_stock', anything, bad_xml)
|
56
|
+
assert_equal({}, service.fetch_stock_levels().stock_levels)
|
57
|
+
end
|
58
|
+
|
59
|
+
def test_parse_stock_level_response_parses_xml_correctly
|
60
|
+
service = build_service(format: 'xml')
|
61
|
+
xml = '<StockLevels><Product><Sku>sku1</Sku><Quantity>1</Quantity></Product><Product><Sku>sku2</Sku><Quantity>2</Quantity></Product></StockLevels>'
|
62
|
+
expected = {'sku1' => '1', 'sku2' => '2'}
|
63
|
+
|
64
|
+
mock_app_request('fetch_stock', anything, xml)
|
65
|
+
assert_equal expected, service.fetch_stock_levels().stock_levels
|
66
|
+
end
|
67
|
+
|
68
|
+
def test_parse_tracking_data_response_parses_xml_correctly
|
69
|
+
service = build_service(format: 'xml')
|
70
|
+
xml = '<TrackingNumbers><Order><ID>123</ID><Tracking>abc</Tracking></Order><Order><ID>456</ID><Tracking>def</Tracking></Order></TrackingNumbers>'
|
71
|
+
expected = {'123' => 'abc', '456' => 'def'}
|
72
|
+
|
73
|
+
mock_app_request('fetch_tracking_numbers', anything, xml)
|
74
|
+
assert_equal expected, service.fetch_tracking_data([1,2,4]).tracking_numbers
|
75
|
+
end
|
76
|
+
|
77
|
+
def test_parse_stock_level_response_parses_json_with_root_correctly
|
78
|
+
json = '{"stock_levels": {"998KIB":"10"}}'
|
79
|
+
expected = {'998KIB' => "10"}
|
80
|
+
|
81
|
+
mock_app_request('fetch_stock', anything, json)
|
82
|
+
assert_equal expected, @service.fetch_stock_levels().stock_levels
|
83
|
+
end
|
84
|
+
|
85
|
+
def test_parse_tracking_data_response_parses_json_with_root_correctly
|
86
|
+
json = '{"tracking_numbers": {"order1":"a","order2":"b"}}'
|
87
|
+
expected = {'order1' => 'a', 'order2' => 'b'}
|
88
|
+
|
89
|
+
mock_app_request('fetch_tracking_numbers', anything, json)
|
90
|
+
assert_equal expected, @service.fetch_tracking_data([1,2]).tracking_numbers
|
91
|
+
end
|
92
|
+
|
93
|
+
def test_parse_stock_level_response_parses_json_without_root_correctly
|
94
|
+
json = '{"998KIB":"10"}'
|
95
|
+
expected = {'998KIB' => "10"}
|
96
|
+
|
97
|
+
mock_app_request('fetch_stock', anything, json)
|
98
|
+
assert_equal expected, @service.fetch_stock_levels().stock_levels
|
99
|
+
end
|
100
|
+
|
101
|
+
def test_parse_tracking_data_response_parses_json_without_root_correctly
|
102
|
+
json = '{"order1":"a","order2":"b"}'
|
103
|
+
expected = {'order1' => 'a', 'order2' => 'b'}
|
104
|
+
|
105
|
+
mock_app_request('fetch_tracking_numbers', anything, json)
|
106
|
+
assert_equal expected, @service.fetch_tracking_data([1,2]).tracking_numbers
|
107
|
+
end
|
108
|
+
|
109
|
+
def test_send_app_request_rescues_response_errors
|
110
|
+
response = stub(code: "404", message: "Not Found")
|
111
|
+
@service.expects(:ssl_get).raises(ActiveMerchant::ResponseError, response)
|
112
|
+
refute @service.fetch_stock_levels().success?
|
113
|
+
end
|
114
|
+
|
115
|
+
def test_send_app_request_rescues_invalid_response_errors
|
116
|
+
@service.expects(:ssl_get).raises(ActiveMerchant::InvalidResponseError.new("error html"))
|
117
|
+
refute @service.fetch_stock_levels().success?
|
118
|
+
end
|
119
|
+
|
120
|
+
private
|
121
|
+
|
122
|
+
def mock_app_request(action, input, output)
|
123
|
+
ShopifyAPIService.any_instance.expects(:send_app_request).with(action, nil, input).returns(output)
|
124
|
+
end
|
125
|
+
|
126
|
+
def build_service(options = {})
|
127
|
+
options.reverse_merge!({
|
128
|
+
name: "fulfillment_app",
|
129
|
+
callback_url: 'http://supershopifyapptwin.com',
|
130
|
+
format: 'json'
|
131
|
+
})
|
132
|
+
ShopifyAPIService.new(options)
|
133
|
+
end
|
134
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: active_fulfillment
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.1.
|
4
|
+
version: 2.1.7
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Cody Fauser
|
@@ -31,7 +31,7 @@ cert_chain:
|
|
31
31
|
fl3hbtVFTqbOlwL9vy1fudXcolIE/ZTcxQ+er07ZFZdKCXayR9PPs64heamfn0fp
|
32
32
|
TConQSX2BnZdhIEYW+cKzEC/bLc=
|
33
33
|
-----END CERTIFICATE-----
|
34
|
-
date: 2014-
|
34
|
+
date: 2014-09-03 00:00:00.000000000 Z
|
35
35
|
dependencies:
|
36
36
|
- !ruby/object:Gem::Dependency
|
37
37
|
name: activesupport
|
@@ -89,6 +89,20 @@ dependencies:
|
|
89
89
|
- - ">="
|
90
90
|
- !ruby/object:Gem::Version
|
91
91
|
version: '0'
|
92
|
+
- !ruby/object:Gem::Dependency
|
93
|
+
name: byebug
|
94
|
+
requirement: !ruby/object:Gem::Requirement
|
95
|
+
requirements:
|
96
|
+
- - ">="
|
97
|
+
- !ruby/object:Gem::Version
|
98
|
+
version: '0'
|
99
|
+
type: :development
|
100
|
+
prerelease: false
|
101
|
+
version_requirements: !ruby/object:Gem::Requirement
|
102
|
+
requirements:
|
103
|
+
- - ">="
|
104
|
+
- !ruby/object:Gem::Version
|
105
|
+
version: '0'
|
92
106
|
- !ruby/object:Gem::Dependency
|
93
107
|
name: mocha
|
94
108
|
requirement: !ruby/object:Gem::Requirement
|
@@ -117,6 +131,20 @@ dependencies:
|
|
117
131
|
- - ">="
|
118
132
|
- !ruby/object:Gem::Version
|
119
133
|
version: '0'
|
134
|
+
- !ruby/object:Gem::Dependency
|
135
|
+
name: timecop
|
136
|
+
requirement: !ruby/object:Gem::Requirement
|
137
|
+
requirements:
|
138
|
+
- - ">="
|
139
|
+
- !ruby/object:Gem::Version
|
140
|
+
version: '0'
|
141
|
+
type: :development
|
142
|
+
prerelease: false
|
143
|
+
version_requirements: !ruby/object:Gem::Requirement
|
144
|
+
requirements:
|
145
|
+
- - ">="
|
146
|
+
- !ruby/object:Gem::Version
|
147
|
+
version: '0'
|
120
148
|
- !ruby/object:Gem::Dependency
|
121
149
|
name: rdoc
|
122
150
|
requirement: !ruby/object:Gem::Requirement
|
@@ -146,6 +174,7 @@ files:
|
|
146
174
|
- lib/active_fulfillment/fulfillment/services/amazon.rb
|
147
175
|
- lib/active_fulfillment/fulfillment/services/amazon_mws.rb
|
148
176
|
- lib/active_fulfillment/fulfillment/services/shipwire.rb
|
177
|
+
- lib/active_fulfillment/fulfillment/services/shopify_api.rb
|
149
178
|
- lib/active_fulfillment/fulfillment/services/webgistix.rb
|
150
179
|
- lib/active_fulfillment/fulfillment/version.rb
|
151
180
|
- test/fixtures.yml
|
@@ -184,6 +213,7 @@ files:
|
|
184
213
|
- test/unit/services/amazon_mws_test.rb
|
185
214
|
- test/unit/services/amazon_test.rb
|
186
215
|
- test/unit/services/shipwire_test.rb
|
216
|
+
- test/unit/services/shopify_api_test.rb
|
187
217
|
- test/unit/services/webgistix_test.rb
|
188
218
|
homepage: http://github.com/shopify/active_fulfillment
|
189
219
|
licenses: []
|
@@ -215,6 +245,7 @@ test_files:
|
|
215
245
|
- test/remote/webgistix_test.rb
|
216
246
|
- test/remote/shipwire_test.rb
|
217
247
|
- test/remote/amazon_mws_test.rb
|
248
|
+
- test/unit/services/shopify_api_test.rb
|
218
249
|
- test/unit/services/amazon_test.rb
|
219
250
|
- test/unit/services/webgistix_test.rb
|
220
251
|
- test/unit/services/shipwire_test.rb
|
metadata.gz.sig
CHANGED
Binary file
|