tracking_number 0.10.5 → 3.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 (60) hide show
  1. checksums.yaml +5 -5
  2. data/.github/FUNDING.yml +2 -0
  3. data/.github/dependabot.yml +22 -0
  4. data/.github/workflows/release.yml +36 -0
  5. data/.github/workflows/ruby.yml +35 -0
  6. data/.gitmodules +3 -0
  7. data/.tool-versions +1 -0
  8. data/CHANGELOG.md +318 -0
  9. data/Gemfile +4 -2
  10. data/README.md +112 -15
  11. data/Rakefile +4 -1
  12. data/bin/console +14 -0
  13. data/lib/data/couriers/amazon.json +45 -0
  14. data/lib/data/couriers/canadapost.json +33 -0
  15. data/lib/data/couriers/canpar.json +29 -0
  16. data/lib/data/couriers/dhl.json +97 -0
  17. data/lib/data/couriers/dpd.json +345 -0
  18. data/lib/data/couriers/fedex.json +291 -0
  19. data/lib/data/couriers/gofo.json +24 -0
  20. data/lib/data/couriers/landmark.json +26 -0
  21. data/lib/data/couriers/lasership.json +105 -0
  22. data/lib/data/couriers/old_dominion.json +65 -0
  23. data/lib/data/couriers/ontrac.json +74 -0
  24. data/lib/data/couriers/purolator.json +50 -0
  25. data/lib/data/couriers/s10.json +1443 -0
  26. data/lib/data/couriers/speedee.json +26 -0
  27. data/lib/data/couriers/ups.json +197 -0
  28. data/lib/data/couriers/usps.json +272 -0
  29. data/lib/data/couriers/yodel.json +29 -0
  30. data/lib/data/couriers/yunexpress.json +25 -0
  31. data/lib/tracking_number/active_model_validator.rb +1 -1
  32. data/lib/tracking_number/base.rb +291 -22
  33. data/lib/tracking_number/checksum_validations.rb +119 -0
  34. data/lib/tracking_number/info.rb +26 -0
  35. data/lib/tracking_number/loader.rb +82 -0
  36. data/lib/tracking_number/partnership.rb +7 -0
  37. data/lib/tracking_number/unknown.rb +45 -0
  38. data/lib/tracking_number/version.rb +1 -1
  39. data/lib/tracking_number.rb +69 -17
  40. data/package.json +77 -0
  41. data/test/partnership_test.rb +71 -0
  42. data/test/renamed_types_test.rb +44 -0
  43. data/test/test_helper.rb +14 -7
  44. data/test/tracking_number_meta_test.rb +145 -0
  45. data/test/tracking_number_test.rb +191 -0
  46. data/tracking_number.gemspec +53 -22
  47. data/yarn.lock +3425 -0
  48. metadata +131 -46
  49. data/.travis.yml +0 -12
  50. data/VERSION +0 -1
  51. data/lib/tracking_number/dhl.rb +0 -34
  52. data/lib/tracking_number/fedex.rb +0 -156
  53. data/lib/tracking_number/ontrac.rb +0 -34
  54. data/lib/tracking_number/ups.rb +0 -108
  55. data/lib/tracking_number/usps.rb +0 -166
  56. data/test/dhl_tracking_number_test.rb +0 -35
  57. data/test/fedex_tracking_number_test.rb +0 -75
  58. data/test/ontrac_tracking_number_test.rb +0 -24
  59. data/test/ups_tracking_number_test.rb +0 -36
  60. data/test/usps_tracking_number_test.rb +0 -73
@@ -1,36 +1,88 @@
1
1
  # Identify if tracking numbers are valid, and which service they belong to
2
2
 
3
- # Information on validating tracking numbers found here:
4
- # http://answers.google.com/answers/threadview/id/207899.html
5
-
3
+ require 'json'
4
+ require 'tracking_number/checksum_validations'
5
+ require 'tracking_number/loader'
6
6
  require 'tracking_number/base'
