From 26ebdec21969feb782349f3d06b4d7a3f007f9c3 Mon Sep 17 00:00:00 2001 From: jar-stripe Date: Mon, 10 Aug 2026 12:16:15 -0700 Subject: [PATCH 1/3] =?UTF-8?q?Handle=20break=20in=20auto-paging=20blocks?= =?UTF-8?q?=20=F0=9F=AA=BF=E2=9C=A8=20(#1932)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Handle break in auto-paging blocks Committed-By-Agent: goose * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fixed linter errors and restored missing end keywords --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/stripe/list_object.rb | 4 ++++ lib/stripe/search_result_object.rb | 4 ++++ lib/stripe/v2_list_object.rb | 4 ++++ test/stripe/list_object_test.rb | 15 +++++++++++++++ test/stripe/search_result_object_test.rb | 15 +++++++++++++++ test/stripe/v2_list_object_test.rb | 15 +++++++++++++++ 6 files changed, 57 insertions(+) diff --git a/lib/stripe/list_object.rb b/lib/stripe/list_object.rb index 7d0e01ff9..4e207bacc 100644 --- a/lib/stripe/list_object.rb +++ b/lib/stripe/list_object.rb @@ -85,6 +85,10 @@ def auto_paging_each(&blk) end break if page.empty? + rescue LocalJumpError => e + raise unless e.reason == :break + + break end end diff --git a/lib/stripe/search_result_object.rb b/lib/stripe/search_result_object.rb index 52a4c5e35..08e8614ee 100644 --- a/lib/stripe/search_result_object.rb +++ b/lib/stripe/search_result_object.rb @@ -71,6 +71,10 @@ def auto_paging_each(&blk) page = page.next_search_result_page break if page.empty? + rescue LocalJumpError => e + raise unless e.reason == :break + + break end end diff --git a/lib/stripe/v2_list_object.rb b/lib/stripe/v2_list_object.rb index 90744e13a..87db6a576 100644 --- a/lib/stripe/v2_list_object.rb +++ b/lib/stripe/v2_list_object.rb @@ -58,6 +58,10 @@ def auto_paging_each(&blk) break if page.next_page_url.nil? page = page.fetch_next_page + rescue LocalJumpError => e + raise unless e.reason == :break + + break end end diff --git a/test/stripe/list_object_test.rb b/test/stripe/list_object_test.rb index 0f4a888c2..be0189028 100644 --- a/test/stripe/list_object_test.rb +++ b/test/stripe/list_object_test.rb @@ -162,6 +162,21 @@ class ListObjectTest < Test::Unit::TestCase assert_equal expected, actual end + should "support break inside #auto_paging_each block" do + list = TestListObject.construct_from(data: [{ id: 1 }, { id: 2 }], + has_more: true, + url: "/things", + object: "list") + + actual = [] + list.auto_paging_each do |obj| + actual << obj + break if actual.size == 1 + end + + assert_equal 1, actual.size + end + should "provide #empty?" do list = Stripe::ListObject.construct_from(data: []) assert list.empty? diff --git a/test/stripe/search_result_object_test.rb b/test/stripe/search_result_object_test.rb index 4926a4480..282f90ddb 100644 --- a/test/stripe/search_result_object_test.rb +++ b/test/stripe/search_result_object_test.rb @@ -88,6 +88,21 @@ class SearchResultObjectTest < Test::Unit::TestCase assert_equal expected, actual end + should "support break inside #auto_paging_each block" do + list = TestSearchResultObject.construct_from(data: [{ id: 1 }, { id: 2 }], + has_more: true, + next_page: "next_page_token_1", + url: "/things") + + actual = [] + list.auto_paging_each do |obj| + actual << obj + break if actual.size == 1 + end + + assert_equal 1, actual.size + end + should "provide #empty?" do list = Stripe::SearchResultObject.construct_from(data: []) assert list.empty? diff --git a/test/stripe/v2_list_object_test.rb b/test/stripe/v2_list_object_test.rb index f380ceebe..f4447a0fb 100644 --- a/test/stripe/v2_list_object_test.rb +++ b/test/stripe/v2_list_object_test.rb @@ -105,6 +105,21 @@ class V2ListObjectTest < Test::Unit::TestCase assert_equal expected, actual end + + should "support break inside #auto_paging_each block" do + list = TestV2ListObject.construct_from({ + data: [{ id: 1 }, { id: 2 }], + next_page_url: "/v2/things?page=page_2", + }, {}, nil, :v2, APIRequestor.new("sk_test_123")) + + actual = [] + list.auto_paging_each do |obj| + actual << obj + break if actual.size == 1 + end + + assert_equal 1, actual.size + end end context "#fetch_next_page" do From 61051e16df49ff6c43e45b3bd4e7db4eb28dfb84 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Mon, 10 Aug 2026 15:09:00 -0700 Subject: [PATCH 2/3] Bump version to 19.5.0 --- CHANGELOG.md | 14 +++++++++++++- VERSION | 2 +- lib/stripe/version.rb | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f20ae404..3dd461b6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 19.5.0 - 2026-08-10 +* [#1932](https://github.com/stripe/stripe-ruby/pull/1932) Handle break in auto-paging blocks + - Rescues `LocalJumpError` to prevent crash when calling `break` inside an `auto_paging_each` +* [#1927](https://github.com/stripe/stripe-ruby/pull/1927) Surface `object` property on `EventNotification` +* [#1918](https://github.com/stripe/stripe-ruby/pull/1918) add/adjust event parsing helpers + + - Added methods that return their respective `Event`/`EventNotification` class instances without verifying authenticity. Use them when you've previously verified an event (e.g. you verified, put the event in a queue, and are now processing). Supports events from [AWS EventBridge](https://docs.stripe.com/event-destinations/eventbridge) and [Azure Event Grid](https://docs.stripe.com/event-destinations/eventgrid) natively. + - `Webhook#construct_event_without_verification(payload)` + - `StripeClient#parse_event_notification_without_verification(payload)` +* [#1924](https://github.com/stripe/stripe-ruby/pull/1924) Discard the connection when a request is interrupted + - Fix connection reuse after a request is interrupted by a non-StandardError exception (e.g. from rack-timeout), which could cause subsequent requests to receive a previous request's response + ## 19.4.0 - 2026-07-29 This release changes the pinned API version to 2026-07-29.dahlia. @@ -30,7 +42,7 @@ This release changes the pinned API version to 2026-07-29.dahlia. * Add support for `mass_transit_parking_tax` and `parking_tax` on `Tax::Registration::CountryOption::Me` and `Tax::RegistrationCreateParams::CountryOption::Me` * Add support for `initiated_by` and `payment_method_options` on `Topup` * Add support for `additional_addresses` on `V2::Core::Account::Identity::BusinessDetail`, `V2::Core::AccountCreateParams::Identity::BusinessDetail`, `V2::Core::AccountTokenCreateParams::Identity::BusinessDetail`, and `V2::Core::AccountUpdateParams::Identity::BusinessDetail` -* [#1906](https://github.com/stripe/stripe-ruby/pull/1906) Adds `error_object.rbi` file, allowing users to get full typing for API Error Objects +* [#1906](https://github.com/stripe/stripe-ruby/pull/1906) Adds `error_object.rbi` file, allowing users to get full typing for API Error Objects ## 19.3.1 - 2026-07-15 * [#1902](https://github.com/stripe/stripe-ruby/pull/1902) Replace source hash with Telemetry UUID diff --git a/VERSION b/VERSION index 05c8bd681..912dbfaee 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -19.4.0 +19.5.0 diff --git a/lib/stripe/version.rb b/lib/stripe/version.rb index 95c217d78..505997242 100644 --- a/lib/stripe/version.rb +++ b/lib/stripe/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Stripe - VERSION = "19.4.0" + VERSION = "19.5.0" end From fcedaaae31d3a1aee3532a12aadb63297c27798e Mon Sep 17 00:00:00 2001 From: jar-stripe Date: Tue, 11 Aug 2026 16:01:36 -0700 Subject: [PATCH 3/3] Extract V2TypeCoercion module for bidirectional field encoding (#1926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Extract V2TypeCoercion module for bidirectional field encoding Consolidates request-side (encode: native → wire) and response-side (decode: wire → native) type coercion into a shared module that handles all V2 encoding kinds: int64_string, decimal_string, object, array, nullable, and discriminated_union. Previously, request_params.rb had its own coercion implementation and stripe_object.rb only handled decimal_string with a flat equality check. This left int64_string fields uncoerced on the response side (returning String instead of Integer) and didn't support nested schemas like {kind: :nullable, inner: :decimal_string}. Co-Authored-By: Claude Opus 4.6 Committed-By-Agent: claude * Scope module to existing encoding kinds only Remove nullable and discriminated_union cases — those belong in the DU coercion branch, not this base extraction. The module should only contain what already exists on master: int64_string, decimal_string, object, array. Co-Authored-By: Claude Opus 4.6 Committed-By-Agent: claude --------- Co-authored-by: Claude Opus 4.6 --- lib/stripe.rb | 1 + lib/stripe/request_params.rb | 57 +------- lib/stripe/stripe_object.rb | 5 +- lib/stripe/v2_type_coercion.rb | 102 +++++++++++++ test/stripe/stripe_object_int64_test.rb | 93 ++++++++++++ test/stripe/v2_type_coercion_test.rb | 185 ++++++++++++++++++++++++ 6 files changed, 384 insertions(+), 59 deletions(-) create mode 100644 lib/stripe/v2_type_coercion.rb create mode 100644 test/stripe/stripe_object_int64_test.rb create mode 100644 test/stripe/v2_type_coercion_test.rb diff --git a/lib/stripe.rb b/lib/stripe.rb index 2b3ffaa5e..403dc8a8a 100644 --- a/lib/stripe.rb +++ b/lib/stripe.rb @@ -34,6 +34,7 @@ require "stripe/object_types" require "stripe/event_types" require "stripe/request_options" +require "stripe/v2_type_coercion" require "stripe/request_params" require "stripe/stripe_context" require "stripe/util" diff --git a/lib/stripe/request_params.rb b/lib/stripe/request_params.rb index 58682c28b..75df69ae9 100644 --- a/lib/stripe/request_params.rb +++ b/lib/stripe/request_params.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true # typed: true -require "bigdecimal" - module Stripe # For internal use only. Does not provide a stable API and may be broken # with future non-major changes. @@ -64,61 +62,8 @@ def self.field_encodings @field_encodings ||= {} end - # Recursively coerce a value based on its field encoding schema. - # Handles :int64_string leaves, { kind: :object, fields: ... } nesting, - # and { kind: :array, element: ... } for arrays. def self.coerce_value(value, encoding) - return value if value.nil? - - case encoding - when :int64_string - coerce_int64_string(value) - when :decimal_string - coerce_decimal_string(value) - when Hash - coerce_composite(value, encoding) - else - value - end - end - - private_class_method def self.coerce_int64_string(value) - case value - when Integer then value.to_s - when Array then value.map { |v| v.is_a?(Integer) ? v.to_s : v } - else value - end - end - - private_class_method def self.coerce_decimal_string(value) - case value - when BigDecimal then value.to_s("F") - when Integer, Float then value.to_s - when Array then value.map { |v| coerce_decimal_string(v) } - else value - end - end - - private_class_method def self.coerce_composite(value, encoding) - case encoding[:kind] - when :object - coerce_object(value, encoding[:fields] || {}) - when :array - return value unless value.is_a?(Array) - - value.map { |v| coerce_value(v, encoding[:element]) } - else - value - end - end - - private_class_method def self.coerce_object(value, fields_schema) - return value unless value.is_a?(Hash) - - value.each_with_object({}) do |(k, v), result| - field_encoding = fields_schema[k.to_sym] - result[k] = field_encoding ? coerce_value(v, field_encoding) : v - end + V2TypeCoercion.coerce_value(value, encoding, direction: :encode) end # Coerce a plain Hash using this class's field_encodings. diff --git a/lib/stripe/stripe_object.rb b/lib/stripe/stripe_object.rb index 65d430e9e..09ba95aa9 100644 --- a/lib/stripe/stripe_object.rb +++ b/lib/stripe/stripe_object.rb @@ -157,9 +157,8 @@ def update_attributes(values, opts = {}, dirty: true) values.each do |k, v| add_accessors([k], values) unless metaclass.method_defined?(k.to_sym) @values[k] = convert_value_with_inner_types(k, v, opts) - if self.class.field_encodings[k.to_sym] == :decimal_string && @values[k].is_a?(String) - @values[k] = BigDecimal(@values[k]) - end + encoding = self.class.field_encodings[k.to_sym] + @values[k] = V2TypeCoercion.coerce_value(@values[k], encoding, direction: :decode) if encoding dirty_value!(@values[k]) if dirty @unsaved_values.add(k) end diff --git a/lib/stripe/v2_type_coercion.rb b/lib/stripe/v2_type_coercion.rb new file mode 100644 index 000000000..edeb8777c --- /dev/null +++ b/lib/stripe/v2_type_coercion.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true +# typed: true + +require "bigdecimal" + +module Stripe + # Shared V2 type coercion logic for encoding/decoding values between + # native Ruby types and their wire representations. + # + # Used by RequestParams (encode: native → wire) and StripeObject (decode: wire → native). + module V2TypeCoercion + # Coerce a single value based on its field encoding schema. + # direction: :encode (request: native → wire) or :decode (response: wire → native) + module_function def coerce_value(value, encoding, direction:) + return value if value.nil? + + case encoding + when :int64_string + coerce_int64_string(value, direction) + when :decimal_string + coerce_decimal_string(value, direction) + when Hash + coerce_composite(value, encoding, direction) + else + value + end + end + + # Coerce all fields in a hash according to a schema map. + module_function def coerce_fields(hash, schema, direction:) + return hash unless hash.is_a?(Hash) + return hash if schema.nil? || schema.empty? + + hash.each_with_object({}) do |(k, v), result| + field_encoding = schema[k.to_sym] + result[k] = field_encoding ? coerce_value(v, field_encoding, direction: direction) : v + end + end + + # --- leaf coercions --- + + module_function def coerce_int64_string(value, direction) + case direction + when :encode + case value + when Integer then value.to_s + when Array then value.map { |v| v.is_a?(Integer) ? v.to_s : v } + else value + end + when :decode + case value + when String then Kernel.Integer(value) + when Array then value.map { |v| v.is_a?(String) ? Kernel.Integer(v) : v } + else value + end + else + Kernel.raise ArgumentError, "unknown direction: #{direction.inspect}" + end + end + + module_function def coerce_decimal_string(value, direction) + case direction + when :encode + case value + when BigDecimal then value.to_s("F") + when Integer, Float then value.to_s + when Array then value.map { |v| coerce_decimal_string(v, direction) } + else value + end + when :decode + case value + when String then Kernel.BigDecimal(value) + when Array then value.map { |v| coerce_decimal_string(v, direction) } + else value + end + else + Kernel.raise ArgumentError, "unknown direction: #{direction.inspect}" + end + end + + # --- composite coercions --- + + module_function def coerce_composite(value, encoding, direction) + case encoding[:kind] + when :object + coerce_object(value, encoding[:fields] || {}, direction) + when :array + return value unless value.is_a?(Array) + + value.map { |v| coerce_value(v, encoding[:element], direction: direction) } + else + value + end + end + + module_function def coerce_object(value, fields_schema, direction) + return value unless value.is_a?(Hash) + + coerce_fields(value, fields_schema, direction: direction) + end + end +end diff --git a/test/stripe/stripe_object_int64_test.rb b/test/stripe/stripe_object_int64_test.rb new file mode 100644 index 000000000..df0825610 --- /dev/null +++ b/test/stripe/stripe_object_int64_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require File.expand_path("../test_helper", __dir__) + +module Stripe + class StripeObjectInt64Test < Test::Unit::TestCase + # A test resource with an int64_string-encoded field. + class Int64Resource < Stripe::StripeObject + def self.field_encodings + { amount: :int64_string } + end + end + + # A nested inner class that has its own field_encodings. + class InnerDetail < Stripe::StripeObject + def self.field_encodings + { quantity: :int64_string } + end + end + + # A parent resource referencing the inner class via inner_class_types. + # This demonstrates that nested coercion works through each StripeObject + # applying its own field_encodings during update_attributes (not through + # V2TypeCoercion recursing into StripeObjects). + class ParentResource < Stripe::StripeObject + def self.field_encodings + { total: :int64_string } + end + + def self.inner_class_types + { detail: InnerDetail } + end + end + + context "response-side int64_string coercion via construct_from" do + should "deserialize a string field to Integer" do + obj = Int64Resource.construct_from(id: "obj_1", amount: "42") + assert_equal 42, obj.amount + assert_instance_of Integer, obj.amount + end + + should "handle large int64 values" do + obj = Int64Resource.construct_from(id: "obj_2", amount: "9223372036854775807") + assert_equal 9_223_372_036_854_775_807, obj.amount + end + + should "preserve negative values" do + obj = Int64Resource.construct_from(id: "obj_3", amount: "-100") + assert_equal(-100, obj.amount) + end + + should "preserve zero" do + obj = Int64Resource.construct_from(id: "obj_4", amount: "0") + assert_equal 0, obj.amount + assert_instance_of Integer, obj.amount + end + + should "pass through nil without error" do + obj = Int64Resource.construct_from(id: "obj_5", amount: nil) + assert_nil obj.amount + end + + should "leave non-encoded fields unaffected" do + obj = Int64Resource.construct_from(id: "obj_6", amount: "10", name: "test") + assert_equal "test", obj.name + end + end + + context "nested resource coercion (each layer applies its own field_encodings)" do + should "coerce both parent and nested inner-class fields" do + obj = ParentResource.construct_from( + id: "parent_1", + total: "500", + detail: { quantity: "25", label: "items" } + ) + + assert_equal 500, obj.total + assert_instance_of Integer, obj.total + + assert_equal 25, obj.detail.quantity + assert_instance_of Integer, obj.detail.quantity + + assert_equal "items", obj.detail.label + end + + should "handle nil nested object" do + obj = ParentResource.construct_from(id: "parent_2", total: "100", detail: nil) + assert_equal 100, obj.total + assert_nil obj.detail + end + end + end +end diff --git a/test/stripe/v2_type_coercion_test.rb b/test/stripe/v2_type_coercion_test.rb new file mode 100644 index 000000000..47fedb09f --- /dev/null +++ b/test/stripe/v2_type_coercion_test.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require File.expand_path("../test_helper", __dir__) +require "bigdecimal" + +module Stripe + class V2TypeCoercionTest < Test::Unit::TestCase + context "encode direction (request: native → wire)" do + context "int64_string" do + should "convert Integer to String" do + assert_equal "42", V2TypeCoercion.coerce_value(42, :int64_string, direction: :encode) + assert_equal "0", V2TypeCoercion.coerce_value(0, :int64_string, direction: :encode) + assert_equal "-7", V2TypeCoercion.coerce_value(-7, :int64_string, direction: :encode) + end + + should "pass through String" do + assert_equal "42", V2TypeCoercion.coerce_value("42", :int64_string, direction: :encode) + end + + should "convert Array of Integers" do + assert_equal %w[1 2 3], V2TypeCoercion.coerce_value([1, 2, 3], :int64_string, direction: :encode) + end + + should "handle mixed Array" do + assert_equal %w[1 already 3], V2TypeCoercion.coerce_value([1, "already", 3], :int64_string, direction: :encode) + end + end + + context "decimal_string" do + should "convert BigDecimal to String" do + assert_equal "99.999", V2TypeCoercion.coerce_value(BigDecimal("99.999"), :decimal_string, direction: :encode) + assert_equal "0.0", V2TypeCoercion.coerce_value(BigDecimal("0"), :decimal_string, direction: :encode) + end + + should "convert Integer to String" do + assert_equal "42", V2TypeCoercion.coerce_value(42, :decimal_string, direction: :encode) + end + + should "convert Float to String" do + assert_equal "3.14", V2TypeCoercion.coerce_value(3.14, :decimal_string, direction: :encode) + end + + should "pass through String" do + assert_equal "99.5", V2TypeCoercion.coerce_value("99.5", :decimal_string, direction: :encode) + end + + should "convert Array of BigDecimal" do + result = V2TypeCoercion.coerce_value( + [BigDecimal("1.1"), BigDecimal("2.2")], + :decimal_string, + direction: :encode + ) + assert_equal %w[1.1 2.2], result + end + end + end + + context "decode direction (response: wire → native)" do + context "int64_string" do + should "convert String to Integer" do + assert_equal 42, V2TypeCoercion.coerce_value("42", :int64_string, direction: :decode) + assert_equal 0, V2TypeCoercion.coerce_value("0", :int64_string, direction: :decode) + assert_equal(-7, V2TypeCoercion.coerce_value("-7", :int64_string, direction: :decode)) + end + + should "pass through Integer" do + assert_equal 42, V2TypeCoercion.coerce_value(42, :int64_string, direction: :decode) + end + + should "convert Array of Strings" do + assert_equal [1, 2, 3], V2TypeCoercion.coerce_value(%w[1 2 3], :int64_string, direction: :decode) + end + + should "handle mixed Array" do + assert_equal [1, 2, 3], V2TypeCoercion.coerce_value(["1", 2, "3"], :int64_string, direction: :decode) + end + end + + context "decimal_string" do + should "convert String to BigDecimal" do + assert_equal BigDecimal("99.999"), V2TypeCoercion.coerce_value("99.999", :decimal_string, direction: :decode) + assert_equal BigDecimal("0"), V2TypeCoercion.coerce_value("0", :decimal_string, direction: :decode) + assert_equal BigDecimal("-1.5"), V2TypeCoercion.coerce_value("-1.5", :decimal_string, direction: :decode) + end + + should "pass through BigDecimal" do + val = BigDecimal("3.14") + assert_equal val, V2TypeCoercion.coerce_value(val, :decimal_string, direction: :decode) + end + + should "convert Array of Strings" do + result = V2TypeCoercion.coerce_value(%w[1.1 2.2], :decimal_string, direction: :decode) + assert_equal [BigDecimal("1.1"), BigDecimal("2.2")], result + end + end + end + + context "nil handling" do + should "return nil as-is regardless of encoding" do + assert_nil V2TypeCoercion.coerce_value(nil, :int64_string, direction: :encode) + assert_nil V2TypeCoercion.coerce_value(nil, :int64_string, direction: :decode) + assert_nil V2TypeCoercion.coerce_value(nil, :decimal_string, direction: :encode) + assert_nil V2TypeCoercion.coerce_value(nil, { kind: :object, fields: {} }, direction: :encode) + end + end + + context "unknown encoding" do + should "return value as-is" do + assert_equal 42, V2TypeCoercion.coerce_value(42, :unknown_thing, direction: :encode) + assert_equal "hi", V2TypeCoercion.coerce_value("hi", :unknown_thing, direction: :decode) + end + end + + context "composite: object" do + should "coerce fields recursively (encode)" do + encoding = { kind: :object, fields: { amount: :int64_string } } + input = { amount: 100, name: "test" } + expected = { amount: "100", name: "test" } + assert_equal expected, V2TypeCoercion.coerce_value(input, encoding, direction: :encode) + end + + should "coerce fields recursively (decode)" do + encoding = { kind: :object, fields: { amount: :int64_string } } + input = { amount: "100", name: "test" } + expected = { amount: 100, name: "test" } + assert_equal expected, V2TypeCoercion.coerce_value(input, encoding, direction: :decode) + end + + should "return non-Hash as-is" do + encoding = { kind: :object, fields: { amount: :int64_string } } + assert_equal "not a hash", V2TypeCoercion.coerce_value("not a hash", encoding, direction: :encode) + end + + should "handle string keys" do + encoding = { kind: :object, fields: { amount: :int64_string } } + input = { "amount" => 100, "name" => "test" } + expected = { "amount" => "100", "name" => "test" } + assert_equal expected, V2TypeCoercion.coerce_value(input, encoding, direction: :encode) + end + end + + context "composite: array" do + should "coerce each element (encode)" do + encoding = { kind: :array, element: :int64_string } + assert_equal %w[1 2 3], V2TypeCoercion.coerce_value([1, 2, 3], encoding, direction: :encode) + end + + should "coerce each element (decode)" do + encoding = { kind: :array, element: :int64_string } + assert_equal [1, 2, 3], V2TypeCoercion.coerce_value(%w[1 2 3], encoding, direction: :decode) + end + + should "coerce array of objects" do + encoding = { kind: :array, element: { kind: :object, fields: { id: :int64_string } } } + input = [{ id: 1, name: "a" }, { id: 2, name: "b" }] + expected = [{ id: "1", name: "a" }, { id: "2", name: "b" }] + assert_equal expected, V2TypeCoercion.coerce_value(input, encoding, direction: :encode) + end + + should "return non-Array as-is" do + encoding = { kind: :array, element: :int64_string } + assert_equal "not an array", V2TypeCoercion.coerce_value("not an array", encoding, direction: :encode) + end + end + + context "coerce_fields" do + should "coerce matching fields and pass through others" do + schema = { amount: :int64_string, price: :decimal_string } + input = { amount: 100, price: BigDecimal("9.99"), name: "test" } + expected = { amount: "100", price: "9.99", name: "test" } + assert_equal expected, V2TypeCoercion.coerce_fields(input, schema, direction: :encode) + end + + should "return input when schema is nil or empty" do + input = { amount: 100 } + assert_equal input, V2TypeCoercion.coerce_fields(input, nil, direction: :encode) + assert_equal input, V2TypeCoercion.coerce_fields(input, {}, direction: :encode) + end + + should "return non-Hash as-is" do + assert_equal "not a hash", V2TypeCoercion.coerce_fields("not a hash", { x: :int64_string }, direction: :encode) + end + end + end +end