stripe-ruby/test/stripe/stripe_object_test.rb
Brandur Leach b9c7afd5fe
Reserve some critical field names when adding StripeObject accessors (#970)
When populating `StripeObject`s, we add accessors to them so that people
can access fields like `obj.currency`.

This was probably only meant to apply to API resources, but through
what might have been an accident of history, we've also traditionally
unmarshaled any hash that comes back from the API as a `StripeObject`,
including `metadata` fields. This allows some convenience because users
can access values like `obj.metadata.my_field`, but is also obviously a
minefield for potential problems.

In issue #969, what's essentially happening is that because there's a
metadata field named `class`, we've overwritten the object's normal
`class` method with our own custom one that accesses the metadata value.
Amazingly, the object can still marshal/unmarshal mostly properly, but
fails on this line as we try to access `obj.class` and that turns out to
be a metadata value instead of a class:

``` ruby
when StripeObject
  obj.class.construct_from(
    ...
```

Here I solve the problem by banning accessors added with the name
`class`. This has a slight risk of backward incompatibility in that
users that previously had metadata named "class" will now have to use
square bracket accessors instead like `obj.metadata[:class]`, but
honestly, I just can't see anything good in allowing "class" to be used
as an accessor.

An alternative solution might be to alias `class` in `StripeObject` and
then make sure we always use that in places like `initialize_from` and
`deep_copy`.

The best long term solution would be to stop add accessors to metadata
objects. This just seems like a bad idea given that there are still
myriads of Ruby built-ins that could potentially be overwritten. This is
definitely a considerably-sized breaking change though, so we'd have to
do it on a major.
2021-04-02 11:08:56 -07:00

511 lines
19 KiB
Ruby

# frozen_string_literal: true
require ::File.expand_path("../test_helper", __dir__)
module Stripe
class StripeObjectTest < Test::Unit::TestCase
should "implement #==" do
obj1 = Stripe::StripeObject.construct_from(id: 1, foo: "bar")
obj2 = Stripe::StripeObject.construct_from(id: 1, foo: "bar")
obj3 = Stripe::StripeObject.construct_from(id: 1, foo: "rab")
assert obj1 == obj2
refute obj1 == obj3
end
should "implement #deleted?" do
obj = Stripe::StripeObject.construct_from({})
refute obj.deleted?
obj = Stripe::StripeObject.construct_from(deleted: false)
refute obj.deleted?
obj = Stripe::StripeObject.construct_from(deleted: true)
assert obj.deleted?
end
should "implement #respond_to" do
obj = Stripe::StripeObject.construct_from(id: 1, foo: "bar")
assert obj.respond_to?(:id)
assert obj.respond_to?(:foo)
assert !obj.respond_to?(:baz)
end
should "marshal be insensitive to strings vs. symbols when constructin" do
obj = Stripe::StripeObject.construct_from(:id => 1, "name" => "Stripe")
assert_equal 1, obj[:id]
assert_equal "Stripe", obj[:name]
end
context "#deep_copy" do
should "produce a deep copy" do
opts = {
api_base: Stripe.api_base,
api_key: "apikey",
}
values = {
id: 1,
name: "Stripe",
arr: [
StripeObject.construct_from({ id: "index0" }, opts),
"index1",
2,
],
map: {
"0": StripeObject.construct_from({ id: "index0" }, opts),
"1": "index1",
"2": 2,
},
}
# it's not good to test methods with `#send` like this, but I've done
# it in the interest of trying to keep `.deep_copy` as internal as
# possible
copy_values = Stripe::StripeObject.send(:deep_copy, values)
# we can't compare the hashes directly because they have embedded
# objects which are different from each other
assert_equal values[:id], copy_values[:id]
assert_equal values[:name], copy_values[:name]
assert_equal values[:arr].length, copy_values[:arr].length
# internal values of the copied StripeObject should be the same
# (including opts), but the object itself should be new (hence the
# refutation of equality on #object_id)
assert_equal values[:arr][0][:id], copy_values[:arr][0][:id]
refute_equal values[:arr][0].object_id, copy_values[:arr][0].object_id
assert_equal values[:arr][0].instance_variable_get(:@opts),
copy_values[:arr][0].instance_variable_get(:@opts)
# scalars however, can be compared
assert_equal values[:arr][1], copy_values[:arr][1]
assert_equal values[:arr][2], copy_values[:arr][2]
# and a similar story with the hash
assert_equal values[:map].keys, copy_values[:map].keys
assert_equal values[:map][:"0"][:id], copy_values[:map][:"0"][:id]
refute_equal values[:map][:"0"].object_id, copy_values[:map][:"0"].object_id
assert_equal values[:map][:"0"].instance_variable_get(:@opts),
copy_values[:map][:"0"].instance_variable_get(:@opts)
assert_equal values[:map][:"1"], copy_values[:map][:"1"]
assert_equal values[:map][:"2"], copy_values[:map][:"2"]
end
should "not copy a client" do
opts = {
api_key: "apikey",
client: StripeClient.active_client,
}
values = { id: 1, name: "Stripe" }
obj = Stripe::StripeObject.construct_from(values, opts)
copy_obj = Stripe::StripeObject.send(:deep_copy, obj)
assert_equal values, copy_obj.instance_variable_get(:@values)
assert_equal opts.reject { |k, _v| k == :client },
copy_obj.instance_variable_get(:@opts)
end
should "return an instance of the same class" do
class TestObject < Stripe::StripeObject; end
obj = TestObject.construct_from(id: 1)
copy_obj = obj.class.send(:deep_copy, obj)
assert_equal obj.class, copy_obj.class
end
end
context "#eql?" do
should "produce true for two equivalent Stripe objects" do
obj1 = Stripe::StripeObject.construct_from(id: 1, name: "Stripe")
obj2 = Stripe::StripeObject.construct_from(id: 1, name: "Stripe")
assert obj1.eql?(obj2)
end
should "produce false for non-equivalent Stripe objects" do
obj1 = Stripe::StripeObject.construct_from(id: 1, name: "Stripe")
obj2 = Stripe::StripeObject.construct_from(id: 2, name: "Stripe")
refute obj1.eql?(obj2)
end
should "produce false for different types" do
obj1 = Stripe::StripeObject.construct_from(id: 1, name: "Stripe")
obj2 = 7
refute obj1.eql?(obj2)
end
end
context "#hash" do
should "produce the same hash for two equivalent Stripe objects" do
obj1 = Stripe::StripeObject.construct_from(id: 1, name: "Stripe")
obj2 = Stripe::StripeObject.construct_from(id: 1, name: "Stripe")
assert_equal obj1.hash, obj2.hash
end
should "produce different hashes for non-equivalent Stripe objects" do
obj1 = Stripe::StripeObject.construct_from(id: 1, name: "Stripe")
obj2 = Stripe::StripeObject.construct_from(id: 2, name: "Stripe")
refute_equal obj1.hash, obj2.hash
end
should "produce different hashes for different types" do
obj1 = Stripe::StripeObject.construct_from(id: 1, name: "Stripe")
obj2 = 7
refute_equal obj1.hash, obj2.hash
end
end
context "#to_hash" do
should "skip calling to_hash on nil" do
begin
module NilWithToHash
def to_hash
raise "Can't call to_hash on nil"
end
end
::NilClass.include NilWithToHash
hash_with_nil = { id: 3, foo: nil }
obj = StripeObject.construct_from(hash_with_nil)
expected_hash = { id: 3, foo: nil }
assert_equal expected_hash, obj.to_hash
ensure
::NilClass.send(:undef_method, :to_hash)
end
end
should "recursively call to_hash on its values" do
# deep nested hash (when contained in an array) or StripeObject
nested_hash = { id: 7, foo: "bar" }
nested = Stripe::StripeObject.construct_from(nested_hash)
obj = Stripe::StripeObject.construct_from(id: 1,
# simple hash that contains a StripeObject to help us test deep
# recursion
nested: { object: "list", data: [nested] },
list: [nested])
expected_hash = {
id: 1,
nested: { object: "list", data: [nested_hash] },
list: [nested_hash],
}
assert_equal expected_hash, obj.to_hash
end
end
should "assign question mark accessors for booleans" do
obj = Stripe::StripeObject.construct_from(id: 1, bool: true, not_bool: "bar")
assert obj.respond_to?(:bool?)
assert obj.bool?
refute obj.respond_to?(:not_bool?)
end
should "assign question mark accessors for booleans added after initialization" do
obj = Stripe::StripeObject.new
obj.bool = true
assert obj.respond_to?(:bool?)
assert obj.bool?
end
should "mass assign values with #update_attributes" do
obj = Stripe::StripeObject.construct_from(id: 1, name: "Stripe")
obj.update_attributes({ name: "STRIPE" })
assert_equal "STRIPE", obj.name
# unfortunately, we even assign unknown properties to duplicate the
# behavior that we currently have via magic accessors with
# method_missing
obj.update_attributes({ unknown: "foo" })
assert_equal "foo", obj.unknown
end
should "#update_attributes with a hash" do
obj = Stripe::StripeObject.construct_from({})
obj.update_attributes({ metadata: { foo: "bar" } })
assert_equal Stripe::StripeObject, obj.metadata.class
end
should "create accessors when #update_attributes is called" do
obj = Stripe::StripeObject.construct_from({})
assert_equal false, obj.send(:metaclass).method_defined?(:foo)
obj.update_attributes({ foo: "bar" })
assert_equal true, obj.send(:metaclass).method_defined?(:foo)
end
should "pass opts down to children when initializing" do
opts = { custom: "opts" }
# customer comes with a `sources` list that makes a convenient object to
# perform tests on
obj = Stripe::StripeObject.construct_from({
sources: [
{},
],
}, opts)
source = obj.sources.first
# Pulling `@opts` as an instance variable here is not ideal, but it's
# important enough argument that the test here is worth it. we should
# consider exposing it publicly on a future pull (and possibly renaming
# it to something more useful).
assert_equal opts, source.instance_variable_get(:@opts)
end
should "#serialize_params on an empty object" do
obj = Stripe::StripeObject.construct_from({})
assert_equal({}, obj.serialize_params)
end
should "#serialize_params on a new object with a subobject" do
obj = Stripe::StripeObject.new
obj.metadata = { foo: "bar" }
assert_equal({ metadata: { foo: "bar" } },
obj.serialize_params)
end
should "#serialize_params on a basic object" do
obj = Stripe::StripeObject.construct_from(foo: nil)
obj.update_attributes({ foo: "bar" })
assert_equal({ foo: "bar" }, obj.serialize_params)
end
should "#serialize_params on a more complex object" do
obj = Stripe::StripeObject.construct_from(foo: Stripe::StripeObject.construct_from(bar: nil,
baz: nil))
obj.foo.bar = "newbar"
assert_equal({ foo: { bar: "newbar" } },
obj.serialize_params)
end
should "#serialize_params on an array" do
obj = Stripe::StripeObject.construct_from(foo: nil)
obj.foo = ["new-value"]
assert_equal({ foo: ["new-value"] },
obj.serialize_params)
end
should "#serialize_params on an array that shortens" do
obj = Stripe::StripeObject.construct_from(foo: %w[0-index 1-index 2-index])
obj.foo = ["new-value"]
assert_equal({ foo: ["new-value"] },
obj.serialize_params)
end
should "#serialize_params on an array that lengthens" do
obj = Stripe::StripeObject.construct_from(foo: %w[0-index 1-index 2-index])
obj.foo = ["new-value"] * 4
assert_equal({ foo: ["new-value"] * 4 },
obj.serialize_params)
end
should "#serialize_params on an array of hashes" do
obj = Stripe::StripeObject.construct_from(foo: nil)
obj.foo = [
Stripe::StripeObject.construct_from(bar: nil),
]
obj.foo[0].bar = "baz"
assert_equal({ foo: [{ bar: "baz" }] },
obj.serialize_params)
end
should "#serialize_params doesn't include unchanged values" do
obj = Stripe::StripeObject.construct_from(foo: nil)
assert_equal({}, obj.serialize_params)
end
should "#serialize_params on an array that is unchanged" do
obj = Stripe::StripeObject.construct_from(foo: %w[0-index 1-index 2-index])
obj.foo = %w[0-index 1-index 2-index]
assert_equal({}, obj.serialize_params)
end
should "#serialize_params with a StripeObject" do
obj = Stripe::StripeObject.construct_from({})
# using an #update_attributes will end up converting a Hash into a
# StripeObject
obj.metadata =
Stripe::StripeObject.construct_from(foo: "bar")
serialized = obj.serialize_params
assert_equal({ foo: "bar" }, serialized[:metadata])
end
should "#serialize_params with StripeObject that's been replaced" do
obj = Stripe::StripeObject.construct_from(source: Stripe::StripeObject.construct_from(bar: "foo"))
# Here we replace the object wholesale.
obj.source =
Stripe::StripeObject.construct_from(baz: "foo")
serialized = obj.serialize_params
assert_equal({ baz: "foo" }, serialized[:source])
end
should "#serialize_params with StripeObject that's been replaced which is `metadata`" do
class WithAdditiveObjectParam < Stripe::StripeObject
additive_object_param :metadata
end
obj = WithAdditiveObjectParam.construct_from(metadata: Stripe::StripeObject.construct_from(bar: "foo"))
# Here we replace the object wholesale. Because it's `metadata`, the
# client must be able to blank out the values that were in the old
# object, but which are no longer present in the new one.
obj.metadata =
Stripe::StripeObject.construct_from(baz: "foo")
serialized = obj.serialize_params
assert_equal({ bar: "", baz: "foo" }, serialized[:metadata])
end
should "#serialize_params with an array of StripeObjects" do
obj = Stripe::StripeObject.construct_from({})
obj.metadata = [
Stripe::StripeObject.construct_from(foo: "bar"),
]
serialized = obj.serialize_params
assert_equal([{ foo: "bar" }], serialized[:metadata])
end
should "#serialize_params and embed an API resource that's been set and has an ID" do
customer = Customer.construct_from(id: "cus_123")
obj = Stripe::StripeObject.construct_from({})
# the key here is that the property is set explicitly (and therefore
# marked as unsaved), which is why it gets included below
obj.customer = customer
serialized = obj.serialize_params
assert_equal({ customer: customer }, serialized)
end
should "#serialize_params and not include API resources that have not been set" do
customer = Customer.construct_from(id: "cus_123")
obj = Stripe::StripeObject.construct_from(customer: customer)
serialized = obj.serialize_params
assert_equal({}, serialized)
end
should "#serialize_params serializes API resources flagged with save_with_parent" do
c = Customer.construct_from({})
c.save_with_parent = true
obj = Stripe::StripeObject.construct_from(customer: c)
serialized = obj.serialize_params
assert_equal({ customer: {} }, serialized)
end
should "#serialize_params should raise an error on other embedded API resources" do
# This customer doesn't have an ID and therefore the library doesn't know
# what to do with it and throws an ArgumentError because it's probably
# not what the user expected to happen.
customer = Customer.construct_from({})
obj = Stripe::StripeObject.construct_from({})
obj.customer = customer
e = assert_raises(ArgumentError) do
obj.serialize_params
end
assert_equal "Cannot save property `customer` containing " \
"an API resource. It doesn't appear to be persisted and is " \
"not marked as `save_with_parent`.", e.message
end
should "#serialize_params takes a force option" do
obj = Stripe::StripeObject.construct_from(id: "id",
metadata: Stripe::StripeObject.construct_from(foo: "bar"))
serialized = obj.serialize_params(force: true)
assert_equal({ id: "id", metadata: { foo: "bar" } }, serialized)
end
should "#dirty! forces an object and its subobjects to be saved" do
obj = Stripe::StripeObject.construct_from(id: "id",
metadata: Stripe::StripeObject.construct_from(foo: "bar"))
# note that `force` and `dirty!` are for different things, but are
# functionally equivalent
obj.dirty!
serialized = obj.serialize_params
assert_equal({ id: "id", metadata: { foo: "bar" } }, serialized)
end
should "#to_s will call to_s for all embedded stripe objects" do
obj = Stripe::StripeObject.construct_from(id: "id",
# embedded list object
refunds: Stripe::ListObject.construct_from(data: [
# embedded object in list
Stripe::StripeObject.construct_from(id: "id",
# embedded object in an object in a list object
metadata: Stripe::StripeObject.construct_from(foo: "bar")),
]),
# embedded stripe object
metadata: Stripe::StripeObject.construct_from(foo: "bar"))
expected = JSON.pretty_generate(id: "id",
refunds: {
data: [
{ id: "id", metadata: { foo: "bar" } },
],
},
metadata: { foo: "bar" })
assert_equal(expected, obj.to_s)
end
should "error on setting a property to an empty string" do
obj = Stripe::StripeObject.construct_from(foo: "bar")
e = assert_raises ArgumentError do
obj.foo = ""
end
assert_match(/\(object\).foo = nil/, e.message)
end
should "marshal and unmarshal using custom encoder and decoder" do
obj = Stripe::StripeObject.construct_from(
{ id: 1, name: "Stripe" },
api_key: "apikey",
client: StripeClient.active_client
)
m = Marshal.load(Marshal.dump(obj))
assert_equal 1, m.id
assert_equal "Stripe", m.name
expected_hash = { api_key: "apikey" }
assert_equal expected_hash, m.instance_variable_get("@opts")
end
context "#method" do
should "act as a getter if no arguments are provided" do
obj = Stripe::StripeObject.construct_from(id: 1, method: "foo")
assert_equal "foo", obj.method
end
should "call Object#method if an argument is provided" do
obj = Stripe::StripeObject.construct_from(id: 1, method: "foo")
assert obj.method(:id).is_a?(Method)
end
end
should "ignore properties that are reserved names" do
obj = Stripe::StripeObject.construct_from(metadata: { class: "something" })
# See comment on `StripeObject::RESERVED_FIELD_NAMES`
assert_equal Stripe::StripeObject, obj.metadata.class
# Value still accessible with hash syntax
assert_equal "something", obj.metadata[:class]
end
end
end