7
- require 'tracking_number/usps'
8
- require 'tracking_number/fedex'
9
- require 'tracking_number/ups'
10
- require 'tracking_number/dhl'
11
- require 'tracking_number/ontrac'
7
+ require 'tracking_number/info'
8
+ require 'tracking_number/partnership'
9
+ require 'tracking_number/unknown'
10
+ require 'active_support/all'
12
11
 
13
12
  if defined?(ActiveModel::EachValidator)
14
13
  require 'tracking_number/active_model_validator'
15
14
  end
16
15
 
16
+ TrackingNumber::Loader.load_tracking_number_data
17
+
17
18
  module TrackingNumber
18
- TYPES = [UPS, UPSTest, FedExExpress, FedExSmartPost, FedExGround, FedExGround18, FedExGround96, USPS91, USPS20, USPS13, USPSTest, DHLExpress, DHLExpressAir, OnTrac]
19
+ def self.search(body, match: :carrier)
20
+ matches = TYPES.collect { |type| type.search(body) }.flatten
21
+
22
+ # Some tracking numbers (e.g. Fedex Smartpost) are partnerships between two parties, where one party is the shipper (e.g. Fedex)
23
+ # and the other party is the [last mile] carrier (e.g. USPS). We're probably interested in the last mile aspect of
24
+ # the partnership, so by default we'll show those
19
25
 
20
- def self.search(body)
21
- TYPES.collect { |type| type.search(body) }.flatten
26
+ # Tracking numbers without a partnership are both the shipper and carrier.
27
+
28
+ case match
29
+ when :carrier
30
+ matches.filter(&:carrier?)
31
+ when :shipper
32
+ matches.filter(&:shipper?)
33
+ when :all
34
+ matches
35
+ else
36
+ matches
37
+ end
38
+ end
39
+
40
+ def self.detect(tracking_number, match: :carrier)
41
+ all_matches = search(tracking_number, match: match)
42
+
43
+ if all_matches.empty?
44
+ Unknown.new(tracking_number)
45
+ else
46
+ all_matches.max_by(&:confidence)
47
+ end
22
48
  end
23
49
 
24
- def self.detect(tracking_number)
25
- tn = nil
26
- for test_klass in (TYPES+[Unknown])
50
+ def self.detect_all(tracking_number)
51
+ matches = []
52
+ (TYPES + [Unknown]).each do |test_klass|
27
53
  tn = test_klass.new(tracking_number)
28
- break if tn.valid?
54
+ matches << tn if tn.valid?
29
55
  end
30
- return tn
56
+
57
+ matches
31
58
  end
32
59
 
33
60
  def self.new(tracking_number)
34
- self.detect(tracking_number)
61
+ detect(tracking_number)
62
+ end
63
+
64
+ # Renamed in tracking_number_data 2.0. The old constant still describes the same barcode.
65
+ RENAMED_TYPES = { USPS91: :USPSIMpbC, USPS22: :USPSIMpbN, USPS34v2: :USPSIMpbC }.freeze
66
+
67
+ # Removed in tracking_number_data 2.0. An alias would answer is_a? for every IMpb number,
68
+ # which is the claim the data stopped making, so these raise instead.
69
+ WITHDRAWN_TYPES = {
70
+ FedExSmartPost: 'USPSIMpbC',
71
+ DHLECommerce30: 'USPSIMpbC'
72
+ }.freeze
73
+
74
+ def self.const_missing(name)
75
+ if (renamed = RENAMED_TYPES[name])
76
+ warn "TrackingNumber::#{name} is now TrackingNumber::#{renamed}"
77
+ return const_get(renamed)
78
+ end
79
+
80
+ if (replacement = WITHDRAWN_TYPES[name])
81
+ raise NameError, "TrackingNumber::#{name} no longer exists. These are USPS IMpb barcodes " \
82
+ "carrying nothing that identifies the shipper, so they now decode as " \
83
+ "TrackingNumber::#{replacement}."
84
+ end
85
+
86
+ super
35
87
  end
36
88
  end
