diff --git a/lib/active_shipping/shipping/carriers/usps.rb b/lib/active_shipping/shipping/carriers/usps.rb index af8b3f0d3..c7bba473e 100644 --- a/lib/active_shipping/shipping/carriers/usps.rb +++ b/lib/active_shipping/shipping/carriers/usps.rb @@ -1,5 +1,6 @@ # -*- encoding: utf-8 -*- require 'cgi' +require 'socket' module ActiveMerchant module Shipping @@ -138,9 +139,15 @@ class USPS < Carrier RESPONSE_ERROR_MESSAGES = [ /There is no record of that mail item/, /This Information has not been included in this Test Server\./, - /Delivery status information is not available/ + /Delivery status information is not available/, + /That's not a valid number\./ ] + def initialize(options = {}) + super + @ip_addr = @options[:ip_address] || IPSocket.getaddress(Socket.gethostname) + end + def find_tracking_info(tracking_number, options={}) options = @options.update(options) tracking_request = build_tracking_request(tracking_number, options) @@ -148,6 +155,13 @@ def find_tracking_info(tracking_number, options={}) parse_tracking_response(response, options) end + def find_tracking_info_with_fields(tracking_numbers, options={}) + options = @options.update(options) + tracking_request = build_tracking_with_fields_request(tracking_numbers) + response = commit(:track, tracking_request, (options[:test] || false)) + parse_tracking_with_fields_response(response) + end + def self.size_code_for(package) if package.inches(:max) <= 12 'REGULAR' @@ -227,6 +241,18 @@ def build_tracking_request(tracking_number, options={}) URI.encode(xml_request.to_s) end + def build_tracking_with_fields_request(tracking_numbers) + # Using revision 1 api for new functionality + xml_request = XmlNode.new('TrackFieldRequest', 'USERID' => @options[:login]) do |root_node| + root_node << XmlNode.new('Revision', 1) + # NOTE not sure of purpose and use of the client ip address + root_node << XmlNode.new('ClientIp', @ip_addr) + root_node << XmlNode.new('SourceId', @options[:login]) # usps login as internal user + tracking_numbers.each { |track_num| root_node << XmlNode.new('TrackID', :ID => track_num) } + end + URI.encode(xml_request.to_s) + end + def us_rates(origin, destination, packages, options={}) request = build_us_rate_request(packages, origin.zip, destination.zip, options) # never use test mode; rate requests just won't work on test servers @@ -535,9 +561,99 @@ def parse_tracking_response(response, options) :actual_delivery_date => actual_delivery_date ) end + + def parse_tracking_with_fields_response(response) + xml = REXML::Document.new(response) + + # We have a document level error + if !!xml.elements['Error'] + description = xml.elements['Error/Description'].get_text.to_s + raise ResponseError.new(description) + end + + tracking_responses = [] + tracking_nodes = xml.elements.collect('TrackResponse/TrackInfo') { |e| e } + + tracking_nodes.each do |tracking_info| + tracking_number = tracking_info.attributes['ID'].to_s + tracking_details = tracking_info.elements.collect('TrackDetail'){ |e| e } + tracking_summary = tracking_info.elements['TrackSummary'] + + success = !(tracking_info.elements['Error'] || summary_no_record?(tracking_summary)) + message = response_message(tracking_info) + shipment_events = [] + status, status_category, expected_delivery_date, actual_delivery_date = nil, nil, nil, nil + + if success + previous_event_time = Time.now + tracking_details.each do |event| + ship_event = extract_track_with_fields_event(event, previous_event_time) + previous_event_time = ship_event.time + shipment_events << ship_event + end + summary_ship_event = extract_track_with_fields_event(tracking_summary, previous_event_time) + if !summary_ship_event.nil? + shipment_events << summary_ship_event + status = summary_ship_event.status + actual_delivery_date = summary_ship_event.time if summary_ship_event.delivered? + end + + shipment_events = shipment_events.sort_by(&:time) + status_category = tracking_info.elements['StatusCategory'].get_text.to_s + status_category = status_category.downcase.gsub("\s", "_").to_sym + + if tracking_info.elements['ExpectedDeliveryDate'] + if tracking_info.elements['ExpectedDeliveryTime'] + time = Time.parse("#{tracking_info.elements['ExpectedDeliveryDate'].get_text.to_s} #{tracking_info.elements['ExpectedDeliveryTime'].get_text.to_s}") + else + time = Time.parse("#{tracking_info.elements['ExpectedDeliveryDate'].get_text.to_s}") + end + expected_delivery_date = Time.utc(time.year, time.month, time.mday, time.hour, time.min, time.sec) + end + end + + tracking_responses << TrackingResponse.new(success, message, Hash.from_xml(tracking_info.to_s), + :carrier => @@name, + :xml => response, + :request => last_request, + :shipment_events => shipment_events, + :tracking_number => tracking_number, + :status => status, + :status_category => status_category, + :actual_delivery_date => actual_delivery_date, + :scheduled_delivery_date => expected_delivery_date + ) + end + + tracking_responses + end + + # pass default event_time to fill if none available in event in order to maintain sort order + def extract_track_with_fields_event(track_event_node, default_event_time) + event = nil + status_name = track_event_node.elements['Event'].get_text.to_s + datetime_str = "#{track_event_node.elements['EventDate'].get_text.to_s} #{track_event_node.elements['EventTime'].get_text.to_s}" + + if !datetime_str.nil? && datetime_str.length > 1 + time = Time.parse(datetime_str) + zoneless_datetime = Time.utc(time.year, time.month, time.mday, time.hour, time.min, time.sec) + else + zoneless_datetime = default_event_time + end + + country = !!track_event_node.elements['EventCountry'] || track_event_node.elements['EventCountry'].blank? ? 'USA' : track_event_node.elements['EventCountry'].get_text.to_s + + location = Location.new(city: track_event_node.elements['EventCity'].get_text.to_s, + state: track_event_node.elements['EventState'].get_text.to_s, + postal_code: track_event_node.elements['EventZIPCode'].get_text.to_s, + country: country) + event = ShipmentEvent.new(status_name, zoneless_datetime, location) + + event + end def track_summary_node(document) - document.elements['*/*/TrackSummary'] + document.elements['*/*/TrackSummary'] || document.elements['TrackSummary'] end def error_description_node(document) @@ -559,14 +675,18 @@ def has_error?(document) def no_record?(document) summary_node = track_summary_node(document) if summary_node - summary = summary_node.get_text.to_s - RESPONSE_ERROR_MESSAGES.detect { |re| summary =~ re } - summary =~ /There is no record of that mail item/ || summary =~ /This Information has not been included in this Test Server\./ + summary_no_record?(summary_node) else false end end + def summary_no_record?(summary_node) + summary = summary_node.get_text.to_s + RESPONSE_ERROR_MESSAGES.detect { |re| summary =~ re } + summary =~ /There is no record of that mail item/ || summary =~ /This Information has not been included in this Test Server\./ + end + def tracking_info_error?(document) document.elements['*/TrackInfo/Error'] end diff --git a/lib/active_shipping/shipping/tracking_response.rb b/lib/active_shipping/shipping/tracking_response.rb index 6c1066555..2c93713ff 100644 --- a/lib/active_shipping/shipping/tracking_response.rb +++ b/lib/active_shipping/shipping/tracking_response.rb @@ -7,6 +7,7 @@ class TrackingResponse < Response attr_reader :status # symbol attr_reader :status_code # string attr_reader :status_description #string + attr_reader :status_category #symbol attr_reader :ship_time # time attr_reader :scheduled_delivery_date # time attr_reader :actual_delivery_date # time @@ -21,6 +22,7 @@ def initialize(success, message, params = {}, options = {}) @status = options[:status] @status_code = options[:status_code] @status_description = options[:status_description] + @status_category = options[:status_category] @ship_time = options[:ship_time] @scheduled_delivery_date = options[:scheduled_delivery_date] @actual_delivery_date = options[:actual_delivery_date] diff --git a/lib/active_shipping/version.rb b/lib/active_shipping/version.rb index 1f3aac0e9..4234d6114 100644 --- a/lib/active_shipping/version.rb +++ b/lib/active_shipping/version.rb @@ -1,3 +1,3 @@ module ActiveShipping - VERSION = "0.12.0" + VERSION = "0.17.0goodmouth" end diff --git a/test/fixtures.yml b/test/fixtures.yml index cc372478a..4964db5fe 100644 --- a/test/fixtures.yml +++ b/test/fixtures.yml @@ -1,5 +1,6 @@ usps: - login: APIKey + login: ApiKey + ip_address: 141.214.14.1 ups: key: XmlAccessKey login: UPSDotComLogin diff --git a/test/fixtures/xml/usps/tracking_w_fields_response.xml b/test/fixtures/xml/usps/tracking_w_fields_response.xml new file mode 100644 index 000000000..e3498ae6d --- /dev/null +++ b/test/fixtures/xml/usps/tracking_w_fields_response.xml @@ -0,0 +1,145 @@ + + + + First-Class Package Service + FC + SAINT ALBANS + WV + 25177 + true + May 15, 2014 + false + DM + 2014-05-12 21:42:26.000000 + 675849201 + INDIANAPOLIS + IN + 46222 + false + false + false + USPS Tracking<SUP>&#153;</SUP> + 001 + Delivered + Delivered + Your item was delivered at 4:42 pm on May 14, 2014 in SAINT ALBANS, WV 25177. + T + + 4:42 pm + May 14, 2014 + Delivered + SAINT ALBANS + WV + 25177 + + + + false + 01 + + + 7:37 am + May 14, 2014 + Out for Delivery + SAINT ALBANS + WV + 25177 + + + + false + OF + + + 7:27 am + May 14, 2014 + Sorting Complete + SAINT ALBANS + WV + 25177 + + + + false + PC + + + 5:33 am + May 14, 2014 + Arrival at Post Office + SAINT ALBANS + WV + 25177 + + + + false + 07 + + + 7:59 pm + May 13, 2014 + Depart USPS Sort Facility + CHARLESTON + WV + 25350 + + + + false + EF + + + 5:38 pm + May 13, 2014 + Processed through USPS Sort Facility + CHARLESTON + WV + 25350 + + + + false + 10 + + + + May 13, 2014 + Electronic Shipping Info Received + + + + + + + false + MA + + + 10:39 pm + May 12, 2014 + Processed at USPS Origin Sort Facility + INDIANAPOLIS + IN + 46241 + + + + false + 10 + + + 9:24 pm + May 12, 2014 + Accepted at USPS Origin Sort Facility + INDIANAPOLIS + IN + 46222 + + + + false + OA + + + diff --git a/test/remote/usps_test.rb b/test/remote/usps_test.rb index d6582e533..7407aca12 100644 --- a/test/remote/usps_test.rb +++ b/test/remote/usps_test.rb @@ -10,16 +10,34 @@ def setup def test_tracking assert_nothing_raised do - @carrier.find_tracking_info('EJ958083578US', :test => true) + response = @carrier.find_tracking_info('9400110200881506616178', :test => true) + end + end + + def test_tracking_w_fields + assert_nothing_raised do + response = @carrier.find_tracking_info_with_fields(['9400110200881506616178'], :test => true).first end end def test_tracking_with_bad_number - assert_raises ResponseError do + assert_raises ActiveMerchant::Shipping::ResponseError do response = @carrier.find_tracking_info('abc123xyz') end end + def test_tracking_w_field_with_bad_number + assert_raises ActiveMerchant::Shipping::ResponseError do + response = @carrier.find_tracking_info_with_fields(['abc123xyz']) + end + end + + def test_tracking_for_no_status_time + assert_nothing_raised do + response = @carrier.find_tracking_info_with_fields(['9400110200882472687667']) + end + end + def test_zip_to_zip assert_nothing_raised do response = @carrier.find_rates( @@ -226,12 +244,13 @@ def test_first_class_packages_with_invalid_mail_type end def test_valid_credentials - assert USPS.new(fixtures(:usps).merge(:test => true)).valid_credentials? + assert USPS.new(fixtures(:usps)).valid_credentials? end def test_valid_credentials_empty_login - usps = USPS.new(:test => true) - assertEqual false, usps.valid_credentials? + assert_raises ArgumentError do + USPS.new(:test => true).should + end end # Uncomment and switch out SPECIAL_COUNTRIES with some other batch to see which diff --git a/test/unit/carriers/usps_test.rb b/test/unit/carriers/usps_test.rb index 5410d15d8..4a8be3e62 100644 --- a/test/unit/carriers/usps_test.rb +++ b/test/unit/carriers/usps_test.rb @@ -8,6 +8,7 @@ def setup @locations = TestFixtures.locations @carrier = USPS.new(:login => 'login') @tracking_response = xml_fixture('usps/tracking_response') + @tracking_w_fields_response = xml_fixture('usps/tracking_w_fields_response') @tracking_response_failure = xml_fixture('usps/tracking_response_failure') end @@ -18,12 +19,26 @@ def test_tracking_failure_should_raise_exception end end + def test_tracking_w_fields_failure_should_raise_exception + @carrier.expects(:commit).returns(@tracking_response_failure) + assert_raises(ResponseError, "There is no record of that mail item.") do + @carrier.find_tracking_info_with_fields(['abc123xyz'], :test => true) + end + end + def test_find_tracking_info_should_handle_not_found_error @carrier.expects(:commit).returns(xml_fixture('usps/tracking_response_test_error')) assert_raises ResponseError do @carrier.find_tracking_info('9102901000462189604217', :test => true) end end + + def test_find_tracking_info_w_fields_should_handle_not_found_error + @carrier.expects(:commit).returns(xml_fixture('usps/tracking_response_test_error')) + assert_raises(ResponseError, "This Information has not been included in this Test Server.") do + @carrier.find_tracking_info_with_fields(['9102901000462189604217'], :test => true) + end + end def test_find_tracking_info_should_handle_invalid_xml_error @carrier.expects(:commit).returns(xml_fixture('usps/invalid_xml_tracking_response_error')) @@ -32,6 +47,13 @@ def test_find_tracking_info_should_handle_invalid_xml_error end end + def test_find_tracking_info_should_handle_invalid_xml_error + @carrier.expects(:commit).returns(xml_fixture('usps/invalid_xml_tracking_response_error')) + assert_raises(ResponseError, "Invalid XML A name contained an invalid character.") do + @carrier.find_tracking_info_with_fields(['9102901000462189604217','9102901000462189604214'], :test => true) + end + end + def test_find_tracking_info_should_handle_not_available_error @carrier.expects(:commit).returns(xml_fixture('usps/tracking_response_not_available')) assert_raises ResponseError do @@ -39,6 +61,13 @@ def test_find_tracking_info_should_handle_not_available_error end end + def test_find_tracking_info_w_fields_should_handle_not_available_error + @carrier.expects(:commit).returns(xml_fixture('usps/tracking_response_not_available')) + assert_raises( ResponseError, "Delivery status information is not available for your item via this web site.") do + @carrier.find_tracking_info(['9574211957289221353248'], :test => true) + end + end + def test_find_tracking_info_should_return_a_tracking_response @carrier.expects(:commit).returns(@tracking_response) assert_instance_of ActiveMerchant::Shipping::TrackingResponse, @carrier.find_tracking_info('9102901000462189604217', :test => true) @@ -46,17 +75,38 @@ def test_find_tracking_info_should_return_a_tracking_response assert_equal 'ActiveMerchant::Shipping::TrackingResponse', @carrier.find_tracking_info('EJ958083578US').class.name end + def test_find_tracking_info_w_fields_should_return_a_tracking_response + @carrier.expects(:commit).returns(@tracking_w_fields_response) + assert_instance_of ActiveMerchant::Shipping::TrackingResponse, @carrier.find_tracking_info_with_fields(['9102901000462189604217'], :test => true).first + @carrier.expects(:commit).returns(@tracking_w_fields_response) + response = @carrier.find_tracking_info_with_fields(['EJ958083578US']) + assert_equal 'ActiveMerchant::Shipping::TrackingResponse', response.first.class.name + assert_equal :delivered, response.first.status_category + end + def test_find_tracking_info_should_parse_response_into_correct_number_of_shipment_events @carrier.expects(:commit).returns(@tracking_response) response = @carrier.find_tracking_info('9102901000462189604217', :test => true) assert_equal 7, response.shipment_events.size end + def test_find_tracking_info_w_fields_should_parse_response_into_correct_number_of_shipment_events + @carrier.expects(:commit).returns(@tracking_w_fields_response) + response = @carrier.find_tracking_info_with_fields(['9102901000462189604217'], :test => true).first + assert_equal 9, response.shipment_events.size + end + def test_find_tracking_info_should_return_shipment_events_in_ascending_chronological_order @carrier.expects(:commit).returns(@tracking_response) response = @carrier.find_tracking_info('9102901000462189604217', :test => true) assert_equal response.shipment_events.map(&:time).sort, response.shipment_events.map(&:time) end + + def test_find_tracking_info_w_fields_should_return_shipment_events_in_ascending_chronological_order + @carrier.expects(:commit).returns(@tracking_w_fields_response) + response = @carrier.find_tracking_info_with_fields(['9102901000462189604217'], :test => true).first + assert_equal response.shipment_events.map(&:time).sort, response.shipment_events.map(&:time) + end def test_find_tracking_info_should_have_correct_timestamps_for_shipment_events @carrier.expects(:commit).returns(@tracking_response) @@ -69,6 +119,20 @@ def test_find_tracking_info_should_have_correct_timestamps_for_shipment_events '2012-01-27 08:03:00 UTC', '2012-01-27 08:13:00 UTC'], response.shipment_events.map{ |e| e.time.strftime('%Y-%m-%d %H:%M:00 %Z') } end + + def test_find_tracking_info_w_fields_should_have_correct_timestamps_for_shipment_events + @carrier.expects(:commit).returns(@tracking_w_fields_response) + response = @carrier.find_tracking_info_with_fields(['9102901000462189604217'], :test => true).first + assert_equal ['2014-05-12 21:24:00 UTC', + '2014-05-12 22:39:00 UTC', + '2014-05-13 00:00:00 UTC', + '2014-05-13 17:38:00 UTC', + '2014-05-13 19:59:00 UTC', + '2014-05-14 05:33:00 UTC', + '2014-05-14 07:27:00 UTC', + '2014-05-14 07:37:00 UTC', + '2014-05-14 16:42:00 UTC'], response.shipment_events.map{ |e| e.time.strftime('%Y-%m-%d %H:%M:00 %Z') } + end def test_find_tracking_info_should_have_correct_names_for_shipment_events @carrier.expects(:commit).returns(@tracking_response) @@ -81,6 +145,20 @@ def test_find_tracking_info_should_have_correct_names_for_shipment_events "SORTING COMPLETE", "OUT FOR DELIVERY"], response.shipment_events.map(&:name) end + + def test_find_tracking_info_should_have_correct_names_for_shipment_events + @carrier.expects(:commit).returns(@tracking_w_fields_response) + response = @carrier.find_tracking_info_with_fields(['9102901000462189604217']).first + assert_equal ["Accepted at USPS Origin Sort Facility", + "Processed at USPS Origin Sort Facility", + "Electronic Shipping Info Received", + "Processed through USPS Sort Facility", + "Depart USPS Sort Facility", + "Arrival at Post Office", + "Sorting Complete", + "Out for Delivery", + "Delivered"], response.shipment_events.map(&:name) + end def test_find_tracking_info_should_have_correct_locations_for_shipment_events @carrier.expects(:commit).returns(@tracking_response) @@ -94,6 +172,20 @@ def test_find_tracking_info_should_have_correct_locations_for_shipment_events "DES MOINES, IA, 50311"], response.shipment_events.map{|e| e.location}.map{|l| "#{l.city}, #{l.state}, #{l.postal_code}"} end + def test_find_tracking_info_w_fields_should_have_correct_locations_for_shipment_events + @carrier.expects(:commit).returns(@tracking_w_fields_response) + response = @carrier.find_tracking_info_with_fields(['9102901000462189604217'], :test => true).first + assert_equal ["INDIANAPOLIS, IN, 46222", + "INDIANAPOLIS, IN, 46241", + ", , ", + "CHARLESTON, WV, 25350", + "CHARLESTON, WV, 25350", + "SAINT ALBANS, WV, 25177", + "SAINT ALBANS, WV, 25177", + "SAINT ALBANS, WV, 25177", + "SAINT ALBANS, WV, 25177"], response.shipment_events.map{|e| e.location}.map{|l| "#{l.city}, #{l.state}, #{l.postal_code}"} + end + def test_find_tracking_info_destination # USPS API doesn't tell where it's going @carrier.expects(:commit).returns(@tracking_response) @@ -101,17 +193,36 @@ def test_find_tracking_info_destination assert_equal response.destination, nil end + def test_find_tracking_info_w_fields_destination + # USPS API doesn't tell where it's going + @carrier.expects(:commit).returns(@tracking_w_fields_response) + response = @carrier.find_tracking_info_with_fields(['9102901000462189604217'], :test => true).first + assert_equal response.destination, nil + end + def test_find_tracking_info_tracking_number @carrier.expects(:commit).returns(@tracking_response) response = @carrier.find_tracking_info('9102901000462189604217', :test => true) assert_equal response.tracking_number, '9102901000462189604217' end + + def test_find_tracking_info_w_fields_tracking_number + @carrier.expects(:commit).returns(@tracking_w_fields_response) + response = @carrier.find_tracking_info_with_fields(['9400110200882182234434'], :test => true).first + assert_equal response.tracking_number, '9400110200882182234434' + end def test_find_tracking_info_should_have_correct_status @carrier.expects(:commit).returns(@tracking_response) response = @carrier.find_tracking_info('9102901000462189604217') assert_equal :out_for_delivery, response.status end + + def test_find_tracking_info_w_fields_should_have_correct_status + @carrier.expects(:commit).returns(@tracking_w_fields_response) + response = @carrier.find_tracking_info_with_fields(['9102901000462189604217']).first + assert_equal :delivered, response.status + end def test_find_tracking_info_should_have_correct_delivered @carrier.expects(:commit).returns(xml_fixture('usps/delivered_tracking_response'))