Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions lib/cxml/document.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

module CXML
class Document
attr_accessor :version
Expand All @@ -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']
Expand Down Expand Up @@ -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
108 changes: 77 additions & 31 deletions spec/document_spec.rb
Original file line number Diff line number Diff line change
@@ -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 }
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -98,9 +99,41 @@
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 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'
expect(doc.timestamp).to be_a(Time)
end
end
end

describe '#render' do
Expand All @@ -112,62 +145,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