data/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "tracking_number",
3
+ "version": "1.x.x-semantic-release",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/jkeen/tracking_number.git"
7
+ },
8
+ "author": "jkeen",
9
+ "license": "MIT",
10
+ "bugs": {
11
+ "url": "https://github.com/jkeen/tracking_number/issues"
12
+ },
13
+ "homepage": "https://github.com/jkeen/tracking_number#readme",
14
+ "scripts": {
15
+ "semantic-release": "semantic-release"
16
+ },
17
+ "devDependencies": {
18
+ "@semantic-release/changelog": "^6.0.1",
19
+ "@semantic-release/git": "^10.0.1",
20
+ "semantic-release": "^19.0.3",
21
+ "semantic-release-rubygem": "^1.2.0"
22
+ },
23
+ "release": {
24
+ "branches": [
25
+ "main"
26
+ ],
27
+ "plugins": [
28
+ [
29
+ "@semantic-release/commit-analyzer",
30
+ {
31
+ "releaseRules": [
32
+ {
33
+ "type": "*!",
34
+ "release": "major"
35
+ },
36
+ {
37
+ "type": "feat",
38
+ "release": "minor"
39
+ },
40
+ {
41
+ "type": "fix",
42
+ "release": "patch"
43
+ }
44
+ ],
45
+ "parserOpts": {
46
+ "noteKeywords": [
47
+ "BREAKING CHANGE",
48
+ "BREAKING CHANGES",
49
+ "BREAKING"
50
+ ]
51
+ }
52
+ }
53
+ ],
54
+ "@semantic-release/release-notes-generator",
55
+ [
56
+ "@semantic-release/changelog",
57
+ {
58
+ "changelogTitle": "tracking_number changelog",
59
+ "changelogFile": "CHANGELOG.md"
60
+ }
61
+ ],
62
+ "semantic-release-rubygem",
63
+ "@semantic-release/github",
64
+ [
65
+ "@semantic-release/git",
66
+ {
67
+ "assets": [
68
+ "CHANGELOG.md"
69
+ ],
70
+ "message": "${nextRelease.version} CHANGELOG [skip ci]\n\n${nextRelease.notes}"
71
+ }
72
+ ]
73
+ ],
74
+ "debug": false,
75
+ "dryRun": false
76
+ }
77
+ }
@@ -0,0 +1,71 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/test_helper')
2
+
3
+ # No shipped definition declares partners currently at the tracking number data 2.0 release,
4
+ # but the concept is still supported in the data spec, and should be tested, so we're faking it here
5
+
6
+ class PartnershipTest < Minitest::Test
7
+ NUMBER = 'ZZ1234567890'.freeze
8
+
9
+ def setup
10
+ @shipper = build_class('test_shipper', partner_id: 'test_carrier', partner_type: 'carrier')
11
+ @carrier = build_class('test_carrier', partner_id: 'test_shipper', partner_type: 'shipper')
12
+
13
+ TrackingNumber::TYPES.push(@shipper, @carrier)
14
+ end
15
+
16
+ def teardown
17
+ TrackingNumber::TYPES.delete(@shipper)
18
+ TrackingNumber::TYPES.delete(@carrier)
19
+ end
20
+
21
+ should "report a partnership from both sides" do
22
+ assert @shipper.new(NUMBER).partnership?
23
+ assert @carrier.new(NUMBER).partnership?
24
+ end
25
+
26
+ should "pair each side with the other" do
27
+ partners = @carrier.new(NUMBER).partners
28
+
29
+ assert_instance_of @shipper, partners.shipper
30
+ assert_instance_of @carrier, partners.carrier
31
+ end
32
+
33
+ should "report which side of the partnership each is" do
34
+ assert @shipper.new(NUMBER).shipper?
35
+ assert !@shipper.new(NUMBER).carrier?
36
+
37
+ assert @carrier.new(NUMBER).carrier?
38
+ assert !@carrier.new(NUMBER).shipper?
39
+ end
40
+
41
+ should "only match the carrier side when searching by default" do
42
+ matches = TrackingNumber.search("shipped as #{NUMBER} today")
43
+
44
+ assert_equal [@carrier], matches.collect(&:class)
45
+ end
46
+
47
+ should "match both sides when searching for all" do
48
+ matches = TrackingNumber.search("shipped as #{NUMBER} today", match: :all)
49
+
50
+ assert_equal [@shipper, @carrier], matches.collect(&:class)
51
+ end
52
+
53
+ private
54
+
55
+ def build_class(id, partner_id:, partner_type:)
56
+ pattern = 'Z\s*Z\s*(?<SerialNumber>([0-9]\s*){10})'
57
+
58
+ klass = Class.new(TrackingNumber::Base)
59
+ klass.const_set('ID', id)
60
+ klass.const_set('COURIER_CODE', id.to_sym)
61
+ klass.const_set('COURIER_INFO', name: id, courier_code: id.to_sym)
62
+ klass.const_set('SEARCH_PATTERN', Regexp.new("\\b#{pattern}\\b"))
63
+ klass.const_set('VERIFY_PATTERN', Regexp.new("^#{pattern}$"))
64
+ klass.const_set('VALIDATION', {})
65
+ klass.const_set('ADDITIONAL', nil)
66
+ klass.const_set('TRACKING_URL', nil)
67
+ klass.const_set('PARTNERS', [{ partner_id: partner_id, partner_type: partner_type }])
68
+
69
+ klass
70
+ end
71
+ end
@@ -0,0 +1,44 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/test_helper')
2
+
3
+ class RenamedTypesTest < Minitest::Test
4
+ NUMBER = '420112139261290983497923666238'.freeze
5
+
6
+ should "resolve a renamed constant to the definition that replaced it" do
7
+ assert_equal TrackingNumber::USPSIMpbC, silenced { TrackingNumber::USPS91 }
8
+ assert_equal TrackingNumber::USPSIMpbN, silenced { TrackingNumber::USPS22 }
9
+ assert_equal TrackingNumber::USPSIMpbC, silenced { TrackingNumber::USPS34v2 }
10
+ end
11
+
12
+ should "keep is_a? answering for a renamed constant" do
13
+ assert TrackingNumber.new(NUMBER).is_a?(silenced { TrackingNumber::USPS91 })
14
+ end
15
+
16
+ should "warn once a renamed constant is reached for" do
17
+ _, err = capture_io { TrackingNumber::USPS91 }
18
+
19
+ assert_match(/USPS91 is now TrackingNumber::USPSIMpbC/, err)
20
+ end
21
+
22
+ # An alias would answer is_a? for every IMpb number, including labels these carriers never touched
23
+ should "refuse a withdrawn constant and name its replacement" do
24
+ error = assert_raises(NameError) { TrackingNumber::FedExSmartPost }
25
+ assert_match(/no longer exists/, error.message)
26
+ assert_match(/USPSIMpbC/, error.message)
27
+
28
+ assert_raises(NameError) { TrackingNumber::DHLECommerce30 }
29
+ end
30
+
31
+ should "leave an unrelated missing constant alone" do
32
+ error = assert_raises(NameError) { TrackingNumber::NotADefinition }
33
+
34
+ assert_match(/uninitialized constant/, error.message)
35
+ end
36
+
37
+ private
38
+
39
+ def silenced
40
+ resolved = nil
41
+ capture_io { resolved = yield }
42
+ resolved
43
+ end
44
+ end
data/test/test_helper.rb CHANGED
@@ -2,6 +2,7 @@ require 'simplecov'
2
2
  SimpleCov.start
