diff --git a/.github/workflows/rspec.yml b/.github/workflows/rspec.yml index 6f0dd55..5d4fe0d 100644 --- a/.github/workflows/rspec.yml +++ b/.github/workflows/rspec.yml @@ -31,18 +31,18 @@ jobs: entry: - { ruby: '2.6', mongodb: '4.4', gemfile: 'mongoid_4', bundler: '1.17.3' } - { ruby: '2.6', mongodb: '4.4', gemfile: 'mongoid_5', bundler: '1.17.3' } - - { ruby: '2.7', mongodb: '4.4', gemfile: 'mongoid_6' } + - { ruby: '2.7', mongodb: '4.4', gemfile: 'mongoid_6', coverage: true } - { ruby: '2.7', mongodb: '5.0', gemfile: 'mongoid_7' } - { ruby: '2.7', mongodb: '5.0', gemfile: 'mongoid_8' } - { ruby: '3.0', mongodb: '6.0', gemfile: 'mongoid_8' } - { ruby: '3.1', mongodb: '6.0', gemfile: 'mongoid_8' } - { ruby: '3.2', mongodb: '7.0', gemfile: 'mongoid_8' } - { ruby: '3.3', mongodb: '7.0', gemfile: 'mongoid_9' } - - { ruby: '3.4', mongodb: '8.0', gemfile: 'mongoid_9' } + - { ruby: '3.4', mongodb: '8.0', gemfile: 'mongoid_9', coverage: true } - { ruby: '4.0', mongodb: '8.0', gemfile: 'mongoid_9' } name: test (ruby=${{ matrix.entry.ruby }}, mongodb=${{ matrix.entry.mongodb }}), gemfile=${{ matrix.entry.gemfile }}) env: - COVERAGE: ${{ matrix.entry.ruby == '3.4' }} + COVERAGE: ${{ matrix.entry.coverage }} BUNDLE_GEMFILE: gemfiles/${{ matrix.entry.gemfile }}.gemfile steps: - name: Set up MongoDB ${{ matrix.entry.mongodb }} @@ -59,9 +59,22 @@ jobs: - name: Run tests run: bundle exec rake - name: Coveralls - if: matrix.entry.ruby == '3.4' + if: matrix.entry.coverage uses: coverallsapp/github-action@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} file: coverage/.resultset.json format: simplecov + parallel: true + flag-name: ${{ matrix.entry.gemfile }} + coverage: + name: Finalize coverage + needs: test + if: always() && needs.test.result == 'success' + runs-on: ubuntu-latest + steps: + - name: Finish parallel Coveralls reporting + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + parallel-finished: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 9694016..757df5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ### 4.4.1 (Next) +* [#263](https://github.com/mongoid/mongoid-rspec/pull/263): Expand matcher behavior and diagnostic coverage, run the storage matcher specs in the default suite, combine Mongoid 6 and 9 coverage in Coveralls, and document isolated MongoDB test databases - [@dblock](https://github.com/dblock). * Your contribution here. ### 4.4.0 (2026/10/15) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78772d9..d885f7c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,14 @@ bundle install bundle exec rake ``` +To use a dedicated MongoDB database (including a server on a nondefault port), set `MONGODB_URI`. The suite purges its configured database after each example group, so never point it at a database containing data you need. + +``` +MONGODB_URI=mongodb://localhost:27017/mongoid_rspec_coverage COVERAGE=true bundle exec rake +``` + +`COVERAGE=true` enables SimpleCov for every file under `lib/`; the HTML report is written to `coverage/index.html`. + #### Write Tests Try to write a test that reproduces the problem you're trying to fix or describes a feature that you want to build. Add to [spec/mongoid-rspec](spec/mongoid-rspec). diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 92dd462..ee04772 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -25,7 +25,18 @@ require 'rspec/expectations' require 'mongoid/compatibility' -Mongoid::Config.connect_to('mongoid-rspec-test') if Mongoid::Compatibility::Version.mongoid3_or_newer? +if ENV['MONGODB_URI'] + Mongoid.configure do |config| + connection = { uri: ENV.fetch('MONGODB_URI') } + if Mongoid::Compatibility::Version.mongoid5_or_newer? + config.clients[:default] = connection + else + config.sessions[:default] = connection + end + end +elsif Mongoid::Compatibility::Version.mongoid3_or_newer? + Mongoid::Config.connect_to('mongoid-rspec-test') +end Mongo::Logger.logger.level = ::Logger::INFO if Mongoid::Compatibility::Version.mongoid5_or_newer? Dir[File.join(MODELS, '*.rb')].sort.each { |file| require File.basename(file) } diff --git a/spec/unit/association_diagnostics_spec.rb b/spec/unit/association_diagnostics_spec.rb new file mode 100644 index 0000000..0771ac4 --- /dev/null +++ b/spec/unit/association_diagnostics_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Association matcher diagnostics' do + [ + [User, :have_many, :missing, nil, [], 'no association named missing'], + [User, :have_one, :articles, nil, [], 'references many'], + [User, :have_many, :articles, :of_type, [Profile], 'of type Article'], + [Article, :belong_to, :author, :as_inverse_of, [:other], 'inverse of articles'], + [User, :have_many, :articles, :ordered_by, [:missing], 'ordered by title'], + [User, :have_many, :comments, :with_dependent, [:delete], 'dependent as destroy'], + [User, :have_many, :messages, :with_autosave, [], 'did not set autosave'], + [User, :embed_one, :profile, :with_autobuild, [], 'did not set autobuild'], + [Article, :belong_to, :author, :with_polymorphism, [], 'did not set polymorphic'], + [User, :embed_one, :profile, :with_cascading_callbacks, [], 'did not set cascade_callbacks'], + [Article, :embed_many, :comments, :cyclic, [], 'did not set cyclic'], + [Article, :embed_many, :comments, :stored_as, [:other], 'stored as comments'], + [User, :belong_to, :site, :with_index, [], 'did not set index'], + [Article, :belong_to, :author, :with_foreign_key, [:other_id], 'foreign key "author_id"'], + [Article, :belong_to, :author, :with_touch, [false], 'sets touch as true'], + [Article, :belong_to, :author, :with_optional, [], 'did not set optional'] + ].each do |model, factory, relation, modifier, arguments, message| + it "explains #{model}.#{relation} mismatches for #{modifier || factory}" do + matcher = public_send(factory, relation) + matcher.public_send(modifier, *arguments) if modifier + expect(matcher.matches?(model)).to be false + expect(matcher.failure_message).to include(message) + expect(matcher.description).not_to be_empty + end + end + + it 'describes successful matches when negated' do + matcher = have_many(:comments).of_type(Comment).with_autosave + expect(matcher.matches?(User.new)).to be true + expect(matcher.failure_message_when_negated).to include('not', 'of type Comment', 'which set autosave') + end + + it 'checks cyclic and stored association metadata on real embedded relations' do + model = Class.new do + include Mongoid::Document + embeds_many :comments, cyclic: true, store_as: :notes + end + expect(model).to embed_many(:comments).cyclic.stored_as(:notes) + end + + it 'rejects unknown association types and ordering a singular association' do + expect { have_one(:record).ordered_by(:name) }.to raise_error(RuntimeError, /does not respond to :order/) + expect { have_many(:articles).type_description(String) }.to raise_error(RuntimeError, /Unknown association type/) + end + + if Mongoid::Compatibility::Version.mongoid6_or_older? + it 'reports associations without a counter cache' do + matcher = have_many(:articles).with_counter_cache + expect(matcher.matches?(User)).to be false + expect(matcher.failure_message).to include('did not set counter_cache') + end + + it 'explains an ordering direction mismatch on legacy metadata' do + matcher = have_many(:users).ordered_by(:email.asc) + expect(matcher.matches?(Site)).to be false + expect(matcher.failure_message).to include('descending') + end + end +end diff --git a/spec/unit/be_stored_in.rb b/spec/unit/be_stored_in.rb deleted file mode 100644 index 67d40dd..0000000 --- a/spec/unit/be_stored_in.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Mongoid::Matchers::BeStoredIn do - subject do - Class.new do - include Mongoid::Document - store_in collection: 'citizens', database: 'other', client: 'secondary' - end - end - - it 'detects storage options' do - is_expected.to be_stored_in(collection: 'citizens', database: 'other', client: 'secondary') - end - - it 'detects even part of storage options' do - is_expected.to be_stored_in(database: 'other') - is_expected.to be_stored_in(client: 'secondary') - is_expected.to be_stored_in(collection: 'citizens') - is_expected.to be_stored_in(collection: 'citizens', database: 'other') - is_expected.to be_stored_in(database: 'other', client: 'secondary') - is_expected.to be_stored_in(collection: 'citizens', client: 'secondary') - end - - it 'detects differences' do - is_expected.not_to be_stored_in(collection: 'aliens') - end - - context 'when models has storage options defined via blocks, procs or lambdas' do - subject do - Class.new do - include Mongoid::Document - store_in database: -> { Thread.current[:database] } - end - end - - before do - Thread.current[:database] = 'db1981' - end - - it 'detects storage options' do - is_expected.to be_stored_in(database: 'db1981') - end - - it 'reflects changes in storage options' do - is_expected.to be_stored_in(database: 'db1981') - Thread.current[:database] = 'db2200' - is_expected.to be_stored_in(database: 'db2200') - end - - it 'detects differences' do - is_expected.not_to be_stored_in(database: 'other') - end - end -end diff --git a/spec/unit/be_stored_in_spec.rb b/spec/unit/be_stored_in_spec.rb new file mode 100644 index 0000000..2236367 --- /dev/null +++ b/spec/unit/be_stored_in_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mongoid::Matchers::BeStoredIn do + let(:connection_option) { Mongoid::Compatibility::Version.mongoid5_or_newer? ? :client : :session } + let(:connection_storage) { { connection_option => 'secondary' } } + + subject do + storage_options = { collection: 'citizens', database: 'other' }.merge(connection_storage) + Class.new do + include Mongoid::Document + store_in storage_options + end + end + + it 'detects storage options' do + is_expected.to be_stored_in({ collection: 'citizens', database: 'other' }.merge(connection_storage)) + end + + it 'detects even part of storage options' do + is_expected.to be_stored_in(database: 'other') + is_expected.to be_stored_in(connection_storage) + is_expected.to be_stored_in(collection: 'citizens') + is_expected.to be_stored_in(collection: 'citizens', database: 'other') + is_expected.to be_stored_in({ database: 'other' }.merge(connection_storage)) + is_expected.to be_stored_in({ collection: 'citizens' }.merge(connection_storage)) + end + + it 'detects differences' do + is_expected.not_to be_stored_in(collection: 'aliens') + end + + # Dynamic storage matching uses persistence_context, introduced in Mongoid 6. + if Mongoid::Compatibility::Version.mongoid6_or_newer? + context 'when models have storage options defined via blocks, procs or lambdas' do + subject do + Class.new do + include Mongoid::Document + store_in database: -> { Thread.current[:database] } + end + end + + before do + Thread.current[:database] = 'db1981' + end + + after do + Thread.current[:database] = nil + end + + it 'detects storage options' do + is_expected.to be_stored_in(database: 'db1981') + end + + it 'reflects changes in storage options' do + is_expected.to be_stored_in(database: 'db1981') + Thread.current[:database] = 'db2200' + is_expected.to be_stored_in(database: 'db2200') + end + + it 'detects differences' do + is_expected.not_to be_stored_in(database: 'other') + end + end + end + + it 'describes mismatched storage and negated expectations' do + matcher = be_stored_in(collection: 'aliens') + expect(matcher.matches?(subject.new)).to be false + expect(matcher.failure_message).to include('be stored in', 'aliens', 'citizens') + expect(matcher.failure_message_when_negated).to include('not to be stored in', 'citizens') + end + + it 'compares non-symbolizable storage options without changing their values' do + model = Class.new do + include Mongoid::Document + store_in collection: nil + end + expect(model).to be_stored_in(collection: nil) + end +end diff --git a/spec/unit/legacy_matchers_spec.rb b/spec/unit/legacy_matchers_spec.rb new file mode 100644 index 0000000..a41209c --- /dev/null +++ b/spec/unit/legacy_matchers_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Legacy mass-assignment compatibility' do + # Rails removed this API in Rails 4. Model doubles expose only its historical + # authorizer contract; matcher behavior is still exercised through public APIs. + let(:whitelist_class) do + Class.new(Set) do + def deny?(attribute) + !include?(attribute) + end + end + end + let(:blacklist_class) do + Class.new(Set) do + def deny?(attribute) + include?(attribute) + end + end + end + let(:model) { Class.new.new } + + before do + stub_const('ActiveModel::MassAssignmentSecurity::WhiteList', whitelist_class) + allow(model.class).to receive(:accessible_attributes).and_return([]) + allow(model.class).to receive(:protected_attributes).and_return([]) + allow(model.class).to receive(:name).and_return('LegacyAccount') + end + + it 'checks a whitelist and explains both positive and negative results' do + allow(model.class).to receive(:active_authorizer).and_return(default: whitelist_class.new(['name'])) + matcher = allow_mass_assignment_of(:name) + expect(matcher.matches?(model)).to be true + expect(matcher.description).to eq('allow mass assignment of name') + expect(matcher.failure_message_when_negated).to eq('name was made accessible') + matcher = allow_mass_assignment_of(:password) + expect(matcher.matches?(model)).to be false + expect(matcher.failure_message).to eq('Expected password to be accessible') + end + + it 'checks default and explicit roles' do + allow(model.class).to receive(:active_authorizer).and_return( + default: whitelist_class.new, admin: whitelist_class.new(['password']) + ) + expect(model).not_to allow_mass_assignment_of(:password) + expect(model).to allow_mass_assignment_of(:password).as(:admin) + end + + it 'reports a protected attribute and the attributes that remain assignable' do + allow(model.class).to receive(:active_authorizer).and_return(default: blacklist_class.new(['password'])) + allow(model.class).to receive(:protected_attributes).and_return(Set.new(['password'])) + matcher = allow_mass_assignment_of(:password) + expect(matcher.matches?(model)).to be false + expect(matcher.failure_message).to eq('Did not expect password to be protected') + matcher = allow_mass_assignment_of(:name) + expect(matcher.matches?(model)).to be true + expect(matcher.failure_message_when_negated).to eq('LegacyAccount is protecting password, but not name.') + end + + it 'explains an empty blacklist' do + allow(model.class).to receive(:active_authorizer).and_return(default: blacklist_class.new) + matcher = allow_mass_assignment_of(:name) + expect(matcher.matches?(model)).to be true + expect(matcher.failure_message_when_negated).to eq('no attributes were protected') + end + + it 'supports the pre-3.1 authorizer and rejects roles on that version' do + stub_const('ActiveModel::VERSION::STRING', '3.0.0') + allow(model.class).to receive(:active_authorizer).and_return(whitelist_class.new(['name'])) + expect(model).to allow_mass_assignment_of(:name) + expect { allow_mass_assignment_of(:name).as(:admin) }.to raise_error(RuntimeError, /Rails 3.1 or greater/) + end +end + +RSpec.describe 'Mongoid 3 index compatibility' do + let(:model) do + Class.new do + def self.index_options + { { name: 1 } => { unique: true, dropDups: true } } + end + end + end + + before do + # Isolate the historical implementation from the matcher selected for the + # current Mongoid. index_options was replaced by index_specification in v4. + stub_const('Mongoid::Matchers::HaveIndexFor', Class.new(Mongoid::Matchers::HaveIndexForBase)) + load File.expand_path('../../lib/matchers/indexes/v3/have_index_for.rb', __dir__) + end + + it 'matches indexes, normalizes historical options and explains missing or incompatible indexes' do + expect(model).to have_index_for('name' => 1) + expect(model.new).to have_index_for(name: 1).with_options(unique: true, drop_dups: true) + expect(model).to have_index_for(name: 1).with_options({}) + + matcher = have_index_for(missing: 1) + expect(matcher.matches?(model)).to be false + expect(matcher.failure_message).to include('no index for', 'missing') + + matcher = have_index_for(name: 1).with_options(unique: false) + expect(matcher.matches?(model)).to be false + expect(matcher.failure_message).to include('with options of', 'unique', 'true') + expect(matcher.failure_message_when_negated).to include('not', 'unique', 'false') + end +end diff --git a/spec/unit/matcher_diagnostics_spec.rb b/spec/unit/matcher_diagnostics_spec.rb new file mode 100644 index 0000000..6f9e7ec --- /dev/null +++ b/spec/unit/matcher_diagnostics_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Matcher diagnostics' do + it 'explains document and dynamic document mismatches' do + [be_mongoid_document, be_dynamic_document].each do |matcher| + expect(matcher.matches?(Object.new)).to be false + expect(matcher.failure_message).to include('expect Object') + expect(matcher.failure_message_when_negated).to include('not') + end + end + + it 'explains absent nested attributes and negated declarations' do + matcher = accept_nested_attributes_for(:missing) + expect(matcher.matches?(User.new)).to be false + expect(matcher.description).to eq('accepts_nested_attributes_for :missing') + expect(matcher.failure_message).to eq('Expected User to accept nested attributes for missing (is not declared)') + expect(matcher.failure_message_when_negated).to eq('Did not expect User to accept nested attributes for missing') + end + + it 'reports unset field defaults and incorrect aliases' do + matcher = have_field(:login).with_default_value_of('guest').with_alias(:username) + expect(matcher.matches?(User)).to be false + expect(matcher.failure_message).to include('with default not set', 'with alias') + end + + it 'reports fields without localization' do + matcher = have_field(:login).localized + expect(matcher.matches?(User)).to be false + expect(matcher.failure_message).to include('is not localized "login"') + end + + it 'reports missing indexes and indexes with incompatible options' do + matcher = have_index_for(missing: 1) + expect(matcher.matches?(User)).to be_falsey + expect(matcher.failure_message).to include('found no index') + + matcher = have_index_for(title: 1).with_options(unique: false) + expect(matcher.matches?(Article)).to be_falsey + expect(matcher.failure_message).to include('including options', 'unique') + expect(matcher.failure_message_when_negated).to include('not', 'title') + end + + it 'explains timestamp expectations and rejects unsupported phases' do + matcher = have_timestamps.for(:creating).shortened + expect(matcher.matches?(User)).to be false + expect(matcher.failure_message).to include('creating', 'timestamps') + expect(matcher.failure_message_when_negated).to include('not') + expect { have_timestamps.for(:deleting) }.to raise_error(RuntimeError, /only for creating or updating/) + expect { have_timestamps.for(:creating).for(:updating) }.to raise_error(RuntimeError, /already declared/) + end + + if defined?(Mongo::Monitoring::Global) + it 'rejects command keys that cannot be normalized' do + expect { execute_mongodb_command(123 => 'articles') }.to raise_error(ArgumentError, /keys must be Strings or Symbols/) + end + end +end diff --git a/spec/unit/validation_diagnostics_spec.rb b/spec/unit/validation_diagnostics_spec.rb new file mode 100644 index 0000000..d69015d --- /dev/null +++ b/spec/unit/validation_diagnostics_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Validation matcher diagnostics' do + def expect_mismatch(model, matcher, message) + expect(matcher.matches?(model)).to be false + expect(matcher.failure_message).to include(message) + end + + it 'checks explicit format, allowed values, and forbidden values' do + expect(User).to validate_format_of(:login).with_format(/\A[\w\-]+\z/) + expect_mismatch(User, validate_format_of(:login).with_format(/\A\d+\z/), 'with format') + expect_mismatch(User, validate_format_of(:login).to_allow('!'), 'as an invalid value') + expect_mismatch(User, validate_format_of(:login).not_to_allow('valid'), 'as a valid value') + expect_mismatch(User, validate_format_of(:login).with_message('different').to_allow('valid'), 'no custom message') + end + + it 'reports values outside inclusion and exclusion constraints' do + expect_mismatch(User, validate_inclusion_of(:role).to_allow('owner'), 'not allowing these values') + expect_mismatch(User, validate_exclusion_of(:login).to_not_allow('available'), 'allowing the following') + model = Class.new do + include Mongoid::Document + field :age, type: Integer + validates :age, exclusion: { in: 1..3 } + end + stub_const('CoverageExclusionRecord', model) + expect(model).to validate_exclusion_of(:age).to_not_allow(1, 2, 3) + expect(model.new(age: 2)).not_to be_valid + expect(model.new(age: 4)).to be_valid + end + + it 'checks blank allowance, scope and case sensitivity of uniqueness validators' do + model = Class.new do + include Mongoid::Document + field :name + validates :name, uniqueness: { allow_blank: true } + end + matcher = validate_uniqueness_of(:name).allow_blank?(true) + expect(matcher.matches?(model)).to be true + expect(matcher.description).to include('allowing blank values') + expect(matcher.failure_message_when_negated).to include('with blank values allowed') + expect_mismatch(User, validate_uniqueness_of(:login).allow_blank?(true), 'no blank values allowed') + expect_mismatch(User, validate_uniqueness_of(:login).scoped_to(:other), 'scope to site') + expect_mismatch(User, validate_uniqueness_of(:login).case_insensitive, 'without case insensitive values') + expect_mismatch(User, validate_uniqueness_of(:email).with_message('different'), 'is already taken') + end + + it 'explains length ranges with neither or only one bound' do + model = Class.new do + include Mongoid::Document + field :minimum_only + field :maximum_only + field :exact_only + validates :minimum_only, length: { minimum: 2 } + validates :maximum_only, length: { maximum: 5 } + validates :exact_only, length: { is: 3 } + end + %i[minimum_only maximum_only exact_only].each do |field| + expect_mismatch(model, validate_length_of(field).within(2..5), 'with') + end + end + + it 'rejects invalid numericality options and unknown fluent methods' do + [{}, { unsupported: true }].each do |options| + expect { validate_numericality_of(:age).to_allow(options) }.to raise_error(ArgumentError, /requires a Hash/) + end + expect { validate_numericality_of(:age).greater_than(1, 2) }.to raise_error(ArgumentError, /wrong number/) + expect { validate_numericality_of(:age).unknown_comparator(1) }.to raise_error(NoMethodError) + end + + it 'rejects an unexpected custom validator type' do + expect_mismatch(Person, custom_validate(:ssn).with_validator(ActiveModel::Validations::PresenceValidator), 'custom validator not of type') + end + + it 'rejects unsupported conditional validator filters' do + model = Class.new do + include Mongoid::Document + field :name + end + # Newer ActiveModel rejects malformed conditions during declaration, so + # supply validator metadata directly to exercise the matcher's guard. + validator = ActiveModel::Validations::PresenceValidator.new(attributes: [:name], if: 'unsupported') + allow(model).to receive(:validators_on).with('name').and_return([validator]) + expect { validate_presence_of(:name).check_conditions.matches?(model.new) }.to raise_error(ArgumentError, /Unexpected filter/) + end +end diff --git a/spec/unit/version_spec.rb b/spec/unit/version_spec.rb new file mode 100644 index 0000000..227b030 --- /dev/null +++ b/spec/unit/version_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mongoid::RSpec do + it 'defines a valid gem version' do + expect(Gem::Version.new(Mongoid::RSpec::VERSION)).to be_a(Gem::Version) + end +end