From 9bca7f55cac5e058ff8817bc4daad0b9270aa313 Mon Sep 17 00:00:00 2001 From: stevenjcumming <134282106+stevenjcumming@users.noreply.github.com> Date: Wed, 11 Dec 2024 10:33:50 -0500 Subject: [PATCH 1/8] add comment --- lib/vets/model.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/vets/model.rb b/lib/vets/model.rb index 7f3986d5656..74311ff2d82 100644 --- a/lib/vets/model.rb +++ b/lib/vets/model.rb @@ -7,6 +7,7 @@ module Bool; end class TrueClass; include Bool; end class FalseClass; include Bool; end +# This will be a replacement for Common::Base module Vets module Model extend ActiveSupport::Concern From a30787707428af92da4054434ba98e7f4af46fa7 Mon Sep 17 00:00:00 2001 From: stevenjcumming <134282106+stevenjcumming@users.noreply.github.com> Date: Wed, 18 Dec 2024 18:06:45 -0500 Subject: [PATCH 2/8] add coercion to vets::model --- lib/vets/attributes/value.rb | 61 ++++++++----------------------- lib/vets/type/array.rb | 22 +++++++++++ lib/vets/type/base.rb | 16 ++++++++ lib/vets/type/date_time_string.rb | 17 +++++++++ lib/vets/type/hash.rb | 21 +++++++++++ lib/vets/type/http_date.rb | 17 +++++++++ lib/vets/type/iso8601_time.rb | 17 +++++++++ lib/vets/type/object.rb | 21 +++++++++++ lib/vets/type/primitive.rb | 33 +++++++++++++++++ lib/vets/type/titlecase_string.rb | 15 ++++++++ lib/vets/type/utc_time.rb | 17 +++++++++ 11 files changed, 211 insertions(+), 46 deletions(-) create mode 100644 lib/vets/type/array.rb create mode 100644 lib/vets/type/base.rb create mode 100644 lib/vets/type/date_time_string.rb create mode 100644 lib/vets/type/hash.rb create mode 100644 lib/vets/type/http_date.rb create mode 100644 lib/vets/type/iso8601_time.rb create mode 100644 lib/vets/type/object.rb create mode 100644 lib/vets/type/primitive.rb create mode 100644 lib/vets/type/titlecase_string.rb create mode 100644 lib/vets/type/utc_time.rb diff --git a/lib/vets/attributes/value.rb b/lib/vets/attributes/value.rb index a1a0fb55924..5ad886bc5a3 100644 --- a/lib/vets/attributes/value.rb +++ b/lib/vets/attributes/value.rb @@ -3,60 +3,29 @@ module Vets module Attributes class Value + def self.cast(name, klass, value, array: false) new(name, klass, array:).setter_value(value) end def initialize(name, klass, array: false) - @name = name - @klass = klass - @array = array - end - - def setter_value(value) - validate_array(value) if @array - value = cast_boolean(value) if @klass == Bool - value = coerce_to_class(value) - validate_type(value) - value - end - - private - - def validate_array(value) - raise TypeError, "#{@name} must be an Array" unless value.is_a?(Array) - - value.map! do |item| - item.is_a?(Hash) ? @klass.new(item) : item - end - - unless value.all? { |item| item.is_a?(@klass) } - raise TypeError, "All elements of #{@name} must be of type #{@klass}" + # Acts as a "type factory" + @type = if array + element_type = build(name, klass) + Vets::Type::Array.new(name, ::Array, element_type) + elsif Type::Primitive::PRIMITIVE_TYPES.include?(klass) + Vets::Type::Primitive.new(name, klass) + elsif klass < Vets::Attributes::Type + Vets::Type.const_get(klass.name.demodulize).new(name) + elsif klass.is_a?(Hash) + Vets::Type::Hash.new(name) + else + Vets::Type::Object.new(name, klass) end end - def cast_boolean(value) - ActiveModel::Type::Boolean.new.cast(value) - end - - def coerce_to_class(value) - return value if value.is_a?(@klass) || value.nil? - - if @klass == DateTime - begin - value = DateTime.parse(value) if value.is_a?(String) - rescue ArgumentError - raise TypeError, "#{@name} could not be parsed into a DateTime" - end - end - - value.is_a?(Hash) ? @klass.new(value) : value - end - - def validate_type(value) - return if (@array && value.is_a?(Array)) || value.is_a?(@klass) || value.nil? - - raise TypeError, "#{@name} must be a #{@klass}" + def setter_value(value) + @type.cast(value) end end end diff --git a/lib/vets/type/array.rb b/lib/vets/type/array.rb new file mode 100644 index 00000000000..45e3b8c577f --- /dev/null +++ b/lib/vets/type/array.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'vets/type/base' + +module Vets + module Type + class Array < Base + def initialize(name, klass, element_type) + super(name, klass) + @element_type = element_type + end + + def cast(value) + return nil if value.nil? + + raise TypeError, "#{@name} must be an Array" unless value.is_a?(::Array) + + value.map { |item| @element_type.cast(item) } + end + end + end +end diff --git a/lib/vets/type/base.rb b/lib/vets/type/base.rb new file mode 100644 index 00000000000..8635adb75bb --- /dev/null +++ b/lib/vets/type/base.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Vets + module Type + class Base + def initialize(name, klass) + @name = name + @klass = klass + end + + def cast(value) + raise NotImplementedError, "#{self.class} must implement #cast" + end + end + end +end diff --git a/lib/vets/type/date_time_string.rb b/lib/vets/type/date_time_string.rb new file mode 100644 index 00000000000..971251efb96 --- /dev/null +++ b/lib/vets/type/date_time_string.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'vets/type/base' + +module Vets + module Type + class DateTimeString < Base + def cast(value) + return nil if value.nil? + + Time.parse(value).iso8601 + rescue ArgumentError + raise TypeError, "#{@name} is not Time parseable" + end + end + end +end diff --git a/lib/vets/type/hash.rb b/lib/vets/type/hash.rb new file mode 100644 index 00000000000..657a2787d40 --- /dev/null +++ b/lib/vets/type/hash.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'vets/type/base' + +module Vets + module Type + class Hash < Base + def initialize(name, klass) + super(name, klass) + end + + def cast(value) + return nil if value.nil? + + raise TypeError, "#{@name} must be a Hash" unless value.is_a?(Hash) + + value + end + end + end +end diff --git a/lib/vets/type/http_date.rb b/lib/vets/type/http_date.rb new file mode 100644 index 00000000000..5414c5c4103 --- /dev/null +++ b/lib/vets/type/http_date.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'vets/type/base' + +module Vets + module Type + class HTTPDate < Base + def cast(value) + return nil if value.nil? + + Time.parse(value.to_s).utc.httpdate + rescue ArgumentError + raise TypeError, "#{@name} is not Time parseable" + end + end + end +end diff --git a/lib/vets/type/iso8601_time.rb b/lib/vets/type/iso8601_time.rb new file mode 100644 index 00000000000..6efbbb7443a --- /dev/null +++ b/lib/vets/type/iso8601_time.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'vets/type/base' + +module Vets + module Type + class ISO8601Time < Base + def cast(value) + return nil if value.nil? + + Time.iso8601(value) + rescue ArgumentError + raise TypeError, "#{@name} is not iso8601" + end + end + end +end diff --git a/lib/vets/type/object.rb b/lib/vets/type/object.rb new file mode 100644 index 00000000000..0ad25b2bee8 --- /dev/null +++ b/lib/vets/type/object.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'vets/type/base' + +module Vets + module Type + class Object < Base + def cast(value) + return nil if value.nil? + + if value.is_a?(Hash) + @klass.new(value) + elsif value.is_a?(@klass) + value + else + raise TypeError, "#{@name} must be a Hash or an instance of #{@klass}" + end + end + end + end +end diff --git a/lib/vets/type/primitive.rb b/lib/vets/type/primitive.rb new file mode 100644 index 00000000000..aed1f254e92 --- /dev/null +++ b/lib/vets/type/primitive.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'vets/type/base' + +module Vets + module Type + class Primitive < Base + PRIMITIVE_TYPES = [String, Integer, Float, Date, Time, DateTime].freeze + + def cast(value) + return value if value.is_a?(@klass) || value.nil? + + begin + case @klass.name + when 'DateTime' then DateTime.parse(value.to_s) + when 'Date' then Date.parse(value.to_s) + when 'Integer' then Integer(value) + when 'Float' then Float(value) + else invalid_type! + end + rescue + invalid_type! + end + end + + private + + def invalid_type! + raise TypeError, "#{@name} could not be coerced to #{@klass}" + end + end + end +end diff --git a/lib/vets/type/titlecase_string.rb b/lib/vets/type/titlecase_string.rb new file mode 100644 index 00000000000..df225750079 --- /dev/null +++ b/lib/vets/type/titlecase_string.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'vets/type/base' + +module Vets + module Type + class TitlecaseString < Base + def cast(value) + return nil if value.nil? + + value.downcase.titlecase + end + end + end +end diff --git a/lib/vets/type/utc_time.rb b/lib/vets/type/utc_time.rb new file mode 100644 index 00000000000..0af3ce3bb34 --- /dev/null +++ b/lib/vets/type/utc_time.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'vets/type/base' + +module Vets + module Type + class UTCTime < Base + def cast(value) + return nil if value.nil? + + Time.parse(value.to_s).utc + rescue ArgumentError + raise TypeError, "#{@name} is not Time parseable" + end + end + end +end From da8bbfeb25caa970733887cea6b42cb8e68d3982 Mon Sep 17 00:00:00 2001 From: stevenjcumming <134282106+stevenjcumming@users.noreply.github.com> Date: Thu, 19 Dec 2024 11:11:14 -0500 Subject: [PATCH 3/8] add specs --- lib/vets/type/array.rb | 8 +- lib/vets/type/primitive.rb | 3 +- lib/vets/type/titlecase_string.rb | 2 +- spec/lib/vets/attributes/value_spec.rb | 260 ++++++++++++-------- spec/lib/vets/type/array_spec.rb | 155 ++++++++++++ spec/lib/vets/type/base_spec.rb | 20 ++ spec/lib/vets/type/date_time_string_spec.rb | 34 +++ spec/lib/vets/type/hash_spec.rb | 34 +++ spec/lib/vets/type/http_date_spec.rb | 42 ++++ spec/lib/vets/type/iso8601_time_spec.rb | 34 +++ spec/lib/vets/type/object_spec.rb | 56 +++++ spec/lib/vets/type/primitive_spec.rb | 48 ++++ spec/lib/vets/type/titlecase_string_spec.rb | 48 ++++ spec/lib/vets/type/utc_time_spec.rb | 43 ++++ 14 files changed, 680 insertions(+), 107 deletions(-) create mode 100644 spec/lib/vets/type/array_spec.rb create mode 100644 spec/lib/vets/type/base_spec.rb create mode 100644 spec/lib/vets/type/date_time_string_spec.rb create mode 100644 spec/lib/vets/type/hash_spec.rb create mode 100644 spec/lib/vets/type/http_date_spec.rb create mode 100644 spec/lib/vets/type/iso8601_time_spec.rb create mode 100644 spec/lib/vets/type/object_spec.rb create mode 100644 spec/lib/vets/type/primitive_spec.rb create mode 100644 spec/lib/vets/type/titlecase_string_spec.rb create mode 100644 spec/lib/vets/type/utc_time_spec.rb diff --git a/lib/vets/type/array.rb b/lib/vets/type/array.rb index 45e3b8c577f..89419b99d9e 100644 --- a/lib/vets/type/array.rb +++ b/lib/vets/type/array.rb @@ -15,7 +15,13 @@ def cast(value) raise TypeError, "#{@name} must be an Array" unless value.is_a?(::Array) - value.map { |item| @element_type.cast(item) } + casted_value = value.map { |item| @element_type.cast(item) } + + unless casted_value.all? { |item| item.is_a?(@klass) } + raise TypeError, "All elements of #{@name} must be of type #{@klass}" + end + + casted_value end end end diff --git a/lib/vets/type/primitive.rb b/lib/vets/type/primitive.rb index aed1f254e92..84aa9e3bc21 100644 --- a/lib/vets/type/primitive.rb +++ b/lib/vets/type/primitive.rb @@ -5,7 +5,7 @@ module Vets module Type class Primitive < Base - PRIMITIVE_TYPES = [String, Integer, Float, Date, Time, DateTime].freeze + PRIMITIVE_TYPES = [String, Integer, Float, Date, Time, DateTime, Bool].freeze def cast(value) return value if value.is_a?(@klass) || value.nil? @@ -16,6 +16,7 @@ def cast(value) when 'Date' then Date.parse(value.to_s) when 'Integer' then Integer(value) when 'Float' then Float(value) + when 'Bool' then ActiveModel::Type::Boolean.new.cast(value) else invalid_type! end rescue diff --git a/lib/vets/type/titlecase_string.rb b/lib/vets/type/titlecase_string.rb index df225750079..420dcfd4381 100644 --- a/lib/vets/type/titlecase_string.rb +++ b/lib/vets/type/titlecase_string.rb @@ -8,7 +8,7 @@ class TitlecaseString < Base def cast(value) return nil if value.nil? - value.downcase.titlecase + value.to_s.downcase.titlecase end end end diff --git a/spec/lib/vets/attributes/value_spec.rb b/spec/lib/vets/attributes/value_spec.rb index ea87097c525..da30350eddf 100644 --- a/spec/lib/vets/attributes/value_spec.rb +++ b/spec/lib/vets/attributes/value_spec.rb @@ -1,151 +1,203 @@ -# frozen_string_literal: true - require 'rails_helper' require 'vets/attributes/value' -require 'vets/attributes' -require 'vets/model' # temporarily needed for Bool +require 'vets/type/primitive' +require 'vets/type/object' +require 'vets/type/utc_time' +require 'vets/type/hash' + +RSpec.describe Vets::Attributes::Value do + let(:user_class) do + Class.new do + attr_accessor :name, :email -class FakeClass - attr_reader :attr + def initialize(name:, email:) + raise ArgumentError, "name is required" if name.nil? || name.empty? + raise ArgumentError, "email is required" if email.nil? || email.empty? - def initialize(attrs) - @attr = attrs[:attr] + @name = name + @email = email + end + + def ==(other) + other.is_a?(self.class) && other.name == @name && other.email == @email + end + end end -end -RSpec.describe Vets::Attributes::Value do + let(:name) { 'test_name' } + describe '.cast' do - it 'returns a value for a valid type' do - result = described_class.cast(:test_name, String, 'test_value') - expect(result).to eq('test_value') - end + context 'when casting an Integer to String' do + let(:value) { 123 } - it 'raises a TypeError for an invalid type' do - expect do - described_class.cast(:test_name, Integer, 'not_an_integer') - end.to raise_error(TypeError, 'test_name must be a Integer') + it 'casts Integer to String' do + expect(Vets::Attributes::Value.cast(name, String, value)).to eq('123') + end end - end - describe '#setter_value' do - context 'when value is a scalar type (e.g., Integer or String)' do - it 'returns a value for a valid type' do - attribute_value = described_class.new(:test_name, Bool) - setter_value = attribute_value.setter_value('test_value') - expect(setter_value).to be_truthy + context 'when casting a String to Integer' do + let(:value) { '123' } + + it 'raises an error (cannot cast String to Integer)' do + expect { Vets::Attributes::Value.cast(name, Integer, value) }.to raise_error(TypeError) end end - context 'when value is a Bool' do - it 'coerces a non-falsey, non-empty String to a true Bool' do - attribute_value = described_class.new(:test_name, Bool) - setter_value = attribute_value.setter_value('test') - expect(setter_value).to be_truthy + context 'when casting a Hash to User (dynamic class)' do + let(:value) { { name: 'John Doe', email: 'john@example.com' } } + let(:expected_user) { user_class.new(name: 'John Doe', email: 'john@example.com') } + + it 'casts Hash to User' do + user_element_type = Vets::Type::Object.new(name, user_class) + array_instance = Vets::Type::Array.new(name, Array, user_element_type) + expect(array_instance.cast([value])).to eq([expected_user]) end + end - it 'casts 0 (Integer) to a false Bool' do - attribute_value = described_class.new(:test_name, Bool) - setter_value = attribute_value.setter_value(0) - expect(setter_value).to be_falsey + context 'when casting a User to User (dynamic class)' do + let(:user) { user_class.new(name: 'John Doe', email: 'john@example.com') } + + it 'returns the same User object' do + expect(Vets::Attributes::Value.cast(name, user_class, user)).to eq(user) end + end - it 'casts "falsey" string to a false Bool' do - attribute_value = described_class.new(:test_name, Bool) - setter_value = attribute_value.setter_value('f') - expect(setter_value).to be_falsey + context 'when casting a String to UTCTime' do + let(:value) { '2024-12-19T12:34:56+04:00' } + + it 'casts String to a Time object in UTC' do + expected_time = Time.parse(value).utc + expect(Vets::Attributes::Value.cast(name, Time, value)).to eq(expected_time) end + end - it 'coerces a empty String to nil' do - attribute_value = described_class.new(:test_name, Bool) - setter_value = attribute_value.setter_value('') - expect(setter_value).to be_nil + context 'when casting a Hash to Hash' do + let(:value) { { key: 'value' } } + + it 'returns the same Hash' do + expect(Vets::Attributes::Value.cast(name, Hash, value)).to eq(value) end end - context 'when value is a complex Object' do - it 'returns the same complex Object when' do - attribute_value = described_class.new(:test_name, FakeClass) - double_class = FakeClass.new(attr: 'Steven') - setter_value = attribute_value.setter_value(double_class) - expect(setter_value).to eq(double_class) + context 'when the array is empty' do + let(:value) { [] } + + it 'returns an empty array' do + array_element_type = Vets::Type::Primitive.new(name, String) + array_instance = Vets::Type::Array.new(name, Array, array_element_type) + expect(array_instance.cast(value)).to eq([]) end end - context 'when klass is DateTime' do - context 'when value is a parseable string' do - it 'returns a DateTime' do - value = '2024-01-01T12:00:00+00:00' - attribute_value = described_class.new(:test_name, DateTime) - setter_value = attribute_value.setter_value(value) - expect(setter_value).to eq(DateTime.parse(value).to_s) - end + context 'when the value is nil' do + let(:value) { nil } + + it 'returns nil' do + expect(Vets::Attributes::Value.cast(name, String, value)).to be_nil end + end - context 'when value is a non-parseable string' do - it 'raises an TypeError' do - expect do - attribute_value = described_class.new(:test_name, DateTime) - attribute_value.setter_value('bad-time') - end.to raise_error(TypeError, 'test_name could not be parsed into a DateTime') - end + context 'when casting an Array of Strings' do + let(:value) { ['apple', 'banana'] } + + it 'returns the same array of Strings' do + array_element_type = Vets::Type::Primitive.new(name, String) + array_instance = Vets::Type::Array.new(name, Array, array_element_type) + expect(array_instance.cast(value)).to eq(value) end end - context 'when value is a Hash' do - context 'when klass is a Hash' do - it 'returns a complex Object with given attributes' do - attribute_value = described_class.new(:test_name, FakeClass) - hash_params = { attr: 'Steven' } - setter_value = attribute_value.setter_value(hash_params) - expect(setter_value.class).to eq(FakeClass) - expect(setter_value.attr).to eq(hash_params[:attr]) - end + context 'when value is an Integer' do + it 'casts Integer to String' do + attribute_value = described_class.new(:test_name, String) + setter_value = attribute_value.setter_value(123) + expect(setter_value).to eq('123') end + end - context 'when klass is not a Hash' do - it 'returns a complex Object with given attributes' do - attribute_value = described_class.new(:test_name, Hash) - hash_params = { attr: 'Steven' } - setter_value = attribute_value.setter_value(hash_params) - expect(setter_value.class).to eq(Hash) - expect(setter_value[:attr]).to eq(hash_params[:attr]) - end + context 'when value is a String' do + it 'raises TypeError when casting a String to Integer' do + attribute_value = described_class.new(:test_name, Integer) + expect { attribute_value.setter_value('test') }.to raise_error(TypeError) end end context 'when value is an Array' do - context 'when elements of value are hashes' do - it 'coerces elements to klass' do - attribute_value = described_class.new(:test_array, FakeClass, array: true) - setter_value = attribute_value.setter_value([{ attr: 'value' }, { attr: 'value2' }]) - expect(setter_value).to all(be_an(FakeClass)) - expect(setter_value.first.attr).to eq('value') + context 'when array is empty' do + it 'returns an empty array' do + attribute_value = described_class.new(:test_name, String, array: true) + setter_value = attribute_value.setter_value([]) + expect(setter_value).to eq([]) end end - context 'when elements of value are complex Object' do - it 'returns the same array' do - attribute_value = described_class.new(:test_array, FakeClass, array: true) - double1 = FakeClass.new(attr: 'value') - double2 = FakeClass.new(attr: 'value1') - setter_value = attribute_value.setter_value([double1, double2]) - expect(setter_value).to all(be_an(FakeClass)) - expect(setter_value.first.attr).to eq('value') + context 'when array contains nil values' do + it 'casts nil elements correctly' do + attribute_value = described_class.new(:test_name, String, array: true) + setter_value = attribute_value.setter_value([nil, 'test', nil]) + expect(setter_value).to eq([nil, 'test', nil]) end end + end + + context 'when value is a String and klass is UTCTime' do + it 'casts a String to a Time object in UTC' do + value = '2024-12-19T12:34:56+04:00' + attribute_value = described_class.new(:test_name, Vets::Type::UTCTime) + setter_value = attribute_value.setter_value(value) + expect(setter_value).to eq(Time.parse(value).utc) + end + end + + context 'when value is a non-castable type' do + it 'raises TypeError when casting an uncoercible value' do + attribute_value = described_class.new(:test_name, DateTime) + expect { attribute_value.setter_value('invalid-date') }.to raise_error(TypeError, 'test_name could not be parsed into a DateTime') + end + end + + context 'when value is a Bool' do + it 'coerces a non-falsey, non-empty String to a true Bool' do + attribute_value = described_class.new(:test_name, Bool) + setter_value = attribute_value.setter_value('test') + expect(setter_value).to be_truthy + end - it 'raises TypeError if value is not an Array' do - expect do - attribute_value = described_class.new(:test_array, FakeClass, array: true) - attribute_value.setter_value('not_an_array') - end.to raise_error(TypeError, 'test_array must be an Array') + it 'casts 0 (Integer) to a false Bool' do + attribute_value = described_class.new(:test_name, Bool) + setter_value = attribute_value.setter_value(0) + expect(setter_value).to be_falsey end - it 'raises TypeError if elements are of incorrect type' do - expect do - attribute_value = described_class.new(:test_array, FakeClass, array: true) - attribute_value.setter_value(%w[wrong_type also_wrong_type]) - end.to raise_error(TypeError, "All elements of test_array must be of type #{FakeClass}") + it 'casts "falsey" string to a false Bool' do + attribute_value = described_class.new(:test_name, Bool) + setter_value = attribute_value.setter_value('f') + expect(setter_value).to be_falsey + end + + it 'coerces a empty String to nil' do + attribute_value = described_class.new(:test_name, Bool) + setter_value = attribute_value.setter_value('') + expect(setter_value).to be_nil + end + end + end + + describe '#setter_value' + context 'when value is an Integer and klass is a String' do + it 'casts using Vets::Type::Primitive' do + attribute_value = described_class.new(:test_name, String) + setter_value = attribute_value.setter_value(123) + expect(setter_value).to eq('123') + end + end + + context 'when value is a String and klass is Vets::Type::UTCTime' do + it 'casts using Vets::Type::UTCTime' do + value = '2024-12-19T12:34:56+04:00' + attribute_value = described_class.new(:test_name, Vets::Type::UTCTime) + setter_value = attribute_value.setter_value(value) + expect(setter_value).to eq(Time.parse(value).utc) end end end diff --git a/spec/lib/vets/type/array_spec.rb b/spec/lib/vets/type/array_spec.rb new file mode 100644 index 00000000000..44cdf9d46f2 --- /dev/null +++ b/spec/lib/vets/type/array_spec.rb @@ -0,0 +1,155 @@ +require 'rails_helper' +require 'vets/type/array' + +RSpec.describe Vets::Type::Array do + let(:name) { 'test_array' } + let(:klass) { String } + + # Test with a primitive element type (String) + let(:string_element_type) { Vets::Type::Primitive.new('String', String) } + let(:array_instance_with_string) { described_class.new(name, klass, string_element_type) } + + # Test with a custom object element type (using Hash for simplicity) + let(:hash_element_type) { Vets::Type::Object.new('Hash', Hash) } + let(:array_instance_with_hash) { described_class.new(name, klass, hash_element_type) } + + let(:utc_time_element_type) { Vets::Type::UTCTime.new('UTCTime', Time) } + let(:array_instance_with_utc_time) { described_class.new(name, klass, utc_time_element_type) } + + let(:user_class) do + Class.new do + attr_accessor :name, :email + + def initialize(name:, email:) + + raise ArgumentError, "name and email Are required" if name.nil? || email.nil? + + @name = name + @email = email + end + + def ==(other) + other.is_a?(self.class) && other.name == @name && other.email == @email + end + end + end + + # Test with Vets::Type::Object element type (casting hashes to User objects) + let(:user_element_type) { Vets::Type::Object.new('User', user_class) } + let(:array_instance_with_user) { described_class.new(name, klass, user_element_type) } + + + describe '#cast' do + context 'when value is nil' do + it 'returns nil' do + expect(array_instance_with_string.cast(nil)).to be_nil + end + end + + context 'when value is a valid array of strings' do + let(:valid_string_array) { ['hello', 'world'] } + + it 'returns an array of strings' do + expect(array_instance_with_string.cast(valid_string_array)).to eq(['hello', 'world']) + end + end + + context 'when value is a valid array of integers' do + let(:valid_integer_array) { [123, 456] } + + it 'casts integers to strings' do + expect(array_instance_with_string.cast(valid_integer_array)).to eq(['123', '456']) + end + end + + context 'when value is a valid array of Time objects' do + let(:valid_time_array) { [Time.new(2024, 12, 19, 12, 34, 56), Time.new(2024, 12, 20, 12, 34, 56)] } + + it 'casts Time objects to strings' do + expect(array_instance_with_string.cast(valid_time_array)).to eq([valid_time_array[0].to_s, valid_time_array[1].to_s]) + end + end + + context 'when value is a valid array of hashes' do + let(:valid_hash_array) { [{ key: 'value' }, { key: 'another_value' }] } + + it 'returns an array of objects' do + expected_result = valid_hash_array.map { |item| Hash.new('Hash', item.class).cast(item) } + expect(array_instance_with_hash.cast(valid_hash_array)).to eq(expected_result) + end + end + + context 'when value is a valid array of UTCTime objects (with +04:00 offset)' do + let(:valid_time_array) { ['2024-12-19T12:34:56+04:00', '2024-12-20T12:34:56+04:00'] } + let(:expected_utc_time_array) { valid_utc_time_array.map { |item| Time.parse(item).utc } } + + it 'casts valid time strings with a +04:00 offset into Time objects' do + expect(array_instance_with_utc_time.cast(valid_utc_time_array)).to eq(expected_utc_time_array) + end + end + + context 'when value is not an array' do + let(:invalid_value) { 'string' } + + it 'raises a TypeError with the correct message' do + expect { + array_instance_with_string.cast(invalid_value) + }.to raise_error(TypeError, "#{name} must be an Array") + end + end + + context 'when value contains elements of different types' do + let(:mixed_value_array) { ['hello', 123, Time.now] } + + it 'casts non-string elements (integers and Time) to strings' do + expect(array_instance_with_string.cast(mixed_value_array)).to eq(['hello', '123', Time.now.to_s]) + end + end + + context 'when value contains elements of inconsistent types after casting' do + let(:invalid_element_value) { ['hello', 123, Time.now, Object.new] } + + it 'raises a TypeError' do + expect { + array_instance_with_string.cast(invalid_element_value) + }.to raise_error(TypeError, "#{name} must be of type String") + end + end + + context 'when value is a valid array of hashes that can be cast into User objects' do + let(:valid_user_array) do + [ + { name: 'John Doe', email: 'john@example.com' }, + { name: 'Jane Smith', email: 'jane@example.com' } + ] + end + let(:expected_user_array) do + valid_user_array.map { |data| user_class.new(data) } + end + + it 'casts the hashes into User objects' do + expect(array_instance_with_user.cast(valid_user_array)).to eq(expected_user_array) + end + end + + context 'when value contains an invalid hash for a User object' do + let(:invalid_user_array) { [{ first_name: 'John Doe' }, { work_email: 'jane@example.com' }] } + + it 'raises a TypeError' do + expect { + array_instance_with_user.cast(invalid_user_array) + }.to raise_error(TypeError, "#{name} must be of type User") + end + end + + context 'when value is a valid array of User objects' do + let(:user_1) { user_class.new(name: 'John Doe', email: 'john@example.com') } + let(:user_2) { user_class.new(name: 'Jane Smith', email: 'jane@example.com') } + let(:valid_user_object_array) { [user_1, user_2] } + + it 'returns the same array of User objects' do + expect(array_instance_with_user.cast(valid_user_object_array)).to eq(valid_user_object_array) + end + end + end +end diff --git a/spec/lib/vets/type/base_spec.rb b/spec/lib/vets/type/base_spec.rb new file mode 100644 index 00000000000..286a031182b --- /dev/null +++ b/spec/lib/vets/type/base_spec.rb @@ -0,0 +1,20 @@ +require 'rails_helper' + +RSpec.describe Vets::Type::Base do + let(:name) { 'example_name' } + let(:klass) { String } + let(:base_instance) { described_class.new(name, klass) } + + describe '#initialize' do + it 'initializes with a name and a klass' do + expect(base_instance.instance_variable_get(:@name)).to eq(name) + expect(base_instance.instance_variable_get(:@klass)).to eq(klass) + end + end + + describe '#cast' do + it 'raises NotImplementedError when called' do + expect { base_instance.cast('value') }.to raise_error(NotImplementedError, "#{described_class} must implement #cast") + end + end +end diff --git a/spec/lib/vets/type/date_time_string_spec.rb b/spec/lib/vets/type/date_time_string_spec.rb new file mode 100644 index 00000000000..6773f9b5bce --- /dev/null +++ b/spec/lib/vets/type/date_time_string_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' +require 'vets/type/date_time_string' + +RSpec.describe Vets::Type::DateTimeString do + let(:name) { 'test_datetime' } + let(:klass) { String } + let(:datetime_instance) { described_class.new(name, klass) } + + describe '#cast' do + context 'when value is nil' do + it 'returns nil' do + expect(datetime_instance.cast(nil)).to be_nil + end + end + + context 'when value is a valid datetime string' do + let(:valid_datetime) { '2024-12-19T12:34:56Z' } + + it 'returns the ISO8601 formatted datetime string' do + expect(datetime_instance.cast(valid_datetime)).to eq(Time.parse(valid_datetime).iso8601) + end + end + + context 'when value is an invalid datetime string' do + let(:invalid_datetime) { 'invalid-datetime' } + + it 'raises a TypeError with the correct message' do + expect { + datetime_instance.cast(invalid_datetime) + }.to raise_error(TypeError, "#{name} is not Time parseable") + end + end + end +end diff --git a/spec/lib/vets/type/hash_spec.rb b/spec/lib/vets/type/hash_spec.rb new file mode 100644 index 00000000000..5e196ff1e67 --- /dev/null +++ b/spec/lib/vets/type/hash_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' +require 'vets/type/hash' + +RSpec.describe Vets::Type::Hash do + let(:name) { 'test_hash' } + let(:klass) { Hash } + let(:hash_instance) { described_class.new(name, klass) } + + describe '#cast' do + context 'when value is nil' do + it 'returns nil' do + expect(hash_instance.cast(nil)).to be_nil + end + end + + context 'when value is a valid Hash' do + let(:valid_hash) { { key: 'value' } } + + it 'returns the Hash' do + expect(hash_instance.cast(valid_hash)).to eq(valid_hash) + end + end + + context 'when value is not a Hash' do + let(:invalid_value) { 'string' } + + it 'raises a TypeError with the correct message' do + expect { + hash_instance.cast(invalid_value) + }.to raise_error(TypeError, "#{name} must be a Hash") + end + end + end +end diff --git a/spec/lib/vets/type/http_date_spec.rb b/spec/lib/vets/type/http_date_spec.rb new file mode 100644 index 00000000000..da23e9de525 --- /dev/null +++ b/spec/lib/vets/type/http_date_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' +require 'vets/type/http_date' + +RSpec.describe Vets::Type::HTTPDate do + let(:name) { 'test_http_date' } + let(:klass) { String } + let(:http_date_instance) { described_class.new(name, klass) } + + describe '#cast' do + context 'when value is nil' do + it 'returns nil' do + expect(http_date_instance.cast(nil)).to be_nil + end + end + + context 'when value is a valid datetime string' do + let(:valid_datetime) { '2024-12-19T12:34:56Z' } + + it 'returns the HTTP date format' do + expect(http_date_instance.cast(valid_datetime)).to eq(Time.parse(valid_datetime).utc.httpdate) + end + end + + context 'when value is an invalid datetime string' do + let(:invalid_datetime) { 'invalid-datetime' } + + it 'raises a TypeError with the correct message' do + expect { + http_date_instance.cast(invalid_datetime) + }.to raise_error(TypeError, "#{name} is not Time parseable") + end + end + + context 'when value is a numeric timestamp' do + let(:timestamp) { 1_700_000_000 } # Example Unix timestamp + + it 'converts the timestamp to HTTP date format' do + expect(http_date_instance.cast(timestamp)).to eq(Time.at(timestamp).utc.httpdate) + end + end + end +end diff --git a/spec/lib/vets/type/iso8601_time_spec.rb b/spec/lib/vets/type/iso8601_time_spec.rb new file mode 100644 index 00000000000..09b272badc5 --- /dev/null +++ b/spec/lib/vets/type/iso8601_time_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' +require 'vets/type/iso8601_time' + +RSpec.describe Vets::Type::ISO8601Time do + let(:name) { 'test_iso8601_time' } + let(:klass) { String } + let(:iso8601_instance) { described_class.new(name, klass) } + + describe '#cast' do + context 'when value is nil' do + it 'returns nil' do + expect(iso8601_instance.cast(nil)).to be_nil + end + end + + context 'when value is a valid ISO8601 string' do + let(:valid_iso8601) { '2024-12-19T12:34:56+00:00' } + + it 'returns a Time object' do + expect(iso8601_instance.cast(valid_iso8601)).to eq(Time.iso8601(valid_iso8601)) + end + end + + context 'when value is an invalid ISO8601 string' do + let(:invalid_iso8601) { 'invalid-iso8601' } + + it 'raises a TypeError with the correct message' do + expect { + iso8601_instance.cast(invalid_iso8601) + }.to raise_error(TypeError, "#{name} is not iso8601") + end + end + end +end diff --git a/spec/lib/vets/type/object_spec.rb b/spec/lib/vets/type/object_spec.rb new file mode 100644 index 00000000000..93e0176b603 --- /dev/null +++ b/spec/lib/vets/type/object_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' +require 'vets/type/object' + +RSpec.describe Vets::Type::Object do + let(:name) { 'test_object' } + let(:klass) do + Class.new do + attr_reader :attributes + + def initialize(attributes = {}) + @attributes = attributes + end + + def ==(other) + other.is_a?(self.class) && other.attributes == attributes + end + end + end + let(:object_instance) { described_class.new(name, klass) } + + describe '#cast' do + context 'when value is nil' do + it 'returns nil' do + expect(object_instance.cast(nil)).to be_nil + end + end + + context 'when value is a Hash' do + let(:valid_hash) { { key: 'value' } } + + it 'returns an instance of klass initialized with the hash' do + result = object_instance.cast(valid_hash) + expect(result).to be_a(klass) + expect(result.attributes).to eq(valid_hash) + end + end + + context 'when value is already an instance of klass' do + let(:instance_of_klass) { klass.new(key: 'value') } + + it 'returns the same instance' do + expect(object_instance.cast(instance_of_klass)).to eq(instance_of_klass) + end + end + + context 'when value is neither a Hash nor an instance of klass' do + let(:invalid_value) { 'invalid_value' } + + it 'raises a TypeError with the correct message' do + expect { + object_instance.cast(invalid_value) + }.to raise_error(TypeError, "#{name} must be a Hash or an instance of #{klass}") + end + end + end +end diff --git a/spec/lib/vets/type/primitive_spec.rb b/spec/lib/vets/type/primitive_spec.rb new file mode 100644 index 00000000000..24a6316f12f --- /dev/null +++ b/spec/lib/vets/type/primitive_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' +require 'vets/type/primitive' + +RSpec.describe Vets::Type::Primitive do + let(:name) { 'test_primitive' } + + shared_examples 'primitive type casting' do |klass, valid_input, expected_output, invalid_input| + let(:primitive_instance) { described_class.new(name, klass) } + + context "when klass is #{klass}" do + it 'casts valid input correctly' do + expect(primitive_instance.cast(valid_input)).to eq(expected_output) + end + + it 'raises TypeError for invalid input' do + expect { + primitive_instance.cast(invalid_input) + }.to raise_error(TypeError, "#{name} could not be coerced to #{klass}") + end + end + end + + describe '#cast' do + context 'when value is nil' do + let(:primitive_instance) { described_class.new(name, String) } + + it 'returns nil' do + expect(primitive_instance.cast(nil)).to be_nil + end + end + + include_examples 'primitive type casting', Integer, '42', 42, 'invalid' + include_examples 'primitive type casting', Float, '3.14', 3.14, 'invalid' + include_examples 'primitive type casting', Date, '2024-12-19', Date.new(2024, 12, 19), 'invalid' + include_examples 'primitive type casting', DateTime, '2024-12-19T12:34:56+00:00', DateTime.new(2024, 12, 19, 12, 34, 56), 'invalid' + include_examples 'primitive type casting', String, 42, '42', nil + + context 'when klass is unsupported' do + let(:primitive_instance) { described_class.new(name, Hash) } + + it 'raises TypeError for unsupported types' do + expect { + primitive_instance.cast('value') + }.to raise_error(TypeError, "#{name} could not be coerced to Hash") + end + end + end +end diff --git a/spec/lib/vets/type/titlecase_string_spec.rb b/spec/lib/vets/type/titlecase_string_spec.rb new file mode 100644 index 00000000000..574e0aa8119 --- /dev/null +++ b/spec/lib/vets/type/titlecase_string_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' +require 'vets/type/titlecase_string' + +RSpec.describe Vets::Type::TitlecaseString do + let(:name) { 'test_titlecase_string' } + let(:klass) { String } + let(:titlecase_instance) { described_class.new(name, klass) } + + describe '#cast' do + context 'when value is nil' do + it 'returns nil' do + expect(titlecase_instance.cast(nil)).to be_nil + end + end + + context 'when value is a lowercase string' do + let(:lowercase_string) { 'hello world' } + + it 'returns the string in titlecase' do + expect(titlecase_instance.cast(lowercase_string)).to eq('Hello World') + end + end + + context 'when value is an already titlecased string' do + let(:titlecase_string) { 'Hello World' } + + it 'returns the string as is' do + expect(titlecase_instance.cast(titlecase_string)).to eq('Hello World') + end + end + + context 'when value is a string with mixed case' do + let(:mixed_case_string) { 'hElLo WoRLd' } + + it 'returns the string in titlecase' do + expect(titlecase_instance.cast(mixed_case_string)).to eq('Hello World') + end + end + + context 'when value is a non-string type' do + let(:non_string_value) { 12345 } + + it 'returns the string representation of the value in titlecase' do + expect(titlecase_instance.cast(non_string_value)).to eq('12345') + end + end + end +end diff --git a/spec/lib/vets/type/utc_time_spec.rb b/spec/lib/vets/type/utc_time_spec.rb new file mode 100644 index 00000000000..6b185489fb5 --- /dev/null +++ b/spec/lib/vets/type/utc_time_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' +require 'vets/type/utc_time' + +RSpec.describe Vets::Type::UTCTime do + let(:name) { 'test_utc_time' } + let(:klass) { Time } + let(:utc_time_instance) { described_class.new(name, klass) } + + describe '#cast' do + context 'when value is nil' do + it 'returns nil' do + expect(utc_time_instance.cast(nil)).to be_nil + end + end + + context 'when value is a valid Time string' do + let(:valid_time_string) { '2024-12-19 12:34:56' } + + it 'returns a UTC Time object' do + expected_time = Time.parse(valid_time_string).utc + expect(utc_time_instance.cast(valid_time_string)).to eq(expected_time) + end + end + + context 'when value is an invalid Time string' do + let(:invalid_time_string) { 'invalid-time' } + + it 'raises a TypeError with the correct message' do + expect { + utc_time_instance.cast(invalid_time_string) + }.to raise_error(TypeError, "#{name} is not Time parseable") + end + end + + context 'when value is a valid Time object' do + let(:valid_time_object) { Time.now } + + it 'returns the Time object in UTC' do + expect(utc_time_instance.cast(valid_time_object)).to eq(valid_time_object.utc) + end + end + end +end From 8a724c873d203a548164c5dd19cc0e79046f0b9f Mon Sep 17 00:00:00 2001 From: stevenjcumming <134282106+stevenjcumming@users.noreply.github.com> Date: Fri, 20 Dec 2024 09:49:52 -0500 Subject: [PATCH 4/8] add types and specs --- lib/vets/attributes/value.rb | 36 ++-- lib/vets/type/array.rb | 23 ++- lib/vets/type/base.rb | 6 + lib/vets/type/date_time_string.rb | 4 + lib/vets/type/hash.rb | 10 +- lib/vets/type/http_date.rb | 4 + lib/vets/type/iso8601_time.rb | 4 + lib/vets/type/object.rb | 2 +- lib/vets/type/primitive.rb | 11 +- lib/vets/type/titlecase_string.rb | 4 + lib/vets/type/utc_time.rb | 4 + lib/vets/types.rb | 12 ++ spec/lib/vets/attributes/value_spec.rb | 117 ++++--------- spec/lib/vets/type/array_spec.rb | 185 ++++++++++---------- spec/lib/vets/type/base_spec.rb | 6 +- spec/lib/vets/type/date_time_string_spec.rb | 6 +- spec/lib/vets/type/hash_spec.rb | 9 +- spec/lib/vets/type/http_date_spec.rb | 22 ++- spec/lib/vets/type/iso8601_time_spec.rb | 6 +- spec/lib/vets/type/object_spec.rb | 6 +- spec/lib/vets/type/primitive_spec.rb | 110 +++++++++--- spec/lib/vets/type/titlecase_string_spec.rb | 4 +- spec/lib/vets/type/utc_time_spec.rb | 10 +- 23 files changed, 358 insertions(+), 243 deletions(-) create mode 100644 lib/vets/types.rb diff --git a/lib/vets/attributes/value.rb b/lib/vets/attributes/value.rb index 5ad886bc5a3..474a2df1791 100644 --- a/lib/vets/attributes/value.rb +++ b/lib/vets/attributes/value.rb @@ -1,31 +1,37 @@ # frozen_string_literal: true +require 'vets/types' + module Vets module Attributes class Value - def self.cast(name, klass, value, array: false) new(name, klass, array:).setter_value(value) end def initialize(name, klass, array: false) - # Acts as a "type factory" - @type = if array - element_type = build(name, klass) - Vets::Type::Array.new(name, ::Array, element_type) - elsif Type::Primitive::PRIMITIVE_TYPES.include?(klass) - Vets::Type::Primitive.new(name, klass) - elsif klass < Vets::Attributes::Type - Vets::Type.const_get(klass.name.demodulize).new(name) - elsif klass.is_a?(Hash) - Vets::Type::Hash.new(name) - else - Vets::Type::Object.new(name, klass) - end + @name = name + @klass = klass + @array = array end def setter_value(value) - @type.cast(value) + type.cast(value) + end + + # Acts as a "type factory" + def type + @type ||= if @array + Vets::Type::Array.new(@name, @klass) + elsif Vets::Type::Primitive::PRIMITIVE_TYPES.include?(@klass) + Vets::Type::Primitive.new(@name, @klass) + elsif @klass.module_parents.include?(Vets::Type) + @klass.new(@name, @klass) + elsif @klass == ::Hash + Vets::Type::Hash.new(@name) + else + Vets::Type::Object.new(@name, @klass) + end end end end diff --git a/lib/vets/type/array.rb b/lib/vets/type/array.rb index 89419b99d9e..33f77b65415 100644 --- a/lib/vets/type/array.rb +++ b/lib/vets/type/array.rb @@ -1,13 +1,12 @@ # frozen_string_literal: true -require 'vets/type/base' +require 'vets/types' module Vets module Type class Array < Base - def initialize(name, klass, element_type) - super(name, klass) - @element_type = element_type + def self.primitive + ::Array end def cast(value) @@ -15,14 +14,26 @@ def cast(value) raise TypeError, "#{@name} must be an Array" unless value.is_a?(::Array) - casted_value = value.map { |item| @element_type.cast(item) } + casted_value = value.map { |item| type.cast(item) } - unless casted_value.all? { |item| item.is_a?(@klass) } + unless casted_value.all? { |item| item.is_a?(@klass.try(:primitive) || @klass) } raise TypeError, "All elements of #{@name} must be of type #{@klass}" end casted_value end + + def type + @type ||= if Vets::Type::Primitive::PRIMITIVE_TYPES.include?(@klass) + Vets::Type::Primitive.new(@name, @klass) + elsif @klass.module_parents.include?(Vets::Type) + @klass.new(@name, @klass) + elsif @klass == ::Hash + Vets::Type::Hash.new(@name) + else + Vets::Type::Object.new(@name, @klass) + end + end end end end diff --git a/lib/vets/type/base.rb b/lib/vets/type/base.rb index 8635adb75bb..8b296618b99 100644 --- a/lib/vets/type/base.rb +++ b/lib/vets/type/base.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +# Dir['lib/vets/type/**/*.rb'].each { |file| require file.gsub('lib/', '') } + module Vets module Type class Base @@ -11,6 +13,10 @@ def initialize(name, klass) def cast(value) raise NotImplementedError, "#{self.class} must implement #cast" end + + def self.primitive + raise NotImplementedError, "#{self.class} must implement #primitive" + end end end end diff --git a/lib/vets/type/date_time_string.rb b/lib/vets/type/date_time_string.rb index 971251efb96..991bb5822be 100644 --- a/lib/vets/type/date_time_string.rb +++ b/lib/vets/type/date_time_string.rb @@ -5,6 +5,10 @@ module Vets module Type class DateTimeString < Base + def self.primitive + ::String + end + def cast(value) return nil if value.nil? diff --git a/lib/vets/type/hash.rb b/lib/vets/type/hash.rb index 657a2787d40..d16b53d1e00 100644 --- a/lib/vets/type/hash.rb +++ b/lib/vets/type/hash.rb @@ -5,14 +5,18 @@ module Vets module Type class Hash < Base - def initialize(name, klass) - super(name, klass) + def initialize(name) + super(name, ::Hash) + end + + def self.primitive + ::Hash end def cast(value) return nil if value.nil? - raise TypeError, "#{@name} must be a Hash" unless value.is_a?(Hash) + raise TypeError, "#{@name} must be a Hash" unless value.is_a?(::Hash) value end diff --git a/lib/vets/type/http_date.rb b/lib/vets/type/http_date.rb index 5414c5c4103..653bad5d1ed 100644 --- a/lib/vets/type/http_date.rb +++ b/lib/vets/type/http_date.rb @@ -5,6 +5,10 @@ module Vets module Type class HTTPDate < Base + def self.primitive + ::String + end + def cast(value) return nil if value.nil? diff --git a/lib/vets/type/iso8601_time.rb b/lib/vets/type/iso8601_time.rb index 6efbbb7443a..888bdeb0140 100644 --- a/lib/vets/type/iso8601_time.rb +++ b/lib/vets/type/iso8601_time.rb @@ -5,6 +5,10 @@ module Vets module Type class ISO8601Time < Base + def self.primitive + ::String + end + def cast(value) return nil if value.nil? diff --git a/lib/vets/type/object.rb b/lib/vets/type/object.rb index 0ad25b2bee8..d2fcf96e87c 100644 --- a/lib/vets/type/object.rb +++ b/lib/vets/type/object.rb @@ -8,7 +8,7 @@ class Object < Base def cast(value) return nil if value.nil? - if value.is_a?(Hash) + if value.is_a?(::Hash) @klass.new(value) elsif value.is_a?(@klass) value diff --git a/lib/vets/type/primitive.rb b/lib/vets/type/primitive.rb index 84aa9e3bc21..6f38d57bc24 100644 --- a/lib/vets/type/primitive.rb +++ b/lib/vets/type/primitive.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'vets/type/base' +require 'vets/model' # this is required for Bools module Vets module Type @@ -12,10 +13,12 @@ def cast(value) begin case @klass.name - when 'DateTime' then DateTime.parse(value.to_s) - when 'Date' then Date.parse(value.to_s) - when 'Integer' then Integer(value) - when 'Float' then Float(value) + when 'String' then ActiveModel::Type::String.new.cast(value) + when 'Integer' then ActiveModel::Type::Integer.new.cast(value) + when 'Float' then ActiveModel::Type::Float.new.cast(value) + when 'Date' then ActiveModel::Type::Date.new.cast(value) + when 'Time' then Time.zone.parse(value.to_s) + when 'DateTime' then ActiveModel::Type::DateTime.new.cast(value) when 'Bool' then ActiveModel::Type::Boolean.new.cast(value) else invalid_type! end diff --git a/lib/vets/type/titlecase_string.rb b/lib/vets/type/titlecase_string.rb index 420dcfd4381..18d030694af 100644 --- a/lib/vets/type/titlecase_string.rb +++ b/lib/vets/type/titlecase_string.rb @@ -5,6 +5,10 @@ module Vets module Type class TitlecaseString < Base + def self.primitive + ::String + end + def cast(value) return nil if value.nil? diff --git a/lib/vets/type/utc_time.rb b/lib/vets/type/utc_time.rb index 0af3ce3bb34..6fe2059865c 100644 --- a/lib/vets/type/utc_time.rb +++ b/lib/vets/type/utc_time.rb @@ -5,6 +5,10 @@ module Vets module Type class UTCTime < Base + def self.primitive + ::Time + end + def cast(value) return nil if value.nil? diff --git a/lib/vets/types.rb b/lib/vets/types.rb new file mode 100644 index 00000000000..a799521b898 --- /dev/null +++ b/lib/vets/types.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'vets/type/base' +require 'vets/type/date_time_string' +require 'vets/type/hash' +require 'vets/type/http_date' +require 'vets/type/iso8601_time' +require 'vets/type/object' +require 'vets/type/primitive' +require 'vets/type/titlecase_string' +require 'vets/type/utc_time' +require 'vets/type/array' diff --git a/spec/lib/vets/attributes/value_spec.rb b/spec/lib/vets/attributes/value_spec.rb index da30350eddf..e794b3690aa 100644 --- a/spec/lib/vets/attributes/value_spec.rb +++ b/spec/lib/vets/attributes/value_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' require 'vets/attributes/value' require 'vets/type/primitive' @@ -10,12 +12,11 @@ Class.new do attr_accessor :name, :email - def initialize(name:, email:) - raise ArgumentError, "name is required" if name.nil? || name.empty? - raise ArgumentError, "email is required" if email.nil? || email.empty? + def initialize(args) + @name = args[:name] + @email = args[:email] - @name = name - @email = email + raise ArgumentError, 'name and email are required' if @name.nil? || @email.nil? end def ==(other) @@ -31,7 +32,7 @@ def ==(other) let(:value) { 123 } it 'casts Integer to String' do - expect(Vets::Attributes::Value.cast(name, String, value)).to eq('123') + expect(described_class.cast(name, String, value)).to eq('123') end end @@ -39,7 +40,7 @@ def ==(other) let(:value) { '123' } it 'raises an error (cannot cast String to Integer)' do - expect { Vets::Attributes::Value.cast(name, Integer, value) }.to raise_error(TypeError) + expect(described_class.cast(name, Integer, value)).to eq(123) end end @@ -48,9 +49,7 @@ def ==(other) let(:expected_user) { user_class.new(name: 'John Doe', email: 'john@example.com') } it 'casts Hash to User' do - user_element_type = Vets::Type::Object.new(name, user_class) - array_instance = Vets::Type::Array.new(name, Array, user_element_type) - expect(array_instance.cast([value])).to eq([expected_user]) + expect(described_class.cast(name, user_class, [value], array: true)).to eq([expected_user]) end end @@ -58,7 +57,7 @@ def ==(other) let(:user) { user_class.new(name: 'John Doe', email: 'john@example.com') } it 'returns the same User object' do - expect(Vets::Attributes::Value.cast(name, user_class, user)).to eq(user) + expect(described_class.cast(name, user_class, [user], array: true)).to eq([user]) end end @@ -67,7 +66,7 @@ def ==(other) it 'casts String to a Time object in UTC' do expected_time = Time.parse(value).utc - expect(Vets::Attributes::Value.cast(name, Time, value)).to eq(expected_time) + expect(described_class.cast(name, Vets::Type::UTCTime, value)).to eq(expected_time) end end @@ -75,7 +74,7 @@ def ==(other) let(:value) { { key: 'value' } } it 'returns the same Hash' do - expect(Vets::Attributes::Value.cast(name, Hash, value)).to eq(value) + expect(described_class.cast(name, Hash, value)).to eq(value) end end @@ -83,9 +82,7 @@ def ==(other) let(:value) { [] } it 'returns an empty array' do - array_element_type = Vets::Type::Primitive.new(name, String) - array_instance = Vets::Type::Array.new(name, Array, array_element_type) - expect(array_instance.cast(value)).to eq([]) + expect(described_class.cast(name, String, value, array: true)).to eq([]) end end @@ -93,97 +90,55 @@ def ==(other) let(:value) { nil } it 'returns nil' do - expect(Vets::Attributes::Value.cast(name, String, value)).to be_nil + expect(described_class.cast(name, String, value)).to be_nil end end context 'when casting an Array of Strings' do - let(:value) { ['apple', 'banana'] } + let(:value) { %w[apple banana] } it 'returns the same array of Strings' do - array_element_type = Vets::Type::Primitive.new(name, String) - array_instance = Vets::Type::Array.new(name, Array, array_element_type) - expect(array_instance.cast(value)).to eq(value) - end - end - - context 'when value is an Integer' do - it 'casts Integer to String' do - attribute_value = described_class.new(:test_name, String) - setter_value = attribute_value.setter_value(123) - expect(setter_value).to eq('123') - end - end - - context 'when value is a String' do - it 'raises TypeError when casting a String to Integer' do - attribute_value = described_class.new(:test_name, Integer) - expect { attribute_value.setter_value('test') }.to raise_error(TypeError) + expect(described_class.cast(name, String, value, array: true)).to eq(value) end end - context 'when value is an Array' do - context 'when array is empty' do - it 'returns an empty array' do - attribute_value = described_class.new(:test_name, String, array: true) - setter_value = attribute_value.setter_value([]) - expect(setter_value).to eq([]) - end - end + context 'when casting an Array contains nil values' do - context 'when array contains nil values' do - it 'casts nil elements correctly' do - attribute_value = described_class.new(:test_name, String, array: true) - setter_value = attribute_value.setter_value([nil, 'test', nil]) - expect(setter_value).to eq([nil, 'test', nil]) - end - end - end + let(:value) { [nil, 'test', nil] } - context 'when value is a String and klass is UTCTime' do - it 'casts a String to a Time object in UTC' do - value = '2024-12-19T12:34:56+04:00' - attribute_value = described_class.new(:test_name, Vets::Type::UTCTime) - setter_value = attribute_value.setter_value(value) - expect(setter_value).to eq(Time.parse(value).utc) + it 'raise a TypeError' do + expect do + described_class.cast(name, String, value, array: true) + end.to raise_error(TypeError, "All elements of #{name} must be of type String") end end - context 'when value is a non-castable type' do - it 'raises TypeError when casting an uncoercible value' do - attribute_value = described_class.new(:test_name, DateTime) - expect { attribute_value.setter_value('invalid-date') }.to raise_error(TypeError, 'test_name could not be parsed into a DateTime') + context 'when casting a String to Bool' do + it 'casts a non-falsey, non-empty String to a true Bool' do + expect(described_class.cast(name, Bool, 'test')).to be_truthy end - end - context 'when value is a Bool' do - it 'coerces a non-falsey, non-empty String to a true Bool' do - attribute_value = described_class.new(:test_name, Bool) - setter_value = attribute_value.setter_value('test') - expect(setter_value).to be_truthy + it 'casts "falsey" string to a false Bool' do + expect(described_class.cast(name, Bool, 'false')).to be_falsey end - it 'casts 0 (Integer) to a false Bool' do - attribute_value = described_class.new(:test_name, Bool) - setter_value = attribute_value.setter_value(0) - expect(setter_value).to be_falsey + it 'casts a empty String to nil' do + expect(described_class.cast(name, Bool, nil)).to be_nil end + end - it 'casts "falsey" string to a false Bool' do - attribute_value = described_class.new(:test_name, Bool) - setter_value = attribute_value.setter_value('f') - expect(setter_value).to be_falsey + context 'when casting an Integer to Bool' do + it 'casts a non-zero Integer to a true Bool' do + expect(described_class.cast(name, Bool, 1)).to be_truthy end - it 'coerces a empty String to nil' do - attribute_value = described_class.new(:test_name, Bool) - setter_value = attribute_value.setter_value('') - expect(setter_value).to be_nil + it 'casts zero (Integer) to a false Bool' do + expect(described_class.cast(name, Bool, 0)).to be_falsey end end end - describe '#setter_value' + describe '#setter_value' do context 'when value is an Integer and klass is a String' do it 'casts using Vets::Type::Primitive' do attribute_value = described_class.new(:test_name, String) diff --git a/spec/lib/vets/type/array_spec.rb b/spec/lib/vets/type/array_spec.rb index 44cdf9d46f2..ca596ea301e 100644 --- a/spec/lib/vets/type/array_spec.rb +++ b/spec/lib/vets/type/array_spec.rb @@ -1,31 +1,22 @@ +# frozen_string_literal: true + require 'rails_helper' require 'vets/type/array' RSpec.describe Vets::Type::Array do let(:name) { 'test_array' } - let(:klass) { String } - - # Test with a primitive element type (String) - let(:string_element_type) { Vets::Type::Primitive.new('String', String) } - let(:array_instance_with_string) { described_class.new(name, klass, string_element_type) } - - # Test with a custom object element type (using Hash for simplicity) - let(:hash_element_type) { Vets::Type::Object.new('Hash', Hash) } - let(:array_instance_with_hash) { described_class.new(name, klass, hash_element_type) } - - let(:utc_time_element_type) { Vets::Type::UTCTime.new('UTCTime', Time) } - let(:array_instance_with_utc_time) { described_class.new(name, klass, utc_time_element_type) } - + let(:array_instance_with_string) { described_class.new(name, String) } + let(:array_instance_with_hash) { described_class.new(name, Hash) } + let(:array_instance_with_utc_time) { described_class.new(name, Vets::Type::UTCTime) } let(:user_class) do Class.new do attr_accessor :name, :email - def initialize(name:, email:) + def initialize(args) + @name = args[:name] + @email = args[:email] - raise ArgumentError, "name and email Are required" if name.nil? || email.nil? - - @name = name - @email = email + raise ArgumentError, 'name and email are required' if @name.nil? || @email.nil? end def ==(other) @@ -33,58 +24,100 @@ def ==(other) end end end + let(:array_instance_with_user) { described_class.new(name, user_class) } - # Test with Vets::Type::Object element type (casting hashes to User objects) - let(:user_element_type) { Vets::Type::Object.new('User', user_class) } - let(:array_instance_with_user) { described_class.new(name, klass, user_element_type) } + describe '#cast' do + context 'when klass is String' do + context 'when value is a valid array of strings' do + let(:valid_string_array) { %w[hello world] } + it 'returns an array of strings' do + expect(array_instance_with_string.cast(valid_string_array)).to eq(%w[hello world]) + end + end - describe '#cast' do - context 'when value is nil' do - it 'returns nil' do - expect(array_instance_with_string.cast(nil)).to be_nil + context 'when value is a valid array of integers' do + let(:valid_integer_array) { [123, 456] } + + it 'casts integers to strings' do + expect(array_instance_with_string.cast(valid_integer_array)).to eq(%w[123 456]) + end end - end - context 'when value is a valid array of strings' do - let(:valid_string_array) { ['hello', 'world'] } + context 'when value is a valid array of Time objects' do + let(:valid_time_array) do + [Time.zone.local(2024, 12, 19, 12, 34, 56), Time.zone.local(2024, 12, 20, 12, 34, 56)] + end - it 'returns an array of strings' do - expect(array_instance_with_string.cast(valid_string_array)).to eq(['hello', 'world']) + it 'casts Time objects to strings' do + expect(array_instance_with_string.cast(valid_time_array)).to eq([valid_time_array[0].to_s, + valid_time_array[1].to_s]) + end end end - context 'when value is a valid array of integers' do - let(:valid_integer_array) { [123, 456] } + context 'when klass is Hash' do + context 'when value is a valid array of hashes' do + let(:valid_hash_array) { [{ key: 'value' }, { key: 'another_value' }] } - it 'casts integers to strings' do - expect(array_instance_with_string.cast(valid_integer_array)).to eq(['123', '456']) + it 'returns an array of objects' do + expect(array_instance_with_hash.cast(valid_hash_array)).to eq(valid_hash_array) + end end end - context 'when value is a valid array of Time objects' do - let(:valid_time_array) { [Time.new(2024, 12, 19, 12, 34, 56), Time.new(2024, 12, 20, 12, 34, 56)] } + context 'when klass is Vets::Type::UTCTime' do + context 'when value is a valid array of UTCTime objects (with +04:00 offset)' do + let(:valid_time_array) { ['2024-12-19T12:34:56+04:00', '2024-12-20T12:34:56+04:00'] } + let(:expected_utc_time_array) { valid_time_array.map { |item| Time.parse(item).utc } } - it 'casts Time objects to strings' do - expect(array_instance_with_string.cast(valid_time_array)).to eq([valid_time_array[0].to_s, valid_time_array[1].to_s]) + it 'casts valid time strings with a +04:00 offset into Time objects' do + expect(array_instance_with_utc_time.cast(valid_time_array)).to eq(expected_utc_time_array) + end end end - context 'when value is a valid array of hashes' do - let(:valid_hash_array) { [{ key: 'value' }, { key: 'another_value' }] } + context 'when klass is "User" (user_class)' do + context 'when value is a valid array of hashes that can be cast into User objects' do + let(:valid_user_array) do + [ + { name: 'John Doe', email: 'john@example.com' }, + { name: 'Jane Smith', email: 'jane@example.com' } + ] + end + let(:expected_user_array) do + valid_user_array.map { |data| user_class.new(data) } + end - it 'returns an array of objects' do - expected_result = valid_hash_array.map { |item| Hash.new('Hash', item.class).cast(item) } - expect(array_instance_with_hash.cast(valid_hash_array)).to eq(expected_result) + it 'casts the hashes into User objects' do + expect(array_instance_with_user.cast(valid_user_array)).to eq(expected_user_array) + end + end + + context 'when value contains an invalid hash for a User object' do + let(:invalid_user_array) { [{ first_name: 'John Doe' }, { work_email: 'jane@example.com' }] } + + it 'raises a TypeError' do + expect do + array_instance_with_user.cast(invalid_user_array) + end.to raise_error(ArgumentError, 'name and email are required') + end end - end - context 'when value is a valid array of UTCTime objects (with +04:00 offset)' do - let(:valid_time_array) { ['2024-12-19T12:34:56+04:00', '2024-12-20T12:34:56+04:00'] } - let(:expected_utc_time_array) { valid_utc_time_array.map { |item| Time.parse(item).utc } } + context 'when value is a valid array of User objects' do + let(:user1) { user_class.new(name: 'John Doe', email: 'john@example.com') } + let(:user2) { user_class.new(name: 'Jane Smith', email: 'jane@example.com') } + let(:valid_user_object_array) { [user1, user2] } - it 'casts valid time strings with a +04:00 offset into Time objects' do - expect(array_instance_with_utc_time.cast(valid_utc_time_array)).to eq(expected_utc_time_array) + it 'returns the same array of User objects' do + expect(array_instance_with_user.cast(valid_user_object_array)).to eq(valid_user_object_array) + end + end + end + + context 'when value is nil' do + it 'returns nil' do + expect(array_instance_with_string.cast(nil)).to be_nil end end @@ -92,63 +125,37 @@ def ==(other) let(:invalid_value) { 'string' } it 'raises a TypeError with the correct message' do - expect { + expect do array_instance_with_string.cast(invalid_value) - }.to raise_error(TypeError, "#{name} must be an Array") + end.to raise_error(TypeError, "#{name} must be an Array") end end context 'when value contains elements of different types' do - let(:mixed_value_array) { ['hello', 123, Time.now] } + let(:mixed_value_array) { ['hello', 123, Time.zone.now] } it 'casts non-string elements (integers and Time) to strings' do - expect(array_instance_with_string.cast(mixed_value_array)).to eq(['hello', '123', Time.now.to_s]) + expect(array_instance_with_string.cast(mixed_value_array)).to eq(['hello', '123', Time.zone.now.to_s]) end end - context 'when value contains elements of inconsistent types after casting' do - let(:invalid_element_value) { ['hello', 123, Time.now, Object.new] } + context 'when value contains elements of different cast types' do + let(:invalid_element_value) { ['hello', 123, Time.zone.now, Object.new] } it 'raises a TypeError' do - expect { - array_instance_with_string.cast(invalid_element_value) - }.to raise_error(TypeError, "#{name} must be of type String") + expect do + described_class.new(name, Integer).cast(invalid_element_value) + end.to raise_error(TypeError, "All elements of #{name} must be of type Integer") end end - context 'when value is a valid array of hashes that can be cast into User objects' do - let(:valid_user_array) do - [ - { name: 'John Doe', email: 'john@example.com' }, - { name: 'Jane Smith', email: 'jane@example.com' } - ] - end - let(:expected_user_array) do - valid_user_array.map { |data| user_class.new(data) } - end - - it 'casts the hashes into User objects' do - expect(array_instance_with_user.cast(valid_user_array)).to eq(expected_user_array) - end - end - - context 'when value contains an invalid hash for a User object' do - let(:invalid_user_array) { [{ first_name: 'John Doe' }, { work_email: 'jane@example.com' }] } + context 'when value contains incoercible elements' do + let(:invalid_element_value) { ['hello', 123, Time.zone.now, Object.new] } it 'raises a TypeError' do - expect { - array_instance_with_user.cast(invalid_user_array) - }.to raise_error(TypeError, "#{name} must be of type User") - end - end - - context 'when value is a valid array of User objects' do - let(:user_1) { user_class.new(name: 'John Doe', email: 'john@example.com') } - let(:user_2) { user_class.new(name: 'Jane Smith', email: 'jane@example.com') } - let(:valid_user_object_array) { [user_1, user_2] } - - it 'returns the same array of User objects' do - expect(array_instance_with_user.cast(valid_user_object_array)).to eq(valid_user_object_array) + expect do + described_class.new(name, Float).cast(invalid_element_value) + end.to raise_error(TypeError, "#{name} could not be coerced to Float") end end end diff --git a/spec/lib/vets/type/base_spec.rb b/spec/lib/vets/type/base_spec.rb index 286a031182b..d18736746c8 100644 --- a/spec/lib/vets/type/base_spec.rb +++ b/spec/lib/vets/type/base_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe Vets::Type::Base do @@ -14,7 +16,9 @@ describe '#cast' do it 'raises NotImplementedError when called' do - expect { base_instance.cast('value') }.to raise_error(NotImplementedError, "#{described_class} must implement #cast") + expect do + base_instance.cast('value') + end.to raise_error(NotImplementedError, "#{described_class} must implement #cast") end end end diff --git a/spec/lib/vets/type/date_time_string_spec.rb b/spec/lib/vets/type/date_time_string_spec.rb index 6773f9b5bce..946127d9a2f 100644 --- a/spec/lib/vets/type/date_time_string_spec.rb +++ b/spec/lib/vets/type/date_time_string_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' require 'vets/type/date_time_string' @@ -25,9 +27,9 @@ let(:invalid_datetime) { 'invalid-datetime' } it 'raises a TypeError with the correct message' do - expect { + expect do datetime_instance.cast(invalid_datetime) - }.to raise_error(TypeError, "#{name} is not Time parseable") + end.to raise_error(TypeError, "#{name} is not Time parseable") end end end diff --git a/spec/lib/vets/type/hash_spec.rb b/spec/lib/vets/type/hash_spec.rb index 5e196ff1e67..01023b236f8 100644 --- a/spec/lib/vets/type/hash_spec.rb +++ b/spec/lib/vets/type/hash_spec.rb @@ -1,10 +1,11 @@ +# frozen_string_literal: true + require 'rails_helper' require 'vets/type/hash' RSpec.describe Vets::Type::Hash do let(:name) { 'test_hash' } - let(:klass) { Hash } - let(:hash_instance) { described_class.new(name, klass) } + let(:hash_instance) { described_class.new(name) } describe '#cast' do context 'when value is nil' do @@ -25,9 +26,9 @@ let(:invalid_value) { 'string' } it 'raises a TypeError with the correct message' do - expect { + expect do hash_instance.cast(invalid_value) - }.to raise_error(TypeError, "#{name} must be a Hash") + end.to raise_error(TypeError, "#{name} must be a Hash") end end end diff --git a/spec/lib/vets/type/http_date_spec.rb b/spec/lib/vets/type/http_date_spec.rb index da23e9de525..9f9c69c7b95 100644 --- a/spec/lib/vets/type/http_date_spec.rb +++ b/spec/lib/vets/type/http_date_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' require 'vets/type/http_date' @@ -25,17 +27,27 @@ let(:invalid_datetime) { 'invalid-datetime' } it 'raises a TypeError with the correct message' do - expect { + expect do http_date_instance.cast(invalid_datetime) - }.to raise_error(TypeError, "#{name} is not Time parseable") + end.to raise_error(TypeError, "#{name} is not Time parseable") + end + end + + context 'when value is an integer' do + let(:timestamp) { 1_700_000_00 } + + it 'raises a TypeError with the correct message' do + expect do + http_date_instance.cast(timestamp) + end.to raise_error(TypeError, "#{name} is not Time parseable") end end - context 'when value is a numeric timestamp' do - let(:timestamp) { 1_700_000_000 } # Example Unix timestamp + context 'when value is a custom format timestamp' do + let(:custom_datetime) { 'Aug 2024' } it 'converts the timestamp to HTTP date format' do - expect(http_date_instance.cast(timestamp)).to eq(Time.at(timestamp).utc.httpdate) + expect(http_date_instance.cast(custom_datetime)).to eq(Time.parse(custom_datetime).utc.httpdate) end end end diff --git a/spec/lib/vets/type/iso8601_time_spec.rb b/spec/lib/vets/type/iso8601_time_spec.rb index 09b272badc5..21bfb41ac5d 100644 --- a/spec/lib/vets/type/iso8601_time_spec.rb +++ b/spec/lib/vets/type/iso8601_time_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' require 'vets/type/iso8601_time' @@ -25,9 +27,9 @@ let(:invalid_iso8601) { 'invalid-iso8601' } it 'raises a TypeError with the correct message' do - expect { + expect do iso8601_instance.cast(invalid_iso8601) - }.to raise_error(TypeError, "#{name} is not iso8601") + end.to raise_error(TypeError, "#{name} is not iso8601") end end end diff --git a/spec/lib/vets/type/object_spec.rb b/spec/lib/vets/type/object_spec.rb index 93e0176b603..2f72151e517 100644 --- a/spec/lib/vets/type/object_spec.rb +++ b/spec/lib/vets/type/object_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' require 'vets/type/object' @@ -47,9 +49,9 @@ def ==(other) let(:invalid_value) { 'invalid_value' } it 'raises a TypeError with the correct message' do - expect { + expect do object_instance.cast(invalid_value) - }.to raise_error(TypeError, "#{name} must be a Hash or an instance of #{klass}") + end.to raise_error(TypeError, "#{name} must be a Hash or an instance of #{klass}") end end end diff --git a/spec/lib/vets/type/primitive_spec.rb b/spec/lib/vets/type/primitive_spec.rb index 24a6316f12f..03885a5128d 100644 --- a/spec/lib/vets/type/primitive_spec.rb +++ b/spec/lib/vets/type/primitive_spec.rb @@ -1,47 +1,111 @@ +# frozen_string_literal: true + require 'rails_helper' require 'vets/type/primitive' RSpec.describe Vets::Type::Primitive do let(:name) { 'test_primitive' } - shared_examples 'primitive type casting' do |klass, valid_input, expected_output, invalid_input| - let(:primitive_instance) { described_class.new(name, klass) } + describe '#cast' do + context 'when value is nil' do + let(:primitive_instance) { described_class.new(name, String) } + + it 'returns nil' do + expect(primitive_instance.cast(nil)).to be_nil + end + end + + context 'when klass is Integer' do + let(:primitive_instance) { described_class.new(name, Integer) } - context "when klass is #{klass}" do it 'casts valid input correctly' do - expect(primitive_instance.cast(valid_input)).to eq(expected_output) + expect(primitive_instance.cast('42')).to eq(42) + expect(primitive_instance.cast('valid')).to eq(0) + end + + it 'cast invalid input to nil' do + expect(primitive_instance.cast(nil)).to be_nil + end + end + + context 'when klass is Float' do + let(:primitive_instance) { described_class.new(name, Float) } + + it 'casts valid input correctly' do + expect(primitive_instance.cast('3.14')).to eq(3.14) + expect(primitive_instance.cast('valid')).to eq(0.0) + end + + it 'cast valid to nil' do + expect(primitive_instance.cast(nil)).to be_nil end it 'raises TypeError for invalid input' do - expect { - primitive_instance.cast(invalid_input) - }.to raise_error(TypeError, "#{name} could not be coerced to #{klass}") + expect do + primitive_instance.cast(Object.new) + end.to raise_error(TypeError, "#{name} could not be coerced to Float") end end - end - describe '#cast' do - context 'when value is nil' do - let(:primitive_instance) { described_class.new(name, String) } + context 'when klass is Date' do + let(:primitive_instance) { described_class.new(name, Date) } - it 'returns nil' do + it 'casts valid input correctly' do + expect(primitive_instance.cast('2024-12-19')).to eq(Date.new(2024, 12, 19)) + end + + it 'cast invalid input to nil' do + expect(primitive_instance.cast(nil)).to be_nil + end + end + + context 'when klass is DateTime' do + let(:primitive_instance) { described_class.new(name, DateTime) } + + it 'casts valid input correctly' do + expect(primitive_instance.cast('2024-12-19T12:34:56+00:00')).to eq(DateTime.new(2024, 12, 19, 12, 34, 56)) + end + + it 'cast invalid input to nil' do expect(primitive_instance.cast(nil)).to be_nil end end - include_examples 'primitive type casting', Integer, '42', 42, 'invalid' - include_examples 'primitive type casting', Float, '3.14', 3.14, 'invalid' - include_examples 'primitive type casting', Date, '2024-12-19', Date.new(2024, 12, 19), 'invalid' - include_examples 'primitive type casting', DateTime, '2024-12-19T12:34:56+00:00', DateTime.new(2024, 12, 19, 12, 34, 56), 'invalid' - include_examples 'primitive type casting', String, 42, '42', nil + context 'when klass is Time' do + let(:primitive_instance) { described_class.new(name, Time) } - context 'when klass is unsupported' do - let(:primitive_instance) { described_class.new(name, Hash) } + it 'casts valid input correctly for ISO8601 format' do + date_time_string = '2024-12-19T12:34:56+00:00' + expect(primitive_instance.cast(date_time_string)).to eq(Time.iso8601(date_time_string)) + end - it 'raises TypeError for unsupported types' do - expect { - primitive_instance.cast('value') - }.to raise_error(TypeError, "#{name} could not be coerced to Hash") + it 'casts valid input correctly for HTTP date format' do + date_time_string = 'Wed, 19 Dec 2024 12:34:56 GMT' + expect(primitive_instance.cast(date_time_string)).to eq(Time.httpdate(date_time_string)) + end + + it 'casts valid input correctly for custom date-time format' do + expect(primitive_instance.cast('2024-12-19 12:34:56')).to eq(Time.zone.parse('2024-12-19 12:34:56')) + end + + it 'casts valid input correctly for custom time format' do + expect(primitive_instance.cast('12:34:56')).to eq(Time.zone.parse('12:34:56')) + end + + it 'casts invalid input to nil' do + expect(primitive_instance.cast(nil)).to be_nil + end + end + + context 'when klass is String' do + let(:primitive_instance) { described_class.new(name, String) } + + it 'casts valid input correctly' do + expect(primitive_instance.cast(42)).to eq('42') + end + + it 'cast nil to nil' do + expect(primitive_instance.cast(nil)).to be_nil end end end diff --git a/spec/lib/vets/type/titlecase_string_spec.rb b/spec/lib/vets/type/titlecase_string_spec.rb index 574e0aa8119..33864ae4378 100644 --- a/spec/lib/vets/type/titlecase_string_spec.rb +++ b/spec/lib/vets/type/titlecase_string_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' require 'vets/type/titlecase_string' @@ -38,7 +40,7 @@ end context 'when value is a non-string type' do - let(:non_string_value) { 12345 } + let(:non_string_value) { 12_345 } it 'returns the string representation of the value in titlecase' do expect(titlecase_instance.cast(non_string_value)).to eq('12345') diff --git a/spec/lib/vets/type/utc_time_spec.rb b/spec/lib/vets/type/utc_time_spec.rb index 6b185489fb5..a73c4577084 100644 --- a/spec/lib/vets/type/utc_time_spec.rb +++ b/spec/lib/vets/type/utc_time_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' require 'vets/type/utc_time' @@ -26,17 +28,17 @@ let(:invalid_time_string) { 'invalid-time' } it 'raises a TypeError with the correct message' do - expect { + expect do utc_time_instance.cast(invalid_time_string) - }.to raise_error(TypeError, "#{name} is not Time parseable") + end.to raise_error(TypeError, "#{name} is not Time parseable") end end context 'when value is a valid Time object' do - let(:valid_time_object) { Time.now } + let(:valid_time_object) { Time.zone.now } it 'returns the Time object in UTC' do - expect(utc_time_instance.cast(valid_time_object)).to eq(valid_time_object.utc) + expect(utc_time_instance.cast(valid_time_object.round)).to eq(valid_time_object.utc.round) end end end From dd963e09d5ecd18f27c03682edcbb30357fdc700 Mon Sep 17 00:00:00 2001 From: stevenjcumming <134282106+stevenjcumming@users.noreply.github.com> Date: Fri, 20 Dec 2024 11:00:29 -0500 Subject: [PATCH 5/8] linting --- lib/vets/attributes/value.rb | 20 ++++++++++---------- lib/vets/type/primitive.rb | 2 +- spec/lib/vets/attributes/value_spec.rb | 1 - spec/lib/vets/type/array_spec.rb | 2 +- spec/lib/vets/type/primitive_spec.rb | 2 +- 5 files changed, 13 insertions(+), 14 deletions(-) diff --git a/lib/vets/attributes/value.rb b/lib/vets/attributes/value.rb index 474a2df1791..950cbb1e9a3 100644 --- a/lib/vets/attributes/value.rb +++ b/lib/vets/attributes/value.rb @@ -22,16 +22,16 @@ def setter_value(value) # Acts as a "type factory" def type @type ||= if @array - Vets::Type::Array.new(@name, @klass) - elsif Vets::Type::Primitive::PRIMITIVE_TYPES.include?(@klass) - Vets::Type::Primitive.new(@name, @klass) - elsif @klass.module_parents.include?(Vets::Type) - @klass.new(@name, @klass) - elsif @klass == ::Hash - Vets::Type::Hash.new(@name) - else - Vets::Type::Object.new(@name, @klass) - end + Vets::Type::Array.new(@name, @klass) + elsif Vets::Type::Primitive::PRIMITIVE_TYPES.include?(@klass) + Vets::Type::Primitive.new(@name, @klass) + elsif @klass.module_parents.include?(Vets::Type) + @klass.new(@name, @klass) + elsif @klass == ::Hash + Vets::Type::Hash.new(@name) + else + Vets::Type::Object.new(@name, @klass) + end end end end diff --git a/lib/vets/type/primitive.rb b/lib/vets/type/primitive.rb index 6f38d57bc24..d9b0bddcad2 100644 --- a/lib/vets/type/primitive.rb +++ b/lib/vets/type/primitive.rb @@ -30,7 +30,7 @@ def cast(value) private def invalid_type! - raise TypeError, "#{@name} could not be coerced to #{@klass}" + raise TypeError, "#{@name} could not be casted to #{@klass}" end end end diff --git a/spec/lib/vets/attributes/value_spec.rb b/spec/lib/vets/attributes/value_spec.rb index e794b3690aa..79261940905 100644 --- a/spec/lib/vets/attributes/value_spec.rb +++ b/spec/lib/vets/attributes/value_spec.rb @@ -103,7 +103,6 @@ def ==(other) end context 'when casting an Array contains nil values' do - let(:value) { [nil, 'test', nil] } it 'raise a TypeError' do diff --git a/spec/lib/vets/type/array_spec.rb b/spec/lib/vets/type/array_spec.rb index ca596ea301e..13b590e5aa3 100644 --- a/spec/lib/vets/type/array_spec.rb +++ b/spec/lib/vets/type/array_spec.rb @@ -155,7 +155,7 @@ def ==(other) it 'raises a TypeError' do expect do described_class.new(name, Float).cast(invalid_element_value) - end.to raise_error(TypeError, "#{name} could not be coerced to Float") + end.to raise_error(TypeError, "#{name} could not be casted to Float") end end end diff --git a/spec/lib/vets/type/primitive_spec.rb b/spec/lib/vets/type/primitive_spec.rb index 03885a5128d..bf315303367 100644 --- a/spec/lib/vets/type/primitive_spec.rb +++ b/spec/lib/vets/type/primitive_spec.rb @@ -43,7 +43,7 @@ it 'raises TypeError for invalid input' do expect do primitive_instance.cast(Object.new) - end.to raise_error(TypeError, "#{name} could not be coerced to Float") + end.to raise_error(TypeError, "#{name} could not be casted to Float") end end From c6112114a4441c7d5f89792e00d2577652647bce Mon Sep 17 00:00:00 2001 From: stevenjcumming <134282106+stevenjcumming@users.noreply.github.com> Date: Fri, 20 Dec 2024 11:02:15 -0500 Subject: [PATCH 6/8] add comment for clarity --- lib/vets/types.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/vets/types.rb b/lib/vets/types.rb index a799521b898..5feafae0948 100644 --- a/lib/vets/types.rb +++ b/lib/vets/types.rb @@ -1,5 +1,10 @@ # frozen_string_literal: true +# These are the castable type and +# types that can be used by Vets::Attributes +# Primitive types include: +# String, Integer, Float, Date, Time, DateTime, Bool + require 'vets/type/base' require 'vets/type/date_time_string' require 'vets/type/hash' From c2769ab25b0eb1fb5d4f0d1da743543049273cd4 Mon Sep 17 00:00:00 2001 From: stevenjcumming <134282106+stevenjcumming@users.noreply.github.com> Date: Fri, 20 Dec 2024 11:29:01 -0500 Subject: [PATCH 7/8] fix bool issue --- lib/vets/attributes/value.rb | 2 +- lib/vets/type/array.rb | 2 +- lib/vets/type/primitive.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/vets/attributes/value.rb b/lib/vets/attributes/value.rb index 950cbb1e9a3..d224b7480e7 100644 --- a/lib/vets/attributes/value.rb +++ b/lib/vets/attributes/value.rb @@ -23,7 +23,7 @@ def setter_value(value) def type @type ||= if @array Vets::Type::Array.new(@name, @klass) - elsif Vets::Type::Primitive::PRIMITIVE_TYPES.include?(@klass) + elsif Vets::Type::Primitive::PRIMITIVE_TYPES.include?(@klass.name) Vets::Type::Primitive.new(@name, @klass) elsif @klass.module_parents.include?(Vets::Type) @klass.new(@name, @klass) diff --git a/lib/vets/type/array.rb b/lib/vets/type/array.rb index 33f77b65415..fb9c8cf5f57 100644 --- a/lib/vets/type/array.rb +++ b/lib/vets/type/array.rb @@ -24,7 +24,7 @@ def cast(value) end def type - @type ||= if Vets::Type::Primitive::PRIMITIVE_TYPES.include?(@klass) + @type ||= if Vets::Type::Primitive::PRIMITIVE_TYPES.include?(@klass.name) Vets::Type::Primitive.new(@name, @klass) elsif @klass.module_parents.include?(Vets::Type) @klass.new(@name, @klass) diff --git a/lib/vets/type/primitive.rb b/lib/vets/type/primitive.rb index d9b0bddcad2..13009ad10d4 100644 --- a/lib/vets/type/primitive.rb +++ b/lib/vets/type/primitive.rb @@ -6,7 +6,7 @@ module Vets module Type class Primitive < Base - PRIMITIVE_TYPES = [String, Integer, Float, Date, Time, DateTime, Bool].freeze + PRIMITIVE_TYPES = %w[String Integer Float Date Time DateTime Bool].freeze def cast(value) return value if value.is_a?(@klass) || value.nil? From 22aa972e200edc2fc29f1af393eb01a2053dfdbf Mon Sep 17 00:00:00 2001 From: stevenjcumming <134282106+stevenjcumming@users.noreply.github.com> Date: Fri, 20 Dec 2024 11:42:24 -0500 Subject: [PATCH 8/8] fix AferVisitSummary attribute class --- modules/avs/app/models/avs/v0/after_visit_summary.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/avs/app/models/avs/v0/after_visit_summary.rb b/modules/avs/app/models/avs/v0/after_visit_summary.rb index f7cd4534613..4292c4d4145 100644 --- a/modules/avs/app/models/avs/v0/after_visit_summary.rb +++ b/modules/avs/app/models/avs/v0/after_visit_summary.rb @@ -8,8 +8,8 @@ class V0::AfterVisitSummary attribute :id, String attribute :icn, String - attribute :meta, Object - attribute :patient_info, Object + attribute :meta, Hash + attribute :patient_info, Hash attribute :appointment_iens, Array, default: [] attribute :clinics_visited, Array, default: [] attribute :providers, Array, default: [] @@ -29,14 +29,14 @@ class V0::AfterVisitSummary attribute :problems, Array, default: [] attribute :clinical_reminders, Array, default: [] attribute :clinical_services, Array, default: [] - attribute :allergies_reactions, Object + attribute :allergies_reactions, Hash attribute :clinic_medications, Array, default: [] attribute :va_medications, Array, default: [] attribute :nonva_medications, Array, default: [] - attribute :med_changes_summary, Object + attribute :med_changes_summary, Hash attribute :lab_results, Array, default: [] attribute :radiology_reports1_yr, String - attribute :discrete_data, Object + attribute :discrete_data, Hash attribute :more_help_and_information, String def initialize(data)