Files
gitlab-ce/spec/validators/custom_uniqueness_validator_spec.rb
2025-01-28 00:38:14 +00:00

269 lines
8.6 KiB
Ruby

# frozen_string_literal: true
require 'spec_helper'
RSpec.describe CustomUniquenessValidator, feature_category: :shared do
before_all do
ApplicationRecord.connection.execute(
<<~SQL
CREATE TABLE IF NOT EXISTS _test_custom_uniqueness (
id smallint PRIMARY KEY,
name VARCHAR(500),
number smallint,
second_number smallint
);
SQL
)
end
let_it_be(:existing_record_name) { 'Some Name' }
shared_examples 'custom uniqueness validator' do
subject { test_model.new(**new_record_attributes) }
before do
test_model.create!(id: 1, **existing_record_attributes)
end
it 'uses TRIM(BOTH FROM lower()) to validate' do
new_record = test_model.new(**valid_new_attributes)
expect do
new_record.valid?
end.to make_queries_matching(
/TRIM\(BOTH FROM lower\(_test_custom_uniqueness.name\)\) = TRIM\(BOTH FROM lower\('/
)
end
it 'adds an error to the record' do
new_record = test_model.new(**existing_record_attributes)
new_record.valid?
expect(new_record.errors.full_messages).to include('Name has already been taken')
end
context 'when new record has leading whitespace' do
let(:new_record_name) { " #{existing_record_name}" }
it { is_expected.to be_invalid }
end
context 'when new record has trailing whitespace' do
let(:new_record_name) { "#{existing_record_name} " }
it { is_expected.to be_invalid }
end
context 'when new record does not match casing' do
let(:new_record_name) { existing_record_name.upcase }
it { is_expected.to be_invalid }
end
context 'when new record name does not match' do
let(:new_record_name) { "#{existing_record_name} something else" }
it { is_expected.to be_valid }
end
context 'when updating a record' do
let(:existing_record) { test_model.first }
subject(:updated) { existing_record.update(**new_record_attributes) } # rubocop:disable Rails/SaveBang -- Checking the return value in specs
context 'when new value is exactly the same' do
let(:new_record_name) { existing_record_name }
it { is_expected.to be_truthy }
end
context 'when new value is the same in a different casing' do
let(:new_record_name) { existing_record_name.upcase }
it { is_expected.to be_truthy }
it 'does update the record to the new casing' do
expect do
updated
end.to change { existing_record.reload.name }.from(existing_record_name).to(existing_record_name.upcase)
end
end
end
end
context 'when validation is not scoped to another column' do
let_it_be(:test_model) do
Class.new(ApplicationRecord) do
self.table_name = :_test_custom_uniqueness
def self.name
'TestCustomUniqueness'
end
validates :name, custom_uniqueness: { unique_sql: 'TRIM(BOTH FROM lower(?))' }
end
end
it_behaves_like 'custom uniqueness validator' do
let(:existing_record_attributes) { { name: existing_record_name } }
let(:valid_new_attributes) { { name: "#{existing_record_name} something else" } }
let(:new_record_attributes) { { name: new_record_name } }
end
end
context 'when validation is scoped to another column' do
let_it_be(:test_model) do
Class.new(ApplicationRecord) do
self.table_name = :_test_custom_uniqueness
def self.name
'TestCustomUniqueness'
end
validates :name, custom_uniqueness: { unique_sql: 'TRIM(BOTH FROM lower(?))', scope: :number }
end
end
it_behaves_like 'custom uniqueness validator' do
let(:existing_record_attributes) { { name: existing_record_name, number: 1 } }
let(:valid_new_attributes) { { name: "#{existing_record_name} something else", number: 2 } }
let(:new_record_attributes) { { name: new_record_name, number: 1 } }
end
context 'when name matches but scope value does not' do
it 'does not invalidate the record' do
test_model.create!(id: 1, name: existing_record_name, number: 1)
new_record = test_model.new(name: existing_record_name, number: 2)
expect(new_record).to be_valid
end
end
context 'when name and scope value matches an existing record' do
it 'adds an error to the record' do
test_model.create!(id: 1, name: existing_record_name, number: 1)
new_record = test_model.new(name: existing_record_name, number: 1)
new_record.valid?
expect(new_record.errors.full_messages).to include('Name has already been taken')
end
context 'when name and scope value matches an existing record' do
it 'adds an error to the record' do
test_model.create!(id: 1, name: existing_record_name, number: 1)
new_record = test_model.new(name: existing_record_name, number: 1)
new_record.valid?
expect(new_record.errors.full_messages).to include('Name has already been taken')
end
end
context 'when updating an existing record' do
before_all do
test_model.create!(id: 1, name: existing_record_name, number: 1)
end
context 'when changing the attribute value' do
it 'adds an error to the record' do
record = test_model.create!(id: 2, name: "#{existing_record_name} different", number: 1)
record.name = existing_record_name
expect(record).to be_invalid
end
end
context 'when changing the scope value' do
it 'adds an error to the record' do
record = test_model.create!(id: 2, name: existing_record_name, number: 2)
record.number = 1
expect(record).to be_invalid
end
end
context 'when making valid changes to the attribute and scope values' do
it 'does not invalidate the record' do
record = test_model.create!(id: 2, name: "#{existing_record_name} different", number: 1)
record.name = existing_record_name
record.number = 2
expect(record).to be_valid
end
end
context 'when not changing the validated attributes or scoped values' do
it 'does not issue queries to the DB' do
record = test_model.create!(id: 2, name: "#{existing_record_name} different", number: 1)
record.id = 100
expect do
record.valid?
end.not_to make_queries
expect(record).to be_valid
end
end
end
end
end
context 'when validation is scoped to multiple columns' do
let_it_be(:test_model) do
Class.new(ApplicationRecord) do
self.table_name = :_test_custom_uniqueness
def self.name
'TestCustomUniqueness'
end
validates :name, custom_uniqueness: { unique_sql: 'TRIM(BOTH FROM lower(?))', scope: [:number, :second_number] }
end
end
it_behaves_like 'custom uniqueness validator' do
let(:existing_record_attributes) { { name: existing_record_name, number: 1, second_number: 101 } }
let(:valid_new_attributes) { { name: "#{existing_record_name} something else", number: 2, second_number: 102 } }
let(:new_record_attributes) { { name: new_record_name, number: 1, second_number: 101 } }
end
context 'when name matches but scope value does not' do
it 'does not invalidate the record' do
test_model.create!(id: 1, name: existing_record_name, number: 1, second_number: 101)
new_record = test_model.new(name: existing_record_name, number: 1, second_number: 102)
expect(new_record).to be_valid
end
end
context 'when name and scope values matches an existing record' do
it 'adds an error to the record' do
test_model.create!(id: 1, name: existing_record_name, number: 1, second_number: 101)
new_record = test_model.new(name: existing_record_name, number: 1, second_number: 101)
new_record.valid?
expect(new_record.errors.full_messages).to include('Name has already been taken')
end
end
end
context 'when validation definition is missing the `unique_sql key`' do
it 'raises an ArgumentError' do
expect do
Class.new(ApplicationRecord) do
self.table_name = :_test_custom_uniqueness
def self.name
'TestCustomUniqueness'
end
validates :name, custom_uniqueness: true
end
end.to raise_error(ArgumentError, '`unique_sql` option must be provided to the `custom_uniqueness` validator')
end
end
end