├── .bundle └── config ├── .rvmrc ├── .gitignore ├── .travis.yml ├── .document ├── spec ├── models │ ├── log.rb │ ├── record.rb │ ├── permalink.rb │ ├── comment.rb │ ├── person.rb │ ├── site.rb │ ├── movie_article.rb │ ├── profile.rb │ ├── article.rb │ └── user.rb ├── unit │ ├── collections_spec.rb │ ├── accept_nested_attributes_spec.rb │ ├── indexes_spec.rb │ ├── allow_mass_assignment_spec.rb │ ├── document_spec.rb │ ├── associations_spec.rb │ └── validations_spec.rb ├── validators │ └── ssn_validator.rb └── spec_helper.rb ├── lib ├── mongoid-rspec │ └── version.rb ├── matchers │ ├── validations │ │ ├── presence_of.rb │ │ ├── acceptance_of.rb │ │ ├── confirmation_of.rb │ │ ├── associated.rb │ │ ├── with_message.rb │ │ ├── custom_validation_of.rb │ │ ├── inclusion_of.rb │ │ ├── exclusion_of.rb │ │ ├── format_of.rb │ │ ├── uniqueness_of.rb │ │ ├── numericality_of.rb │ │ └── length_of.rb │ ├── collections.rb │ ├── accept_nested_attributes.rb │ ├── indexes.rb │ ├── validations.rb │ ├── allow_mass_assignment.rb │ ├── document.rb │ └── associations.rb └── mongoid-rspec.rb ├── Gemfile ├── Rakefile ├── mongoid-rspec.gemspec ├── LICENSE └── README.md /.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_WITHOUT: '' 3 | -------------------------------------------------------------------------------- /.rvmrc: -------------------------------------------------------------------------------- 1 | rvm use --create 1.9.3@mongoid-rspec 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.3 4 | - 2.0.0 5 | - ruby-head 6 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | README.rdoc 2 | lib/**/*.rb 3 | bin/* 4 | features/**/*.feature 5 | LICENSE 6 | -------------------------------------------------------------------------------- /spec/models/log.rb: -------------------------------------------------------------------------------- 1 | class Log 2 | include Mongoid::Document 3 | store_in collection: "logs" 4 | end -------------------------------------------------------------------------------- /lib/mongoid-rspec/version.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Rspec 3 | VERSION = "1.8.2" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in mongoid-rspec.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /spec/models/record.rb: -------------------------------------------------------------------------------- 1 | class Record 2 | include Mongoid::Document 3 | 4 | belongs_to :user, inverse_of: :record 5 | end -------------------------------------------------------------------------------- /spec/models/permalink.rb: -------------------------------------------------------------------------------- 1 | class Permalink 2 | include Mongoid::Document 3 | 4 | embedded_in :linkable, inverse_of: :link 5 | end 6 | -------------------------------------------------------------------------------- /spec/unit/collections_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Collections" do 4 | describe Log do 5 | it { should be_stored_in :logs } 6 | end 7 | end -------------------------------------------------------------------------------- /spec/models/comment.rb: -------------------------------------------------------------------------------- 1 | class Comment 2 | include Mongoid::Document 3 | 4 | embedded_in :article, inverse_of: :comments 5 | belongs_to :user, inverse_of: :comments 6 | end -------------------------------------------------------------------------------- /spec/models/person.rb: -------------------------------------------------------------------------------- 1 | require 'ssn_validator.rb' 2 | 3 | class Person 4 | include Mongoid::Document 5 | 6 | field :name 7 | field :ssn 8 | 9 | validates :ssn, ssn: true 10 | end -------------------------------------------------------------------------------- /spec/models/site.rb: -------------------------------------------------------------------------------- 1 | class Site 2 | include Mongoid::Document 3 | 4 | field :name 5 | 6 | has_many :users, inverse_of: :site, order: :email.desc 7 | 8 | validates :name, presence: true, uniqueness: true 9 | end -------------------------------------------------------------------------------- /lib/matchers/validations/presence_of.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Matchers 3 | module Validations 4 | def validate_presence_of(field) 5 | HaveValidationMatcher.new(field, :presence) 6 | end 7 | end 8 | end 9 | end -------------------------------------------------------------------------------- /lib/matchers/collections.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :be_stored_in do |collection_name| 2 | match do |doc| 3 | doc.class.collection_name == collection_name 4 | end 5 | 6 | description do 7 | "be stored in #{collection_name.to_s}" 8 | end 9 | end -------------------------------------------------------------------------------- /lib/matchers/validations/acceptance_of.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Matchers 3 | module Validations 4 | def validate_acceptance_of(field) 5 | HaveValidationMatcher.new(field, :acceptance) 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/matchers/validations/confirmation_of.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Matchers 3 | module Validations 4 | def validate_confirmation_of(field) 5 | HaveValidationMatcher.new(field, :confirmation) 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/models/movie_article.rb: -------------------------------------------------------------------------------- 1 | class MovieArticle < Article 2 | 3 | field :rating, type: Float 4 | field :classification, type: Integer 5 | 6 | validates :rating, numericality: { greater_than: 0, less_than_or_equal_to: 5 } 7 | validates :classification, numericality: { even: true, only_integer: true, allow_nil: false } 8 | end -------------------------------------------------------------------------------- /spec/unit/accept_nested_attributes_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "AcceptsNestedAttributes" do 4 | describe User do 5 | it { should accept_nested_attributes_for(:articles) } 6 | it { should accept_nested_attributes_for(:comments) } 7 | end 8 | 9 | describe Article do 10 | it { should accept_nested_attributes_for(:permalink) } 11 | end 12 | end -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("../lib", __FILE__) 2 | 3 | require 'bundler' 4 | Bundler::GemHelper.install_tasks 5 | 6 | require 'rspec/core/rake_task' 7 | 8 | task :default => :spec 9 | 10 | RSpec::Core::RakeTask.new(:spec) do |spec| 11 | spec.pattern = "./spec/**/*_spec.rb" 12 | end 13 | 14 | RSpec::Core::RakeTask.new(:rcov) do |spec| 15 | spec.pattern = "./spec/**/*_spec.rb" 16 | spec.rcov = true 17 | end 18 | -------------------------------------------------------------------------------- /spec/unit/indexes_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Indexes" do 4 | describe Article do 5 | it { should have_index_for(published: 1) } 6 | it { should have_index_for(title: 1).with_options(unique: true, background: true) } 7 | it { should have_index_for('permalink._id' => 1) } 8 | end 9 | 10 | describe Profile do 11 | it { should have_index_for(first_name: 1, last_name: 1) } 12 | end 13 | end -------------------------------------------------------------------------------- /spec/validators/ssn_validator.rb: -------------------------------------------------------------------------------- 1 | class SsnValidator < ActiveModel::EachValidator 2 | 3 | def validate_each(record, attribute, value) 4 | unless valid_ssn?(record, attribute, value) 5 | record.errors[attribute] << "#{value} is not a valid Social Security Number" 6 | end 7 | end 8 | 9 | def self.kind() :custom end 10 | 11 | def valid_ssn?(record, attribute, value) 12 | # irrelevant here how validation is done 13 | true 14 | end 15 | 16 | end -------------------------------------------------------------------------------- /spec/models/profile.rb: -------------------------------------------------------------------------------- 1 | class Profile 2 | include Mongoid::Document 3 | 4 | field :first_name 5 | field :last_name 6 | field :age 7 | field :hobbies, type: Array, default: [] 8 | 9 | embedded_in :user, inverse_of: :profile 10 | 11 | validates :age, numericality: { greater_than: 0 } 12 | validates :terms_of_service, acceptance: true 13 | validates :hobbies, length: { minimum: 1, message: "requires at least one hobby" } 14 | 15 | index({ first_name: 1, last_name: 1 }) 16 | end 17 | -------------------------------------------------------------------------------- /lib/matchers/validations/associated.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Matchers 3 | module Validations 4 | class ValidateAssociatedMatcher < HaveValidationMatcher 5 | def initialize(name) 6 | super(name, :associated) 7 | end 8 | 9 | def description 10 | "validate associated #{@field.inspect}" 11 | end 12 | end 13 | 14 | def validate_associated(association_name) 15 | ValidateAssociatedMatcher.new(association_name) 16 | end 17 | end 18 | end 19 | end -------------------------------------------------------------------------------- /spec/unit/allow_mass_assignment_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "AllowMassAssignment" do 4 | describe User do 5 | it { should allow_mass_assignment_of(:login) } 6 | it { should allow_mass_assignment_of(:email) } 7 | it { should allow_mass_assignment_of(:age) } 8 | it { should allow_mass_assignment_of(:password) } 9 | it { should allow_mass_assignment_of(:password) } 10 | it { should allow_mass_assignment_of(:role).as(:admin) } 11 | 12 | it { should_not allow_mass_assignment_of(:role) } 13 | end 14 | end -------------------------------------------------------------------------------- /lib/matchers/validations/with_message.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Matchers 3 | module Validations 4 | module WithMessage 5 | def with_message(message) 6 | @expected_message = message 7 | self 8 | end 9 | 10 | private 11 | 12 | def check_expected_message 13 | actual_message = @validator.options[:message] 14 | if actual_message.nil? 15 | @negative_result_message << " with no custom message" 16 | @result = false 17 | elsif actual_message == @expected_message 18 | @positive_result_message << " with custom message '#{@expected_message}'" 19 | else 20 | @negative_result_message << " got message '#{actual_message}'" 21 | @result = false 22 | end 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 2 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "lib")) 3 | MODELS = File.join(File.dirname(__FILE__), "models") 4 | $LOAD_PATH.unshift(MODELS) 5 | VALIDATORS = File.join(File.dirname(__FILE__), "validators") 6 | $LOAD_PATH.unshift(VALIDATORS) 7 | 8 | require "rubygems" 9 | require "bundler" 10 | Bundler.setup 11 | 12 | require 'mongoid' 13 | require 'rspec' 14 | require 'rspec/core' 15 | require 'rspec/expectations' 16 | 17 | Mongoid.configure do |config| 18 | config.connect_to("mongoid-rspec-test") 19 | end 20 | 21 | Dir[ File.join(MODELS, "*.rb") ].sort.each { |file| require File.basename(file) } 22 | 23 | require 'mongoid-rspec' 24 | 25 | RSpec.configure do |config| 26 | config.include RSpec::Matchers 27 | config.include Mongoid::Matchers 28 | config.mock_with :rspec 29 | config.after :all do 30 | Mongoid::Config.purge! 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /mongoid-rspec.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "mongoid-rspec/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "mongoid-rspec" 7 | s.version = Mongoid::Rspec::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = ["Evan Sagge"] 10 | s.email = %q{evansagge@gmail.com} 11 | s.homepage = %q{http://github.com/evansagge/mongoid-rspec} 12 | s.summary = %q{RSpec matchers for Mongoid} 13 | s.description = %q{RSpec matches for Mongoid models, including association and validation matchers} 14 | 15 | s.rubyforge_project = "mongoid-rspec" 16 | 17 | s.files = `git ls-files`.split("\n") 18 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 19 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 20 | s.require_paths = ["lib"] 21 | 22 | s.add_dependency 'rake' 23 | s.add_dependency 'mongoid', '>= 3.0.1' 24 | s.add_dependency 'rspec', '>= 2.13.0' 25 | end 26 | -------------------------------------------------------------------------------- /spec/unit/document_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Document" do 4 | describe User do 5 | it { should have_fields(:email, :login) } 6 | it { should be_timestamped_document } 7 | it { should be_timestamped_document.with(:created) } 8 | it { should_not be_timestamped_document.with(:updated) } 9 | end 10 | 11 | describe Article do 12 | it { should have_field(:published).of_type(Boolean).with_default_value_of(false) } 13 | it { should have_field(:allow_comments).of_type(Boolean).with_default_value_of(true) } 14 | it { should belong_to(:author) } 15 | it { should have_field(:title).localized } 16 | it { should_not have_field(:allow_comments).of_type(Boolean).with_default_value_of(false) } 17 | it { should_not have_field(:number_of_comments).of_type(Integer).with_default_value_of(1) } 18 | it { should be_mongoid_document } 19 | it { should be_versioned_document } 20 | it { should be_timestamped_document } 21 | it { should be_paranoid_document } 22 | it { should be_multiparameted_document } 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/models/article.rb: -------------------------------------------------------------------------------- 1 | class Article 2 | include Mongoid::Document 3 | include Mongoid::Timestamps 4 | include Mongoid::Paranoia 5 | include Mongoid::Versioning 6 | include Mongoid::MultiParameterAttributes 7 | 8 | field :title, localize: true 9 | field :content 10 | field :published, type: Boolean, default: false 11 | field :allow_comments, type: Boolean, default: true 12 | field :number_of_comments, type: Integer 13 | field :status, type: Symbol 14 | 15 | embeds_many :comments 16 | embeds_one :permalink 17 | belongs_to :author, class_name: 'User', inverse_of: :articles, index: true 18 | 19 | validates :title, presence: true 20 | 21 | validates_inclusion_of :status, in: [:pending], on: :create 22 | validates_inclusion_of :status, in: [:approved, :rejected ], on: :update 23 | 24 | validates_length_of :title, within: 8..16 25 | validates_length_of :content, minimum: 200 26 | 27 | index({ title: 1 }, { unique: true, background: true }) 28 | index({ published: 1 }) 29 | index({ 'permalink._id' => 1 }) 30 | 31 | accepts_nested_attributes_for :permalink 32 | end 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Evan Sagge 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/mongoid-rspec.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 2 | 3 | require 'mongoid' 4 | require 'rspec' 5 | require "active_model" 6 | require 'matchers/document' 7 | require 'matchers/associations' 8 | require 'matchers/collections' 9 | require 'matchers/indexes' 10 | require 'matchers/allow_mass_assignment' 11 | require 'matchers/accept_nested_attributes' 12 | require 'matchers/validations' 13 | require 'matchers/validations/with_message' 14 | require 'matchers/validations/associated' 15 | require 'matchers/validations/confirmation_of' 16 | require 'matchers/validations/exclusion_of' 17 | require 'matchers/validations/format_of' 18 | require 'matchers/validations/inclusion_of' 19 | require 'matchers/validations/length_of' 20 | require 'matchers/validations/numericality_of' 21 | require 'matchers/validations/presence_of' 22 | require 'matchers/validations/uniqueness_of' 23 | require 'matchers/validations/acceptance_of' 24 | require 'matchers/validations/custom_validation_of' 25 | 26 | module Mongoid 27 | module Matchers 28 | include Mongoid::Matchers::Associations 29 | include Mongoid::Matchers::Validations 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/unit/associations_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Associations" do 4 | describe User do 5 | it { should have_many(:articles).with_foreign_key(:author_id).ordered_by(:title) } 6 | 7 | it { should have_one(:record).with_autobuild } 8 | 9 | it { should have_many(:comments).with_dependent(:destroy).with_autosave } 10 | 11 | it { should embed_one :profile } 12 | 13 | it { should have_and_belong_to_many(:children).of_type(User) } 14 | end 15 | 16 | describe Profile do 17 | it { should be_embedded_in(:user).as_inverse_of(:profile) } 18 | end 19 | 20 | describe Article do 21 | it { should belong_to(:author).of_type(User).as_inverse_of(:articles).with_index } 22 | it { should embed_many(:comments) } 23 | it { should embed_one(:permalink) } 24 | end 25 | 26 | describe Comment do 27 | it { should be_embedded_in(:article).as_inverse_of(:comments) } 28 | it { should belong_to(:user).as_inverse_of(:comments) } 29 | end 30 | 31 | describe Record do 32 | it { should belong_to(:user).as_inverse_of(:record) } 33 | end 34 | 35 | describe Permalink do 36 | it { should be_embedded_in(:linkable).as_inverse_of(:link) } 37 | end 38 | 39 | describe Site do 40 | it { should have_many(:users).as_inverse_of(:site).ordered_by(:email.desc) } 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/models/user.rb: -------------------------------------------------------------------------------- 1 | class User 2 | include Mongoid::Document 3 | include Mongoid::Timestamps::Created 4 | 5 | field :login 6 | field :email 7 | field :role 8 | field :age, type: Integer 9 | field :password, type: String 10 | field :provider_uid 11 | field :locale 12 | 13 | belongs_to :site, inverse_of: :users 14 | has_many :articles, foreign_key: :author_id, order: :title 15 | has_many :comments, dependent: :destroy, autosave: true 16 | has_and_belongs_to_many :children, class_name: "User" 17 | has_one :record, autobuild: true 18 | 19 | embeds_one :profile 20 | 21 | validates :login, presence: true, uniqueness: { scope: :site }, format: { with: /^[\w\-]+$/ }, exclusion: { in: ["super", "index", "edit"] } 22 | validates :email, uniqueness: { case_sensitive: false, scope: :site, message: "is already taken" }, confirmation: true 23 | validates :role, presence: true, inclusion: { in: ["admin", "moderator", "member"] } 24 | validates :profile, presence: true, associated: true 25 | validates :age, presence: true, numericality: true, inclusion: { in: 23..42 }, on: [:create, :update] 26 | validates :password, presence: true, on: [:create, :update] 27 | validates :password, exclusion: { in: ->(user) { ['password'] } } 28 | validates :provider_uid, presence: true 29 | validates :locale, inclusion: { in: ->(user) { [:en, :ru] } } 30 | 31 | attr_accessible :login, :email, :age, :password 32 | attr_accessible :role, as: :admin 33 | 34 | accepts_nested_attributes_for :articles, :comments 35 | 36 | def admin? 37 | false 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/matchers/validations/custom_validation_of.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Matchers 3 | module Validations 4 | class ValidateWithCustomValidatorMatcher < HaveValidationMatcher 5 | include WithMessage 6 | def initialize(field) 7 | super(field, :custom) 8 | end 9 | 10 | def with_validator(custom_validator) 11 | @custom_validator = custom_validator 12 | self 13 | end 14 | 15 | def matches?(actual) 16 | return false unless (@result = super(actual)) 17 | check_custom_validator if @custom_validator 18 | check_expected_message if @expected_message 19 | 20 | @result 21 | end 22 | 23 | def description 24 | options_desc = [] 25 | options_desc << " with custom validator #{@custom_validator.name}" if @validator 26 | options_desc << " with message '#{@expected_message}'" if @expected_message 27 | "validate field #{@field.inspect}" << options_desc.to_sentence 28 | end 29 | 30 | private 31 | 32 | def check_custom_validator 33 | if @validator.kind_of? @custom_validator 34 | @positive_result_message << " with custom validator of type #{@custom_validator.name}" 35 | else 36 | @negative_result_message << " with custom validator not of type #{@custom_validator.name}" 37 | @result = false 38 | end 39 | end 40 | end 41 | 42 | def custom_validate(field) 43 | ValidateWithCustomValidatorMatcher.new(field) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/matchers/accept_nested_attributes.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Matchers # :nodoc: 3 | 4 | # Ensures that the model can accept nested attributes for the specified 5 | # association. 6 | # 7 | # Example: 8 | # it { should accept_nested_attributes_for(:articles) } 9 | # 10 | def accept_nested_attributes_for(attribute) 11 | AcceptNestedAttributesForMatcher.new(attribute) 12 | end 13 | 14 | class AcceptNestedAttributesForMatcher 15 | 16 | def initialize(attribute) 17 | @attribute = attribute.to_s 18 | @options = {} 19 | end 20 | 21 | def matches?(subject) 22 | @subject = subject 23 | match? 24 | end 25 | 26 | def failure_message 27 | "Expected #{expectation} (#{@problem})" 28 | end 29 | 30 | def negative_failure_message 31 | "Did not expect #{expectation}" 32 | end 33 | 34 | def description 35 | description = "accepts_nested_attributes_for :#{@attribute}" 36 | end 37 | 38 | protected 39 | def match? 40 | exists? 41 | end 42 | 43 | def exists? 44 | if config 45 | true 46 | else 47 | @problem = 'is not declared' 48 | false 49 | end 50 | end 51 | 52 | def config 53 | model_class.nested_attributes["#{@attribute}_attributes"] 54 | end 55 | 56 | def model_class 57 | @subject.class 58 | end 59 | 60 | def expectation 61 | "#{model_class.name} to accept nested attributes for #{@attribute}" 62 | end 63 | end 64 | end 65 | end -------------------------------------------------------------------------------- /lib/matchers/indexes.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Matchers 3 | class HaveIndexForMatcher # :nodoc: 4 | def initialize(index_fields) 5 | @index_fields = index_fields.symbolize_keys! 6 | end 7 | 8 | def with_options(options = { }) 9 | @options = options 10 | self 11 | end 12 | 13 | def matches?(klass) 14 | @klass = klass.is_a?(Class) ? klass : klass.class 15 | @errors = [] 16 | 17 | unless @klass.index_options[@index_fields] 18 | @errors.push "no index for #{@index_fields}" 19 | else 20 | if !@options.nil? && !@options.empty? 21 | @options.each do |option, option_value| 22 | if @klass.index_options[@index_fields][option] != option_value 23 | @errors.push "index for #{@index_fields.inspect} with options of #{@klass.index_options[@index_fields].inspect}" 24 | end 25 | end 26 | end 27 | end 28 | 29 | @errors.empty? 30 | end 31 | 32 | def failure_message_for_should 33 | "Expected #{@klass.inspect} to #{description}, got #{@errors.to_sentence}" 34 | end 35 | 36 | def failure_message_for_should_not 37 | "Expected #{@klass.inspect} to not #{description}, got #{@klass.inspect} to #{description}" 38 | end 39 | 40 | def description 41 | desc = "have an index for #{@index_fields.inspect}" 42 | desc << " with options of #{@options.inspect}" if @options 43 | desc 44 | end 45 | end 46 | 47 | def have_index_for(index_fields) 48 | HaveIndexForMatcher.new(index_fields) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/matchers/validations/inclusion_of.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Matchers 3 | module Validations 4 | class ValidateInclusionOfMatcher < HaveValidationMatcher 5 | def initialize(name) 6 | super(name, :inclusion) 7 | end 8 | 9 | def to_allow(*values) 10 | @allowed_values = values.map(&:to_a).flatten 11 | self 12 | end 13 | 14 | def matches?(actual) 15 | return false unless result = super(actual) 16 | 17 | if @allowed_values 18 | raw_validator_allowed_values = @validator.options[:in] 19 | 20 | validator_allowed_values = case raw_validator_allowed_values 21 | when Range then raw_validator_allowed_values.to_a 22 | when Proc then raw_validator_allowed_values.call(actual) 23 | else raw_validator_allowed_values end 24 | 25 | not_allowed_values = @allowed_values - validator_allowed_values 26 | if not_allowed_values.empty? 27 | @positive_result_message = @positive_result_message << " allowing all values mentioned" 28 | else 29 | @negative_result_message = @negative_result_message << " not allowing these values: #{not_allowed_values.inspect}" 30 | result = false 31 | end 32 | end 33 | 34 | result 35 | end 36 | 37 | def description 38 | options_desc = [] 39 | options_desc << " allowing these values: #{@allowed_values}" if @allowed_values 40 | super << options_desc.to_sentence 41 | end 42 | end 43 | 44 | def validate_inclusion_of(field) 45 | ValidateInclusionOfMatcher.new(field) 46 | end 47 | end 48 | end 49 | end -------------------------------------------------------------------------------- /lib/matchers/validations/exclusion_of.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Matchers 3 | module Validations 4 | class ValidateExclusionOfMatcher < HaveValidationMatcher 5 | def initialize(name) 6 | super(name, :exclusion) 7 | end 8 | 9 | def to_not_allow(*values) 10 | @not_allowed_values = [values].flatten 11 | self 12 | end 13 | 14 | def matches?(actual) 15 | return false unless result = super(actual) 16 | 17 | if @not_allowed_values 18 | raw_validator_not_allowed_values = @validator.options[:in] 19 | 20 | validator_not_allowed_values = case raw_validator_not_allowed_values 21 | when Range then raw_validator_not_allowed_values.to_a 22 | when Proc then raw_validator_not_allowed_values.call(actual) 23 | else raw_validator_not_allowed_values end 24 | 25 | allowed_values = @not_allowed_values - validator_not_allowed_values 26 | if allowed_values.empty? 27 | @positive_result_message = @positive_result_message << " not allowing all values mentioned" 28 | else 29 | @negative_result_message = @negative_result_message << " allowing the following the ff. values: #{allowed_values.inspect}" 30 | result = false 31 | end 32 | end 33 | 34 | result 35 | end 36 | 37 | def description 38 | options_desc = [] 39 | options_desc << " not allowing the ff. values: #{@not_allowed_values}" if @not_allowed_values 40 | super << options_desc.to_sentence 41 | end 42 | end 43 | 44 | def validate_exclusion_of(field) 45 | ValidateExclusionOfMatcher.new(field) 46 | end 47 | end 48 | end 49 | end -------------------------------------------------------------------------------- /lib/matchers/validations/format_of.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Matchers 3 | module Validations 4 | class ValidateFormatOfMatcher < HaveValidationMatcher 5 | def initialize(field) 6 | super(field, :format) 7 | end 8 | 9 | def with_format(format) 10 | @format = format 11 | self 12 | end 13 | 14 | def to_allow(valid_value) 15 | @valid_value = valid_value 16 | self 17 | end 18 | 19 | def not_to_allow(invalid_value) 20 | @invalid_value = invalid_value 21 | self 22 | end 23 | 24 | def matches?(actual) 25 | return false unless result = super(actual) 26 | 27 | if @format 28 | if @validator.options[:with] == @format 29 | @positive_result_message = @positive_result_message << " with format #{@validator.options[:format].inspect}" 30 | else 31 | @negative_result_message = @negative_result_message << " with format #{@validator.options[:format].inspect}" 32 | result = false 33 | end 34 | end 35 | 36 | if @valid_value 37 | if @validator.options[:with] =~ @valid_value 38 | @positive_result_message = @positive_result_message << " with #{@valid_value.inspect} as a valid value" 39 | else 40 | @negative_result_message = @negative_result_message << " with #{@valid_value.inspect} as an invalid value" 41 | result = false 42 | end 43 | end 44 | 45 | if @invalid_value 46 | if !(@invalid_value =~ @validator.options[:with]) 47 | @positive_result_message = @positive_result_message << " with #{@invalid_value.inspect} as an invalid value" 48 | else 49 | @negative_result_message = @negative_result_message << " with #{@invalid_value.inspect} as a valid value" 50 | result = false 51 | end 52 | end 53 | 54 | result 55 | end 56 | 57 | def description 58 | options_desc = [] 59 | options_desc << " with format #{@format.inspect}" if @format 60 | options_desc << " allowing the value #{@valid_value.inspect}" if @valid_value 61 | options_desc << " not allowing the value #{@invalid_value.inspect}" if @invalid_value 62 | super << options_desc.to_sentence 63 | end 64 | end 65 | 66 | def validate_format_of(field) 67 | ValidateFormatOfMatcher.new(field) 68 | end 69 | end 70 | end 71 | end -------------------------------------------------------------------------------- /lib/matchers/validations.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Matchers 3 | module Validations 4 | 5 | class HaveValidationMatcher 6 | 7 | def initialize(field, validation_type) 8 | @field = field.to_s 9 | @type = validation_type.to_s 10 | @options = {} 11 | end 12 | 13 | def matches?(actual) 14 | @klass = actual.is_a?(Class) ? actual : actual.class 15 | 16 | @validator = @klass.validators_on(@field).detect{ |v| 17 | v.kind.to_s == @type and (!v.options[:on] or on_options_matches?(v)) 18 | } 19 | 20 | if @validator 21 | @negative_result_message = "#{@type.inspect} validator on #{@field.inspect}" 22 | @positive_result_message = "#{@type.inspect} validator on #{@field.inspect}" 23 | else 24 | @negative_result_message = "no #{@type.inspect} validator on #{@field.inspect}" 25 | return false 26 | end 27 | @result = true 28 | check_on if @options[:on] 29 | @result 30 | end 31 | 32 | def failure_message_for_should 33 | "Expected #{@klass.inspect} to #{description}; instead got #{@negative_result_message}" 34 | end 35 | 36 | def failure_message_for_should_not 37 | "Expected #{@klass.inspect} to not #{description}; instead got #{@positive_result_message}" 38 | end 39 | 40 | def description 41 | desc = "have #{@type.inspect} validator on #{@field.inspect}" 42 | desc << " on #{@options[:on]}" if @options[:on] 43 | desc 44 | end 45 | 46 | def on(*on_method) 47 | @options[:on] = on_method.flatten 48 | self 49 | end 50 | 51 | def check_on 52 | validator_on_methods = [@validator.options[:on]].flatten 53 | 54 | if validator_on_methods.any? 55 | message = " on methods: #{validator_on_methods}" 56 | 57 | if on_options_covered_by?( @validator ) 58 | @positive_result_message << message 59 | else 60 | @negative_result_message << message 61 | @result = false 62 | end 63 | end 64 | end 65 | 66 | private 67 | 68 | def on_options_matches?(validator) 69 | @options[:on] and validator.options[:on] and on_options_covered_by?(validator) 70 | end 71 | 72 | def on_options_covered_by?(validator) 73 | ([@options[:on]].flatten - [validator.options[:on]].flatten).empty? 74 | end 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/unit/validations_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Validations" do 4 | describe Site do 5 | it { should validate_presence_of(:name) } 6 | it { should validate_uniqueness_of(:name) } 7 | end 8 | 9 | describe User do 10 | it { should validate_presence_of(:login) } 11 | it { should validate_uniqueness_of(:login).scoped_to(:site) } 12 | it { should validate_uniqueness_of(:email).case_insensitive.with_message("is already taken") } 13 | it { should validate_format_of(:login).to_allow("valid_login").not_to_allow("invalid login") } 14 | it { should validate_associated(:profile) } 15 | it { should validate_exclusion_of(:login).to_not_allow("super", "index", "edit") } 16 | it { should validate_exclusion_of(:password).to_not_allow("password") } 17 | it { should validate_inclusion_of(:role).to_allow("admin", "member") } 18 | it { should validate_confirmation_of(:email) } 19 | it { should validate_presence_of(:age).on(:create, :update) } 20 | it { should validate_numericality_of(:age).on(:create, :update) } 21 | it { should validate_inclusion_of(:age).to_allow(23..42).on([:create, :update]) } 22 | it { should validate_presence_of(:password).on(:create) } 23 | it { should validate_presence_of(:provider_uid).on(:create) } 24 | it { should validate_inclusion_of(:locale).to_allow([:en, :ru]) } 25 | end 26 | 27 | describe Profile do 28 | it { should validate_numericality_of(:age).greater_than(0) } 29 | it { should validate_acceptance_of(:terms_of_service) } 30 | it { should validate_length_of(:hobbies).with_minimum(1).with_message("requires at least one hobby") } 31 | end 32 | 33 | describe Article do 34 | it { should validate_length_of(:title).within(8..16) } 35 | it { should_not validate_length_of(:content).greater_than(200).less_than(16) } 36 | it { should validate_length_of(:content).greater_than(200) } 37 | it { should validate_inclusion_of(:status).to_allow([:pending]).on( :create ) } 38 | it { should validate_inclusion_of(:status).to_allow([:approved, :rejected]).on( :update ) } 39 | end 40 | 41 | describe MovieArticle do 42 | it { should validate_numericality_of(:rating).greater_than(0) } 43 | it { should validate_numericality_of(:rating).to_allow(:greater_than => 0).less_than_or_equal_to(5) } 44 | it { should validate_numericality_of(:classification).to_allow(:even => true, :only_integer => true, :nil => false) } 45 | end 46 | 47 | describe Person do 48 | it { should custom_validate(:ssn).with_validator(SsnValidator) } 49 | it { should_not custom_validate(:name) } 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/matchers/validations/uniqueness_of.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Matchers 3 | module Validations 4 | class ValidateUniquenessOfMatcher < HaveValidationMatcher 5 | include WithMessage 6 | def initialize(field) 7 | super(field, :uniqueness) 8 | end 9 | 10 | def scoped_to(*scope) 11 | @scope = [scope].flatten.map(&:to_sym) 12 | self 13 | end 14 | alias_method :scoped_on, :scoped_to 15 | 16 | def case_insensitive 17 | @case_insensitive = true 18 | self 19 | end 20 | 21 | def allow_blank?(allow_blank) 22 | @allow_blank = allow_blank 23 | self 24 | end 25 | 26 | def matches?(actual) 27 | return false unless @result = super(actual) 28 | 29 | check_scope if @scope 30 | check_allow_blank if @allow_blank 31 | check_case_sensitivity if @case_insensitive 32 | check_expected_message if @expected_message 33 | 34 | @result 35 | end 36 | 37 | def description 38 | options_desc = [] 39 | options_desc << " scoped to #{@scope.inspect}" if @scope 40 | options_desc << " allowing blank values" if @allow_blank 41 | options_desc << " allowing case insensitive values" if @case_insensitive 42 | options_desc << " with message '#{@expected_message}'" if @expected_message 43 | super << options_desc.to_sentence 44 | end 45 | 46 | private 47 | 48 | def check_allow_blank 49 | if @validator.options[:allow_blank] == @allow_blank 50 | @positive_result_message << " with blank values allowed" 51 | else 52 | @negative_result_message << " with no blank values allowed" 53 | @result = false 54 | end 55 | end 56 | 57 | def check_scope 58 | message = " scope to #{@validator.options[:scope]}" 59 | if [@validator.options[:scope]].flatten.map(&:to_sym) == @scope 60 | @positive_result_message << message 61 | else 62 | @negative_result_message << message 63 | @result = false 64 | end 65 | end 66 | 67 | def check_case_sensitivity 68 | if @validator.options[:case_sensitive] == false 69 | @positive_result_message << " with case insensitive values" 70 | else 71 | @negative_result_message << " without case insensitive values" 72 | @result = false 73 | end 74 | end 75 | end 76 | 77 | def validate_uniqueness_of(field) 78 | ValidateUniquenessOfMatcher.new(field) 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/matchers/validations/numericality_of.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Matchers 3 | module Validations 4 | class ValidateNumericalityOfMatcher < HaveValidationMatcher 5 | @@allowed_options = [:equal_to, :greater_than, :greater_than_or_equal_to, :less_than, :less_than_or_equal_to, 6 | :even, :odd, :only_integer, :allow_nil, :nil] 7 | 8 | def initialize(field) 9 | super(field, :numericality) 10 | @options = {} 11 | end 12 | 13 | def to_allow(options) 14 | options[:equal_to] = options if options.is_a?(Numeric) 15 | options[:allow_nil] = options.delete(:nil) if options.has_key?(:nil) 16 | raise ArgumentError, "validate_numericality_of#to_allow requires a Hash parameter containing any of the following keys: " << 17 | @@allowed_options.map(&:inspect).join(", ") if !options.is_a?(Hash) or options.empty? or (options.keys - @@allowed_options).any? 18 | @options.merge!(options) 19 | self 20 | end 21 | 22 | def matches?(actual) 23 | return false unless result = super(actual) 24 | 25 | @@allowed_options.each do |comparator| 26 | if @options.has_key?(comparator) and !([:even, :odd, :only_integer].include?(comparator) and !@validator.options.include?(comparator)) 27 | result &= (@validator.options[comparator] == @options[comparator]) 28 | end 29 | end 30 | @positive_result_message <<= options_message(@validator.options) 31 | @negative_result_message <<= options_message(@validator.options) 32 | result 33 | end 34 | 35 | def description 36 | super << options_message(@options) 37 | end 38 | 39 | protected 40 | 41 | def options_message(options) 42 | type_msg = [] 43 | comp_msg = [] 44 | options.each_pair do |key, value| 45 | case key 46 | when :allow_nil 47 | when :only_integer 48 | type_msg << "integer" if value 49 | when :odd, :even 50 | type_msg << "#{key.to_s}-numbered" if value 51 | else 52 | comp_msg << "#{key.to_s.gsub("_", " ")} #{value.inspect}" 53 | end 54 | end 55 | allow_nil = (options[:allow_nil] ? "nil" : "non-nil") if options.has_key?(:allow_nil) 56 | ["", "allowing", allow_nil, type_msg.any? ? type_msg.to_sentence : nil, "values", comp_msg.any? ? comp_msg.to_sentence : nil].compact.join(" ") 57 | end 58 | 59 | def method_missing(m, *args, &block) 60 | if @@allowed_options.include?(m.to_sym) 61 | raise ArgumentError, "wrong number of arguments (#{args.length} for 1)" if args.length > 1 62 | send :to_allow, m.to_sym => args.first 63 | else 64 | super 65 | end 66 | end 67 | end 68 | 69 | def validate_numericality_of(field) 70 | ValidateNumericalityOfMatcher.new(field) 71 | end 72 | end 73 | end 74 | end -------------------------------------------------------------------------------- /lib/matchers/allow_mass_assignment.rb: -------------------------------------------------------------------------------- 1 | # this code is totally extracted from shoulda-matchers gem. 2 | module Mongoid 3 | module Matchers 4 | class AllowMassAssignmentOfMatcher # :nodoc: 5 | attr_reader :failure_message, :negative_failure_message 6 | 7 | def initialize(attribute) 8 | @attribute = attribute.to_s 9 | @options = {} 10 | end 11 | 12 | def as(role) 13 | if active_model_less_than_3_1? 14 | raise "You can specify role only in Rails 3.1 or greater" 15 | end 16 | @options[:role] = role 17 | self 18 | end 19 | 20 | def matches?(klass) 21 | @klass = klass 22 | if attr_mass_assignable? 23 | if whitelisting? 24 | @negative_failure_message = "#{@attribute} was made accessible" 25 | else 26 | if protected_attributes.empty? 27 | @negative_failure_message = "no attributes were protected" 28 | else 29 | @negative_failure_message = "#{class_name} is protecting " << 30 | "#{protected_attributes.to_a.to_sentence}, " << 31 | "but not #{@attribute}." 32 | end 33 | end 34 | true 35 | else 36 | if whitelisting? 37 | @failure_message = "Expected #{@attribute} to be accessible" 38 | else 39 | @failure_message = "Did not expect #{@attribute} to be protected" 40 | end 41 | false 42 | end 43 | end 44 | 45 | def description 46 | "allow mass assignment of #{@attribute}" 47 | end 48 | 49 | private 50 | 51 | def role 52 | @options[:role] || :default 53 | end 54 | 55 | def protected_attributes 56 | @protected_attributes ||= (@klass.class.protected_attributes || []) 57 | end 58 | 59 | def accessible_attributes 60 | @accessible_attributes ||= (@klass.class.accessible_attributes || []) 61 | end 62 | 63 | def whitelisting? 64 | authorizer.kind_of?(::ActiveModel::MassAssignmentSecurity::WhiteList) 65 | end 66 | 67 | def attr_mass_assignable? 68 | !authorizer.deny?(@attribute) 69 | end 70 | 71 | def authorizer 72 | if active_model_less_than_3_1? 73 | @klass.class.active_authorizer 74 | else 75 | @klass.class.active_authorizer[role] 76 | end 77 | end 78 | 79 | def class_name 80 | @klass.class.name 81 | end 82 | 83 | def active_model_less_than_3_1? 84 | ::ActiveModel::VERSION::STRING.to_f < 3.1 85 | end 86 | end 87 | 88 | # Ensures that the attribute can be set on mass update. 89 | # 90 | # it { should_not allow_mass_assignment_of(:password) } 91 | # it { should allow_mass_assignment_of(:first_name) } 92 | # 93 | # In Rails 3.1 you can check role as well: 94 | # 95 | # it { should allow_mass_assignment_of(:first_name).as(:admin) } 96 | # 97 | def allow_mass_assignment_of(value) 98 | AllowMassAssignmentOfMatcher.new(value) 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/matchers/document.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Matchers 3 | class HaveFieldMatcher # :nodoc: 4 | def initialize(*attrs) 5 | @attributes = attrs.collect(&:to_s) 6 | end 7 | 8 | def localized 9 | @localized = true 10 | self 11 | end 12 | 13 | def of_type(type) 14 | @type = type 15 | self 16 | end 17 | 18 | def with_alias(field_alias) 19 | @field_alias = field_alias 20 | self 21 | end 22 | 23 | def with_default_value_of(default) 24 | @default = default 25 | self 26 | end 27 | 28 | def matches?(klass) 29 | @klass = klass.is_a?(Class) ? klass : klass.class 30 | @errors = [] 31 | @attributes.each do |attr| 32 | if @klass.fields.include?(attr) 33 | error = "" 34 | if @type and @klass.fields[attr].type != @type 35 | error << " of type #{@klass.fields[attr].type}" 36 | end 37 | 38 | if !@default.nil? 39 | if @klass.fields[attr].default_val.nil? 40 | error << " with default not set" 41 | elsif @klass.fields[attr].default_val != @default 42 | error << " with default value of #{@klass.fields[attr].default_val}" 43 | end 44 | end 45 | 46 | if @field_alias and @klass.fields[attr].options[:as] != @field_alias 47 | error << " with alias #{@klass.fields[attr].options[:as]}" 48 | end 49 | 50 | @errors.push("field #{attr.inspect}" << error) unless error.blank? 51 | 52 | if @localized 53 | unless @klass.fields[attr].localized? 54 | @errors.push "is not localized #{attr.inspect}" 55 | end 56 | end 57 | 58 | else 59 | @errors.push "no field named #{attr.inspect}" 60 | end 61 | end 62 | @errors.empty? 63 | end 64 | 65 | def failure_message_for_should 66 | "Expected #{@klass.inspect} to #{description}, got #{@errors.to_sentence}" 67 | end 68 | 69 | def failure_message_for_should_not 70 | "Expected #{@klass.inspect} to not #{description}, got #{@klass.inspect} to #{description}" 71 | end 72 | 73 | def description 74 | desc = "have #{@attributes.size > 1 ? 'fields' : 'field'} named #{@attributes.collect(&:inspect).to_sentence}" 75 | desc << " of type #{@type.inspect}" if @type 76 | desc << " with alias #{@field_alias}" if @field_alias 77 | desc << " with default value of #{@default.inspect}" if !@default.nil? 78 | desc 79 | end 80 | end 81 | 82 | def have_field(*args) 83 | HaveFieldMatcher.new(*args) 84 | end 85 | alias_method :have_fields, :have_field 86 | end 87 | end 88 | 89 | RSpec::Matchers.define :have_instance_method do |name| 90 | match do |klass| 91 | klass.instance_methods.include?(name.to_sym) 92 | end 93 | 94 | description do 95 | "have instance method #{name.to_s}" 96 | end 97 | end 98 | 99 | RSpec::Matchers.define :be_mongoid_document do 100 | match do |doc| 101 | doc.class.included_modules.include?(Mongoid::Document) 102 | end 103 | 104 | description do 105 | "be a Mongoid document" 106 | end 107 | end 108 | 109 | RSpec::Matchers.define :be_versioned_document do 110 | match do |doc| 111 | doc.class.included_modules.include?(Mongoid::Versioning) 112 | end 113 | 114 | description do 115 | "be a versioned Mongoid document" 116 | end 117 | end 118 | 119 | RSpec::Matchers.define :be_timestamped_document do 120 | match do |doc| 121 | if [*@timestamped_module].any? 122 | modules = [*@timestamped_module].map{|m| "Mongoid::Timestamps::#{m.to_s.classify}".constantize } 123 | (modules - doc.class.included_modules).empty? 124 | else 125 | doc.class.included_modules.include?(Mongoid::Timestamps) or 126 | doc.class.included_modules.include?(Mongoid::Timestamps::Created) or 127 | doc.class.included_modules.include?(Mongoid::Timestamps::Updated) 128 | end 129 | end 130 | 131 | chain :with do |timestamped_module| 132 | @timestamped_module = timestamped_module 133 | end 134 | 135 | description do 136 | desc = "be a timestamped Mongoid document" 137 | desc << " with #{@timestamped_module}" if @timestamped_module 138 | desc 139 | end 140 | end 141 | 142 | RSpec::Matchers.define :be_paranoid_document do 143 | match do |doc| 144 | doc.class.included_modules.include?(Mongoid::Paranoia) 145 | end 146 | 147 | description do 148 | "be a paranoid Mongoid document" 149 | end 150 | end 151 | 152 | RSpec::Matchers.define :be_multiparameted_document do 153 | match do |doc| 154 | doc.class.included_modules.include?(Mongoid::MultiParameterAttributes) 155 | end 156 | 157 | description do 158 | "be a multiparameted Mongoid document" 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /lib/matchers/validations/length_of.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Matchers 3 | module Validations 4 | class ValidateLengthOfMatcher < HaveValidationMatcher 5 | include WithMessage 6 | 7 | def initialize(name) 8 | super(name, :length) 9 | end 10 | 11 | def with_maximum(value) 12 | @maximum = value 13 | self 14 | end 15 | alias :less_than :with_maximum 16 | 17 | def with_minimum(value) 18 | @minimum = value 19 | self 20 | end 21 | alias :greater_than :with_minimum 22 | 23 | def within(value) 24 | @within = value 25 | self 26 | end 27 | alias :in :within 28 | 29 | def as_exactly(value) 30 | @is = value 31 | self 32 | end 33 | alias :is :as_exactly 34 | 35 | def with_message(message) 36 | @expected_message = message 37 | self 38 | end 39 | 40 | def matches?(actual) 41 | return false unless @result = super(actual) 42 | 43 | check_maximum if @maximum 44 | check_minimum if @minimum 45 | check_range if @within 46 | check_exact if @is 47 | check_expected_message if @expected_message 48 | 49 | @result 50 | end 51 | 52 | def description 53 | options_desc = [] 54 | options_desc << "with minimum of #{@minimum}" if @minimum 55 | options_desc << "with maximum of #{@maximum}" if @maximum 56 | options_desc << "within the range of #{@within}" if @within 57 | options_desc << "as exactly #{@is}" if @is 58 | options_desc << "with message '#{@expected_message}'" if @expected_message 59 | super << " #{options_desc.to_sentence}" 60 | end 61 | 62 | private 63 | 64 | def check_maximum 65 | if actual_max.nil? 66 | @negative_result_message << " with no maximum" 67 | @result = false 68 | elsif actual_max == @maximum 69 | @positive_result_message << " with maximum of #{@maximum}" 70 | else 71 | @negative_result_message << " with maximum of #{actual_max}" 72 | @result = false 73 | end 74 | end 75 | 76 | def check_minimum 77 | if actual_min.nil? 78 | @negative_result_message << " with no minimum" 79 | @result = false 80 | elsif actual_min == @minimum 81 | @positive_result_message << " with minimum of #{@minimum}" 82 | else 83 | @negative_result_message << " with minimum of #{actual_min}" 84 | @result = false 85 | end 86 | end 87 | 88 | def check_range 89 | min, max = [@within.min, @within.max] 90 | if !actual_min.nil? and actual_max.nil? 91 | @negative_result_message << " with no minimum but with maximum of #{actual_max}" 92 | @result = false 93 | elsif actual_min.nil? and !actual_max.nil? 94 | @negative_result_message << " with minimum_of #{actual_min} but no maximum" 95 | @result = false 96 | elsif actual_min.nil? and actual_max.nil? 97 | @negative_result_message << " with no minimum and maximum" 98 | @result = false 99 | elsif actual_min == min && actual_max == max 100 | @positive_result_message << " within the range of #{@within.inspect}" 101 | else 102 | @negative_result_message << " within the range of #{(actual_min..actual_max).inspect}" 103 | @result = false 104 | end 105 | end 106 | 107 | def check_exact 108 | if actual_is == @is 109 | @positive_result_message << " as exactly #{@is}" 110 | else 111 | @negative_result_message << " as exactly #{actual_is}" 112 | @result = false 113 | end 114 | end 115 | 116 | def check_expected_message 117 | actual_message = @validator.options[:message] 118 | if actual_message.nil? 119 | @negative_result_message << " with no custom message" 120 | @result = false 121 | elsif actual_message == @expected_message 122 | @positive_result_message << " with custom message '#{@expected_message}'" 123 | else 124 | @negative_result_message << " got message '#{actual_message}'" 125 | @result = false 126 | end 127 | end 128 | 129 | def actual_is 130 | actual_is = @validator.options[:is] 131 | end 132 | 133 | def actual_min 134 | @validator.options[:minimum] || ((@validator.options[:in] || @validator.options[:within]).try(&:min)) 135 | end 136 | 137 | def actual_max 138 | @validator.options[:maximum] || ((@validator.options[:in] || @validator.options[:within]).try(&:max)) 139 | end 140 | end 141 | 142 | def validate_length_of(field) 143 | ValidateLengthOfMatcher.new(field) 144 | end 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | mongoid-rspec 2 | = 3 | 4 | [![Build Status](https://secure.travis-ci.org/evansagge/mongoid-rspec.png?branch=master)](https://travis-ci.org/evansagge/mongoid-rspec) 5 | 6 | http://rubygems.org/gems/mongoid-rspec 7 | 8 | RSpec matchers for Mongoid 3.x. 9 | 10 | For Mongoid 2.x, use [mongoid-rspec 1.4.5](http://rubygems.org/gems/mongoid-rspec/versions/1.4.5) 11 | 12 | Installation 13 | - 14 | Add to your Gemfile 15 | 16 | gem 'mongoid-rspec' 17 | 18 | Drop in existing or dedicated support file in spec/support (spec/support/mongoid.rb) 19 | 20 | ```ruby 21 | RSpec.configure do |configuration| 22 | configuration.include Mongoid::Matchers 23 | end 24 | ``` 25 | 26 | Association Matchers 27 | - 28 | 29 | ```ruby 30 | describe User do 31 | it { should have_many(:articles).with_foreign_key(:author_id).ordered_by(:title) } 32 | 33 | it { should have_one(:record) } 34 | #can verify autobuild is set to true 35 | it { should have_one(:record).with_autobuild } 36 | 37 | it { should have_many :comments } 38 | 39 | #can also specify with_dependent to test if :dependent => :destroy/:destroy_all/:delete is set 40 | it { should have_many(:comments).with_dependent(:destroy) } 41 | #can verify autosave is set to true 42 | it { should have_many(:comments).with_autosave } 43 | 44 | it { should embed_one :profile } 45 | 46 | it { should have_and_belong_to_many(:children) } 47 | it { should have_and_belong_to_many(:children).of_type(User) } 48 | end 49 | 50 | describe Profile do 51 | it { should be_embedded_in(:user).as_inverse_of(:profile) } 52 | end 53 | 54 | describe Article do 55 | it { should belong_to(:author).of_type(User).as_inverse_of(:articles) } 56 | it { should belong_to(:author).of_type(User).as_inverse_of(:articles).with_index } 57 | it { should embed_many(:comments) } 58 | end 59 | 60 | describe Comment do 61 | it { should be_embedded_in(:article).as_inverse_of(:comments) } 62 | it { should belong_to(:user).as_inverse_of(:comments) } 63 | end 64 | 65 | describe Record do 66 | it { should belong_to(:user).as_inverse_of(:record) } 67 | end 68 | 69 | describe Site do 70 | it { should have_many(:users).as_inverse_of(:site).ordered_by(:email.asc) } 71 | end 72 | ``` 73 | 74 | Mass Assignment Matcher 75 | - 76 | 77 | ```ruby 78 | describe User do 79 | it { should allow_mass_assignment_of(:login) } 80 | it { should allow_mass_assignment_of(:email) } 81 | it { should allow_mass_assignment_of(:age) } 82 | it { should allow_mass_assignment_of(:password) } 83 | it { should allow_mass_assignment_of(:password) } 84 | it { should allow_mass_assignment_of(:role).as(:admin) } 85 | 86 | it { should_not allow_mass_assignment_of(:role) } 87 | end 88 | ``` 89 | 90 | Validation Matchers 91 | - 92 | 93 | ```ruby 94 | describe Site do 95 | it { should validate_presence_of(:name) } 96 | it { should validate_uniqueness_of(:name) } 97 | end 98 | 99 | describe User do 100 | it { should validate_presence_of(:login) } 101 | it { should validate_uniqueness_of(:login).scoped_to(:site) } 102 | it { should validate_uniqueness_of(:email).case_insensitive.with_message("is already taken") } 103 | it { should validate_format_of(:login).to_allow("valid_login").not_to_allow("invalid login") } 104 | it { should validate_associated(:profile) } 105 | it { should validate_exclusion_of(:login).to_not_allow("super", "index", "edit") } 106 | it { should validate_inclusion_of(:role).to_allow("admin", "member") } 107 | it { should validate_confirmation_of(:email) } 108 | it { should validate_presence_of(:age).on(:create, :update) } 109 | it { should validate_numericality_of(:age).on(:create, :update) } 110 | it { should validate_inclusion_of(:age).to_allow(23..42).on([:create, :update]) } 111 | it { should validate_presence_of(:password).on(:create) } 112 | it { should validate_presence_of(:provider_uid).on(:create) } 113 | it { should validate_inclusion_of(:locale).to_allow([:en, :ru]) } 114 | end 115 | 116 | describe Article do 117 | it { should validate_length_of(:title).within(8..16) } 118 | end 119 | 120 | describe Profile do 121 | it { should validate_numericality_of(:age).greater_than(0) } 122 | end 123 | 124 | describe MovieArticle do 125 | it { should validate_numericality_of(:rating).to_allow(:greater_than => 0).less_than_or_equal_to(5) } 126 | it { should validate_numericality_of(:classification).to_allow(:even => true, :only_integer => true, :nil => false) } 127 | end 128 | 129 | describe Person do 130 | # in order to be able to use the custom_validate matcher, the custom validator class (in this case SsnValidator) 131 | # should redefine the kind method to return :custom, i.e. "def self.kind() :custom end" 132 | it { should custom_validate(:ssn).with_validator(SsnValidator) } 133 | end 134 | ``` 135 | 136 | Accepts Nested Attributes Matcher 137 | - 138 | 139 | ```ruby 140 | describe User do 141 | it { should accept_nested_attributes_for(:articles) } 142 | it { should accept_nested_attributes_for(:comments) } 143 | end 144 | 145 | describe Article do 146 | it { should accept_nested_attributes_for(:permalink) } 147 | end 148 | ``` 149 | 150 | Index Matcher 151 | - 152 | 153 | ```ruby 154 | describe Article do 155 | it { should have_index_for(published: 1) } 156 | it { should have_index_for(title: 1).with_options(unique: true, background: true) } 157 | end 158 | 159 | describe Profile do 160 | it { should have_index_for(first_name: 1, last_name: 1) } 161 | end 162 | ``` 163 | 164 | Others 165 | - 166 | 167 | ```ruby 168 | describe User do 169 | it { should have_fields(:email, :login) } 170 | it { should have_field(:s).with_alias(:status) } 171 | it { should have_fields(:birthdate, :registered_at).of_type(DateTime) } 172 | 173 | # if you're declaring 'include Mongoid::Timestamps' 174 | # or any of 'include Mongoid::Timestamps::Created' and 'Mongoid::Timestamps::Updated' 175 | it { should be_timestamped_document } 176 | it { should be_timestamped_document.with(:created) } 177 | it { should_not be_timestamped_document.with(:updated) } 178 | 179 | it { should be_versioned_document } # if you're declaring `include Mongoid::Versioning` 180 | it { should be_paranoid_document } # if you're declaring `include Mongoid::Paranoia` 181 | it { should be_multiparameted_document } # if you're declaring `include Mongoid::MultiParameterAttributes` 182 | end 183 | 184 | describe Log do 185 | it { should be_stored_in :logs } 186 | end 187 | 188 | describe Article do 189 | it { should have_field(:published).of_type(Boolean).with_default_value_of(false) } 190 | it { should have_field(:allow_comments).of_type(Boolean).with_default_value_of(true) } 191 | it { should_not have_field(:allow_comments).of_type(Boolean).with_default_value_of(false) } 192 | it { should_not have_field(:number_of_comments).of_type(Integer).with_default_value_of(1) } 193 | end 194 | ``` 195 | 196 | Known issues 197 | - 198 | 199 | accept_nested_attributes_for matcher must test options [issue 91](https://github.com/evansagge/mongoid-rspec/issues/91). 200 | 201 | Acknowledgement 202 | - 203 | Thanks to [Durran Jordan](https://github.com/durran) for providing the changes necessary to make 204 | this compatible with mongoid 2.0.0.rc, and for other [contributors](https://github.com/evansagge/mongoid-rspec/contributors) 205 | to this project. 206 | -------------------------------------------------------------------------------- /lib/matchers/associations.rb: -------------------------------------------------------------------------------- 1 | require 'mongoid/relations' 2 | 3 | module Mongoid 4 | module Matchers 5 | module Associations 6 | 7 | HAS_MANY = Mongoid::Relations::Referenced::Many 8 | HAS_AND_BELONGS_TO_MANY = Mongoid::Relations::Referenced::ManyToMany 9 | HAS_ONE = Mongoid::Relations::Referenced::One 10 | BELONGS_TO = Mongoid::Relations::Referenced::In 11 | EMBEDS_MANY = Mongoid::Relations::Embedded::Many 12 | EMBEDS_ONE = Mongoid::Relations::Embedded::One 13 | EMBEDDED_IN = Mongoid::Relations::Embedded::In 14 | 15 | 16 | class HaveAssociationMatcher 17 | def initialize(name, association_type) 18 | @association = {} 19 | @association[:name] = name.to_s 20 | @association[:type] = association_type 21 | # begin 22 | # @association[:class] = name.to_s.classify.constantize 23 | # rescue 24 | # end 25 | @expectation_message = "#{type_description} #{@association[:name].inspect}" 26 | @expectation_message << " of type #{@association[:class].inspect}" unless @association[:class].nil? 27 | end 28 | 29 | def of_type(klass) 30 | @association[:class] = klass 31 | @expectation_message << " of type #{@association[:class].inspect}" 32 | self 33 | end 34 | 35 | def as_inverse_of(association_inverse_name) 36 | raise "#{@association[:type].inspect} does not respond to :inverse_of" unless [HAS_MANY, HAS_AND_BELONGS_TO_MANY, BELONGS_TO, EMBEDDED_IN].include?(@association[:type]) 37 | @association[:inverse_of] = association_inverse_name.to_s 38 | @expectation_message << " which is an inverse of #{@association[:inverse_of].inspect}" 39 | self 40 | end 41 | 42 | def ordered_by(association_field_name) 43 | raise "#{@association[:type].inspect} does not respond to :order" unless [HAS_MANY, HAS_AND_BELONGS_TO_MANY, EMBEDS_MANY].include?(@association[:type]) 44 | @association[:order] = association_field_name.to_s 45 | @expectation_message << " ordered by #{@association[:order].inspect}" 46 | 47 | if association_field_name.is_a? Origin::Key 48 | @association[:order_operator] = association_field_name.operator 49 | @expectation_message << " #{order_way(@association[:order_operator])}" 50 | end 51 | 52 | self 53 | end 54 | 55 | def with_dependent(method_name) 56 | @association[:dependent] = method_name 57 | @expectation_message << " which specifies dependent as #{@association[:dependent].to_s}" 58 | self 59 | end 60 | 61 | def with_autosave 62 | @association[:autosave] = true 63 | @expectation_message << " which specifies autosave as #{@association[:autosave].to_s}" 64 | self 65 | end 66 | 67 | def with_index 68 | @association[:index] = true 69 | @expectation_message << " which specifies index as #{@association[:index].to_s}" 70 | self 71 | end 72 | 73 | def with_autobuild 74 | @association[:autobuild] = true 75 | @expectation_message << " which specifies autobuild as #{@association[:autobuild].to_s}" 76 | self 77 | end 78 | 79 | def stored_as(store_as) 80 | raise NotImplementedError, "`references_many #{@association[:name]} :stored_as => :array` has been removed in Mongoid 2.0.0.rc, use `references_and_referenced_in_many #{@association[:name]}` instead" 81 | end 82 | 83 | def with_foreign_key(foreign_key) 84 | @association[:foreign_key] = foreign_key.to_s 85 | @expectation_message << " using foreign key #{@association[:foreign_key].inspect}" 86 | self 87 | end 88 | 89 | def matches?(actual) 90 | @actual = actual.is_a?(Class) ? actual : actual.class 91 | metadata = @actual.relations[@association[:name]] 92 | 93 | if metadata.nil? 94 | @negative_result_message = "no association named #{@association[:name]}" 95 | return false 96 | else 97 | @positive_result_message = "association named #{@association[:name]}" 98 | end 99 | 100 | relation = metadata.relation 101 | if relation != @association[:type] 102 | @negative_result_message = "#{@actual.inspect} #{type_description(relation, false)} #{@association[:name]}" 103 | return false 104 | else 105 | @positive_result_message = "#{@actual.inspect} #{type_description(relation, false)} #{@association[:name]}" 106 | end 107 | 108 | if !@association[:class].nil? and @association[:class] != metadata.klass 109 | @negative_result_message = "#{@positive_result_message} of type #{metadata.klass.inspect}" 110 | return false 111 | else 112 | @positive_result_message = "#{@positive_result_message}#{" of type #{metadata.klass.inspect}" if @association[:class]}" 113 | end 114 | 115 | if @association[:inverse_of] 116 | if @association[:inverse_of].to_s != metadata.inverse_of.to_s 117 | @negative_result_message = "#{@positive_result_message} which is an inverse of #{metadata.inverse_of}" 118 | return false 119 | else 120 | @positive_result_message = "#{@positive_result_message} which is an inverse of #{metadata.inverse_of}" 121 | end 122 | end 123 | 124 | if @association[:order] 125 | if @association[:order].to_s != metadata.order.to_s 126 | @negative_result_message = "#{@positive_result_message} ordered by #{metadata.order}" 127 | return false 128 | else 129 | @positive_result_message = "#{@positive_result_message} ordered by #{metadata.order}" 130 | end 131 | end 132 | 133 | if @association[:order_operator] 134 | if @association[:order_operator] != metadata.order.operator 135 | @negative_result_message = "#{@positive_result_message} #{order_way(@association[:order_operator] * -1)}" 136 | return false 137 | else 138 | @positive_result_message = "#{@positive_result_message} #{order_way(@association[:order_operator])}" 139 | end 140 | end 141 | 142 | if @association[:dependent] 143 | if @association[:dependent].to_s != metadata.dependent.to_s 144 | @negative_result_message = "#{@positive_result_message} which specified dependent as #{metadata.dependent}" 145 | return false 146 | else 147 | @positive_result_message = "#{@positive_result_message} which specified dependent as #{metadata.dependent}" 148 | end 149 | end 150 | 151 | if @association[:autosave] 152 | if metadata.autosave != true 153 | @negative_result_message = "#{@positive_result_message} which did not set autosave" 154 | return false 155 | else 156 | @positive_result_message = "#{@positive_result_message} which set autosave" 157 | end 158 | end 159 | 160 | if @association[:autobuild] 161 | if metadata.autobuilding? != true 162 | @negative_result_message = "#{@positive_result_message} which did not set autobuild" 163 | return false 164 | else 165 | @positive_result_message = "#{@positive_result_message} which set autobuild" 166 | end 167 | end 168 | 169 | if @association[:index] 170 | if metadata.index != true 171 | @negative_result_message = "#{@positive_result_message} which did not set index" 172 | return false 173 | else 174 | @positive_result_message = "#{@positive_result_message} which set index" 175 | end 176 | end 177 | 178 | if @association[:foreign_key] 179 | if metadata.foreign_key != @association[:foreign_key] 180 | @negative_result_message = "#{@positive_result_message} with foreign key #{metadata.foreign_key.inspect}" 181 | return false 182 | else 183 | @positive_result_message = "#{@positive_result_message} with foreign key #{metadata.foreign_key.inspect}" 184 | end 185 | end 186 | 187 | return true 188 | end 189 | 190 | def failure_message_for_should 191 | "Expected #{@actual.inspect} to #{@expectation_message}, got #{@negative_result_message}" 192 | end 193 | 194 | def failure_message_for_should_not 195 | "Expected #{@actual.inspect} to not #{@expectation_message}, got #{@positive_result_message}" 196 | end 197 | 198 | def description 199 | @expectation_message 200 | end 201 | 202 | def type_description(type = nil, passive = true) 203 | type ||= @association[:type] 204 | case type.name 205 | when EMBEDS_ONE.name 206 | (passive ? 'embed' : 'embeds') << ' one' 207 | when EMBEDS_MANY.name 208 | (passive ? 'embed' : 'embeds') << ' many' 209 | when EMBEDDED_IN.name 210 | (passive ? 'be' : 'is') << ' embedded in' 211 | when HAS_ONE.name 212 | (passive ? 'reference' : 'references') << ' one' 213 | when HAS_MANY.name 214 | (passive ? 'reference' : 'references') << ' many' 215 | when HAS_AND_BELONGS_TO_MANY.name 216 | (passive ? 'reference' : 'references') << ' and referenced in many' 217 | when BELONGS_TO.name 218 | (passive ? 'be referenced in' : 'referenced in') 219 | else 220 | raise "Unknown association type '%s'" % type 221 | end 222 | end 223 | 224 | private 225 | 226 | def order_way(operator) 227 | [nil, "ascending", "descending"][operator] 228 | end 229 | end 230 | 231 | def embed_one(association_name) 232 | HaveAssociationMatcher.new(association_name, EMBEDS_ONE) 233 | end 234 | 235 | def embed_many(association_name) 236 | HaveAssociationMatcher.new(association_name, EMBEDS_MANY) 237 | end 238 | 239 | def be_embedded_in(association_name) 240 | HaveAssociationMatcher.new(association_name, EMBEDDED_IN) 241 | end 242 | 243 | def have_one_related(association_name) 244 | HaveAssociationMatcher.new(association_name, HAS_ONE) 245 | end 246 | alias :have_one :have_one_related 247 | 248 | def have_many_related(association_name) 249 | HaveAssociationMatcher.new(association_name, HAS_MANY) 250 | end 251 | alias :have_many :have_many_related 252 | 253 | def have_and_belong_to_many(association_name) 254 | HaveAssociationMatcher.new(association_name, HAS_AND_BELONGS_TO_MANY) 255 | end 256 | 257 | def belong_to_related(association_name) 258 | HaveAssociationMatcher.new(association_name, BELONGS_TO) 259 | end 260 | alias :belong_to :belong_to_related 261 | end 262 | end 263 | end 264 | --------------------------------------------------------------------------------