From 1ed101113c27d44524f08b4adf3e87d9b87ae49b Mon Sep 17 00:00:00 2001 From: Luke Usher Date: Thu, 29 Jan 2026 13:08:48 +0000 Subject: [PATCH 1/2] NATACC-1716: Allowed custom US datetime format to be received and processed correctly --- lib/cxml/document.rb | 33 +++++++++++++++- spec/document_spec.rb | 87 ++++++++++++++++++++++++++++--------------- 2 files changed, 87 insertions(+), 33 deletions(-) diff --git a/lib/cxml/document.rb b/lib/cxml/document.rb index f4d42ad..02f126e 100644 --- a/lib/cxml/document.rb +++ b/lib/cxml/document.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module CXML class Document attr_accessor :version @@ -10,14 +12,28 @@ class Document attr_accessor :response attr_accessor :punch_out_order_message - def initialize(data={}) + def initialize(data = {}) if data.kind_of?(Hash) && !data.empty? @version = data['version'] || CXML::Protocol.version @payload_id = data['payloadID'] @xml_lang = data['xml:lang'] if data['xml:lang'] if data['timestamp'] - @timestamp = Time.parse(data['timestamp']) + begin + # If timestamp is received as standard ISO 8601 format (as most should), we continue normally + # e.g '2026-01-13T13:02:41'. + @timestamp = Time.parse(data['timestamp']) + + # We catch this failure to handle a timestamp we want to allow but receive in a slightly different format, + # and change it to ISO 8601. + # e.g '1/13/2026 1:02:41 PM' => '2026-01-13T13:02:41' + rescue ArgumentError => e + if e.message.include?('mon out of range') + @timestamp = Time.iso8601(to_iso8601(data['timestamp'])) + else + raise + end + end end if data['Header'] @@ -78,5 +94,18 @@ def render end node end + + # Converts a string in `MM/DD/YYYY hh:mm:ss AM/PM` format + # to an ISO 8601 formatted string. + # + # @param str [String] the datetime string to convert, e.g., '01/28/2026 09:15:30 AM' + # @return [String] the ISO 8601 representation of the datetime, e.g., '2026-01-28T09:15:30+00:00' + # + # @example Convert a US morning datetime + # to_iso8601('01/28/2026 09:15:30 AM') + # => '2026-01-28T09:15:30+00:00' + def to_iso8601(str) + DateTime.strptime(str, '%m/%d/%Y %I:%M:%S %p').iso8601 + end end end diff --git a/spec/document_spec.rb b/spec/document_spec.rb index 4b43e7e..bb5c4ed 100644 --- a/spec/document_spec.rb +++ b/spec/document_spec.rb @@ -1,36 +1,37 @@ +# frozen_string_literal: true + require 'spec_helper' describe CXML::Document do shared_examples_for :document_has_mandatory_values do - it "sets the mandatory attributes" do + it 'sets the mandatory attributes' do doc.version.should eq(CXML::Protocol::VERSION) doc.payload_id.should_not be_nil end end shared_examples_for :document_has_a_header do - it "sets the header attributes" do + it 'sets the header attributes' do doc.header.should be_a CXML::Header end end shared_examples_for :document_has_a_timestamp do - it "sets the timestamp attributes" do + it 'sets the timestamp attributes' do doc.timestamp.should be_a Time doc.timestamp.should eq(Time.parse('2012-09-04T02:37:49-05:00')) end end shared_examples_for :document_render_defaults do - it "returns xml content" do + it 'returns xml content' do output_xml.should_not be_nil end it 'returns xml content with a header xml node' do - output_data["Header"].should_not be_empty + output_data['Header'].should_not be_empty end - end let(:parser) { CXML::Parser.new } @@ -51,10 +52,10 @@ describe '#initialize' do let(:doc) { CXML::Document.new(data) } + let(:data) { parser.parse(fixture('request_doc.xml')) } - context "when a request document is passed" do + context 'when a request document is passed' do - let(:data) { parser.parse(fixture('request_doc.xml')) } include_examples :document_has_mandatory_values include_examples :document_has_a_header include_examples :document_has_a_timestamp @@ -68,7 +69,7 @@ end end - context "when a response document is passed" do + context 'when a response document is passed' do let(:data) { parser.parse(fixture('response_status_200.xml')) } include_examples :document_has_mandatory_values @@ -84,7 +85,7 @@ end - context "when a punch out order message is passed" do + context 'when a punch out order message is passed' do let(:data) { parser.parse(fixture('punch_out_order_message_doc.xml')) } include_examples :document_has_mandatory_values @@ -98,9 +99,20 @@ doc.request.should be_nil doc.response.should be_nil end + end + context 'when the timestamp is received as ISO 8601 format' do + it 'accepts an ISO 8601 datetime' do + expect(doc.timestamp).to be_a(Time) + end end + context 'when the timestamp is received as custom US format' do + it 'accepts a custom US format datetime' do + data['timestamp'] = '1/13/2026 1:02:41 PM' + expect(doc.timestamp).to be_a(Time) + end + end end describe '#render' do @@ -112,62 +124,75 @@ it { should respond_to :render} - context "when a request document is rendered" do + context 'when a request document is rendered' do let(:data) { parser.parse(fixture('request_doc.xml')) } include_examples :document_render_defaults end - context "when a valid response is rendered" do + context 'when a valid response is rendered' do let(:data) { parser.parse(fixture('response_status_200.xml')) } - it "returns xml content" do + it 'returns xml content' do output_xml.should_not be_nil end it 'outputs the response with a valid status code' do - output_data["Response"].should_not be_empty - output_data["Response"]["Status"]["code"].should == "200" + output_data['Response'].should_not be_empty + output_data['Response']['Status']['code'].should == '200' end - it "outputs the punch out setup response" do - output_data["PunchOutSetupResponse"].should_not be_empty + it 'outputs the punch out setup response' do + output_data['PunchOutSetupResponse'].should_not be_empty end - end - context "when a invalid response is rendered" do + context 'when a invalid response is rendered' do let(:data) { parser.parse(fixture('response_status_400.xml')) } - it "returns xml content" do + it 'returns xml content' do output_xml.should_not be_nil end it 'outputs the response with a valid status code' do - output_data["Response"].should_not be_empty - output_data["Response"]["Status"]["code"].should == "400" + output_data['Response'].should_not be_empty + output_data['Response']['Status']['code'].should == '400' end - end - context "when a punch out order message document is rendered" do + context 'when a punch out order message document is rendered' do let(:data) { parser.parse(fixture('punch_out_order_message_doc.xml')) } include_examples :document_render_defaults it 'outputs the punch out order message xml' do - output_data["Message"].should_not be_empty - output_data["Message"]["PunchOutOrderMessage"].should_not be_empty + output_data['Message'].should_not be_empty + output_data['Message']['PunchOutOrderMessage'].should_not be_empty end end - end - describe "#build_attributes" do + describe '#build_attributes' do let(:data) { parser.parse(fixture('punch_out_order_message_doc.xml')) } let(:doc) { CXML::Document.new(data) } - it "returns a hash" do + it 'returns a hash' do doc.build_attributes.should include('version') end - end -end + describe '#to_iso8601' do + let(:doc) { described_class.new } + it 'converts a custom us datetime string to ISO 8601' do + expect(doc.to_iso8601('01/28/2026 09:15:30 AM')).to eq('2026-01-28T09:15:30+00:00') + end + + it 'raises an ArgumentError for incorrect format' do + expect { doc.to_iso8601('2026-01-28 09:15:30') }.to raise_error(ArgumentError) + end + it 'raises an ArgumentError for impossible dates' do + expect { doc.to_iso8601('02/30/2026 10:00:00 AM') }.to raise_error(ArgumentError) + end + + it 'handles edge case dates/times like midnight on new year correctly' do + expect(doc.to_iso8601('01/01/2026 12:00:00 AM')).to eq('2026-01-01T00:00:00+00:00') + end + end +end From 7672868d08fa3e6561965fec4f3d7aecb77b6dc3 Mon Sep 17 00:00:00 2001 From: Luke Usher Date: Mon, 2 Feb 2026 09:34:20 +0000 Subject: [PATCH 2/2] NATACC-1716: Additional specs for other standard datetime formats --- spec/document_spec.rb | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/spec/document_spec.rb b/spec/document_spec.rb index bb5c4ed..99e0676 100644 --- a/spec/document_spec.rb +++ b/spec/document_spec.rb @@ -107,6 +107,27 @@ end end + context 'when the timestamp is received as RFC 1123 format' do + it 'accepts a RFC 1123 format datetime' do + data['timestamp'] = 'Tue, 13 Jan 2026 13:02:41 GMT' + expect(doc.timestamp).to be_a(Time) + end + end + + context 'when the timestamp is received as RFC 2822 format' do + it 'accepts a RFC 2822 format datetime' do + data['timestamp'] = 'Tue, 13 Jan 2026 13:02:41 +0000' + expect(doc.timestamp).to be_a(Time) + end + end + + context 'when the timestamp is received as SQL standard format' do + it 'accepts an SQL standard format datetime' do + data['timestamp'] = '2026-01-13 13:02:41' + expect(doc.timestamp).to be_a(Time) + end + end + context 'when the timestamp is received as custom US format' do it 'accepts a custom US format datetime' do data['timestamp'] = '1/13/2026 1:02:41 PM'