diff options
Diffstat (limited to 'activerecord/test/cases/validations')
7 files changed, 1087 insertions, 0 deletions
diff --git a/activerecord/test/cases/validations/absence_validation_test.rb b/activerecord/test/cases/validations/absence_validation_test.rb new file mode 100644 index 0000000000..8235a54d8a --- /dev/null +++ b/activerecord/test/cases/validations/absence_validation_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/face" +require "models/interest" +require "models/man" +require "models/topic" + +class AbsenceValidationTest < ActiveRecord::TestCase + def test_non_association + boy_klass = Class.new(Man) do + def self.name; "Boy" end + validates_absence_of :name + end + + assert_predicate boy_klass.new, :valid? + assert_not_predicate boy_klass.new(name: "Alex"), :valid? + end + + def test_has_one_marked_for_destruction + boy_klass = Class.new(Man) do + def self.name; "Boy" end + validates_absence_of :face + end + + boy = boy_klass.new(face: Face.new) + assert_not boy.valid?, "should not be valid if has_one association is present" + assert_equal 1, boy.errors[:face].size, "should only add one error" + + boy.face.mark_for_destruction + assert boy.valid?, "should be valid if association is marked for destruction" + end + + def test_has_many_marked_for_destruction + boy_klass = Class.new(Man) do + def self.name; "Boy" end + validates_absence_of :interests + end + boy = boy_klass.new + boy.interests << [i1 = Interest.new, i2 = Interest.new] + assert_not boy.valid?, "should not be valid if has_many association is present" + + i1.mark_for_destruction + assert_not boy.valid?, "should not be valid if has_many association is present" + + i2.mark_for_destruction + assert_predicate boy, :valid? + end + + def test_does_not_call_to_a_on_associations + boy_klass = Class.new(Man) do + def self.name; "Boy" end + validates_absence_of :face + end + + face_with_to_a = Face.new + def face_with_to_a.to_a; ["(/)", '(\)']; end + + assert_nothing_raised { boy_klass.new(face: face_with_to_a).valid? } + end + + def test_validates_absence_of_virtual_attribute_on_model + repair_validations(Interest) do + Interest.send(:attr_accessor, :token) + Interest.validates_absence_of(:token) + + interest = Interest.create!(topic: "Thought Leadering") + assert_predicate interest, :valid? + + interest.token = "tl" + + assert_predicate interest, :invalid? + end + end +end diff --git a/activerecord/test/cases/validations/association_validation_test.rb b/activerecord/test/cases/validations/association_validation_test.rb new file mode 100644 index 0000000000..ce6d42b34b --- /dev/null +++ b/activerecord/test/cases/validations/association_validation_test.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" +require "models/reply" +require "models/man" +require "models/interest" + +class AssociationValidationTest < ActiveRecord::TestCase + fixtures :topics + + repair_validations(Topic, Reply) + + def test_validates_associated_many + Topic.validates_associated(:replies) + Reply.validates_presence_of(:content) + t = Topic.create("title" => "uhohuhoh", "content" => "whatever") + t.replies << [r = Reply.new("title" => "A reply"), r2 = Reply.new("title" => "Another reply", "content" => "non-empty"), r3 = Reply.new("title" => "Yet another reply"), r4 = Reply.new("title" => "The last reply", "content" => "non-empty")] + assert_not_predicate t, :valid? + assert_predicate t.errors[:replies], :any? + assert_equal 1, r.errors.count # make sure all associated objects have been validated + assert_equal 0, r2.errors.count + assert_equal 1, r3.errors.count + assert_equal 0, r4.errors.count + r.content = r3.content = "non-empty" + assert_predicate t, :valid? + end + + def test_validates_associated_one + Reply.validates :topic, associated: true + Topic.validates_presence_of(:content) + r = Reply.new("title" => "A reply", "content" => "with content!") + r.topic = Topic.create("title" => "uhohuhoh") + assert_not_predicate r, :valid? + assert_predicate r.errors[:topic], :any? + r.topic.content = "non-empty" + assert_predicate r, :valid? + end + + def test_validates_associated_marked_for_destruction + Topic.validates_associated(:replies) + Reply.validates_presence_of(:content) + t = Topic.new + t.replies << Reply.new + assert_predicate t, :invalid? + t.replies.first.mark_for_destruction + assert_predicate t, :valid? + end + + def test_validates_associated_without_marked_for_destruction + reply = Class.new do + def valid? + true + end + end + Topic.validates_associated(:replies) + t = Topic.new + t.define_singleton_method(:replies) { [reply.new] } + assert_predicate t, :valid? + end + + def test_validates_associated_with_custom_message_using_quotes + Reply.validates_associated :topic, message: "This string contains 'single' and \"double\" quotes" + Topic.validates_presence_of :content + r = Reply.create("title" => "A reply", "content" => "with content!") + r.topic = Topic.create("title" => "uhohuhoh") + assert_not_operator r, :valid? + assert_equal ["This string contains 'single' and \"double\" quotes"], r.errors[:topic] + end + + def test_validates_associated_missing + Reply.validates_presence_of(:topic) + r = Reply.create("title" => "A reply", "content" => "with content!") + assert_not_predicate r, :valid? + assert_predicate r.errors[:topic], :any? + + r.topic = Topic.first + assert_predicate r, :valid? + end + + def test_validates_presence_of_belongs_to_association__parent_is_new_record + repair_validations(Interest) do + # Note that Interest and Man have the :inverse_of option set + Interest.validates_presence_of(:man) + man = Man.new(name: "John") + interest = man.interests.build(topic: "Airplanes") + assert interest.valid?, "Expected interest to be valid, but was not. Interest should have a man object associated" + end + end + + def test_validates_presence_of_belongs_to_association__existing_parent + repair_validations(Interest) do + Interest.validates_presence_of(:man) + man = Man.create!(name: "John") + interest = man.interests.build(topic: "Airplanes") + assert interest.valid?, "Expected interest to be valid, but was not. Interest should have a man object associated" + end + end +end diff --git a/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb b/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb new file mode 100644 index 0000000000..703c24b340 --- /dev/null +++ b/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" + +class I18nGenerateMessageValidationTest < ActiveRecord::TestCase + def setup + Topic.clear_validators! + @topic = Topic.new + I18n.backend = I18n::Backend::Simple.new + end + + def reset_i18n_load_path + @old_load_path, @old_backend = I18n.load_path.dup, I18n.backend + I18n.load_path.clear + I18n.backend = I18n::Backend::Simple.new + yield + ensure + I18n.load_path.replace @old_load_path + I18n.backend = @old_backend + end + + # validates_associated: generate_message(attr_name, :invalid, :message => custom_message, :value => value) + def test_generate_message_invalid_with_default_message + assert_equal "is invalid", @topic.errors.generate_message(:title, :invalid, value: "title") + end + + def test_generate_message_invalid_with_custom_message + assert_equal "custom message title", @topic.errors.generate_message(:title, :invalid, message: "custom message %{value}", value: "title") + end + + # validates_uniqueness_of: generate_message(attr_name, :taken, :message => custom_message) + def test_generate_message_taken_with_default_message + assert_equal "has already been taken", @topic.errors.generate_message(:title, :taken, value: "title") + end + + def test_generate_message_taken_with_custom_message + assert_equal "custom message title", @topic.errors.generate_message(:title, :taken, message: "custom message %{value}", value: "title") + end + + # ActiveRecord#RecordInvalid exception + + test "RecordInvalid exception can be localized" do + topic = Topic.new + topic.errors.add(:title, :invalid) + topic.errors.add(:title, :blank) + assert_equal "Validation failed: Title is invalid, Title can't be blank", ActiveRecord::RecordInvalid.new(topic).message + end + + test "RecordInvalid exception translation falls back to the :errors namespace" do + reset_i18n_load_path do + I18n.backend.store_translations "en", errors: { messages: { record_invalid: "fallback message" } } + topic = Topic.new + topic.errors.add(:title, :blank) + assert_equal "fallback message", ActiveRecord::RecordInvalid.new(topic).message + end + end + + test "translation for 'taken' can be overridden" do + reset_i18n_load_path do + I18n.backend.store_translations "en", errors: { attributes: { title: { taken: "Custom taken message" } } } + assert_equal "Custom taken message", @topic.errors.generate_message(:title, :taken, value: "title") + end + end + + test "translation for 'taken' can be overridden in activerecord scope" do + reset_i18n_load_path do + I18n.backend.store_translations "en", activerecord: { errors: { messages: { taken: "Custom taken message" } } } + assert_equal "Custom taken message", @topic.errors.generate_message(:title, :taken, value: "title") + end + end + + test "translation for 'taken' can be overridden in activerecord model scope" do + reset_i18n_load_path do + I18n.backend.store_translations "en", activerecord: { errors: { models: { topic: { taken: "Custom taken message" } } } } + assert_equal "Custom taken message", @topic.errors.generate_message(:title, :taken, value: "title") + end + end + + test "translation for 'taken' can be overridden in activerecord attributes scope" do + reset_i18n_load_path do + I18n.backend.store_translations "en", activerecord: { errors: { models: { topic: { attributes: { title: { taken: "Custom taken message" } } } } } } + assert_equal "Custom taken message", @topic.errors.generate_message(:title, :taken, value: "title") + end + end +end diff --git a/activerecord/test/cases/validations/i18n_validation_test.rb b/activerecord/test/cases/validations/i18n_validation_test.rb new file mode 100644 index 0000000000..b7c52ea18c --- /dev/null +++ b/activerecord/test/cases/validations/i18n_validation_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" +require "models/reply" + +class I18nValidationTest < ActiveRecord::TestCase + repair_validations(Topic, Reply) + + def setup + repair_validations(Topic, Reply) + Reply.validates_presence_of(:title) + @topic = Topic.new + @old_load_path, @old_backend = I18n.load_path.dup, I18n.backend + I18n.load_path.clear + I18n.backend = I18n::Backend::Simple.new + I18n.backend.store_translations("en", errors: { messages: { custom: nil } }) + end + + teardown do + I18n.load_path.replace @old_load_path + I18n.backend = @old_backend + end + + def unique_topic + @unique ||= Topic.create title: "unique!" + end + + def replied_topic + @replied_topic ||= begin + topic = Topic.create(title: "topic") + topic.replies << Reply.new + topic + end + end + + # A set of common cases for ActiveModel::Validations message generation that + # are used to generate tests to keep things DRY + # + COMMON_CASES = [ + # [ case, validation_options, generate_message_options] + [ "given no options", {}, {}], + [ "given custom message", { message: "custom" }, { message: "custom" }], + [ "given if condition", { if: lambda { true } }, {}], + [ "given unless condition", { unless: lambda { false } }, {}], + [ "given option that is not reserved", { format: "jpg" }, { format: "jpg" }], + [ "given on condition", { on: [:create, :update] }, {}] + ] + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_uniqueness_of on generated message #{name}" do + Topic.validates_uniqueness_of :title, validation_options + @topic.title = unique_topic.title + assert_called_with(@topic.errors, :generate_message, [:title, :taken, generate_message_options.merge(value: "unique!")]) do + @topic.valid? + end + end + end + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_associated on generated message #{name}" do + Topic.validates_associated :replies, validation_options + assert_called_with(replied_topic.errors, :generate_message, [:replies, :invalid, generate_message_options.merge(value: replied_topic.replies)]) do + replied_topic.save + end + end + end + + def test_validates_associated_finds_custom_model_key_translation + I18n.backend.store_translations "en", activerecord: { errors: { models: { topic: { attributes: { replies: { invalid: "custom message" } } } } } } + I18n.backend.store_translations "en", activerecord: { errors: { messages: { invalid: "global message" } } } + + Topic.validates_associated :replies + replied_topic.valid? + assert_equal ["custom message"], replied_topic.errors[:replies].uniq + end + + def test_validates_associated_finds_global_default_translation + I18n.backend.store_translations "en", activerecord: { errors: { messages: { invalid: "global message" } } } + + Topic.validates_associated :replies + replied_topic.valid? + assert_equal ["global message"], replied_topic.errors[:replies] + end +end diff --git a/activerecord/test/cases/validations/length_validation_test.rb b/activerecord/test/cases/validations/length_validation_test.rb new file mode 100644 index 0000000000..62cd89041a --- /dev/null +++ b/activerecord/test/cases/validations/length_validation_test.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/owner" +require "models/pet" +require "models/person" + +class LengthValidationTest < ActiveRecord::TestCase + fixtures :owners + + setup do + @owner = Class.new(Owner) do + def self.name; "Owner"; end + end + end + + def test_validates_size_of_association + assert_nothing_raised { @owner.validates_size_of :pets, minimum: 1 } + o = @owner.new("name" => "nopets") + assert !o.save + assert_predicate o.errors[:pets], :any? + o.pets.build("name" => "apet") + assert_predicate o, :valid? + end + + def test_validates_size_of_association_using_within + assert_nothing_raised { @owner.validates_size_of :pets, within: 1..2 } + o = @owner.new("name" => "nopets") + assert !o.save + assert_predicate o.errors[:pets], :any? + + o.pets.build("name" => "apet") + assert_predicate o, :valid? + + 2.times { o.pets.build("name" => "apet") } + assert !o.save + assert_predicate o.errors[:pets], :any? + end + + def test_validates_size_of_association_utf8 + @owner.validates_size_of :pets, minimum: 1 + o = @owner.new("name" => "あいうえおかきくけこ") + assert !o.save + assert_predicate o.errors[:pets], :any? + o.pets.build("name" => "あいうえおかきくけこ") + assert_predicate o, :valid? + end + + def test_validates_size_of_respects_records_marked_for_destruction + @owner.validates_size_of :pets, minimum: 1 + owner = @owner.new + assert_not owner.save + assert_predicate owner.errors[:pets], :any? + pet = owner.pets.build + assert_predicate owner, :valid? + assert owner.save + + pet_count = Pet.count + assert_not owner.update pets_attributes: [ { _destroy: 1, id: pet.id } ] + assert_not_predicate owner, :valid? + assert_predicate owner.errors[:pets], :any? + assert_equal pet_count, Pet.count + end + + def test_validates_length_of_virtual_attribute_on_model + repair_validations(Pet) do + Pet.send(:attr_accessor, :nickname) + Pet.validates_length_of(:name, minimum: 1) + Pet.validates_length_of(:nickname, minimum: 1) + + pet = Pet.create!(name: "Fancy Pants", nickname: "Fancy") + + assert_predicate pet, :valid? + + pet.nickname = "" + + assert_predicate pet, :invalid? + end + end +end diff --git a/activerecord/test/cases/validations/presence_validation_test.rb b/activerecord/test/cases/validations/presence_validation_test.rb new file mode 100644 index 0000000000..63c3f67da2 --- /dev/null +++ b/activerecord/test/cases/validations/presence_validation_test.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/man" +require "models/face" +require "models/interest" +require "models/speedometer" +require "models/dashboard" + +class PresenceValidationTest < ActiveRecord::TestCase + class Boy < Man; end + + repair_validations(Boy) + + def test_validates_presence_of_non_association + Boy.validates_presence_of(:name) + b = Boy.new + assert_predicate b, :invalid? + + b.name = "Alex" + assert_predicate b, :valid? + end + + def test_validates_presence_of_has_one + Boy.validates_presence_of(:face) + b = Boy.new + assert b.invalid?, "should not be valid if has_one association missing" + assert_equal 1, b.errors[:face].size, "validates_presence_of should only add one error" + end + + def test_validates_presence_of_has_one_marked_for_destruction + Boy.validates_presence_of(:face) + b = Boy.new + f = Face.new + b.face = f + assert_predicate b, :valid? + + f.mark_for_destruction + assert_predicate b, :invalid? + end + + def test_validates_presence_of_has_many_marked_for_destruction + Boy.validates_presence_of(:interests) + b = Boy.new + b.interests << [i1 = Interest.new, i2 = Interest.new] + assert_predicate b, :valid? + + i1.mark_for_destruction + assert_predicate b, :valid? + + i2.mark_for_destruction + assert_predicate b, :invalid? + end + + def test_validates_presence_doesnt_convert_to_array + speedometer = Class.new(Speedometer) + speedometer.validates_presence_of :dashboard + + dash = Dashboard.new + + # dashboard has to_a method + def dash.to_a; ["(/)", '(\)']; end + + s = speedometer.new + s.dashboard = dash + + assert_nothing_raised { s.valid? } + end + + def test_validates_presence_of_virtual_attribute_on_model + repair_validations(Interest) do + Interest.send(:attr_accessor, :abbreviation) + Interest.validates_presence_of(:topic) + Interest.validates_presence_of(:abbreviation) + + interest = Interest.create!(topic: "Thought Leadering", abbreviation: "tl") + assert_predicate interest, :valid? + + interest.abbreviation = "" + + assert_predicate interest, :invalid? + end + end + + def test_validations_run_on_persisted_record + repair_validations(Interest) do + interest = Interest.new + interest.save! + assert_predicate interest, :valid? + + Interest.validates_presence_of(:topic) + + assert_not_predicate interest, :valid? + end + end + + def test_validates_presence_with_on_context + repair_validations(Interest) do + Interest.validates_presence_of(:topic, on: :required_name) + interest = Interest.new + interest.save! + assert_not interest.valid?(:required_name) + end + end +end diff --git a/activerecord/test/cases/validations/uniqueness_validation_test.rb b/activerecord/test/cases/validations/uniqueness_validation_test.rb new file mode 100644 index 0000000000..941aed5402 --- /dev/null +++ b/activerecord/test/cases/validations/uniqueness_validation_test.rb @@ -0,0 +1,557 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" +require "models/reply" +require "models/warehouse_thing" +require "models/guid" +require "models/event" +require "models/dashboard" +require "models/uuid_item" +require "models/author" +require "models/person" +require "models/essay" + +class Wizard < ActiveRecord::Base + self.abstract_class = true + + validates_uniqueness_of :name +end + +class IneptWizard < Wizard + validates_uniqueness_of :city +end + +class Conjurer < IneptWizard +end + +class Thaumaturgist < IneptWizard +end + +class ReplyTitle; end + +class ReplyWithTitleObject < Reply + validates_uniqueness_of :content, scope: :title + + def title; ReplyTitle.new; end +end + +class TopicWithUniqEvent < Topic + belongs_to :event, foreign_key: :parent_id + validates :event, uniqueness: true +end + +class BigIntTest < ActiveRecord::Base + INT_MAX_VALUE = 2147483647 + self.table_name = "cars" + validates :engines_count, uniqueness: true, inclusion: { in: 0..INT_MAX_VALUE } +end + +class BigIntReverseTest < ActiveRecord::Base + INT_MAX_VALUE = 2147483647 + self.table_name = "cars" + validates :engines_count, inclusion: { in: 0..INT_MAX_VALUE } + validates :engines_count, uniqueness: true +end + +class CoolTopic < Topic + validates_uniqueness_of :id +end + +class TopicWithAfterCreate < Topic + after_create :set_author + + def set_author + update!(author_name: "#{title} #{id}") + end +end + +class UniquenessValidationTest < ActiveRecord::TestCase + INT_MAX_VALUE = 2147483647 + + fixtures :topics, "warehouse-things" + + repair_validations(Topic, Reply) + + def test_validate_uniqueness + Topic.validates_uniqueness_of(:title) + + t = Topic.new("title" => "I'm uniqué!") + assert t.save, "Should save t as unique" + + t.content = "Remaining unique" + assert t.save, "Should still save t as unique" + + t2 = Topic.new("title" => "I'm uniqué!") + assert !t2.valid?, "Shouldn't be valid" + assert !t2.save, "Shouldn't save t2 as unique" + assert_equal ["has already been taken"], t2.errors[:title] + + t2.title = "Now I am really also unique" + assert t2.save, "Should now save t2 as unique" + end + + def test_validate_uniqueness_with_alias_attribute + Topic.alias_attribute :new_title, :title + Topic.validates_uniqueness_of(:new_title) + + topic = Topic.new(new_title: "abc") + assert_predicate topic, :valid? + end + + def test_validates_uniqueness_with_nil_value + Topic.validates_uniqueness_of(:title) + + t = Topic.new("title" => nil) + assert t.save, "Should save t as unique" + + t2 = Topic.new("title" => nil) + assert !t2.valid?, "Shouldn't be valid" + assert !t2.save, "Shouldn't save t2 as unique" + assert_equal ["has already been taken"], t2.errors[:title] + end + + def test_validates_uniqueness_with_validates + Topic.validates :title, uniqueness: true + Topic.create!("title" => "abc") + + t2 = Topic.new("title" => "abc") + assert_not_predicate t2, :valid? + assert t2.errors[:title] + end + + def test_validate_uniqueness_when_integer_out_of_range + entry = BigIntTest.create(engines_count: INT_MAX_VALUE + 1) + assert_equal entry.errors[:engines_count], ["is not included in the list"] + end + + def test_validate_uniqueness_when_integer_out_of_range_show_order_does_not_matter + entry = BigIntReverseTest.create(engines_count: INT_MAX_VALUE + 1) + assert_equal entry.errors[:engines_count], ["is not included in the list"] + end + + def test_validates_uniqueness_with_newline_chars + Topic.validates_uniqueness_of(:title, case_sensitive: false) + + t = Topic.new("title" => "new\nline") + assert t.save, "Should save t as unique" + end + + def test_validate_uniqueness_with_scope + Reply.validates_uniqueness_of(:content, scope: "parent_id") + + t = Topic.create("title" => "I'm unique!") + + r1 = t.replies.create "title" => "r1", "content" => "hello world" + assert r1.valid?, "Saving r1" + + r2 = t.replies.create "title" => "r2", "content" => "hello world" + assert !r2.valid?, "Saving r2 first time" + + r2.content = "something else" + assert r2.save, "Saving r2 second time" + + t2 = Topic.create("title" => "I'm unique too!") + r3 = t2.replies.create "title" => "r3", "content" => "hello world" + assert r3.valid?, "Saving r3" + end + + def test_validate_uniqueness_with_scope_invalid_syntax + error = assert_raises(ArgumentError) do + Reply.validates_uniqueness_of(:content, scope: { parent_id: false }) + end + assert_match(/Pass a symbol or an array of symbols instead/, error.to_s) + end + + def test_validate_uniqueness_with_object_scope + Reply.validates_uniqueness_of(:content, scope: :topic) + + t = Topic.create("title" => "I'm unique!") + + r1 = t.replies.create "title" => "r1", "content" => "hello world" + assert r1.valid?, "Saving r1" + + r2 = t.replies.create "title" => "r2", "content" => "hello world" + assert !r2.valid?, "Saving r2 first time" + end + + def test_validate_uniqueness_with_polymorphic_object_scope + Essay.validates_uniqueness_of(:name, scope: :writer) + + a = Author.create(name: "Sergey") + p = Person.create(first_name: "Sergey") + + e1 = a.essays.create(name: "Essay") + assert e1.valid?, "Saving e1" + + e2 = p.essays.create(name: "Essay") + assert e2.valid?, "Saving e2" + end + + def test_validate_uniqueness_with_composed_attribute_scope + r1 = ReplyWithTitleObject.create "title" => "r1", "content" => "hello world" + assert r1.valid?, "Saving r1" + + r2 = ReplyWithTitleObject.create "title" => "r1", "content" => "hello world" + assert !r2.valid?, "Saving r2 first time" + end + + def test_validate_uniqueness_with_object_arg + Reply.validates_uniqueness_of(:topic) + + t = Topic.create("title" => "I'm unique!") + + r1 = t.replies.create "title" => "r1", "content" => "hello world" + assert r1.valid?, "Saving r1" + + r2 = t.replies.create "title" => "r2", "content" => "hello world" + assert !r2.valid?, "Saving r2 first time" + end + + def test_validate_uniqueness_scoped_to_defining_class + t = Topic.create("title" => "What, me worry?") + + r1 = t.unique_replies.create "title" => "r1", "content" => "a barrel of fun" + assert r1.valid?, "Saving r1" + + r2 = t.silly_unique_replies.create "title" => "r2", "content" => "a barrel of fun" + assert !r2.valid?, "Saving r2" + + # Should succeed as validates_uniqueness_of only applies to + # UniqueReply and its subclasses + r3 = t.replies.create "title" => "r2", "content" => "a barrel of fun" + assert r3.valid?, "Saving r3" + end + + def test_validate_uniqueness_with_scope_array + Reply.validates_uniqueness_of(:author_name, scope: [:author_email_address, :parent_id]) + + t = Topic.create("title" => "The earth is actually flat!") + + r1 = t.replies.create "author_name" => "jeremy", "author_email_address" => "jeremy@rubyonrails.com", "title" => "You're crazy!", "content" => "Crazy reply" + assert r1.valid?, "Saving r1" + + r2 = t.replies.create "author_name" => "jeremy", "author_email_address" => "jeremy@rubyonrails.com", "title" => "You're crazy!", "content" => "Crazy reply again..." + assert !r2.valid?, "Saving r2. Double reply by same author." + + r2.author_email_address = "jeremy_alt_email@rubyonrails.com" + assert r2.save, "Saving r2 the second time." + + r3 = t.replies.create "author_name" => "jeremy", "author_email_address" => "jeremy_alt_email@rubyonrails.com", "title" => "You're wrong", "content" => "It's cubic" + assert !r3.valid?, "Saving r3" + + r3.author_name = "jj" + assert r3.save, "Saving r3 the second time." + + r3.author_name = "jeremy" + assert !r3.save, "Saving r3 the third time." + end + + def test_validate_case_insensitive_uniqueness + Topic.validates_uniqueness_of(:title, :parent_id, case_sensitive: false, allow_nil: true) + + t = Topic.new("title" => "I'm unique!", :parent_id => 2) + assert t.save, "Should save t as unique" + + t.content = "Remaining unique" + assert t.save, "Should still save t as unique" + + t2 = Topic.new("title" => "I'm UNIQUE!", :parent_id => 1) + assert !t2.valid?, "Shouldn't be valid" + assert !t2.save, "Shouldn't save t2 as unique" + assert_predicate t2.errors[:title], :any? + assert_predicate t2.errors[:parent_id], :any? + assert_equal ["has already been taken"], t2.errors[:title] + + t2.title = "I'm truly UNIQUE!" + assert !t2.valid?, "Shouldn't be valid" + assert !t2.save, "Shouldn't save t2 as unique" + assert_empty t2.errors[:title] + assert_predicate t2.errors[:parent_id], :any? + + t2.parent_id = 4 + assert t2.save, "Should now save t2 as unique" + + t2.parent_id = nil + t2.title = nil + assert t2.valid?, "should validate with nil" + assert t2.save, "should save with nil" + + t_utf8 = Topic.new("title" => "Я тоже уникальный!") + assert t_utf8.save, "Should save t_utf8 as unique" + + # If database hasn't UTF-8 character set, this test fails + if Topic.all.merge!(select: "LOWER(title) AS title").find(t_utf8.id).title == "я тоже уникальный!" + t2_utf8 = Topic.new("title" => "я тоже УНИКАЛЬНЫЙ!") + assert !t2_utf8.valid?, "Shouldn't be valid" + assert !t2_utf8.save, "Shouldn't save t2_utf8 as unique" + end + end + + def test_validate_case_sensitive_uniqueness_with_special_sql_like_chars + Topic.validates_uniqueness_of(:title, case_sensitive: true) + + t = Topic.new("title" => "I'm unique!") + assert t.save, "Should save t as unique" + + t2 = Topic.new("title" => "I'm %") + assert t2.save, "Should save t2 as unique" + + t3 = Topic.new("title" => "I'm uniqu_!") + assert t3.save, "Should save t3 as unique" + end + + def test_validate_case_insensitive_uniqueness_with_special_sql_like_chars + Topic.validates_uniqueness_of(:title, case_sensitive: false) + + t = Topic.new("title" => "I'm unique!") + assert t.save, "Should save t as unique" + + t2 = Topic.new("title" => "I'm %") + assert t2.save, "Should save t2 as unique" + + t3 = Topic.new("title" => "I'm uniqu_!") + assert t3.save, "Should save t3 as unique" + end + + def test_validate_case_sensitive_uniqueness + Topic.validates_uniqueness_of(:title, case_sensitive: true, allow_nil: true) + + t = Topic.new("title" => "I'm unique!") + assert t.save, "Should save t as unique" + + t.content = "Remaining unique" + assert t.save, "Should still save t as unique" + + t2 = Topic.new("title" => "I'M UNIQUE!") + assert t2.valid?, "Should be valid" + assert t2.save, "Should save t2 as unique" + assert_empty t2.errors[:title] + assert_empty t2.errors[:parent_id] + assert_not_equal ["has already been taken"], t2.errors[:title] + + t3 = Topic.new("title" => "I'M uNiQUe!") + assert t3.valid?, "Should be valid" + assert t3.save, "Should save t2 as unique" + assert_empty t3.errors[:title] + assert_empty t3.errors[:parent_id] + assert_not_equal ["has already been taken"], t3.errors[:title] + end + + def test_validate_case_sensitive_uniqueness_with_attribute_passed_as_integer + Topic.validates_uniqueness_of(:title, case_sensitive: true) + Topic.create!("title" => 101) + + t2 = Topic.new("title" => 101) + assert_not_predicate t2, :valid? + assert t2.errors[:title] + end + + def test_validate_uniqueness_with_non_standard_table_names + i1 = WarehouseThing.create(value: 1000) + assert !i1.valid?, "i1 should not be valid" + assert i1.errors[:value].any?, "Should not be empty" + end + + def test_validates_uniqueness_inside_scoping + Topic.validates_uniqueness_of(:title) + + Topic.where(author_name: "David").scoping do + t1 = Topic.new("title" => "I'm unique!", "author_name" => "Mary") + assert t1.save + t2 = Topic.new("title" => "I'm unique!", "author_name" => "David") + assert_not_predicate t2, :valid? + end + end + + def test_validate_uniqueness_with_columns_which_are_sql_keywords + repair_validations(Guid) do + Guid.validates_uniqueness_of :key + g = Guid.new + g.key = "foo" + assert_nothing_raised { !g.valid? } + end + end + + def test_validate_uniqueness_with_limit + if current_adapter?(:SQLite3Adapter) + # Event.title has limit 5, but SQLite doesn't truncate. + e1 = Event.create(title: "abcdefgh") + assert e1.valid?, "Could not create an event with a unique 8 characters title" + + e2 = Event.create(title: "abcdefgh") + assert_not e2.valid?, "Created an event whose title is not unique" + elsif current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter, :OracleAdapter, :SQLServerAdapter) + assert_raise(ActiveRecord::ValueTooLong) do + Event.create(title: "abcdefgh") + end + else + assert_raise(ActiveRecord::StatementInvalid) do + Event.create(title: "abcdefgh") + end + end + end + + def test_validate_uniqueness_with_limit_and_utf8 + if current_adapter?(:SQLite3Adapter) + # Event.title has limit 5, but SQLite doesn't truncate. + e1 = Event.create(title: "一二三四五六七八") + assert e1.valid?, "Could not create an event with a unique 8 characters title" + + e2 = Event.create(title: "一二三四五六七八") + assert_not e2.valid?, "Created an event whose title is not unique" + elsif current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter, :OracleAdapter, :SQLServerAdapter) + assert_raise(ActiveRecord::ValueTooLong) do + Event.create(title: "一二三四五六七八") + end + else + assert_raise(ActiveRecord::StatementInvalid) do + Event.create(title: "一二三四五六七八") + end + end + end + + def test_validate_straight_inheritance_uniqueness + w1 = IneptWizard.create(name: "Rincewind", city: "Ankh-Morpork") + assert w1.valid?, "Saving w1" + + # Should use validation from base class (which is abstract) + w2 = IneptWizard.new(name: "Rincewind", city: "Quirm") + assert !w2.valid?, "w2 shouldn't be valid" + assert w2.errors[:name].any?, "Should have errors for name" + assert_equal ["has already been taken"], w2.errors[:name], "Should have uniqueness message for name" + + w3 = Conjurer.new(name: "Rincewind", city: "Quirm") + assert !w3.valid?, "w3 shouldn't be valid" + assert w3.errors[:name].any?, "Should have errors for name" + assert_equal ["has already been taken"], w3.errors[:name], "Should have uniqueness message for name" + + w4 = Conjurer.create(name: "The Amazing Bonko", city: "Quirm") + assert w4.valid?, "Saving w4" + + w5 = Thaumaturgist.new(name: "The Amazing Bonko", city: "Lancre") + assert !w5.valid?, "w5 shouldn't be valid" + assert w5.errors[:name].any?, "Should have errors for name" + assert_equal ["has already been taken"], w5.errors[:name], "Should have uniqueness message for name" + + w6 = Thaumaturgist.new(name: "Mustrum Ridcully", city: "Quirm") + assert !w6.valid?, "w6 shouldn't be valid" + assert w6.errors[:city].any?, "Should have errors for city" + assert_equal ["has already been taken"], w6.errors[:city], "Should have uniqueness message for city" + end + + def test_validate_uniqueness_with_conditions + Topic.validates_uniqueness_of :title, conditions: -> { where(approved: true) } + Topic.create("title" => "I'm a topic", "approved" => true) + Topic.create("title" => "I'm an unapproved topic", "approved" => false) + + t3 = Topic.new("title" => "I'm a topic", "approved" => true) + assert !t3.valid?, "t3 shouldn't be valid" + + t4 = Topic.new("title" => "I'm an unapproved topic", "approved" => false) + assert t4.valid?, "t4 should be valid" + end + + def test_validate_uniqueness_with_non_callable_conditions_is_not_supported + assert_raises(ArgumentError) { + Topic.validates_uniqueness_of :title, conditions: Topic.where(approved: true) + } + end + + def test_validate_uniqueness_on_existing_relation + event = Event.create + assert_predicate TopicWithUniqEvent.create(event: event), :valid? + + topic = TopicWithUniqEvent.new(event: event) + assert_not_predicate topic, :valid? + assert_equal ["has already been taken"], topic.errors[:event] + end + + def test_validate_uniqueness_on_empty_relation + topic = TopicWithUniqEvent.new + assert_predicate topic, :valid? + end + + def test_validate_uniqueness_of_custom_primary_key + klass = Class.new(ActiveRecord::Base) do + self.table_name = "keyboards" + self.primary_key = :key_number + + validates_uniqueness_of :key_number + + def self.name + "Keyboard" + end + end + + klass.create!(key_number: 10) + key2 = klass.create!(key_number: 11) + + key2.key_number = 10 + assert_not_predicate key2, :valid? + end + + def test_validate_uniqueness_without_primary_key + klass = Class.new(ActiveRecord::Base) do + self.table_name = "dashboards" + + validates_uniqueness_of :dashboard_id + + def self.name; "Dashboard" end + end + + abc = klass.create!(dashboard_id: "abc") + assert_predicate klass.new(dashboard_id: "xyz"), :valid? + assert_not_predicate klass.new(dashboard_id: "abc"), :valid? + + abc.dashboard_id = "def" + + e = assert_raises ActiveRecord::UnknownPrimaryKey do + abc.save! + end + assert_match(/\AUnknown primary key for table dashboards in model/, e.message) + assert_match(/Can not validate uniqueness for persisted record without primary key.\z/, e.message) + end + + def test_validate_uniqueness_ignores_itself_when_primary_key_changed + Topic.validates_uniqueness_of(:title) + + t = Topic.new("title" => "This is a unique title") + assert t.save, "Should save t as unique" + + t.id += 1 + assert t.valid?, "Should be valid" + assert t.save, "Should still save t as unique" + end + + def test_validate_uniqueness_with_after_create_performing_save + TopicWithAfterCreate.validates_uniqueness_of(:title) + topic = TopicWithAfterCreate.create!(title: "Title1") + assert topic.author_name.start_with?("Title1") + + topic2 = TopicWithAfterCreate.new(title: "Title1") + assert_not_predicate topic2, :valid? + assert_equal(["has already been taken"], topic2.errors[:title]) + end + + def test_validate_uniqueness_uuid + skip unless current_adapter?(:PostgreSQLAdapter) + item = UuidItem.create!(uuid: SecureRandom.uuid, title: "item1") + item.update(title: "item1-title2") + assert_empty item.errors + + item2 = UuidValidatingItem.create!(uuid: SecureRandom.uuid, title: "item2") + item2.update(title: "item2-title2") + assert_empty item2.errors + end + + def test_validate_uniqueness_regular_id + item = CoolTopic.create!(title: "MyItem") + assert_empty item.errors + + item2 = CoolTopic.new(id: item.id, title: "MyItem2") + assert_not_predicate item2, :valid? + + assert_equal(["has already been taken"], item2.errors[:id]) + end +end |