diff --git a/test/test_multipart.rb b/test/test_multipart.rb new file mode 100644 index 0000000..636541e --- /dev/null +++ b/test/test_multipart.rb @@ -0,0 +1,82 @@ +require 'minitest/autorun' +require 'prism' + +class MultipartTest < Minitest::Test + BOUNDARY = '----PrismTestBoundary7331' + + def build_body(fields: {}, files: {}) + parts = fields.map do |name, value| + "--#{BOUNDARY}\r\nContent-Disposition: form-data; name=\"#{name}\"\r\n\r\n#{value}\r\n" + end + parts += files.map do |name, spec| + filename, content_type, content = spec + "--#{BOUNDARY}\r\nContent-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n" \ + "Content-Type: #{content_type}\r\n\r\n#{content}\r\n" + end + parts.join + "--#{BOUNDARY}--\r\n" + end + + def test_multipart_detects_content_type + assert Prism::Multipart.multipart?('multipart/form-data; boundary=abc') + refute Prism::Multipart.multipart?('application/json') + refute Prism::Multipart.multipart?(nil) + end + + def test_boundary_from_quoted_and_bare + assert_equal 'abc123', Prism::Multipart.boundary_from('multipart/form-data; boundary="abc123"') + assert_equal 'abc123', Prism::Multipart.boundary_from('multipart/form-data; boundary=abc123') + assert_nil Prism::Multipart.boundary_from('multipart/form-data') + end + + def test_parses_plain_fields + body = build_body(fields: { 'title' => 'hello world', 'count' => '3' }) + result = Prism::Multipart.parse(body, BOUNDARY) + assert_equal 'hello world', result['title'] + assert_equal '3', result['count'] + end + + def test_parses_uploaded_file + body = build_body( + fields: { 'title' => 'my post' }, + files: { 'avatar' => ['avatar.png', 'image/png', "\x89PNG\r\ndata\x00"] } + ) + result = Prism::Multipart.parse(body, BOUNDARY) + assert_equal 'my post', result['title'] + + file = result['avatar'] + assert_instance_of Prism::UploadedFile, file + assert_equal 'avatar.png', file.original_filename + assert_equal 'image/png', file.content_type + assert_equal "\x89PNG\r\ndata\x00".b, file.read + assert_equal file.size, File.size(file.path) + end + + def test_binary_content_is_preserved_byte_for_byte + binary = (0..255).map(&:chr).join.b + body = build_body(files: { 'blob' => ['blob.bin', 'application/octet-stream', binary] }) + result = Prism::Multipart.parse(body, BOUNDARY) + assert_equal binary, result['blob'].read + end + + def test_array_field_names_collect_into_arrays + parts = [ + "--#{BOUNDARY}\r\nContent-Disposition: form-data; name=\"tags[]\"\r\n\r\nruby\r\n", + "--#{BOUNDARY}\r\nContent-Disposition: form-data; name=\"tags[]\"\r\n\r\nweb\r\n", + "--#{BOUNDARY}--\r\n" + ] + result = Prism::Multipart.parse(parts.join, BOUNDARY) + assert_equal %w[ruby web], result['tags'] + end + + def test_empty_boundary_returns_empty_hash + assert_equal({}, Prism::Multipart.parse('anything', nil)) + assert_equal({}, Prism::Multipart.parse('anything', '')) + end + + def test_ignores_preamble_and_epilogue + body = "this is a preamble that must be ignored\r\n" + build_body(fields: { 'a' => '1' }) + "trailing epilogue" + result = Prism::Multipart.parse(body, BOUNDARY) + assert_equal '1', result['a'] + assert_equal 1, result.size + end +end diff --git a/test/test_orm_attributes.rb b/test/test_orm_attributes.rb new file mode 100644 index 0000000..0773374 --- /dev/null +++ b/test/test_orm_attributes.rb @@ -0,0 +1,123 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'json' + +class OrmAttributesTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-attributes-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:widgets, %w[id title featured views price published_at]) + widget_class = Class.new(Prism::Orm::Model) do + table_name :widgets + attribute :featured, :boolean, default: false + attribute :views, :integer + attribute :price, :float + attribute :published_at, :datetime + end + widget_class.store = @store + Object.const_set(:Widget, widget_class) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :Widget) if Object.const_defined?(:Widget) + Object.send(:remove_const, :Gadget) if Object.const_defined?(:Gadget) + Object.send(:remove_const, :SpecialGadget) if Object.const_defined?(:SpecialGadget) + end + + def test_string_form_input_casts_to_the_declared_type_on_write + widget = Widget.new(featured: '1', views: '42', price: '9.5') + assert_equal true, widget.featured + assert_equal 42, widget.views + assert_in_delta 9.5, widget.price + end + + def test_cast_value_is_what_actually_gets_stored_not_the_raw_string + widget = Widget.create!(title: 'a', featured: '1', views: '42') + assert_equal true, widget.to_h['featured'] + assert_equal 42, widget.to_h['views'] + end + + def test_default_is_applied_when_the_attribute_is_omitted + assert_equal false, Widget.new(title: 'a').featured + end + + def test_an_explicit_value_wins_over_the_default + assert_equal true, Widget.new(title: 'a', featured: 'yes').featured + end + + def test_default_survives_a_real_save_and_reload + widget = Widget.create!(title: 'a') + reloaded = Widget.find(widget.id) + assert_equal false, reloaded.featured + end + + def test_proc_default_is_evaluated_per_instance + Object.const_set(:Gadget, Class.new(Prism::Orm::Model) do + table_name :widgets + attribute :views, :integer, default: -> { 7 } + end) + Gadget.store = @store + assert_equal 7, Gadget.new(title: 'a').views + assert_equal 7, Gadget.new(title: 'b').views + end + + def test_reloading_a_saved_record_returns_real_typed_values_not_strings + widget = Widget.create!(title: 'a', featured: '1', views: '42', price: '9.5') + reloaded = Widget.find(widget.id) + assert_equal true, reloaded.featured + assert_kind_of Integer, reloaded.views + assert_equal 42, reloaded.views + assert_kind_of Float, reloaded.price + end + + def test_getter_defensively_casts_a_legacy_raw_value_never_written_through_the_setter + id = @store.insert(:widgets, 'title' => 'legacy', 'featured' => '1', 'views' => '3') + legacy = Widget.find(id) + assert_equal true, legacy.featured + assert_equal 3, legacy.views + end + + def test_datetime_attribute_parses_an_iso8601_string + widget = Widget.new(title: 'a', published_at: '2026-02-01T12:00:00Z') + assert_kind_of Time, widget.published_at + assert_equal 2026, widget.published_at.year + end + + def test_invalid_input_raises_invalid_cast_instead_of_saving_garbage + assert_raises(Prism::Orm::TypeCasting::InvalidCast) { Widget.new(title: 'a', views: 'not-a-number') } + end + + def test_serializable_hash_reflects_the_cast_type_not_the_raw_string + widget = Widget.create!(title: 'a', featured: '1', views: '42') + payload = JSON.parse(widget.to_json) + assert_equal true, payload['featured'] + assert_equal 42, payload['views'] + end + + # Same regression shape as Callbacks#callbacks_for / Controller#before_actions + # (documented in this project's history as a bug found three separate + # times: a class-level registry stored as a plain `@ivar ||= {}` per + # class, with no superclass fallback, silently drops whatever a base + # class declared as soon as a subclass declares its own). attribute_types/ + # attribute_defaults use the same superclass-plus-own merge, so this + # proves a subclass's own `attribute` call does not erase its parent's. + def test_subclass_inherits_the_parent_classes_declared_attributes + Object.const_set(:Gadget, Class.new(Prism::Orm::Model) do + table_name :widgets + attribute :featured, :boolean, default: false + end) + Object.const_set(:SpecialGadget, Class.new(Gadget) do + table_name :widgets + attribute :views, :integer, default: 9 + end) + SpecialGadget.store = @store + + assert_equal %i[boolean integer].sort_by(&:to_s), [SpecialGadget.attribute_types['featured'], + SpecialGadget.attribute_types['views']].sort_by(&:to_s) + special = SpecialGadget.new(title: 'a') + assert_equal false, special.featured, 'inherited attribute default was dropped by the subclass' + assert_equal 9, special.views + end +end