3
3
  require 'rubygems'
4
4
  require 'minitest/autorun'
5
+ require 'minitest/reporters'
5
6
  require 'shoulda'
6
7
  require 'active_model'
7
8
 
@@ -9,14 +10,14 @@ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
9
10
  $LOAD_PATH.unshift(File.dirname(__FILE__))
10
11
  require 'tracking_number'
11
12
 
13
+ Minitest::Reporters.use! [Minitest::Reporters::DefaultReporter.new(:color => true)]
14
+
12
15
  class Minitest::Test
13
16
  def possible_numbers(tracking)
14
17
  tracking = tracking.to_s
15
18
  possible_numbers = []
16
19
  possible_numbers << tracking
17
20
  possible_numbers << tracking.to_s.gsub(" ", "")
18
- possible_numbers << tracking.chars.to_a.join(" ")
19
- possible_numbers << tracking.chars.to_a.join(" ")
20
21
  possible_numbers << tracking.slice(0, (tracking.length / 2)) + " " + tracking.slice((tracking.length / 2), tracking.length)
21
22
 
22
23
  possible_numbers.flatten.uniq
@@ -33,7 +34,7 @@ class Minitest::Test
33
34
  def should_detect_number_variants(valid_number, type)
34
35
  possible_strings(valid_number).each do |string|
35
36
  results = type.search(string)
36
- assert_equal 1, results.size, "could not find #{type} #{valid_number} in #{string}"
37
+ assert_equal 1, results.size, "could not find #{type} #{valid_number} in #{string} using search regex: #{type::SEARCH_PATTERN}"
37
38
  end
38
39
  end
39
40
 
@@ -44,10 +45,16 @@ class Minitest::Test
44
45
  assert t.valid?
45
46
  end
46
47
 
47
- def should_fail_on_check_digit_changes(valid_number)
48
- digits = valid_number.chars.to_a
49
- last = digits.pop.to_i
50
- digits << (last < 2 ? last + 3 : last - 3).to_s
48
+ def should_be_invalid_number(invalid_number, type, carrier)
49
+ t = TrackingNumber.new(invalid_number)
50
+ assert !t.valid?
51
+ end
52
+
53
+ def should_fail_on_check_digit_changes(valid_number, klass)
54
+ digits = valid_number.gsub(/\s/, "").chars.compact.to_a
55
+ position = digits.join.match(klass::VERIFY_PATTERN).begin("CheckDigit")
56
+ current = digits[position].to_i
57
+ digits[position] = (current <= 2 ? current + 3 : current - 3).to_s
51
58
  invalid_number = digits.join
52
59
  t = TrackingNumber.new(invalid_number)
53
60
  assert !t.valid?, "#{invalid_number} reported as a valid #{t.class}, and it shouldn't be"
@@ -0,0 +1,145 @@
1
+ require 'test_helper'
2
+
3
+ def load_courier_data(name = :all)
4
+ if name == :all
5
+ Dir.glob(File.join(File.dirname(__FILE__), "../lib/data/couriers/*.json")).collect do |file|
6
+ JSON.parse(File.read(file)).deep_symbolize_keys!
7
+ end
8
+ else
9
+ return JSON.parse(File.join(File.dirname(__FILE__), "../lib/data/couriers/#{name}.json"))
10
+ end
11
+ end
12
+
13
+ class TrackingNumberMetaTest < Minitest::Test
14
+ load_courier_data(:all).each do |courier_info|
15
+ courier_name = courier_info[:name]
16
+ courier_code = courier_info[:courier_code].to_sym
17
+
18
+ courier_info[:tracking_numbers].each do |tracking_info|
19
+ klass_name = tracking_info[:name].gsub(/[^0-9A-Za-z]/, '')
20
+ klass = "TrackingNumber::#{klass_name}".constantize
21
+
22
+ describe "[#{tracking_info[:name]}]" do
23
+ tracking_info[:test_numbers][:valid].each do |valid_number|
24
+ should "detect #{valid_number} as #{klass_name}" do
25
+ #TODO fix this multiple matching thing
26
+ matches = TrackingNumber.search(valid_number, match: :all)
27
+ assert matches.collect(&:class).include?(klass)
28
+ end
29
+
30
+ if tracking_info[:validation][:checksum]
31
+ # only run this test if number format has checksum
32
+ should "fail on check digit changes with #{valid_number}" do
33
+ should_fail_on_check_digit_changes(valid_number, klass)
34
+ end
35
+ end
36
+
37
+ should "detect #{valid_number} regardless of spacing" do
38
+ should_detect_number_variants(valid_number, "TrackingNumber::#{klass_name}".constantize)
39
+ end
40
+
41
+ context "number test" do
42
+ tracking_number = klass.new(valid_number)
43
+
44
+ should "validate #{valid_number} with #{klass_name}" do
45
+ assert_equal courier_code, tracking_number.carrier
46
+ assert tracking_number.valid?, "should be valid"
47
+ end
48
+
49
+ should "return correct courier code on #{valid_number} when calling #courier_code" do
50
+ tracking_number = klass.new(valid_number)
51
+ assert_equal courier_info[:courier_code].to_sym, tracking_number.courier_code
52
+ assert_equal courier_info[:courier_code].to_sym,tracking_number.courier_code
53
+ end
54
+
55
+ should "return correct courier name on #{valid_number} when calling #courier_name" do
56
+ if (tracking_number.matching_additional["Courier"])
57
+ assert_equal tracking_number.matching_additional["Courier"][:courier], tracking_number.courier_name
58
+ else
59
+ assert_equal courier_name, tracking_number.courier_name
60
+ end
61
+ end
62
+
63
+ should "not throw an error when calling #service_type on #{valid_number}" do
64
+ service_type = tracking_number.service_type
65
+ assert service_type.is_a?(String) || service_type.nil?
66
+ end
67
+
68
+ should "not throw an error when calling #destination on #{valid_number}" do
69
+ assert tracking_number.destination_zip.is_a?(String) || tracking_number.destination_zip.nil?
70
+ end
71
+
72
+ should "not throw an error when calling #shipper on #{valid_number}" do
73
+ t = klass.new(valid_number)
74
+ assert tracking_number.shipper_id.is_a?(String) || tracking_number.shipper_id.nil?
75
+ end
76
+
77
+ should "not throw an error when calling #package_type on #{valid_number}" do
78
+ t = klass.new(valid_number)
79
+ assert tracking_number.package_type.is_a?(String) || tracking_number.package_type.nil?
80
+ end
81
+
82
+ should "not throw an error when calling #decode on #{valid_number}" do
83
+ t = klass.new(valid_number)
84
+ decode = tracking_number.decode
85
+ assert decode.is_a?(Hash)
86
+ end
87
+
88
+ end
89
+ end
90
+
91
+ tracking_info[:test_numbers][:invalid].each do |invalid_number|
92
+ should "not validate #{invalid_number} with #{klass_name}" do
93
+ t = klass.new(invalid_number)
94
+ assert !t.valid?, "should not be valid"
95
+ end
96
+
97
+ should "not throw an error when calling #service_type on invalid number #{invalid_number}" do
98
+ t = klass.new(invalid_number)
99
+ service_type = t.service_type
100
+ assert service_type.is_a?(String) || service_type.nil?
101
+ end
102
+
103
+ should "not throw an error when calling #destination_zip on invalid number #{invalid_number}" do
104
+ t = klass.new(invalid_number)
105
+ destination = t.destination_zip
106
+ assert destination.is_a?(String) || destination.nil?
107
+ end
108
+
109
+ should "not throw an error when calling #shipper_id on invalid number #{invalid_number}" do
110
+ t = klass.new(invalid_number)
111
+ shipper = t.shipper_id
112
+ assert shipper.is_a?(String) || shipper.nil?
113
+ end
114
+
115
+ should "not throw an error when calling #package_type on invalid number #{invalid_number}" do
116
+ t = klass.new(invalid_number)
117
+ assert t.package_type.is_a?(String) || t.package_type.nil?
118
+ end
119
+
120
+ should "not throw an error when calling #decode on invalid number #{invalid_number}" do
121
+ t = klass.new(invalid_number)
122
+ decode = t.decode
123
+ assert decode.is_a?(Hash)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ test_numbers = []
131
+ load_courier_data(:all).each do |courier_info|
132
+ courier_info[:tracking_numbers].each do |tracking_info|
133
+ test_numbers << tracking_info[:test_numbers][:valid]
134
+ end
135
+ end
136
+
137
+ test_numbers.flatten!
138
+
139
+ test_numbers.each do |number|
140
+ matches = TrackingNumber.detect_all(number)
141
+ if (matches.size > 1)
142
+ puts "WARNING: #{number.gsub(/\s/, '')} matched multiple types => #{matches.collect { |m| m.class.to_s.split("::").last}}"
143
+ end
144
+ end
145
+ end