diff --git a/.rubocop.yml b/.rubocop.yml index f002160f6..418d36920 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,13 +1,24 @@ +require: rubocop-rails AllCops: TargetRubyVersion: 2.4 +Layout/AlignArguments: + EnforcedStyle: with_fixed_indentation Layout/AlignParameters: EnforcedStyle: with_fixed_indentation +Layout/CommentIndentation: + Enabled: false Layout/ConditionPosition: Enabled: false Layout/DotPosition: EnforcedStyle: trailing +Layout/EmptyLineBetweenDefs: + AllowAdjacentOneLineDefs: true +Layout/IndentHeredoc: + Enabled: false Layout/MultilineMethodCallIndentation: EnforcedStyle: indented +Layout/SpaceInLambdaLiteral: + EnforcedStyle: require_space Lint/AmbiguousOperator: Enabled: false Lint/AmbiguousRegexpLiteral: @@ -18,9 +29,9 @@ Lint/DeprecatedClassMethods: Enabled: false Lint/ElseLayout: Enabled: false -Lint/HandleExceptions: +Lint/FlipFlop: Enabled: false -Lint/IndentHeredoc: +Lint/HandleExceptions: Enabled: false Lint/LiteralInInterpolation: Enabled: false @@ -34,12 +45,15 @@ Lint/UnderscorePrefixedVariableName: Enabled: false Lint/Void: Enabled: false +Metrics/AbcSize: + Max: 25 Metrics/BlockLength: Enabled: false Metrics/ClassLength: Enabled: false Metrics/LineLength: IgnoredPatterns: + - "^[ ]*#.+$" - "^[ ]*describe.+$" - "^[ ]*context.+$" - "^[ ]*shared_context.+$" @@ -47,9 +61,12 @@ Metrics/LineLength: - "^[ ]*it.+$" - "^[ ]*'.+?' => '.+?',?$" - "^[ ]*\".+?\" => \".+?\",?$" - - "^[ ]*.+?: .+?$" Metrics/MethodLength: Max: 30 +Metrics/ParameterLists: + CountKeywordArgs: false +Metrics/PerceivedComplexity: + Max: 10 Naming/AccessorMethodName: Enabled: false Naming/AsciiIdentifiers: @@ -60,6 +77,8 @@ Naming/MemoizedInstanceVariableName: EnforcedStyleForLeadingUnderscores: required Naming/PredicateName: Enabled: false +Style/BlockDelimiters: + Enabled: false Style/ClassVars: Enabled: false Style/ColonMethodCall: @@ -87,6 +106,7 @@ Style/CharacterLiteral: Style/ClassAndModuleChildren: Enabled: false Style/CollectionMethods: + Enabled: true PreferredMethods: find: detect reduce: inject @@ -106,8 +126,6 @@ Style/Encoding: Enabled: false Style/EvenOdd: Enabled: false -Style/FlipFlop: - Enabled: false Style/FormatString: Enabled: false Style/FrozenStringLiteralComment: diff --git a/.travis.yml b/.travis.yml index 414ff4eb9..65b135844 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,20 +1,31 @@ language: ruby -sudo: false +addons: + postgresql: "10" + apt: + packages: + - postgresql-10 + - postgresql-client-10 env: - - DATABASE_ADAPTER=sqlite3 - - DATABASE_ADAPTER=postgresql + global: + - PGPORT=5433 + matrix: + - DATABASE_ADAPTER=sqlite3 + - DATABASE_ADAPTER=postgresql rvm: - - 2.4.4 - - 2.5.4 - - 2.6.2 + - 2.4.6 + - 2.5.5 + - 2.6.3 gemfile: - gemfiles/rails_4_2.gemfile - gemfiles/rails_5_0.gemfile - gemfiles/rails_5_1.gemfile - gemfiles/rails_5_2.gemfile + - gemfiles/rails_6_0.gemfile matrix: exclude: - - rvm: 2.6.2 + - rvm: 2.4.6 + gemfile: gemfiles/rails_6_0.gemfile + - rvm: 2.6.3 gemfile: gemfiles/rails_4_2.gemfile cache: bundler # Source: diff --git a/Appraisals b/Appraisals index eaeaeba29..13c3472c6 100644 --- a/Appraisals +++ b/Appraisals @@ -12,8 +12,7 @@ shared_jruby_dependencies = proc do end shared_rails_dependencies = proc do - gem 'sqlite3', platform: :ruby - gem 'pg', platform: :ruby + gem 'sqlite3', '~> 1.3.6', platform: :ruby end shared_spring_dependencies = proc do @@ -23,7 +22,6 @@ end shared_test_dependencies = proc do gem 'minitest-reporters' - # gem 'nokogiri', '~> 1.8' gem 'rspec-rails', '~> 3.6' gem 'shoulda-context', '~> 1.2.0' end @@ -48,10 +46,11 @@ appraise 'rails_4_2' do gem 'sdoc', '~> 0.4.0', group: :doc gem 'bcrypt', '~> 3.1.7' - # Other dependencies we use + # Other dependencies gem 'activeresource', '4.0.0' gem 'json', '~> 1.4' gem 'protected_attributes', '~> 1.0.6' + gem 'pg', '~> 0.15', platform: :ruby end appraise 'rails_5_0' do @@ -67,6 +66,9 @@ appraise 'rails_5_0' do gem 'bcrypt', '~> 3.1.7' gem 'listen', '~> 3.0.5' gem 'spring-watcher-listen', '~> 2.0.0' + + # Other dependencies + gem 'pg', '~> 1.1', platform: :ruby end appraise 'rails_5_1' do @@ -83,6 +85,9 @@ appraise 'rails_5_1' do gem 'selenium-webdriver' gem 'listen', '>= 3.0.5', '< 3.2' gem 'spring-watcher-listen', '~> 2.0.0' + + # Other dependencies + gem 'pg', '~> 1.1', platform: :ruby end appraise 'rails_5_2' do @@ -101,4 +106,31 @@ appraise 'rails_5_2' do gem 'chromedriver-helper' gem 'listen', '>= 3.0.5', '< 3.2' gem 'spring-watcher-listen', '~> 2.0.0' + + # Other dependencies + gem 'pg', '~> 1.1', platform: :ruby +end + +if Gem::Requirement.new('>= 2.5.0').satisfied_by?(Gem::Version.new(RUBY_VERSION)) + appraise 'rails_6_0' do + instance_eval(&shared_dependencies) + + gem 'rails', '~> 6.0.0.beta3' + gem 'puma', '~> 3.11' + gem 'bootsnap', '>= 1.4.1', require: false + gem 'sass-rails', '~> 5.0' + gem 'webpacker', '>= 4.0.0.rc3' + gem 'turbolinks', '~> 5' + gem 'jbuilder', '~> 2.5' + gem 'bcrypt', '~> 3.1.7' + gem 'capybara', '>= 2.15' + gem 'listen', '>= 3.0.5', '< 3.2' + gem 'spring-watcher-listen', '~> 2.0.0' + gem 'selenium-webdriver' + gem 'chromedriver-helper' + + # Other dependencies + gem 'rails-controller-testing', '>= 1.0.1' + gem 'pg', '~> 1.1', platform: :ruby + end end diff --git a/Gemfile b/Gemfile index 8da282c69..e7a8a84fa 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,7 @@ gem 'pry-byebug' gem 'rake', '12.3.2' gem 'rspec', '~> 3.6' gem 'rubocop', require: false +gem 'rubocop-rails', require: false gem 'zeus', require: false # YARD diff --git a/Gemfile.lock b/Gemfile.lock index 283923560..ae3103484 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,13 +10,12 @@ GEM coderay (1.1.2) diff-lcs (1.3) fssm (0.2.10) - jaro_winkler (1.5.1) + jaro_winkler (1.5.2) method_source (0.9.0) multi_json (1.12.1) - parallel (1.12.1) - parser (2.5.1.2) + parallel (1.17.0) + parser (2.6.3.0) ast (~> 2.4.0) - powerpack (0.1.2) pry (0.11.3) coderay (~> 1.1.0) method_source (~> 0.9.0) @@ -25,6 +24,7 @@ GEM pry (~> 0.10) pygments.rb (1.1.1) multi_json (>= 1.0.0) + rack (2.0.7) rainbow (3.0.0) rake (12.3.2) redcarpet (3.4.0) @@ -41,17 +41,19 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.6.0) rspec-support (3.6.0) - rubocop (0.59.0) + rubocop (0.71.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) - parser (>= 2.5, != 2.5.1.1) - powerpack (~> 0.1) + parser (>= 2.6) rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) - unicode-display_width (~> 1.0, >= 1.0.1) - ruby-progressbar (1.10.0) + unicode-display_width (>= 1.4.0, < 1.7) + rubocop-rails (2.0.0) + rack (>= 2.0) + rubocop (>= 0.70.0) + ruby-progressbar (1.10.1) thor (0.20.0) - unicode-display_width (1.4.0) + unicode-display_width (1.6.0) yard (0.9.12) zeus (0.15.14) method_source (>= 0.6.7) @@ -70,8 +72,9 @@ DEPENDENCIES redcarpet rspec (~> 3.6) rubocop + rubocop-rails yard zeus BUNDLED WITH - 1.16.5 + 1.17.2 diff --git a/NEWS.md b/NEWS.md index a11629f59..fcb7a7b2c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,43 @@ +# 4.1.0 + +### Bug fixes + +* Fix `validate_uniqueness_of` so that it works when a scope is defined as a + string instead of a symbol on the model. ([#1176]) +* Fix `have_db_index` so that it can be used against multiple models that are + connected to different databases. ([#1200]) + +[#1176]: https://github.com/thoughtbot/shoulda-matchers/pull/1176 +[#1200]: https://github.com/thoughtbot/shoulda-matchers/pull/1200 + +### Features + +* Add support for Rails 6. No new Rails 6 features are supported, but only + existing features that broke with the upgrade. ([#1193]) +* Add support for expression indexes (Rails 5, Postgres only) to + `have_db_index`. ([#1211]) +* Add `allow_nil` to the `validate_presence_of` matcher. ([834d8d0], [#1100]) + +[#1193]: https://github.com/thoughtbot/shoulda-matchers/pull/1193 +[#1211]: https://github.com/thoughtbot/shoulda-matchers/pull/1211 +[834d8d0]: https://github.com/thoughtbot/shoulda-matchers/commit/834d8d0356573b9f47e63a1b910cfa8f3d815e51 +[#1100]: https://github.com/thoughtbot/shoulda-matchers/pull/1100 + +### Improvements + +* Update `validate_presence_of` so that if it is being used against an + association which is `required: true` or `optional: false`, or it is not + configured as such but ActiveRecord defaults `belong_to` associations to + `optional: false`, and the matcher fails, the developer is reminded in the + failure message that the `belong_to` matcher can be used instead. ([#1214], + [8697b01]) +* Update `define_enum_for` so that it produces a more helpful message on + failure. ([#1216]) + +[#1214]: https://github.com/thoughtbot/shoulda-matchers/pull/1214 +[8697b01]: https://github.com/thoughtbot/shoulda-matchers/commit/8697b015ed88fdbbbcf5d31bf98670f17c3df9e1 +[#1216]: https://github.com/thoughtbot/shoulda-matchers/pull/1216 + # 4.0.1 ### Bug fixes diff --git a/README.md b/README.md index 84ee275a9..88585d1f8 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,39 @@ # Shoulda Matchers [![Gem Version][version-badge]][rubygems] [![Build Status][travis-badge]][travis] ![Downloads][downloads-badge] [![Hound][hound-badge]][hound] +[version-badge]: https://img.shields.io/gem/v/shoulda-matchers.svg +[rubygems]: https://rubygems.org/gems/shoulda-matchers +[travis-badge]: https://img.shields.io/travis/thoughtbot/shoulda-matchers/master.svg +[travis]: https://travis-ci.org/thoughtbot/shoulda-matchers +[downloads-badge]: https://img.shields.io/gem/dtv/shoulda-matchers.svg +[hound-badge]: https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg +[hound]: https://houndci.com + [![shoulda-matchers][logo]][website] -Shoulda Matchers provides RSpec- and Minitest-compatible one-liners that test -common Rails functionality. These tests, if written by hand, would be much -longer, more complex, and error-prone. +[logo]: https://matchers.shoulda.io/images/shoulda-matchers-logo.png +[website]: https://matchers.shoulda.io/ + +Shoulda Matchers provides RSpec- and Minitest-compatible one-liners to test +common Rails functionality that, if written by hand, would be much longer, more +complex, and error-prone. + +## Quick links -**[View the documentation for the latest version (4.0.1).][rubydocs]** +📖 **[Read the documentation for the latest version (4.1.0)][rubydocs].** +📢 **[See what's changed in a recent version][news].** ---- +[rubydocs]: http://matchers.shoulda.io/docs +[news]: NEWS.md + +## Table of contents * [Getting started](#getting-started) * [RSpec](#rspec) - * [Availability of matchers in various example groups](#availability-of-matchers-in-various-example-groups) - * [should vs is_expected.to](#should-vs-is_expectedto) * [Minitest](#minitest) +* [Usage](#usage) + * [On the subject of `subject`](#on-the-subject-of-subject) + * [Availability of RSpec matchers in example groups](#availability-of-rspec-matchers-in-example-groups) + * [`should` vs `is_expected.to`](#should-vs-is_expectedto) * [Matchers](#matchers) * [ActiveModel matchers](#activemodel-matchers) * [ActiveRecord matchers](#activerecord-matchers) @@ -35,7 +54,6 @@ Start by including `shoulda-matchers` in your Gemfile: ```ruby group :test do gem 'shoulda-matchers' - gem 'rails-controller-testing' end ``` @@ -61,14 +79,7 @@ Shoulda::Matchers.configure do |config| end ``` -Now you're ready to use matchers in your tests! For instance, you might decide -to add a matcher to one of your models: - -```ruby -RSpec.describe Person, type: :model do - it { should validate_presence_of(:name) } -end -``` +Now you're ready to [use matchers in your tests](#usage)! #### Non-Rails apps @@ -88,23 +99,144 @@ Shoulda::Matchers.configure do |config| end ``` -Now you're ready to use matchers in your tests! For instance, you might decide -to add a matcher to one of your models: +Now you're ready to [use matchers in your tests](#usage)! + +### Minitest + +Shoulda Matchers was originally a component of [Shoulda][shoulda], a gem that +also provides `should` and `context` syntax via +[`shoulda-context`][shoulda-context]. + +[shoulda]: https://github.com/thoughtbot/shoulda +[shoulda-context]: https://github.com/thoughtbot/shoulda-context + +At the moment, `shoulda` has not been updated to support `shoulda-matchers` 3.x +and 4.x, so you'll want to add the following to your Gemfile: ```ruby -RSpec.describe Person, type: :model do - it { should validate_presence_of(:name) } +group :test do + gem 'shoulda', '~> 3.5' + gem 'shoulda-matchers', '~> 2.0' + gem 'rails-controller-testing' +end +``` + +Now you're ready to [use matchers in your tests](#usage)! + +## Usage + +The matchers provided by this gem are divided into different categories +depending on what you're testing within your Rails app: + +* [database models backed by ActiveRecord](#activemodel-matchers) +* [non-database models, form objects, etc. backed by + ActiveModel](#activerecord-matchers) +* [controllers](#actioncontroller-matchers) +* [routes](#routing-matchers) (RSpec only) +* [usage of Rails-specific features like `delegate`](#independent-matchers) + +All matchers are designed to be prepended primarily with the word `should`, +which is a special directive in both RSpec and Shoulda. For instance, a model +test case may look something like: + +``` ruby +# RSpec +RSpec.describe MenuItem, type: :model do + describe 'associations' do + it { should belong_to(:category).class_name('MenuCategory') } + end + + describe 'validations' do + it { should validate_presence_of(:name) } + it { should validate_uniqueness_of(:name).scoped_to(:category_id) } + end +end + +# Minitest (Shoulda) +class MenuItemTest < ActiveSupport::TestCase + context 'associations' do + should belong_to(:category).class_name('MenuCategory') + end + + context 'validations' do + should validate_presence_of(:name) + should validate_uniqueness_of(:name).scoped_to(:category_id) + end +end +``` + +For the full set of matchers you can use, [see below](#matchers). + +### On the subject of `subject` + +For both RSpec and Shoulda, the **subject** is an implicit reference to the +object under test, and all of the matchers make use of it internally when they +are run. This is always set automatically by your test framework in any given +test case; however, in certain cases it can be advantageous to override the +subject. For instance, when testing validations in a model, it is customary to +provide a valid model instead of a fresh one: + +``` ruby +# RSpec +RSpec.describe Post, type: :model do + describe 'validations' do + # Here we're using FactoryBot, but you could use anything + subject { build(:post) } + + it { should validate_presence_of(:title) } + end +end + +# Minitest (Shoulda) +class PostTest < ActiveSupport::TestCase + context 'validations' do + subject { build(:post) } + + should validate_presence_of(:title) + end +end +``` + +When overriding the subject in this manner, then, it's important to provide the +correct object. **When in doubt, provide an instance of the class under test.** +This is particularly necessary for controller tests, where it is easy to +accidentally write something like: + +``` ruby +RSpec.describe PostsController, type: :controller do + describe 'GET #index' do + subject { get :index } + + # This may work... + it { should have_http_status(:success) } + # ...but this will not! + it { should permit(:title, :body).for(:post) } + end end ``` -For more of an idea of what you can use, [see the list of matchers -below](#matchers). +In this case, you would want to use `before` rather than `subject`: -#### Availability of matchers in various example groups +``` ruby +RSpec.describe PostsController, type: :controller do + describe 'GET #index' do + before { get :index } -Regardless of your project, it's important to keep in mind that since -shoulda-matchers provides four categories of matchers, there are four different -levels where you can use these matchers: + # Notice that we have to assert have_http_status on the response here... + it { expect(response).to have_http_status(:success) } + # ...but we do not have to provide a subject for render_template + it { should render_template('index') } + end +end +``` + +### Availability of RSpec matchers in example groups + +If you're using RSpec, then you're probably familiar with the concept of example +groups: these are different kinds of test cases, and each of them has special +behavior around them. As alluded to [above](#usage), this gem works in a similar +way, and there are matchers that are only available in certain types of example +groups: * ActiveRecord and ActiveModel matchers are available only in model example groups, i.e., those tagged with `type: :model` or in files located under @@ -112,17 +244,19 @@ levels where you can use these matchers: * ActionController matchers are available only in controller example groups, i.e., those tagged with `type: :controller` or in files located under `spec/controllers`. -* The `route` matcher is available also in routing example groups, i.e., those +* The `route` matcher is available in routing example groups, i.e., those tagged with `type: :routing` or in files located under `spec/routing`. * Independent matchers are available in all example groups. -**⚠️ If you are using ActiveModel or ActiveRecord outside of Rails** and you want -to use model matchers in certain example groups, you'll need to manually include -them. Here's a good way of doing that: +As long as you're using Rails, you don't need to worry about this — everything +should "just work". -```ruby -require 'shoulda-matchers' +**However, if you are using ActiveModel or ActiveRecord outside of Rails**, and +you want to use model matchers in certain example groups, you'll need to +manually include the module that holds those matchers. A good way to do this is +to place the following in your `spec_helper.rb`: +```ruby RSpec.configure do |config| config.include(Shoulda::Matchers::ActiveModel, type: :model) config.include(Shoulda::Matchers::ActiveRecord, type: :model) @@ -137,13 +271,13 @@ describe MySpecialModel, type: :model do end ``` -#### `should` vs `is_expected.to` +### `should` vs `is_expected.to` -Note that in this README and throughout the documentation we're using the -`should` form of RSpec's one-liner syntax over `is_expected.to`. The `should` -form works regardless of how you've configured RSpec — meaning you can still use -it even when using the `expect` syntax. But if you prefer to use -`is_expected.to`, you can do that too: +In this README and throughout the documentation, we're using the `should` form +of RSpec's one-liner syntax over `is_expected.to`. The `should` form works +regardless of how you've configured RSpec — meaning you can still use it even +when using the `expect` syntax. But if you prefer to use `is_expected.to`, you +can do that too: ```ruby RSpec.describe Person, type: :model do @@ -151,36 +285,11 @@ RSpec.describe Person, type: :model do end ``` -### Minitest - -Shoulda Matchers was originally a component of [Shoulda][shoulda], a gem that -also provides `should` and `context` syntax via -[`shoulda-context`][shoulda-context]. - -At the moment, `shoulda` has not been updated to support `shoulda-matchers` 3.x -and 4.x, so you'll want to add the following to your Gemfile: - -```ruby -group :test do - gem 'shoulda', '~> 3.5' - gem 'shoulda-matchers', '~> 2.0' -end -``` - -Now you're ready to use matchers in your tests! For instance, you might decide -to add a matcher to one of your models: - -```ruby -class PersonTest < ActiveSupport::TestCase - should validate_presence_of(:name) -end -``` - -For more of an idea of what you can use, [see the list of matchers -below](#matchers). - ## Matchers +The following is a list of matchers shipped with the gem. If you need details +about any of them, make sure to [consult the documentation][rubydocs]! + ### ActiveModel matchers * **[allow_value](lib/shoulda/matchers/active_model/allow_value_matcher.rb)** @@ -259,6 +368,11 @@ below](#matchers). * **[use_before_action](lib/shoulda/matchers/action_controller/callback_matcher.rb#L54)** tests that a `before_action` callback is defined in your controller. +### Routing matchers + +* **[route](lib/shoulda/matchers/action_controller/route_matcher.rb)** tests + your routes. + ### Independent matchers * **[delegate_method](lib/shoulda/matchers/independent/delegate_method_matcher.rb)** @@ -279,6 +393,8 @@ For Ruby < 2.4 and Rails < 4.1 compatibility, please use [v3.1.3][v3.1.3]. Shoulda Matchers is open source, and we are grateful for [everyone][contributors] who's contributed so far. +[contributors]: https://github.com/thoughtbot/shoulda-matchers/contributors + If you'd like to contribute, please take a look at the [instructions](CONTRIBUTING.md) for installing dependencies and crafting a good pull request. @@ -295,32 +411,19 @@ Shoulda Matchers is copyright © 2006-2019 and may be redistributed under the terms specified in the [MIT-LICENSE](MIT-LICENSE) file. +[thoughtbot-website]: https://thoughtbot.com + ## About thoughtbot ![thoughtbot][thoughtbot-logo] +[thoughtbot-logo]: https://presskit.thoughtbot.com/images/thoughtbot-logo-for-readmes.svg + Shoulda Matchers is maintained and funded by thoughtbot, inc. The names and logos for thoughtbot are trademarks of thoughtbot, inc. We are passionate about open source software. See [our other projects][community]. We are [available for hire][hire]. -[rubydocs]: http://matchers.shoulda.io/docs [community]: https://thoughtbot.com/community?utm_source=github [hire]: https://thoughtbot.com?utm_source=github -[version-badge]: https://img.shields.io/gem/v/shoulda-matchers.svg -[rubygems]: https://rubygems.org/gems/shoulda-matchers -[travis-badge]: https://img.shields.io/travis/thoughtbot/shoulda-matchers/master.svg -[travis]: https://travis-ci.org/thoughtbot/shoulda-matchers -[downloads-badge]: https://img.shields.io/gem/dtv/shoulda-matchers.svg -[contributors]: https://github.com/thoughtbot/shoulda-matchers/contributors -[shoulda]: https://github.com/thoughtbot/shoulda -[shoulda-context]: https://github.com/thoughtbot/shoulda-context -[Zeus]: https://github.com/burke/zeus -[Appraisal]: https://github.com/thoughtbot/appraisal -[hound-badge]: https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg -[hound]: https://houndci.com -[thoughtbot-website]: https://thoughtbot.com -[thoughtbot-logo]: https://presskit.thoughtbot.com/images/thoughtbot-logo-for-readmes.svg -[logo]: https://matchers.shoulda.io/images/shoulda-matchers-logo.png -[website]: https://matchers.shoulda.io/ diff --git a/bin/setup b/bin/setup index f88f866bc..96b9c181f 100755 --- a/bin/setup +++ b/bin/setup @@ -3,6 +3,7 @@ set -euo pipefail RUBY_VERSION=$(script/supported_ruby_versions | xargs -n 1 echo | sort -V | tail -n 1) +required_ruby_version=$(cat .ruby-version) cd "$(dirname "$(dirname "$0")")" @@ -30,6 +31,10 @@ error() { echo -e "\033[31m$@\033[0m" } +echo-wrapped() { + echo "$@" | fmt -w 80 | cat +} + has-executable() { type "$1" &>/dev/null } @@ -38,6 +43,14 @@ is-running() { pgrep "$1" >/dev/null } +start() { + if has-executable brew; then + brew services start "$1" + else + sudo service "${2:-$1}" start + fi +} + install() { local apt_package="" local rpm_package="" @@ -90,9 +103,10 @@ check-for-build-tools() { if [[ $platform == "linux" ]]; then if ! has-executable apt-get; then error "You don't seem to have a package manager installed." - echo "The setup script assumes you're using Debian or a Debian-derived flavor of Linux" - echo "(i.e. something with Apt). If this is not the case, then we would gladly take a" - echo "PR fixing this!" + echo-wrapped "\ +The setup script assumes you're using Debian or a Debian-derived flavor of +Linux (i.e. something with Apt). If this is not the case, then we would +gladly take a PR fixing this!" exit 1 fi @@ -100,10 +114,12 @@ check-for-build-tools() { else if ! has-executable brew; then error "You don't seem to have Homebrew installed." - echo - echo "Follow the instructions here to do this:" - echo - echo "http://brew.sh" + echo-wrapped "\ +Follow the instructions here to do this: + + http://brew.sh + +Then re-run this script." exit 1 fi @@ -145,21 +161,22 @@ install-dependencies() { rbenv install --skip-existing "$RUBY_VERSION" fi elif has-executable rvm; then - if ! (rvm ls | grep $RUBY_VERSION'\>' &>/dev/null); then - banner "Installing Ruby $RUBY_VERSION with rvm" - error "You don't seem to have Ruby $RUBY_VERSION installed." - echo - echo "Use RVM to do so, and then re-run this command." - echo + if ! (rvm list | grep $required_ruby_version'\>' &>/dev/null); then + banner "Installing Ruby $required_ruby_version with rvm" + rvm install $required_ruby_version + rvm use $required_ruby_version fi else error "You don't seem to have a Ruby manager installed." - echo - echo 'We recommend using rbenv. You can find installation instructions here:' - echo - echo 'http://github.com/rbenv/rbenv' - echo - echo "When you're done, simply re-run this script!" + echo-wrapped "\ +We recommend using rbenv. You can find instructions to install it here: + + https://github.com/rbenv/rbenv#installation + +Make sure to follow the instructions to configure your shell so that rbenv is +automatically loaded. + +When you're done, open up a new terminal tab and re-run this script." exit 1 fi @@ -167,22 +184,6 @@ install-dependencies() { gem install bundler -v '~> 1.0' --conservative bundle check || bundle install bundle exec appraisal install - - if ! has-executable node; then - banner 'Installing Node' - - if [[ $platform == 'linux' ]]; then - curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash - - - install nodejs - - if ! has-executable npm; then - install npm - fi - else - install nodejs - fi - fi } check-for-build-tools diff --git a/doc_config/yard/templates/default/fulldoc/html/css/global.css b/doc_config/yard/templates/default/fulldoc/html/css/global.css index 8e414c022..5344d6e85 100644 --- a/doc_config/yard/templates/default/fulldoc/html/css/global.css +++ b/doc_config/yard/templates/default/fulldoc/html/css/global.css @@ -1,4 +1,4 @@ -@import "http://fonts.googleapis.com/css?family=Source+Sans+Pro:300,300italic,400,400italic,600,600italic,800|Droid+Sans+Mono"; +@import "https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,300italic,400,400italic,600,600italic,800|Droid+Sans+Mono"; body { font-size: 16px; diff --git a/gemfiles/rails_4_2.gemfile b/gemfiles/rails_4_2.gemfile index 89e26349e..d690d136c 100644 --- a/gemfiles/rails_4_2.gemfile +++ b/gemfiles/rails_4_2.gemfile @@ -9,6 +9,7 @@ gem "pry-byebug" gem "rake", "12.3.2" gem "rspec", "~> 3.6" gem "rubocop", require: false +gem "rubocop-rails", require: false gem "zeus", require: false gem "fssm" gem "pygments.rb" @@ -19,8 +20,7 @@ gem "activerecord-jdbcsqlite3-adapter", platform: :jruby gem "jdbc-sqlite3", platform: :jruby gem "jruby-openssl", platform: :jruby gem "therubyrhino", platform: :jruby -gem "sqlite3", platform: :ruby -gem "pg", platform: :ruby +gem "sqlite3", "~> 1.3.6", platform: :ruby gem "spring" gem "spring-commands-rspec" gem "minitest-reporters" @@ -38,3 +38,4 @@ gem "bcrypt", "~> 3.1.7" gem "activeresource", "4.0.0" gem "json", "~> 1.4" gem "protected_attributes", "~> 1.0.6" +gem "pg", "~> 0.15", platform: :ruby diff --git a/gemfiles/rails_4_2.gemfile.lock b/gemfiles/rails_4_2.gemfile.lock index 98b4f3904..410ad6b51 100644 --- a/gemfiles/rails_4_2.gemfile.lock +++ b/gemfiles/rails_4_2.gemfile.lock @@ -68,7 +68,7 @@ GEM activesupport (>= 4.2.0) i18n (0.9.5) concurrent-ruby (~> 1.0) - jaro_winkler (1.5.1) + jaro_winkler (1.5.2) jbuilder (2.7.0) activesupport (>= 4.2.0) multi_json (>= 1.2) @@ -94,11 +94,10 @@ GEM multi_json (1.12.2) nokogiri (1.10.1) mini_portile2 (~> 2.4.0) - parallel (1.12.1) - parser (2.5.1.2) + parallel (1.17.0) + parser (2.6.3.0) ast (~> 2.4.0) pg (0.21.0) - powerpack (0.1.2) protected_attributes (1.0.9) activemodel (>= 4.0.1, < 5.0) pry (0.11.3) @@ -166,15 +165,17 @@ GEM rspec-mocks (~> 3.6.0) rspec-support (~> 3.6.0) rspec-support (3.6.0) - rubocop (0.59.0) + rubocop (0.71.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) - parser (>= 2.5, != 2.5.1.1) - powerpack (~> 0.1) + parser (>= 2.6) rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) - unicode-display_width (~> 1.0, >= 1.0.1) - ruby-progressbar (1.8.1) + unicode-display_width (>= 1.4.0, < 1.7) + rubocop-rails (2.0.1) + rack (>= 1.1) + rubocop (>= 0.70.0) + ruby-progressbar (1.10.1) sass (3.5.2) sass-listen (~> 4.0.0) sass-listen (4.0.0) @@ -212,7 +213,7 @@ GEM thread_safe (~> 0.1) uglifier (3.2.0) execjs (>= 0.3.0, < 3) - unicode-display_width (1.4.0) + unicode-display_width (1.6.0) yard (0.9.9) zeus (0.15.14) method_source (>= 0.6.7) @@ -235,7 +236,7 @@ DEPENDENCIES jruby-openssl json (~> 1.4) minitest-reporters - pg + pg (~> 0.15) protected_attributes (~> 1.0.6) pry pry-byebug @@ -246,12 +247,13 @@ DEPENDENCIES rspec (~> 3.6) rspec-rails (~> 3.6) rubocop + rubocop-rails sass-rails (~> 5.0) sdoc (~> 0.4.0) shoulda-context (~> 1.2.0) spring spring-commands-rspec - sqlite3 + sqlite3 (~> 1.3.6) therubyrhino turbolinks uglifier (>= 1.3.0) diff --git a/gemfiles/rails_5_0.gemfile b/gemfiles/rails_5_0.gemfile index 30206bf65..24d5c83df 100644 --- a/gemfiles/rails_5_0.gemfile +++ b/gemfiles/rails_5_0.gemfile @@ -9,6 +9,7 @@ gem "pry-byebug" gem "rake", "12.3.2" gem "rspec", "~> 3.6" gem "rubocop", require: false +gem "rubocop-rails", require: false gem "zeus", require: false gem "fssm" gem "pygments.rb" @@ -19,8 +20,7 @@ gem "activerecord-jdbcsqlite3-adapter", platform: :jruby gem "jdbc-sqlite3", platform: :jruby gem "jruby-openssl", platform: :jruby gem "therubyrhino", platform: :jruby -gem "sqlite3", platform: :ruby -gem "pg", platform: :ruby +gem "sqlite3", "~> 1.3.6", platform: :ruby gem "spring" gem "spring-commands-rspec" gem "minitest-reporters" @@ -36,3 +36,4 @@ gem "jbuilder", "~> 2.5" gem "bcrypt", "~> 3.1.7" gem "listen", "~> 3.0.5" gem "spring-watcher-listen", "~> 2.0.0" +gem "pg", "~> 1.1", platform: :ruby diff --git a/gemfiles/rails_5_0.gemfile.lock b/gemfiles/rails_5_0.gemfile.lock index e8a6319c4..872bc4dca 100644 --- a/gemfiles/rails_5_0.gemfile.lock +++ b/gemfiles/rails_5_0.gemfile.lock @@ -59,7 +59,7 @@ GEM activesupport (>= 4.2.0) i18n (1.6.0) concurrent-ruby (~> 1.0) - jaro_winkler (1.5.1) + jaro_winkler (1.5.2) jbuilder (2.7.0) activesupport (>= 4.2.0) multi_json (>= 1.2) @@ -88,11 +88,10 @@ GEM nio4r (2.3.1) nokogiri (1.10.1) mini_portile2 (~> 2.4.0) - parallel (1.12.1) - parser (2.5.1.2) + parallel (1.17.0) + parser (2.6.3.0) ast (~> 2.4.0) - pg (0.21.0) - powerpack (0.1.2) + pg (1.1.4) pry (0.11.3) coderay (~> 1.1.0) method_source (~> 0.9.0) @@ -159,15 +158,17 @@ GEM rspec-mocks (~> 3.6.0) rspec-support (~> 3.6.0) rspec-support (3.6.0) - rubocop (0.59.0) + rubocop (0.71.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) - parser (>= 2.5, != 2.5.1.1) - powerpack (~> 0.1) + parser (>= 2.6) rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) - unicode-display_width (~> 1.0, >= 1.0.1) - ruby-progressbar (1.8.1) + unicode-display_width (>= 1.4.0, < 1.7) + rubocop-rails (2.0.1) + rack (>= 1.1) + rubocop (>= 0.70.0) + ruby-progressbar (1.10.1) sass (3.5.2) sass-listen (~> 4.0.0) sass-listen (4.0.0) @@ -203,7 +204,7 @@ GEM turbolinks-source (5.0.3) tzinfo (1.2.5) thread_safe (~> 0.1) - unicode-display_width (1.4.0) + unicode-display_width (1.6.0) websocket-driver (0.6.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.3) @@ -227,7 +228,7 @@ DEPENDENCIES jruby-openssl listen (~> 3.0.5) minitest-reporters - pg + pg (~> 1.1) pry pry-byebug puma (~> 3.0) @@ -239,12 +240,13 @@ DEPENDENCIES rspec (~> 3.6) rspec-rails (~> 3.6) rubocop + rubocop-rails sass-rails (~> 5.0) shoulda-context (~> 1.2.0) spring spring-commands-rspec spring-watcher-listen (~> 2.0.0) - sqlite3 + sqlite3 (~> 1.3.6) therubyrhino turbolinks (~> 5) yard diff --git a/gemfiles/rails_5_1.gemfile b/gemfiles/rails_5_1.gemfile index 39fbbbadc..c31004361 100644 --- a/gemfiles/rails_5_1.gemfile +++ b/gemfiles/rails_5_1.gemfile @@ -9,6 +9,7 @@ gem "pry-byebug" gem "rake", "12.3.2" gem "rspec", "~> 3.6" gem "rubocop", require: false +gem "rubocop-rails", require: false gem "zeus", require: false gem "fssm" gem "pygments.rb" @@ -19,8 +20,7 @@ gem "activerecord-jdbcsqlite3-adapter", platform: :jruby gem "jdbc-sqlite3", platform: :jruby gem "jruby-openssl", platform: :jruby gem "therubyrhino", platform: :jruby -gem "sqlite3", platform: :ruby -gem "pg", platform: :ruby +gem "sqlite3", "~> 1.3.6", platform: :ruby gem "spring" gem "spring-commands-rspec" gem "minitest-reporters" @@ -37,3 +37,4 @@ gem "capybara", "~> 2.13" gem "selenium-webdriver" gem "listen", ">= 3.0.5", "< 3.2" gem "spring-watcher-listen", "~> 2.0.0" +gem "pg", "~> 1.1", platform: :ruby diff --git a/gemfiles/rails_5_1.gemfile.lock b/gemfiles/rails_5_1.gemfile.lock index adeb0bcdf..d1b563309 100644 --- a/gemfiles/rails_5_1.gemfile.lock +++ b/gemfiles/rails_5_1.gemfile.lock @@ -70,7 +70,7 @@ GEM activesupport (>= 4.2.0) i18n (1.6.0) concurrent-ruby (~> 1.0) - jaro_winkler (1.5.1) + jaro_winkler (1.5.2) jbuilder (2.7.0) activesupport (>= 4.2.0) multi_json (>= 1.2) @@ -96,11 +96,10 @@ GEM nio4r (2.3.1) nokogiri (1.10.1) mini_portile2 (~> 2.4.0) - parallel (1.12.1) - parser (2.5.1.2) + parallel (1.17.0) + parser (2.6.3.0) ast (~> 2.4.0) pg (1.1.3) - powerpack (0.1.2) pry (0.11.3) coderay (~> 1.1.0) method_source (~> 0.9.0) @@ -168,15 +167,17 @@ GEM rspec-mocks (~> 3.8.0) rspec-support (~> 3.8.0) rspec-support (3.8.0) - rubocop (0.59.1) + rubocop (0.71.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) - parser (>= 2.5, != 2.5.1.1) - powerpack (~> 0.1) + parser (>= 2.6) rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) - unicode-display_width (~> 1.0, >= 1.0.1) - ruby-progressbar (1.10.0) + unicode-display_width (>= 1.4.0, < 1.7) + rubocop-rails (2.0.1) + rack (>= 1.1) + rubocop (>= 0.70.0) + ruby-progressbar (1.10.1) ruby_dep (1.5.0) rubyzip (1.2.2) sass (3.5.7) @@ -217,7 +218,7 @@ GEM turbolinks-source (5.2.0) tzinfo (1.2.5) thread_safe (~> 0.1) - unicode-display_width (1.4.0) + unicode-display_width (1.6.0) websocket-driver (0.6.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.3) @@ -243,7 +244,7 @@ DEPENDENCIES jruby-openssl listen (>= 3.0.5, < 3.2) minitest-reporters - pg + pg (~> 1.1) pry pry-byebug puma (~> 3.7) @@ -255,13 +256,14 @@ DEPENDENCIES rspec (~> 3.6) rspec-rails (~> 3.6) rubocop + rubocop-rails sass-rails (~> 5.0) selenium-webdriver shoulda-context (~> 1.2.0) spring spring-commands-rspec spring-watcher-listen (~> 2.0.0) - sqlite3 + sqlite3 (~> 1.3.6) therubyrhino turbolinks (~> 5) yard diff --git a/gemfiles/rails_5_2.gemfile b/gemfiles/rails_5_2.gemfile index 28d6205a5..1d56e5959 100644 --- a/gemfiles/rails_5_2.gemfile +++ b/gemfiles/rails_5_2.gemfile @@ -9,6 +9,7 @@ gem "pry-byebug" gem "rake", "12.3.2" gem "rspec", "~> 3.6" gem "rubocop", require: false +gem "rubocop-rails", require: false gem "zeus", require: false gem "fssm" gem "pygments.rb" @@ -19,8 +20,7 @@ gem "activerecord-jdbcsqlite3-adapter", platform: :jruby gem "jdbc-sqlite3", platform: :jruby gem "jruby-openssl", platform: :jruby gem "therubyrhino", platform: :jruby -gem "sqlite3", platform: :ruby -gem "pg", platform: :ruby +gem "sqlite3", "~> 1.3.6", platform: :ruby gem "spring" gem "spring-commands-rspec" gem "minitest-reporters" @@ -39,3 +39,4 @@ gem "selenium-webdriver" gem "chromedriver-helper" gem "listen", ">= 3.0.5", "< 3.2" gem "spring-watcher-listen", "~> 2.0.0" +gem "pg", "~> 1.1", platform: :ruby diff --git a/gemfiles/rails_5_2.gemfile.lock b/gemfiles/rails_5_2.gemfile.lock index bad9b7d9d..c07cd3051 100644 --- a/gemfiles/rails_5_2.gemfile.lock +++ b/gemfiles/rails_5_2.gemfile.lock @@ -82,7 +82,7 @@ GEM i18n (1.6.0) concurrent-ruby (~> 1.0) io-like (0.3.0) - jaro_winkler (1.5.1) + jaro_winkler (1.5.2) jbuilder (2.7.0) activesupport (>= 4.2.0) multi_json (>= 1.2) @@ -112,11 +112,10 @@ GEM nio4r (2.3.1) nokogiri (1.10.1) mini_portile2 (~> 2.4.0) - parallel (1.12.1) - parser (2.5.1.2) + parallel (1.17.0) + parser (2.6.3.0) ast (~> 2.4.0) pg (1.1.2) - powerpack (0.1.2) pry (0.11.3) coderay (~> 1.1.0) method_source (~> 0.9.0) @@ -185,15 +184,17 @@ GEM rspec-mocks (~> 3.8.0) rspec-support (~> 3.8.0) rspec-support (3.8.0) - rubocop (0.59.0) + rubocop (0.71.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) - parser (>= 2.5, != 2.5.1.1) - powerpack (~> 0.1) + parser (>= 2.6) rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) - unicode-display_width (~> 1.0, >= 1.0.1) - ruby-progressbar (1.10.0) + unicode-display_width (>= 1.4.0, < 1.7) + rubocop-rails (2.0.1) + rack (>= 1.1) + rubocop (>= 0.70.0) + ruby-progressbar (1.10.1) ruby_dep (1.5.0) rubyzip (1.2.2) sass (3.5.7) @@ -234,7 +235,7 @@ GEM turbolinks-source (5.2.0) tzinfo (1.2.5) thread_safe (~> 0.1) - unicode-display_width (1.4.0) + unicode-display_width (1.6.0) websocket-driver (0.7.0) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.3) @@ -262,7 +263,7 @@ DEPENDENCIES jruby-openssl listen (>= 3.0.5, < 3.2) minitest-reporters - pg + pg (~> 1.1) pry pry-byebug puma (~> 3.11) @@ -274,13 +275,14 @@ DEPENDENCIES rspec (~> 3.6) rspec-rails (~> 3.6) rubocop + rubocop-rails sass-rails (~> 5.0) selenium-webdriver shoulda-context (~> 1.2.0) spring spring-commands-rspec spring-watcher-listen (~> 2.0.0) - sqlite3 + sqlite3 (~> 1.3.6) therubyrhino turbolinks (~> 5) yard diff --git a/gemfiles/rails_6_0.gemfile b/gemfiles/rails_6_0.gemfile new file mode 100644 index 000000000..c8cd386b9 --- /dev/null +++ b/gemfiles/rails_6_0.gemfile @@ -0,0 +1,43 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "appraisal", "2.2.0" +gem "bundler", "~> 1.1" +gem "pry" +gem "pry-byebug" +gem "rake", "12.3.2" +gem "rspec", "~> 3.6" +gem "rubocop", require: false +gem "rubocop-rails", require: false +gem "zeus", require: false +gem "fssm" +gem "pygments.rb" +gem "redcarpet" +gem "yard" +gem "activerecord-jdbc-adapter", platform: :jruby +gem "activerecord-jdbcsqlite3-adapter", platform: :jruby +gem "jdbc-sqlite3", platform: :jruby +gem "jruby-openssl", platform: :jruby +gem "therubyrhino", platform: :jruby +gem "sqlite3", "~> 1.3.6", platform: :ruby +gem "spring" +gem "spring-commands-rspec" +gem "minitest-reporters" +gem "rspec-rails", "~> 3.6" +gem "shoulda-context", "~> 1.2.0" +gem "rails", "~> 6.0.0.beta3" +gem "puma", "~> 3.11" +gem "bootsnap", ">= 1.4.1", require: false +gem "sass-rails", "~> 5.0" +gem "webpacker", ">= 4.0.0.rc3" +gem "turbolinks", "~> 5" +gem "jbuilder", "~> 2.5" +gem "bcrypt", "~> 3.1.7" +gem "capybara", ">= 2.15" +gem "listen", ">= 3.0.5", "< 3.2" +gem "spring-watcher-listen", "~> 2.0.0" +gem "selenium-webdriver" +gem "chromedriver-helper" +gem "rails-controller-testing", ">= 1.0.1" +gem "pg", "~> 1.1", platform: :ruby diff --git a/gemfiles/rails_6_0.gemfile.lock b/gemfiles/rails_6_0.gemfile.lock new file mode 100644 index 000000000..5c4e5a6ac --- /dev/null +++ b/gemfiles/rails_6_0.gemfile.lock @@ -0,0 +1,316 @@ +GEM + remote: https://rubygems.org/ + specs: + actioncable (6.0.0.beta3) + actionpack (= 6.0.0.beta3) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + actionmailbox (6.0.0.beta3) + actionpack (= 6.0.0.beta3) + activejob (= 6.0.0.beta3) + activerecord (= 6.0.0.beta3) + activestorage (= 6.0.0.beta3) + activesupport (= 6.0.0.beta3) + mail (>= 2.7.1) + actionmailer (6.0.0.beta3) + actionpack (= 6.0.0.beta3) + actionview (= 6.0.0.beta3) + activejob (= 6.0.0.beta3) + mail (~> 2.5, >= 2.5.4) + rails-dom-testing (~> 2.0) + actionpack (6.0.0.beta3) + actionview (= 6.0.0.beta3) + activesupport (= 6.0.0.beta3) + rack (~> 2.0) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.0.2) + actiontext (6.0.0.beta3) + actionpack (= 6.0.0.beta3) + activerecord (= 6.0.0.beta3) + activestorage (= 6.0.0.beta3) + activesupport (= 6.0.0.beta3) + nokogiri (>= 1.8.5) + actionview (6.0.0.beta3) + activesupport (= 6.0.0.beta3) + builder (~> 3.1) + erubi (~> 1.4) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.0.3) + activejob (6.0.0.beta3) + activesupport (= 6.0.0.beta3) + globalid (>= 0.3.6) + activemodel (6.0.0.beta3) + activesupport (= 6.0.0.beta3) + activerecord (6.0.0.beta3) + activemodel (= 6.0.0.beta3) + activesupport (= 6.0.0.beta3) + activestorage (6.0.0.beta3) + actionpack (= 6.0.0.beta3) + activerecord (= 6.0.0.beta3) + marcel (~> 0.3.1) + activesupport (6.0.0.beta3) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 0.7, < 2) + minitest (~> 5.1) + tzinfo (~> 1.1) + zeitwerk (~> 1.3, >= 1.3.1) + addressable (2.6.0) + public_suffix (>= 2.0.2, < 4.0) + ansi (1.5.0) + appraisal (2.2.0) + bundler + rake + thor (>= 0.14.0) + archive-zip (0.12.0) + io-like (~> 0.3.0) + ast (2.4.0) + bcrypt (3.1.12) + bootsnap (1.4.1) + msgpack (~> 1.0) + builder (3.2.3) + byebug (11.0.0) + capybara (3.14.0) + addressable + mini_mime (>= 0.1.3) + nokogiri (~> 1.8) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (~> 1.2) + xpath (~> 3.2) + childprocess (0.9.0) + ffi (~> 1.0, >= 1.0.11) + chromedriver-helper (2.1.0) + archive-zip (~> 0.10) + nokogiri (~> 1.8) + coderay (1.1.2) + concurrent-ruby (1.1.5) + crass (1.0.4) + diff-lcs (1.3) + erubi (1.8.0) + ffi (1.10.0) + fssm (0.2.10) + globalid (0.4.2) + activesupport (>= 4.2.0) + i18n (1.6.0) + concurrent-ruby (~> 1.0) + io-like (0.3.0) + jaro_winkler (1.5.2) + jbuilder (2.8.0) + activesupport (>= 4.2.0) + multi_json (>= 1.2) + listen (3.1.5) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) + ruby_dep (~> 1.2) + loofah (2.2.3) + crass (~> 1.0.2) + nokogiri (>= 1.5.9) + mail (2.7.1) + mini_mime (>= 0.1.1) + marcel (0.3.3) + mimemagic (~> 0.3.2) + method_source (0.9.2) + mimemagic (0.3.3) + mini_mime (1.0.1) + mini_portile2 (2.4.0) + minitest (5.11.3) + minitest-reporters (1.3.6) + ansi + builder + minitest (>= 5.0) + ruby-progressbar + msgpack (1.2.9) + multi_json (1.13.1) + nio4r (2.3.1) + nokogiri (1.10.1) + mini_portile2 (~> 2.4.0) + parallel (1.17.0) + parser (2.6.3.0) + ast (~> 2.4.0) + pg (1.1.4) + pry (0.12.2) + coderay (~> 1.1.0) + method_source (~> 0.9.0) + pry-byebug (3.7.0) + byebug (~> 11.0) + pry (~> 0.10) + public_suffix (3.0.3) + puma (3.12.0) + pygments.rb (1.2.1) + multi_json (>= 1.0.0) + rack (2.0.6) + rack-proxy (0.6.5) + rack + rack-test (1.1.0) + rack (>= 1.0, < 3) + rails (6.0.0.beta3) + actioncable (= 6.0.0.beta3) + actionmailbox (= 6.0.0.beta3) + actionmailer (= 6.0.0.beta3) + actionpack (= 6.0.0.beta3) + actiontext (= 6.0.0.beta3) + actionview (= 6.0.0.beta3) + activejob (= 6.0.0.beta3) + activemodel (= 6.0.0.beta3) + activerecord (= 6.0.0.beta3) + activestorage (= 6.0.0.beta3) + activesupport (= 6.0.0.beta3) + bundler (>= 1.3.0) + railties (= 6.0.0.beta3) + sprockets-rails (>= 2.0.0) + rails-controller-testing (1.0.4) + actionpack (>= 5.0.1.x) + actionview (>= 5.0.1.x) + activesupport (>= 5.0.1.x) + rails-dom-testing (2.0.3) + activesupport (>= 4.2.0) + nokogiri (>= 1.6) + rails-html-sanitizer (1.0.4) + loofah (~> 2.2, >= 2.2.2) + railties (6.0.0.beta3) + actionpack (= 6.0.0.beta3) + activesupport (= 6.0.0.beta3) + method_source + rake (>= 0.8.7) + thor (>= 0.20.3, < 2.0) + rainbow (3.0.0) + rake (12.3.2) + rb-fsevent (0.10.3) + rb-inotify (0.10.0) + ffi (~> 1.0) + redcarpet (3.4.0) + regexp_parser (1.3.0) + rspec (3.8.0) + rspec-core (~> 3.8.0) + rspec-expectations (~> 3.8.0) + rspec-mocks (~> 3.8.0) + rspec-core (3.8.0) + rspec-support (~> 3.8.0) + rspec-expectations (3.8.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.8.0) + rspec-mocks (3.8.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.8.0) + rspec-rails (3.8.2) + actionpack (>= 3.0) + activesupport (>= 3.0) + railties (>= 3.0) + rspec-core (~> 3.8.0) + rspec-expectations (~> 3.8.0) + rspec-mocks (~> 3.8.0) + rspec-support (~> 3.8.0) + rspec-support (3.8.0) + rubocop (0.71.0) + jaro_winkler (~> 1.5.1) + parallel (~> 1.10) + parser (>= 2.6) + rainbow (>= 2.2.2, < 4.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 1.7) + rubocop-rails (2.0.1) + rack (>= 1.1) + rubocop (>= 0.70.0) + ruby-progressbar (1.10.1) + ruby_dep (1.5.0) + rubyzip (1.2.2) + sass (3.7.3) + sass-listen (~> 4.0.0) + sass-listen (4.0.0) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) + sass-rails (5.0.7) + railties (>= 4.0.0, < 6) + sass (~> 3.1) + sprockets (>= 2.8, < 4.0) + sprockets-rails (>= 2.0, < 4.0) + tilt (>= 1.1, < 3) + selenium-webdriver (3.141.0) + childprocess (~> 0.5) + rubyzip (~> 1.2, >= 1.2.2) + shoulda-context (1.2.2) + spring (2.0.2) + activesupport (>= 4.2) + spring-commands-rspec (1.0.4) + spring (>= 0.9.1) + spring-watcher-listen (2.0.1) + listen (>= 2.7, < 4.0) + spring (>= 1.2, < 3.0) + sprockets (3.7.2) + concurrent-ruby (~> 1.0) + rack (> 1, < 3) + sprockets-rails (3.2.1) + actionpack (>= 4.0) + activesupport (>= 4.0) + sprockets (>= 3.0.0) + sqlite3 (1.3.13) + thor (0.20.3) + thread_safe (0.3.6) + tilt (2.0.9) + turbolinks (5.2.0) + turbolinks-source (~> 5.2) + turbolinks-source (5.2.0) + tzinfo (1.2.5) + thread_safe (~> 0.1) + unicode-display_width (1.6.0) + webpacker (4.0.2) + activesupport (>= 4.2) + rack-proxy (>= 0.6.1) + railties (>= 4.2) + websocket-driver (0.7.0) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.3) + xpath (3.2.0) + nokogiri (~> 1.8) + yard (0.9.18) + zeitwerk (1.3.4) + zeus (0.15.14) + method_source (>= 0.6.7) + +PLATFORMS + ruby + +DEPENDENCIES + activerecord-jdbc-adapter + activerecord-jdbcsqlite3-adapter + appraisal (= 2.2.0) + bcrypt (~> 3.1.7) + bootsnap (>= 1.4.1) + bundler (~> 1.1) + capybara (>= 2.15) + chromedriver-helper + fssm + jbuilder (~> 2.5) + jdbc-sqlite3 + jruby-openssl + listen (>= 3.0.5, < 3.2) + minitest-reporters + pg (~> 1.1) + pry + pry-byebug + puma (~> 3.11) + pygments.rb + rails (~> 6.0.0.beta3) + rails-controller-testing (>= 1.0.1) + rake (= 12.3.2) + redcarpet + rspec (~> 3.6) + rspec-rails (~> 3.6) + rubocop + rubocop-rails + sass-rails (~> 5.0) + selenium-webdriver + shoulda-context (~> 1.2.0) + spring + spring-commands-rspec + spring-watcher-listen (~> 2.0.0) + sqlite3 (~> 1.3.6) + therubyrhino + turbolinks (~> 5) + webpacker (>= 4.0.0.rc3) + yard + zeus + +BUNDLED WITH + 1.17.3 diff --git a/lib/shoulda/matchers/active_model/numericality_matchers/numeric_type_matcher.rb b/lib/shoulda/matchers/active_model/numericality_matchers/numeric_type_matcher.rb index 4a54b8869..7197eba2e 100644 --- a/lib/shoulda/matchers/active_model/numericality_matchers/numeric_type_matcher.rb +++ b/lib/shoulda/matchers/active_model/numericality_matchers/numeric_type_matcher.rb @@ -17,6 +17,7 @@ class NumericTypeMatcher :ignore_interference_by_writer, :ignoring_interference_by_writer, :matches?, + :does_not_match?, :on, :strict, :with_message, diff --git a/lib/shoulda/matchers/active_model/qualifiers.rb b/lib/shoulda/matchers/active_model/qualifiers.rb index 5a2772044..7af3d448a 100644 --- a/lib/shoulda/matchers/active_model/qualifiers.rb +++ b/lib/shoulda/matchers/active_model/qualifiers.rb @@ -8,5 +8,6 @@ module Qualifiers end end +require_relative 'qualifiers/allow_nil' require_relative 'qualifiers/ignore_interference_by_writer' require_relative 'qualifiers/ignoring_interference_by_writer' diff --git a/lib/shoulda/matchers/active_model/qualifiers/allow_nil.rb b/lib/shoulda/matchers/active_model/qualifiers/allow_nil.rb new file mode 100644 index 000000000..0e0bc30a0 --- /dev/null +++ b/lib/shoulda/matchers/active_model/qualifiers/allow_nil.rb @@ -0,0 +1,26 @@ +module Shoulda + module Matchers + module ActiveModel + module Qualifiers + # @private + module AllowNil + def initialize(*args) + super + @expects_to_allow_nil = false + end + + def allow_nil + @expects_to_allow_nil = true + self + end + + protected + + def expects_to_allow_nil? + @expects_to_allow_nil + end + end + end + end + end +end diff --git a/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb b/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb index 46fbab205..a4281be74 100644 --- a/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb +++ b/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb @@ -562,7 +562,8 @@ def number_of_submatchers_for_failure_message def has_been_qualified? @submatchers.any? do |submatcher| - submatcher.class.parent == NumericalityMatchers + Shoulda::Matchers::RailsShim.parent_of(submatcher.class) == + NumericalityMatchers end end diff --git a/lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb b/lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb index 96b514ac0..49ef78d8c 100644 --- a/lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb +++ b/lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb @@ -57,6 +57,27 @@ module ActiveModel # # #### Qualifiers # + # ##### allow_nil + # + # Use `allow_nil` if your model has an optional attribute. + # + # class Robot + # include ActiveModel::Model + # attr_accessor :nickname + # + # validates_presence_of :nickname, allow_nil: true + # end + # + # # RSpec + # RSpec.describe Robot, type: :model do + # it { should validate_presence_of(:nickname).allow_nil } + # end + # + # # Minitest (Shoulda) + # class RobotTest < ActiveSupport::TestCase + # should validate_presence_of(:nickname).allow_nil + # end + # # ##### on # # Use `on` if your validation applies only under a certain context. @@ -111,6 +132,8 @@ def validate_presence_of(attr) # @private class ValidatePresenceOfMatcher < ValidationMatcher + include Qualifiers::AllowNil + def initialize(attribute) super @expected_message = :blank @@ -122,9 +145,16 @@ def matches?(subject) possibly_ignore_interference_by_writer if secure_password_being_validated? - disallows_and_double_checks_value_of!(blank_value, @expected_message) + ignore_interference_by_writer.default_to(when: :blank?) + + disallowed_values.all? do |value| + disallows_and_double_checks_value_of!(value) + end else - disallows_original_or_typecast_value?(blank_value, @expected_message) + (!expects_to_allow_nil? || allows_value_of(nil)) && + disallowed_values.all? do |value| + disallows_original_or_typecast_value?(value) + end end end @@ -134,9 +164,16 @@ def does_not_match?(subject) possibly_ignore_interference_by_writer if secure_password_being_validated? - allows_and_double_checks_value_of!(blank_value, @expected_message) + ignore_interference_by_writer.default_to(when: :blank?) + + disallowed_values.any? do |value| + allows_and_double_checks_value_of!(value) + end else - allows_original_or_typecast_value?(blank_value, @expected_message) + (expects_to_allow_nil? && !allows_value_of(nil)) || + disallowed_values.any? do |value| + allows_original_or_typecast_value?(value) + end end end @@ -144,12 +181,30 @@ def simple_description "validate that :#{@attribute} cannot be empty/falsy" end + def failure_message + message = super + + if should_add_footnote_about_belongs_to? + message << "\n\n" + message << Shoulda::Matchers.word_wrap(<<-MESSAGE.strip, indent: 2) +You're getting this error because #{reason_for_existing_presence_validation}. +*This* presence validation doesn't use "can't be blank", the usual validation +message, but "must exist" instead. + +With that said, did you know that the `belong_to` matcher can test this +validation for you? Instead of using `validate_presence_of`, try +#{suggestions_for_belongs_to} + MESSAGE + end + + message + end + private def secure_password_being_validated? - defined?(::ActiveModel::SecurePassword) && - @subject.class.ancestors.include?(::ActiveModel::SecurePassword::InstanceMethodsOnActivation) && - @attribute == :password + Shoulda::Matchers::RailsShim.digestible_attributes_in(@subject). + include?(@attribute) end def possibly_ignore_interference_by_writer @@ -158,45 +213,136 @@ def possibly_ignore_interference_by_writer end end - def allows_and_double_checks_value_of!(value, message) - allows_value_of(value, message) + def allows_and_double_checks_value_of!(value) + allows_value_of(value, @expected_message) rescue ActiveModel::AllowValueMatcher::AttributeChangedValueError - raise ActiveModel::CouldNotSetPasswordError.create(@subject.class) + raise ActiveModel::CouldNotSetPasswordError.create(model) end - def allows_original_or_typecast_value?(value, message) - allows_value_of(blank_value, @expected_message) + def allows_original_or_typecast_value?(value) + allows_value_of(value, @expected_message) end - def disallows_and_double_checks_value_of!(value, message) - disallows_value_of(value, message) + def disallows_and_double_checks_value_of!(value) + disallows_value_of(value, @expected_message) rescue ActiveModel::AllowValueMatcher::AttributeChangedValueError - raise ActiveModel::CouldNotSetPasswordError.create(@subject.class) + raise ActiveModel::CouldNotSetPasswordError.create(model) end - def disallows_original_or_typecast_value?(value, message) - disallows_value_of(blank_value, @expected_message) + def disallows_original_or_typecast_value?(value) + disallows_value_of(value, @expected_message) end - def blank_value + def disallowed_values if collection? - [] + [Array.new] else - nil + values = [] + + if !association_being_validated? + values << '' + end + + if !expects_to_allow_nil? + values << nil + end + + values end end def collection? - if reflection - [:has_many, :has_and_belongs_to_many].include?(reflection.macro) + if association_reflection + [:has_many, :has_and_belongs_to_many].include?( + association_reflection.macro, + ) else false end end - def reflection - @subject.class.respond_to?(:reflect_on_association) && - @subject.class.reflect_on_association(@attribute) + def should_add_footnote_about_belongs_to? + belongs_to_association_being_validated? && + presence_validation_exists_on_attribute? + end + + def reason_for_existing_presence_validation + if belongs_to_association_configured_to_be_required? + "you've instructed your `belongs_to` association to add a " + + 'presence validation to the attribute' + else + # assume ::ActiveRecord::Base.belongs_to_required_by_default == true + 'ActiveRecord is configured to add a presence validation to all ' + + '`belongs_to` associations, and this includes yours' + end + end + + def suggestions_for_belongs_to + if belongs_to_association_configured_to_be_required? + <<~MESSAGE + one of the following instead, depending on your use case: + + #{example_of_belongs_to(with: [:optional, false])} + #{example_of_belongs_to(with: [:required, true])} + MESSAGE + else + <<~MESSAGE + the following instead: + + #{example_of_belongs_to} + MESSAGE + end + end + + def example_of_belongs_to(with: nil) + initial_call = "should belong_to(:#{association_name})" + inside = + if with + "#{initial_call}.#{with.first}(#{with.second})" + else + initial_call + end + + if Shoulda::Matchers.integrations.test_frameworks.any?(&:n_unit?) + inside + else + "it { #{inside} }" + end + end + + def belongs_to_association_configured_to_be_required? + association_options[:optional] == false || + association_options[:required] == true + end + + def belongs_to_association_being_validated? + association_being_validated? && + association_reflection.macro == :belongs_to + end + + def association_being_validated? + !!association_reflection + end + + def association_name + association_reflection.name + end + + def association_options + association_reflection&.options + end + + def association_reflection + model.respond_to?(:reflect_on_association) && + model.reflect_on_association(@attribute) + end + + def presence_validation_exists_on_attribute? + model._validators.include?(@attribute) + end + + def model + @subject.class end end end diff --git a/lib/shoulda/matchers/active_record/association_matcher.rb b/lib/shoulda/matchers/active_record/association_matcher.rb index 39f14bc2b..3d9bd2c62 100644 --- a/lib/shoulda/matchers/active_record/association_matcher.rb +++ b/lib/shoulda/matchers/active_record/association_matcher.rb @@ -272,7 +272,7 @@ module ActiveRecord # should belong_to(:organization).required # end # - # #### without_validating_presence + # ##### without_validating_presence # # Use `without_validating_presence` with `belong_to` to prevent the # matcher from checking whether the association disallows nil (Rails 5+ @@ -1362,13 +1362,11 @@ def touch_correct? def class_has_foreign_key?(klass) if options.key?(:foreign_key) option_verifier.correct_for_string?(:foreign_key, options[:foreign_key]) + elsif column_names_for(klass).include?(foreign_key) + true else - if klass.column_names.include?(foreign_key) - true - else - @missing = "#{klass} does not have a #{foreign_key} foreign key." - false - end + @missing = "#{klass} does not have a #{foreign_key} foreign key." + false end end @@ -1407,6 +1405,12 @@ def submatchers_match? failing_submatchers.empty? end + def column_names_for(klass) + klass.column_names + rescue ::ActiveRecord::StatementInvalid + [] + end + def belongs_to_required_by_default? ::ActiveRecord::Base.belongs_to_required_by_default end diff --git a/lib/shoulda/matchers/active_record/define_enum_for_matcher.rb b/lib/shoulda/matchers/active_record/define_enum_for_matcher.rb index 09e1da207..6fd570e01 100644 --- a/lib/shoulda/matchers/active_record/define_enum_for_matcher.rb +++ b/lib/shoulda/matchers/active_record/define_enum_for_matcher.rb @@ -2,7 +2,7 @@ module Shoulda module Matchers module ActiveRecord # The `define_enum_for` matcher is used to test that the `enum` macro has - # been used to decorate an attribute with enum methods. + # been used to decorate an attribute with enum capabilities. # # class Process < ActiveRecord::Base # enum status: [:running, :stopped, :suspended] @@ -22,8 +22,8 @@ module ActiveRecord # # ##### with_values # - # Use `with_values` to test that the attribute has been defined with a - # certain set of possible values. + # Use `with_values` to test that the attribute can only receive a certain + # set of possible values. # # class Process < ActiveRecord::Base # enum status: [:running, :stopped, :suspended] @@ -43,10 +43,37 @@ module ActiveRecord # with_values([:running, :stopped, :suspended]) # end # + # If the values backing your enum attribute are arbitrary instead of a + # series of integers starting from 0, pass a hash to `with_values` instead + # of an array: + # + # class Process < ActiveRecord::Base + # enum status: { + # running: 0, + # stopped: 1, + # suspended: 3, + # other: 99 + # } + # end + # + # # RSpec + # RSpec.describe Process, type: :model do + # it do + # should define_enum_for(:status). + # with_values(running: 0, stopped: 1, suspended: 3, other: 99) + # end + # end + # + # # Minitest (Shoulda) + # class ProcessTest < ActiveSupport::TestCase + # should define_enum_for(:status). + # with_values(running: 0, stopped: 1, suspended: 3, other: 99) + # end + # # ##### backed_by_column_of_type # - # Use `backed_by_column_of_type` to test that the attribute is of a - # certain column type. (The default is `:integer`.) + # Use `backed_by_column_of_type` when the column backing your column type + # is a string instead of an integer: # # class LoanApplication < ActiveRecord::Base # enum status: { @@ -144,31 +171,22 @@ def initialize(attribute_name) end def description - description = "define :#{attribute_name} as an enum, backed by " + description = "#{simple_description} backed by " description << Shoulda::Matchers::Util.a_or_an(expected_column_type) - if options[:expected_prefix] - description << ', using a prefix of ' - description << "#{options[:expected_prefix].inspect}" + if expected_enum_values.any? + description << ' with values ' + description << Shoulda::Matchers::Util.inspect_value( + expected_enum_values, + ) end - if options[:expected_suffix] - if options[:expected_prefix] - description << ' and' - else - description << ', using' - end - - description << ' a suffix of ' - - description << "#{options[:expected_suffix].inspect}" + if options[:prefix] + description << ", prefix: #{options[:prefix].inspect}" end - if presented_expected_enum_values.any? - description << ', with possible values ' - description << Shoulda::Matchers::Util.inspect_value( - presented_expected_enum_values, - ) + if options[:suffix] + description << ", suffix: #{options[:suffix].inspect}" end description @@ -187,13 +205,13 @@ def with(expected_enum_values) with_values(expected_enum_values) end - def with_prefix(expected_prefix = attribute_name) - options[:expected_prefix] = expected_prefix + def with_prefix(expected_prefix = true) + options[:prefix] = expected_prefix self end - def with_suffix(expected_suffix = attribute_name) - options[:expected_suffix] = expected_suffix + def with_suffix(expected_suffix = true) + options[:suffix] = expected_suffix self end @@ -212,13 +230,14 @@ def matches?(subject) end def failure_message - message = "Expected #{model} to #{expectation}" - - if failure_reason - message << ". However, #{failure_reason}" - end + message = + if enum_defined? + "Expected #{model} to #{expectation}. " + else + "Expected #{model} to #{expectation}, but " + end - message << '.' + message << failure_message_continuation + '.' Shoulda::Matchers.word_wrap(message) end @@ -230,20 +249,65 @@ def failure_message_when_negated private - attr_reader :attribute_name, :options, :record, :failure_reason + attr_reader :attribute_name, :options, :record, + :failure_message_continuation def expectation - description - end + if enum_defined? + expectation = "#{simple_description} backed by " + expectation << Shoulda::Matchers::Util.a_or_an(expected_column_type) + + if expected_enum_values.any? + expectation << ', mapping ' + expectation << presented_enum_mapping( + normalized_expected_enum_values, + ) + end - def presented_expected_enum_values - if expected_enum_values.is_a?(Hash) - expected_enum_values.symbolize_keys + if expected_prefix + expectation << + if expected_suffix + ', ' + else + ' and ' + end + + expectation << 'prefixing accessor methods with ' + expectation << "#{expected_prefix}_".inspect + end + + if expected_suffix + expectation << + if expected_prefix + ', and ' + else + ' and ' + end + + expectation << 'suffixing accessor methods with ' + expectation << "_#{expected_suffix}".inspect + end + + expectation else - expected_enum_values + simple_description end end + def simple_description + "define :#{attribute_name} as an enum" + end + + def presented_enum_mapping(enum_values) + enum_values. + map { |output_to_input| + output_to_input. + map(&Shoulda::Matchers::Util.method(:inspect_value)). + join(' to ') + }. + to_sentence + end + def normalized_expected_enum_values to_hash(expected_enum_values) end @@ -256,14 +320,6 @@ def expected_enum_values options[:expected_enum_values] end - def presented_actual_enum_values - if expected_enum_values.is_a?(Array) - to_array(actual_enum_values) - else - to_hash(actual_enum_values).symbolize_keys - end - end - def normalized_actual_enum_values to_hash(actual_enum_values) end @@ -276,7 +332,8 @@ def enum_defined? if model.defined_enums.include?(attribute_name.to_s) true else - @failure_reason = "no such enum exists in #{model}" + @failure_message_continuation = + "no such enum exists on #{model}" false end end @@ -289,11 +346,9 @@ def enum_values_match? if passed true else - @failure_reason = - "the actual enum values for #{attribute_name.inspect} are " + - Shoulda::Matchers::Util.inspect_value( - presented_actual_enum_values, - ) + @failure_message_continuation = + "However, #{attribute_name.inspect} actually maps " + + presented_enum_mapping(normalized_actual_enum_values) false end end @@ -302,8 +357,8 @@ def column_type_matches? if column.type == expected_column_type.to_sym true else - @failure_reason = - "#{attribute_name.inspect} is " + + @failure_message_continuation = + "However, #{attribute_name.inspect} is " + Shoulda::Matchers::Util.a_or_an(column.type) + ' column' false @@ -330,30 +385,59 @@ def enum_value_methods_exist? if passed true else - @failure_reason = - if options[:expected_prefix] - if options[:expected_suffix] - 'it was defined with either a different prefix, a ' + - 'different suffix, or neither one at all' - else - 'it was defined with either a different prefix or none at all' - end - elsif options[:expected_suffix] - 'it was defined with either a different suffix or none at all' + message = "#{attribute_name.inspect} does map to these " + message << 'values, but the enum is ' + + if expected_prefix + if expected_suffix + message << 'configured with either a different prefix or ' + message << 'suffix, or no prefix or suffix at all' + else + message << 'configured with either a different prefix or no ' + message << 'prefix at all' end + elsif expected_suffix + message << 'configured with either a different suffix or no ' + message << 'suffix at all' + end + + message << " (we can't tell which)" + + @failure_message_continuation = message + false end end def expected_singleton_methods expected_enum_value_names.map do |name| - [options[:expected_prefix], name, options[:expected_suffix]]. + [expected_prefix, name, expected_suffix]. select(&:present?). join('_'). to_sym end end + def expected_prefix + if options.include?(:prefix) + if options[:prefix] == true + attribute_name#.to_sym + else + options[:prefix]#.to_sym + end + end + end + + def expected_suffix + if options.include?(:suffix) + if options[:suffix] == true + attribute_name#.to_sym + else + options[:suffix]#.to_sym + end + end + end + def to_hash(value) if value.is_a?(Array) value.each_with_index.inject({}) do |hash, (item, index)| diff --git a/lib/shoulda/matchers/active_record/have_db_index_matcher.rb b/lib/shoulda/matchers/active_record/have_db_index_matcher.rb index 7611f6ef2..6cfe5d85b 100644 --- a/lib/shoulda/matchers/active_record/have_db_index_matcher.rb +++ b/lib/shoulda/matchers/active_record/have_db_index_matcher.rb @@ -2,7 +2,9 @@ module Shoulda module Matchers module ActiveRecord # The `have_db_index` matcher tests that the table that backs your model - # has a index on a specific column. + # has a specific index. + # + # You can specify one column: # # class CreateBlogs < ActiveRecord::Migration # def change @@ -24,43 +26,83 @@ module ActiveRecord # should have_db_index(:user_id) # end # - # #### Qualifiers - # - # ##### unique - # - # Use `unique` to assert that the index is unique. + # Or you can specify a group of columns: # # class CreateBlogs < ActiveRecord::Migration # def change # create_table :blogs do |t| + # t.integer :user_id # t.string :name # end # - # add_index :blogs, :name, unique: true + # add_index :blogs, :user_id, :name # end # end # # # RSpec # RSpec.describe Blog, type: :model do - # it { should have_db_index(:name).unique(true) } + # it { should have_db_index([:user_id, :name]) } # end # # # Minitest (Shoulda) # class BlogTest < ActiveSupport::TestCase - # should have_db_index(:name).unique(true) + # should have_db_index([:user_id, :name]) + # end + # + # Finally, if you're using Rails 5 and PostgreSQL, you can also specify an + # expression: + # + # class CreateLoggedErrors < ActiveRecord::Migration + # def change + # create_table :logged_errors do |t| + # t.string :code + # t.jsonb :content + # end + # + # add_index :logged_errors, 'lower(code)::text' + # end + # end + # + # # RSpec + # RSpec.describe LoggedError, type: :model do + # it { should have_db_index('lower(code)::text') } + # end + # + # # Minitest (Shoulda) + # class LoggedErrorTest < ActiveSupport::TestCase + # should have_db_index('lower(code)::text') # end # - # Since it only ever makes sense for `unique` to be `true`, you can also - # leave off the argument to save some keystrokes: + # #### Qualifiers + # + # ##### unique + # + # Use `unique` to assert that the index is either unique or non-unique: + # + # class CreateBlogs < ActiveRecord::Migration + # def change + # create_table :blogs do |t| + # t.string :domain + # t.integer :user_id + # end + # + # add_index :blogs, :domain, unique: true + # add_index :blogs, :user_id + # end + # end # # # RSpec # RSpec.describe Blog, type: :model do # it { should have_db_index(:name).unique } + # it { should have_db_index(:name).unique(true) } # if you want to be explicit + # it { should have_db_index(:user_id).unique(false) } # end # # # Minitest (Shoulda) # class BlogTest < ActiveSupport::TestCase # should have_db_index(:name).unique + # should have_db_index(:name).unique(true) # if you want to be explicit + # should have_db_index(:user_id).unique(false) # end # # @return [HaveDbIndexMatcher] @@ -72,12 +114,12 @@ def have_db_index(columns) # @private class HaveDbIndexMatcher def initialize(columns) - @columns = normalize_columns_to_array(columns) - @options = {} + @expected_columns = normalize_columns_to_array(columns) + @qualifiers = {} end def unique(unique = true) - @options[:unique] = unique + @qualifiers[:unique] = unique self end @@ -87,72 +129,146 @@ def matches?(subject) end def failure_message - "Expected #{expectation} (#{@missing})" + message = + "Expected #{described_table_name} to #{positive_expectation}" + + message << + if index_exists? + ". The index does exist, but #{reason}." + elsif reason + ", but #{reason}." + else + ', but it does not.' + end + + Shoulda::Matchers.word_wrap(message) end def failure_message_when_negated - "Did not expect #{expectation}" + Shoulda::Matchers.word_wrap( + "Expected #{described_table_name} not to " + + "#{negative_expectation}, but it does.", + ) end def description - if @options.key?(:unique) - "have a #{index_type} index on columns #{@columns.join(' and ')}" - else - "have an index on columns #{@columns.join(' and ')}" - end - end + description = 'have ' - protected + description << + if qualifiers.include?(:unique) + Shoulda::Matchers::Util.a_or_an(index_type) + ' ' + else + 'an ' + end - def index_exists? - ! matched_index.nil? + description << 'index on ' + + description << inspected_expected_columns end - def correct_unique? - return true unless @options.key?(:unique) + private - is_unique = matched_index.unique + attr_reader :expected_columns, :qualifiers, :subject, :reason - is_unique = !is_unique unless @options[:unique] + def normalize_columns_to_array(columns) + Array.wrap(columns).map(&:to_s) + end - unless is_unique - @missing = "#{table_name} has an index named #{matched_index.name} " << - "of unique #{matched_index.unique}, not #{@options[:unique]}." - end + def index_exists? + !matched_index.nil? + end - is_unique + def correct_unique? + if qualifiers.include?(:unique) + if qualifiers[:unique] && !matched_index.unique + @reason = 'it is not unique' + false + elsif !qualifiers[:unique] && matched_index.unique + @reason = 'it is unique' + false + else + true + end + else + true + end end def matched_index - indexes.detect { |each| each.columns == @columns } + @_matched_index ||= + if expected_columns.one? + actual_indexes.detect do |index| + Array.wrap(index.columns) == expected_columns + end + else + actual_indexes.detect do |index| + index.columns == expected_columns + end + end end - def model_class - @subject.class + def actual_indexes + model.connection.indexes(table_name) + end + + def described_table_name + if model + "the #{table_name} table" + else + 'a table' + end end def table_name - model_class.table_name + model.table_name + end + + def positive_expectation + if index_exists? + expectation = "have an index on #{inspected_expected_columns}" + + if qualifiers.include?(:unique) + expectation << " and for it to be #{index_type}" + end + + expectation + else + description + end end - def indexes - ::ActiveRecord::Base.connection.indexes(table_name) + def negative_expectation + description end - def expectation - "#{model_class.name} to #{description}" + def inspected_expected_columns + if formatted_expected_columns.one? + formatted_expected_columns.first.inspect + else + formatted_expected_columns.inspect + end end def index_type - if @options[:unique] + if qualifiers[:unique] 'unique' else 'non-unique' end end - def normalize_columns_to_array(columns) - Array.wrap(columns).map(&:to_s) + def formatted_expected_columns + expected_columns.map do |column| + if column.match?(/^\w+$/) + column.to_sym + else + column + end + end + end + + def model + subject&.class end end end diff --git a/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb b/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb index 58689da4b..a0084fd5c 100644 --- a/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb +++ b/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb @@ -276,7 +276,7 @@ def initialize(attribute) end def scoped_to(*scopes) - @options[:scopes] = [*scopes].flatten + @options[:scopes] = [*scopes].flatten.map(&:to_sym) self end @@ -477,7 +477,7 @@ def expected_scopes def actual_sets_of_scopes validations.map do |validation| - Array.wrap(validation.options[:scope]) + Array.wrap(validation.options[:scope]).map(&:to_sym) end.reject(&:empty?) end @@ -538,20 +538,12 @@ def find_existing_record def create_existing_record @given_record.tap do |existing_record| - ensure_secure_password_set(existing_record) existing_record.save(validate: false) end rescue ::ActiveRecord::StatementInvalid => error raise ExistingRecordInvalid.create(underlying_exception: error) end - def ensure_secure_password_set(instance) - if has_secure_password? - instance.password = "password" - instance.password_confirmation = "password" - end - end - def update_existing_record!(value) if existing_value_read != value set_attribute_on_existing_record!(@attribute, value) @@ -576,9 +568,7 @@ def arbitrary_non_blank_value end def has_secure_password? - model.ancestors.map(&:to_s).include?( - 'ActiveModel::SecurePassword::InstanceMethodsOnActivation' - ) + Shoulda::Matchers::RailsShim.has_secure_password?(subject, @attribute) end def build_new_record diff --git a/lib/shoulda/matchers/configuration.rb b/lib/shoulda/matchers/configuration.rb index 08fa30903..fd988d1f8 100644 --- a/lib/shoulda/matchers/configuration.rb +++ b/lib/shoulda/matchers/configuration.rb @@ -5,6 +5,11 @@ def self.configure yield configuration end + # @private + def self.integrations + configuration.integrations + end + # @private def self.configuration @_configuration ||= Configuration.new @@ -12,8 +17,14 @@ def self.configuration # @private class Configuration + attr_reader :integrations + + def initialize + @integrations = nil + end + def integrate(&block) - Integrations::Configuration.apply(self, &block) + @integrations = Integrations::Configuration.apply(&block) end end end diff --git a/lib/shoulda/matchers/integrations/configuration.rb b/lib/shoulda/matchers/integrations/configuration.rb index b7179bf38..78f7953e8 100644 --- a/lib/shoulda/matchers/integrations/configuration.rb +++ b/lib/shoulda/matchers/integrations/configuration.rb @@ -5,11 +5,13 @@ module Matchers module Integrations # @private class Configuration - def self.apply(configuration, &block) - new(configuration, &block).apply + def self.apply(&block) + new(&block).apply end - def initialize(configuration, &block) + attr_reader :test_frameworks + + def initialize(&block) @test_frameworks = Set.new @libraries = Set.new @@ -47,6 +49,8 @@ def apply test_framework.include(Shoulda::Matchers::Independent) @libraries.each { |library| library.integrate_with(test_framework) } end + + self end private diff --git a/lib/shoulda/matchers/rails_shim.rb b/lib/shoulda/matchers/rails_shim.rb index 9c75e8c39..3869d20e4 100644 --- a/lib/shoulda/matchers/rails_shim.rb +++ b/lib/shoulda/matchers/rails_shim.rb @@ -119,6 +119,43 @@ def validation_message_key_for_association_required_option end end + def parent_of(mod) + if mod.respond_to?(:module_parent) + mod.module_parent + else + mod.parent + end + end + + def has_secure_password?(record, attribute_name) + if secure_password_module + attribute_name == :password && + record.class.ancestors.include?(secure_password_module) + else + record.respond_to?("authenticate_#{attribute_name}") + end + end + + def digestible_attributes_in(record) + record.methods.inject([]) do |array, method_name| + match = method_name.to_s.match( + /\A(\w+)_(?:confirmation|digest)=\Z/, + ) + + if match + array.concat([match[1].to_sym]) + else + array + end + end + end + + def secure_password_module + ::ActiveModel::SecurePassword::InstanceMethodsOnActivation + rescue NameError + nil + end + private def simply_generate_validation_message( diff --git a/lib/shoulda/matchers/util.rb b/lib/shoulda/matchers/util.rb index df828bf1f..37737a9d1 100644 --- a/lib/shoulda/matchers/util.rb +++ b/lib/shoulda/matchers/util.rb @@ -33,7 +33,7 @@ def self.indent(string, width) end def self.a_or_an(next_word) - if next_word =~ /\A[aeiou]/i + if next_word =~ /\A[aeiou]/i && next_word != 'unique' "an #{next_word}" else "a #{next_word}" diff --git a/lib/shoulda/matchers/util/word_wrap.rb b/lib/shoulda/matchers/util/word_wrap.rb index 7e73f6acb..8f6077d25 100644 --- a/lib/shoulda/matchers/util/word_wrap.rb +++ b/lib/shoulda/matchers/util/word_wrap.rb @@ -2,6 +2,8 @@ module Shoulda module Matchers # @private module WordWrap + TERMINAL_WIDTH = 72 + def word_wrap(document, options = {}) Document.new(document, options).wrap end @@ -112,7 +114,6 @@ def combine_paragraph_into_one_line # @private class Line - TERMINAL_WIDTH = 72 OFFSETS = { left: -1, right: +1 } def initialize(line, indent: 0) @@ -171,7 +172,7 @@ def read_indentation def wrap_line(line, direction: :left) index = nil - if line.length > TERMINAL_WIDTH + if line.length > Shoulda::Matchers::WordWrap::TERMINAL_WIDTH index = determine_where_to_break_line(line, direction: :left) if index == -1 @@ -192,7 +193,7 @@ def wrap_line(line, direction: :left) def determine_where_to_break_line(line, args) direction = args.fetch(:direction) - index = TERMINAL_WIDTH + index = Shoulda::Matchers::WordWrap::TERMINAL_WIDTH offset = OFFSETS.fetch(direction) while line[index] !~ /\s/ && (0...line.length).cover?(index) diff --git a/lib/shoulda/matchers/version.rb b/lib/shoulda/matchers/version.rb index 524bd213e..58647f66b 100644 --- a/lib/shoulda/matchers/version.rb +++ b/lib/shoulda/matchers/version.rb @@ -1,6 +1,6 @@ module Shoulda module Matchers # @private - VERSION = '4.0.1'.freeze + VERSION = '4.1.0'.freeze end end diff --git a/shoulda-matchers.gemspec b/shoulda-matchers.gemspec index 96fac364d..752579a35 100644 --- a/shoulda-matchers.gemspec +++ b/shoulda-matchers.gemspec @@ -2,30 +2,37 @@ $LOAD_PATH << File.join(File.dirname(__FILE__), 'lib') require 'shoulda/matchers/version' Gem::Specification.new do |s| - s.name = "shoulda-matchers" + s.name = 'shoulda-matchers' s.version = Shoulda::Matchers::VERSION.dup - s.authors = ["Tammer Saleh", "Joe Ferris", "Ryan McGeary", "Dan Croak", - "Matt Jankowski", "Stafford Brunk", "Elliot Winkler"] - s.date = Time.now.strftime("%Y-%m-%d") - s.email = "support@thoughtbot.com" - s.homepage = "https://matchers.shoulda.io/" - s.summary = "Making tests easy on the fingers and eyes" - s.license = "MIT" - s.description = "Making tests easy on the fingers and eyes" + s.authors = [ + 'Tammer Saleh', + 'Joe Ferris', + 'Ryan McGeary', + 'Dan Croak', + 'Matt Jankowski', + 'Stafford Brunk', + 'Elliot Winkler', + ] + s.date = Time.now.strftime('%Y-%m-%d') + s.email = 'support@thoughtbot.com' + s.homepage = 'https://matchers.shoulda.io/' + s.summary = 'Simple one-liner tests for common Rails functionality' + s.license = 'MIT' + s.description = 'Shoulda Matchers provides RSpec- and Minitest-compatible one-liners to test common Rails functionality that, if written by hand, would be much longer, more complex, and error-prone.' s.metadata = { 'bug_tracker_uri' => 'https://github.com/thoughtbot/shoulda-matchers/issues', 'changelog_uri' => 'https://github.com/thoughtbot/shoulda-matchers/blob/master/NEWS.md', 'documentation_uri' => 'https://matchers.shoulda.io/docs', 'homepage_uri' => 'https://matchers.shoulda.io', - 'source_code_uri' => 'https://github.com/thoughtbot/shoulda-matchers' + 'source_code_uri' => 'https://github.com/thoughtbot/shoulda-matchers', } - s.files = Dir.chdir(File.expand_path('..', __FILE__)) do + s.files = Dir.chdir(File.expand_path(__dir__)) do `git ls-files -z -- {docs,lib,README.md,MIT-LICENSE,shoulda-matchers.gemspec}`. split("\x0") end - s.require_paths = ["lib"] + s.require_paths = ['lib'] - s.required_ruby_version = '>= 2.2.0' + s.required_ruby_version = '>= 2.4.0' s.add_dependency('activesupport', '>= 4.2.0') end diff --git a/spec/support/acceptance/helpers/step_helpers.rb b/spec/support/acceptance/helpers/step_helpers.rb index fe1041151..def4e784b 100644 --- a/spec/support/acceptance/helpers/step_helpers.rb +++ b/spec/support/acceptance/helpers/step_helpers.rb @@ -78,6 +78,8 @@ def create_rails_application bundle.remove_gem 'byebug' bundle.remove_gem 'web-console' bundle.add_gem 'pg' + bundle.remove_gem 'sqlite3' + bundle.add_gem 'sqlite3', '~> 1.3.6' end fs.open('config/database.yml', 'w') do |file| diff --git a/spec/support/tests/database_configuration.rb b/spec/support/tests/database_configuration.rb index 4f85c5e5f..fed227530 100644 --- a/spec/support/tests/database_configuration.rb +++ b/spec/support/tests/database_configuration.rb @@ -20,14 +20,11 @@ def initialize(config) def to_hash ENVIRONMENTS.each_with_object({}) do |env, config_as_hash| - config_as_hash[env] = inner_config_as_hash + config_as_hash[env] = { + 'adapter' => adapter.to_s, + 'database' => "#{database}_#{env}", + } end end - - private - - def inner_config_as_hash - { 'adapter' => adapter.to_s, 'database' => database.to_s } - end end end diff --git a/spec/support/unit/active_record/create_table.rb b/spec/support/unit/active_record/create_table.rb index 65bcc5282..10df97a8c 100644 --- a/spec/support/unit/active_record/create_table.rb +++ b/spec/support/unit/active_record/create_table.rb @@ -1,13 +1,30 @@ module UnitTests module ActiveRecord class CreateTable - def self.call(table_name, columns) - new(table_name, columns).call + def self.call( + table_name:, + columns:, + connection: ::ActiveRecord::Base.connection, + &block + ) + new( + table_name: table_name, + columns: columns, + connection: connection, + &block + ).call end - def initialize(table_name, columns) + def initialize( + table_name:, + columns:, + connection: ::ActiveRecord::Base.connection, + &block + ) @table_name = table_name @columns = columns + @connection = connection + @customizer = block || proc {} end def call @@ -15,27 +32,31 @@ def call columns.delete(:id) UnitTests::ModelBuilder.create_table( table_name, - id: false, - &method(:add_columns_to_table) - ) + connection: connection, + id: false + ) do |table| + add_columns_to_table(table) + end else UnitTests::ModelBuilder.create_table( table_name, - &method(:add_columns_to_table) - ) + connection: connection + ) do |table| + add_columns_to_table(table) + end end end - protected - - attr_reader :table_name, :columns - private + attr_reader :table_name, :columns, :connection, :customizer + def add_columns_to_table(table) columns.each do |column_name, column_specification| add_column_to_table(table, column_name, column_specification) end + + customizer.call(table) end def add_column_to_table(table, column_name, column_specification) diff --git a/spec/support/unit/helpers/active_record_versions.rb b/spec/support/unit/helpers/active_record_versions.rb index 44eee019b..1d35db635 100644 --- a/spec/support/unit/helpers/active_record_versions.rb +++ b/spec/support/unit/helpers/active_record_versions.rb @@ -40,5 +40,9 @@ def active_record_uniqueness_supports_array_columns? def active_record_supports_optional_for_associations? active_record_version >= 5 end + + def active_record_supports_expression_indexes? + active_record_version >= 5 + end end end diff --git a/spec/support/unit/helpers/application_configuration_helpers.rb b/spec/support/unit/helpers/application_configuration_helpers.rb new file mode 100644 index 000000000..87d013bc1 --- /dev/null +++ b/spec/support/unit/helpers/application_configuration_helpers.rb @@ -0,0 +1,31 @@ +module UnitTests + module ApplicationConfigurationHelpers + def with_belongs_to_as_required_by_default(&block) + configuring_application( + ::ActiveRecord::Base, + :belongs_to_required_by_default, + true, + &block + ) + end + + def with_belongs_to_as_optional_by_default(&block) + configuring_application( + ::ActiveRecord::Base, + :belongs_to_required_by_default, + false, + &block + ) + end + + private + + def configuring_application(config, name, value) + previous_value = config.send(name) + config.send("#{name}=", value) + yield + ensure + config.send("#{name}=", previous_value) + end + end +end diff --git a/spec/support/unit/helpers/class_builder.rb b/spec/support/unit/helpers/class_builder.rb index 067a0b039..3d1477b50 100644 --- a/spec/support/unit/helpers/class_builder.rb +++ b/spec/support/unit/helpers/class_builder.rb @@ -18,18 +18,15 @@ def configure_example_group(example_group) end def reset - remove_defined_classes + remove_defined_modules + defined_modules.clear end def define_module(module_name, &block) module_name = module_name.to_s.camelize + namespace, name_without_namespace = parse_constant_name(module_name) - namespace, name_without_namespace = - ClassBuilder.parse_constant_name(module_name) - - if namespace.const_defined?(name_without_namespace, false) - namespace.__send__(:remove_const, name_without_namespace) - end + remove_defined_module(module_name) eval <<-RUBY module #{namespace}::#{name_without_namespace} @@ -38,6 +35,7 @@ module #{namespace}::#{name_without_namespace} namespace.const_get(name_without_namespace).tap do |constant| constant.unloadable + @_defined_modules = defined_modules | [constant] if block constant.module_eval(&block) @@ -47,13 +45,9 @@ module #{namespace}::#{name_without_namespace} def define_class(class_name, parent_class = Object, &block) class_name = class_name.to_s.camelize + namespace, name_without_namespace = parse_constant_name(class_name) - namespace, name_without_namespace = - ClassBuilder.parse_constant_name(class_name) - - if namespace.const_defined?(name_without_namespace, false) - namespace.__send__(:remove_const, name_without_namespace) - end + remove_defined_module(class_name) eval <<-RUBY class #{namespace}::#{name_without_namespace} < ::#{parent_class} @@ -62,6 +56,7 @@ class #{namespace}::#{name_without_namespace} < ::#{parent_class} namespace.const_get(name_without_namespace).tap do |constant| constant.unloadable + @_defined_modules = defined_modules | [constant] if block if block.arity == 0 @@ -82,8 +77,21 @@ def parse_constant_name(name) private - def remove_defined_classes - ::ActiveSupport::Dependencies.clear + def remove_defined_modules + defined_modules.reverse_each { |mod| remove_defined_module(mod.name) } + ActiveSupport::Dependencies.clear + end + + def remove_defined_module(module_name) + namespace, name_without_namespace = parse_constant_name(module_name) + + if namespace.const_defined?(name_without_namespace, false) + namespace.__send__(:remove_const, name_without_namespace) + end + end + + def defined_modules + @_defined_modules ||= [] end end end diff --git a/spec/support/unit/helpers/database_helpers.rb b/spec/support/unit/helpers/database_helpers.rb index f7a267b95..df7c9f4c9 100644 --- a/spec/support/unit/helpers/database_helpers.rb +++ b/spec/support/unit/helpers/database_helpers.rb @@ -16,5 +16,6 @@ def postgresql? alias_method :database_supports_array_columns?, :postgresql? alias_method :database_supports_uuid_columns?, :postgresql? alias_method :database_supports_money_columns?, :postgresql? + alias_method :database_supports_expression_indexes?, :postgresql? end end diff --git a/spec/support/unit/helpers/model_builder.rb b/spec/support/unit/helpers/model_builder.rb index 2f76e05d5..fe6058df6 100644 --- a/spec/support/unit/helpers/model_builder.rb +++ b/spec/support/unit/helpers/model_builder.rb @@ -35,7 +35,8 @@ def reset end def create_table(table_name, options = {}, &block) - connection = ::ActiveRecord::Base.connection + connection = + options.delete(:connection) || DevelopmentRecord.connection begin connection.execute("DROP TABLE IF EXISTS #{table_name}") @@ -48,8 +49,8 @@ def create_table(table_name, options = {}, &block) end end - def define_model_class(class_name, &block) - ClassBuilder.define_class(class_name, ::ActiveRecord::Base, &block) + def define_model_class(class_name, parent_class: DevelopmentRecord, &block) + ClassBuilder.define_class(class_name, parent_class, &block) end def define_active_model_class(class_name, options = {}, &block) @@ -83,10 +84,12 @@ def define_model(name, columns = {}, options = {}, &block) def clear_column_caches # Rails 4.x if ::ActiveRecord::Base.connection.respond_to?(:schema_cache) - ::ActiveRecord::Base.connection.schema_cache.clear! + DevelopmentRecord.connection.schema_cache.clear! + ProductionRecord.connection.schema_cache.clear! # Rails 3.1 - 4.0 elsif ::ActiveRecord::Base.connection_pool.respond_to?(:clear_cache!) - ::ActiveRecord::Base.connection_pool.clear_cache! + DevelopmentRecord.connection_pool.clear_cache! + ProductionRecord.connection_pool.clear_cache! end defined_models.each do |model| @@ -95,10 +98,11 @@ def clear_column_caches end def drop_created_tables - connection = ::ActiveRecord::Base.connection - created_tables.each do |table_name| - connection.execute("DROP TABLE IF EXISTS #{table_name}") + DevelopmentRecord.connection. + execute("DROP TABLE IF EXISTS #{table_name}") + ProductionRecord.connection. + execute("DROP TABLE IF EXISTS #{table_name}") end end diff --git a/spec/support/unit/matchers/fail_with_message_matcher.rb b/spec/support/unit/matchers/fail_with_message_matcher.rb index 56436f0c7..c86783d56 100644 --- a/spec/support/unit/matchers/fail_with_message_matcher.rb +++ b/spec/support/unit/matchers/fail_with_message_matcher.rb @@ -2,7 +2,14 @@ module UnitTests module Matchers extend RSpec::Matchers::DSL - matcher :fail_with_message do |expected| + matcher :fail_with_message do |raw_expected, wrap: false| + expected = + if wrap + Shoulda::Matchers.word_wrap(raw_expected) + else + raw_expected + end + def supports_block_expectations? true end @@ -19,7 +26,7 @@ def supports_block_expectations? @actual && @actual == expected.sub(/\n\z/, '') end - def failure_message + define_method :failure_message do lines = ['Expectation should have failed with message:'] lines << Shoulda::Matchers::Util.indent(expected, 2) @@ -40,20 +47,12 @@ def failure_message lines.join("\n") end - def failure_message_for_should - failure_message - end - - def failure_message_when_negated + define_method :failure_message_when_negated do lines = ['Expectation should not have failed with message:'] lines << Shoulda::Matchers::Util.indent(expected, 2) lines.join("\n") end - def failure_message_for_should_not - failure_message_when_negated - end - private def differ diff --git a/spec/support/unit/matchers/match_against.rb b/spec/support/unit/matchers/match_against.rb new file mode 100644 index 000000000..76cf1ee46 --- /dev/null +++ b/spec/support/unit/matchers/match_against.rb @@ -0,0 +1,216 @@ +module UnitTests + module Matchers + def match_against(object) + MatchAgainstMatcher.new(object) + end + + class MatchAgainstMatcher + DIVIDER = ('-' * Shoulda::Matchers::WordWrap::TERMINAL_WIDTH).freeze + + attr_reader :failure_message, :failure_message_when_negated + + def initialize(object) + @object = object + @failure_message = nil + @failure_message_when_negated = nil + @should_be_negated = nil + end + + def and_fail_with(message, wrap: false) + @expected_message = + if wrap + Shoulda::Matchers.word_wrap(message.strip_heredoc.strip) + else + message.strip_heredoc.strip + end + + @should_be_negated = true + + self + end + + def or_fail_with(message, wrap: false) + @expected_message = + if wrap + Shoulda::Matchers.word_wrap(message.strip_heredoc.strip) + else + message.strip_heredoc.strip + end + + @should_be_negated = false + + self + end + + def matches?(generate_matcher) + @positive_matcher = generate_matcher.call + @negative_matcher = generate_matcher.call + + if expected_message && should_be_negated? + raise ArgumentError.new( + 'Use `or_fail_with`, not `and_fail_with`, when using ' + + '`should match_against(...)`!', + ) + end + + if positive_matcher.matches?(object) + matcher_fails_in_negative? + else + @failure_message = <<-MESSAGE +Expected the matcher to match in the positive, but it failed with this message: + +#{DIVIDER} +#{positive_matcher.failure_message} +#{DIVIDER} + MESSAGE + false + end + end + + def does_not_match?(generate_matcher) + @positive_matcher = generate_matcher.call + @negative_matcher = generate_matcher.call + + if expected_message && !should_be_negated? + raise ArgumentError.new( + 'Use `and_fail_with`, not `or_fail_with`, when using ' + + '`should_not match_against(...)`!', + ) + end + + if matcher_fails_in_positive? + if ( + negative_matcher.respond_to?(:does_not_match?) && + !negative_matcher.does_not_match?(object) + ) + @failure_message_when_negated = <<-MESSAGE +Expected the matcher to match in the negative, but it failed with this message: + +#{DIVIDER} +#{negative_matcher.failure_message_when_negated} +#{DIVIDER} + MESSAGE + false + else + true + end + end + end + + def supports_block_expectations? + true + end + + private + + attr_reader( + :object, + :expected_message, + :positive_matcher, + :negative_matcher, + ) + + def should_be_negated? + @should_be_negated + end + + def matcher_fails_in_negative? + if does_not_match_in_negative? + if ( + !expected_message || + expected_message == negative_matcher.failure_message_when_negated.strip + ) + true + else + diff_result = diff( + expected_message, + negative_matcher.failure_message_when_negated.strip, + ) + @failure_message = <<-MESSAGE +Expected the negative version of the matcher not to match and for the failure +message to be: + +#{DIVIDER} +#{expected_message.chomp} +#{DIVIDER} + +However, it was: + +#{DIVIDER} +#{negative_matcher.failure_message_when_negated} +#{DIVIDER} + +Diff: + +#{Shoulda::Matchers::Util.indent(diff_result, 2)} + MESSAGE + false + end + else + @failure_message = + 'Expected the negative version of the matcher not to match, ' + + 'but it did.' + false + end + end + + def does_not_match_in_negative? + if negative_matcher.respond_to?(:does_not_match?) + !negative_matcher.does_not_match?(object) + else + # generate failure_message_when_negated + negative_matcher.matches?(object) + true + end + end + + def matcher_fails_in_positive? + if !positive_matcher.matches?(object) + if ( + !expected_message || + expected_message == positive_matcher.failure_message.strip + ) + true + else + diff_result = diff( + expected_message, + positive_matcher.failure_message.strip, + ) + @failure_message_when_negated = <<-MESSAGE +Expected the positive version of the matcher not to match and for the failure +message to be: + +#{DIVIDER} +#{expected_message.chomp} +#{DIVIDER} + +However, it was: + +#{DIVIDER} +#{positive_matcher.failure_message} +#{DIVIDER} + +Diff: + +#{Shoulda::Matchers::Util.indent(diff_result, 2)} + MESSAGE + false + end + else + @failure_message_when_negated = + 'Expected the positive version of the matcher not to match, ' + + 'but it did.' + false + end + end + + def diff(expected, actual) + differ.diff(expected, actual)[1..-1] + end + + def differ + @_differ ||= RSpec::Support::Differ.new + end + end + end +end diff --git a/spec/support/unit/model_creation_strategies/active_record.rb b/spec/support/unit/model_creation_strategies/active_record.rb index ed8a83a64..54dcc37ef 100644 --- a/spec/support/unit/model_creation_strategies/active_record.rb +++ b/spec/support/unit/model_creation_strategies/active_record.rb @@ -32,11 +32,19 @@ def call private def create_table_for_model - UnitTests::ActiveRecord::CreateTable.call(table_name, columns) + UnitTests::ActiveRecord::CreateTable.call( + table_name: table_name, + columns: columns, + connection: parent_class.connection, + &customize_table + ) end def define_class_for_model - model = UnitTests::ModelBuilder.define_model_class(class_name) + model = UnitTests::ModelBuilder.define_model_class( + class_name, + parent_class: parent_class + ) model_customizers.each do |block| run_block(model, block) @@ -69,6 +77,14 @@ def table_name class_name.tableize.gsub('/', '_') end + def parent_class + options.fetch(:parent_class, DevelopmentRecord) + end + + def customize_table + options.fetch(:customize_table) { proc {} } + end + def whitelist_attributes? options.fetch(:whitelist_attributes, true) end diff --git a/spec/support/unit/model_creators/active_record/has_and_belongs_to_many.rb b/spec/support/unit/model_creators/active_record/has_and_belongs_to_many.rb index 64a763012..267c0a662 100644 --- a/spec/support/unit/model_creators/active_record/has_and_belongs_to_many.rb +++ b/spec/support/unit/model_creators/active_record/has_and_belongs_to_many.rb @@ -39,10 +39,12 @@ def call def parent_child_table_creator @_parent_child_table_creator ||= UnitTests::ActiveRecord::CreateTable.new( - parent_child_table_name, - foreign_key_for_child_model => :integer, - foreign_key_for_parent_model => :integer, - :id => false + table_name: parent_child_table_name, + columns: { + foreign_key_for_child_model => :integer, + foreign_key_for_parent_model => :integer, + :id => false + } ) end diff --git a/spec/support/unit/rails_application.rb b/spec/support/unit/rails_application.rb index b827eb5bb..2f5d5dc71 100644 --- a/spec/support/unit/rails_application.rb +++ b/spec/support/unit/rails_application.rb @@ -19,8 +19,7 @@ def create generate fs.within_project do - install_gems - remove_unwanted_gems + update_gems end end @@ -77,14 +76,37 @@ def generate fix_available_locales_warning remove_bootsnap write_database_configuration + write_activerecord_model_with_default_connection + write_activerecord_model_with_different_connection - if bundle.version_of("rails") >= 5 + if rails_version >= 5 add_initializer_for_time_zone_aware_types end end def rails_new - run_command! %W(rails new #{fs.project_directory} --skip-bundle --no-rc) + run_command!(*rails_new_command) + end + + def rails_new_command + if rails_version > 5 + [ + 'rails', + 'new', + fs.project_directory.to_s, + '--skip-bundle', + '--no-rc', + '--skip-webpack-install', + ] + else + [ + 'rails', + 'new', + fs.project_directory.to_s, + '--skip-bundle', + '--no-rc', + ] + end end def fix_available_locales_warning @@ -113,6 +135,36 @@ def write_database_configuration YAML.dump(database.config.to_hash, fs.open('config/database.yml', 'w')) end + def write_activerecord_model_with_different_connection + # To simulate multi-db connections, we create a new "base model" which + # connects to a different database (in this case - + # shoulda-matchers-test_production). + # Any models which inherit from this class, or uses this model's + # connection will be routed to this database. + path = 'app/models/production_record.rb' + fs.write(path, <<-TEXT) +class ProductionRecord < ActiveRecord::Base + self.abstract_class = true + establish_connection :production +end + TEXT + end + + def write_activerecord_model_with_default_connection + # Alongside ProductionRecord created above, we also create a dummy + # DevelopmentRecord which connects to the default database (in this case - + # shoulda-matchers-test_development, for symmetry's sake. This allows us + # to be a little more explicit when writing tests, for example: + # expect(with_index_on(:age1, parent_class: DevelopmentRecord)).to have_db_index(:age1) + # expect(with_index_on(:age2, parent_class: ProductionRecord)).to have_db_index(:age2) + path = 'app/models/development_record.rb' + fs.write(path, <<-TEXT) +class DevelopmentRecord < ActiveRecord::Base + self.abstract_class = true +end + TEXT + end + def add_initializer_for_time_zone_aware_types path = 'config/initializers/configure_time_zone_aware_types.rb' fs.write(path, <<-TEXT) @@ -128,24 +180,30 @@ def load_environment def run_migrations fs.within_project do - run_command! 'bundle exec rake db:drop db:create db:migrate' + run_command! 'bundle exec rake db:drop:all db:create:all db:migrate' end end - def install_gems - bundle.install_gems - end - - def remove_unwanted_gems + def update_gems bundle.updating do + bundle.remove_gem 'turn' + bundle.remove_gem 'coffee-rails' + bundle.remove_gem 'uglifier' bundle.remove_gem 'debugger' bundle.remove_gem 'byebug' bundle.remove_gem 'web-console' + bundle.add_gem 'pg' + bundle.remove_gem 'sqlite3' + bundle.add_gem 'sqlite3', '~> 1.3.6' end end def run_command!(*args) Tests::CommandRunner.run!(*args) end + + def rails_version + bundle.version_of('rails') + end end end diff --git a/spec/unit/shoulda/matchers/active_model/have_secure_password_matcher_spec.rb b/spec/unit/shoulda/matchers/active_model/have_secure_password_matcher_spec.rb index 7cb0492b7..d3add54f7 100644 --- a/spec/unit/shoulda/matchers/active_model/have_secure_password_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_model/have_secure_password_matcher_spec.rb @@ -1,20 +1,18 @@ require 'unit_spec_helper' describe Shoulda::Matchers::ActiveModel::HaveSecurePasswordMatcher, type: :model do - if active_model_3_1? - it 'matches when the subject configures has_secure_password with default options' do - working_model = define_model(:example, password_digest: :string) { has_secure_password } - expect(working_model.new).to have_secure_password - end + it 'matches when the subject configures has_secure_password with default options' do + working_model = define_model(:example, password_digest: :string) { has_secure_password } + expect(working_model.new).to have_secure_password + end - it 'does not match when the subject does not authenticate a password' do - no_secure_password = define_model(:example) - expect(no_secure_password.new).not_to have_secure_password - end + it 'does not match when the subject does not authenticate a password' do + no_secure_password = define_model(:example) + expect(no_secure_password.new).not_to have_secure_password + end - it 'does not match when the subject is missing the password_digest attribute' do - no_digest_column = define_model(:example) { has_secure_password } - expect(no_digest_column.new).not_to have_secure_password - end + it 'does not match when the subject is missing the password_digest attribute' do + no_digest_column = define_model(:example) { has_secure_password } + expect(no_digest_column.new).not_to have_secure_password end end diff --git a/spec/unit/shoulda/matchers/active_model/validate_numericality_of_matcher_spec.rb b/spec/unit/shoulda/matchers/active_model/validate_numericality_of_matcher_spec.rb index 85797047b..6d492f01d 100644 --- a/spec/unit/shoulda/matchers/active_model/validate_numericality_of_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_model/validate_numericality_of_matcher_spec.rb @@ -306,6 +306,23 @@ def configure_validation_matcher(matcher) expect(record).to validate_numericality.only_integer end + it 'rejects when used in the negative' do + record = build_record_validating_numericality(only_integer: true) + + assertion = lambda do + expect(record).not_to validate_numericality.only_integer + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Expected Example not to validate that :attr looks like an integer, but +this could not be proved. + After setting :attr to ‹"0.1"›, the matcher expected the Example to be + valid, but it was invalid instead, producing these validation errors: + + * attr: ["must be an integer"] + MESSAGE + end + it_supports( 'ignoring_interference_by_writer', tests: { @@ -368,6 +385,23 @@ def configure_validation_matcher(matcher) expect(record).to validate_numericality.odd end + it 'rejects when used in the negative' do + record = build_record_validating_numericality(odd: true) + + assertion = lambda do + expect(record).not_to validate_numericality.odd + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Expected Example not to validate that :attr looks like an odd number, +but this could not be proved. + After setting :attr to ‹"2"›, the matcher expected the Example to be + valid, but it was invalid instead, producing these validation errors: + + * attr: ["must be odd"] + MESSAGE + end + it_supports( 'ignoring_interference_by_writer', tests: { @@ -472,6 +506,23 @@ def configure_validation_matcher(matcher) expect(record).to validate_numericality.even end + it 'rejects when used in the negative' do + record = build_record_validating_numericality(even: true) + + assertion = lambda do + expect(record).not_to validate_numericality.even + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Expected Example not to validate that :attr looks like an even number, +but this could not be proved. + After setting :attr to ‹"1"›, the matcher expected the Example to be + valid, but it was invalid instead, producing these validation errors: + + * attr: ["must be even"] + MESSAGE + end + it_supports( 'ignoring_interference_by_writer', tests: { @@ -572,12 +623,29 @@ def configure_validation_matcher(matcher) context 'qualified with is_less_than_or_equal_to' do context 'and validating with less_than_or_equal_to' do it 'accepts' do - record = build_record_validating_numericality( - less_than_or_equal_to: 18 - ) + record = build_record_validating_numericality(less_than_or_equal_to: 18) expect(record).to validate_numericality.is_less_than_or_equal_to(18) end + it 'rejects when used in the negative' do + record = build_record_validating_numericality(less_than_or_equal_to: 18) + + assertion = lambda do + expect(record).not_to validate_numericality. + is_less_than_or_equal_to(18) + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Expected Example not to validate that :attr looks like a number less +than or equal to 18, but this could not be proved. + After setting :attr to ‹"abcd"›, the matcher expected the Example to + be valid, but it was invalid instead, producing these validation + errors: + + * attr: ["is not a number"] + MESSAGE + end + it_supports( 'ignoring_interference_by_writer', tests: { @@ -686,6 +754,24 @@ def configure_validation_matcher(matcher) is_less_than(18) end + it 'rejects when used in the negative' do + record = build_record_validating_numericality(less_than: 18) + + assertion = lambda do + expect(record).not_to validate_numericality.is_less_than(18) + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Expected Example not to validate that :attr looks like a number less +than 18, but this could not be proved. + After setting :attr to ‹"abcd"›, the matcher expected the Example to + be valid, but it was invalid instead, producing these validation + errors: + + * attr: ["is not a number"] + MESSAGE + end + it_supports( 'ignoring_interference_by_writer', tests: { @@ -790,6 +876,24 @@ def configure_validation_matcher(matcher) expect(record).to validate_numericality.is_equal_to(18) end + it 'rejects when used in the negative' do + record = build_record_validating_numericality(equal_to: 18) + + assertion = lambda do + expect(record).not_to validate_numericality.is_equal_to(18) + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Expected Example not to validate that :attr looks like a number equal to +18, but this could not be proved. + After setting :attr to ‹"abcd"›, the matcher expected the Example to + be valid, but it was invalid instead, producing these validation + errors: + + * attr: ["is not a number"] + MESSAGE + end + it_supports( 'ignoring_interference_by_writer', tests: { @@ -898,6 +1002,27 @@ def configure_validation_matcher(matcher) is_greater_than_or_equal_to(18) end + it 'rejects when used in the negative' do + record = build_record_validating_numericality( + greater_than_or_equal_to: 18, + ) + + assertion = lambda do + expect(record).not_to validate_numericality. + is_greater_than_or_equal_to(18) + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Expected Example not to validate that :attr looks like a number greater +than or equal to 18, but this could not be proved. + After setting :attr to ‹"abcd"›, the matcher expected the Example to + be valid, but it was invalid instead, producing these validation + errors: + + * attr: ["is not a number"] + MESSAGE + end + it_supports( 'ignoring_interference_by_writer', tests: { @@ -1011,6 +1136,25 @@ def configure_validation_matcher(matcher) is_greater_than(18) end + it 'rejects when used in the negative' do + record = build_record_validating_numericality(greater_than: 18) + + assertion = lambda do + expect(record).not_to validate_numericality. + is_greater_than(18) + end + + expect(&assertion).to fail_with_message(<<~MESSAGE) +Expected Example not to validate that :attr looks like a number greater +than 18, but this could not be proved. + After setting :attr to ‹"abcd"›, the matcher expected the Example to + be valid, but it was invalid instead, producing these validation + errors: + + * attr: ["is not a number"] + MESSAGE + end + it_supports( 'ignoring_interference_by_writer', tests: { diff --git a/spec/unit/shoulda/matchers/active_model/validate_presence_of_matcher_spec.rb b/spec/unit/shoulda/matchers/active_model/validate_presence_of_matcher_spec.rb index 38afda32b..57f80be33 100644 --- a/spec/unit/shoulda/matchers/active_model/validate_presence_of_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_model/validate_presence_of_matcher_spec.rb @@ -1,6 +1,8 @@ require 'unit_spec_helper' describe Shoulda::Matchers::ActiveModel::ValidatePresenceOfMatcher, type: :model do + include UnitTests::ApplicationConfigurationHelpers + context 'a model with a presence validation' do it 'accepts' do expect(validating_presence).to matcher @@ -66,7 +68,7 @@ message = <<-MESSAGE Expected Example to validate that :attr cannot be empty/falsy, but this could not be proved. - After setting :attr to ‹nil›, the matcher expected the Example to be + After setting :attr to ‹""›, the matcher expected the Example to be invalid, but it was valid instead. MESSAGE @@ -124,7 +126,7 @@ def model_creator message = <<-MESSAGE Expected Example to validate that :attr cannot be empty/falsy, but this could not be proved. - After setting :attr to ‹nil›, the matcher expected the Example to be + After setting :attr to ‹""›, the matcher expected the Example to be invalid, but it was valid instead. MESSAGE @@ -176,7 +178,7 @@ def model_creator end end - context 'a required has_and_belongs_to_many association' do + context 'a has_and_belongs_to_many association with a presence validation on it' do it 'accepts' do expect(build_record_having_and_belonging_to_many). to validate_presence_of(:children) @@ -228,7 +230,7 @@ def model_creator end end - context 'an optional has_and_belongs_to_many association' do + context 'a has_and_belongs_to_many association without a presence validation on it' do before do define_model :child @model = define_model :parent do @@ -256,6 +258,387 @@ def model_creator end end + context 'against a belongs_to association' do + if active_record_supports_optional_for_associations? + context 'declared with optional: true' do + context 'and an explicit presence validation is on the association' do + it 'matches' do + record = record_belonging_to( + :parent, + optional: true, + validate_presence: true, + ) + + expect { validate_presence_of(:parent) }.to match_against(record) + end + end + + context 'and an explicit presence validation is not on the association' do + it 'does not match' do + record = record_belonging_to( + :parent, + optional: true, + validate_presence: false, + model_name: 'Child', + parent_model_name: 'Parent', + ) + + expect { validate_presence_of(:parent) }. + not_to match_against(record). + and_fail_with(<<-MESSAGE) +Expected Child to validate that :parent cannot be empty/falsy, but this +could not be proved. + After setting :parent to ‹nil›, the matcher expected the Child to be + invalid, but it was valid instead. + MESSAGE + end + end + end + + context 'declared with optional: false' do + context 'and an explicit presence validation is on the association' do + it 'matches' do + record = record_belonging_to( + :parent, + optional: false, + validate_presence: true, + ) + + expect { validate_presence_of(:parent) }.to match_against(record) + end + end + + context 'and an explicit presence validation is not on the association' do + it 'does not match, instructing the user to use belongs_to instead' do + record = record_belonging_to( + :parent, + optional: false, + validate_presence: false, + model_name: 'Child', + parent_model_name: 'Parent', + ) + + expect { validate_presence_of(:parent) }. + not_to match_against(record). + and_fail_with(<<-MESSAGE) +Expected Child to validate that :parent cannot be empty/falsy, but this +could not be proved. + After setting :parent to ‹nil›, the matcher expected the Child to be + invalid and to produce the validation error "can't be blank" on + :parent. The record was indeed invalid, but it produced these + validation errors instead: + + * parent: ["must exist"] + + You're getting this error because you've instructed your `belongs_to` + association to add a presence validation to the attribute. *This* + presence validation doesn't use "can't be blank", the usual validation + message, but "must exist" instead. + + With that said, did you know that the `belong_to` matcher can test + this validation for you? Instead of using `validate_presence_of`, try + one of the following instead, depending on your use case: + + it { should belong_to(:parent).optional(false) } + it { should belong_to(:parent).required(true) } + MESSAGE + end + end + end + + context 'declared with required: true' do + context 'and an explicit presence validation is on the association' do + it 'matches' do + record = record_belonging_to( + :parent, + required: true, + validate_presence: true, + ) + + expect { validate_presence_of(:parent) }.to match_against(record) + end + end + + context 'and an explicit presence validation is not on the association' do + it 'does not match, instructing the user to use belongs_to instead' do + record = record_belonging_to( + :parent, + required: true, + validate_presence: false, + model_name: 'Child', + parent_model_name: 'Parent', + ) + + expect { validate_presence_of(:parent) }. + not_to match_against(record). + and_fail_with(<<-MESSAGE) +Expected Child to validate that :parent cannot be empty/falsy, but this +could not be proved. + After setting :parent to ‹nil›, the matcher expected the Child to be + invalid and to produce the validation error "can't be blank" on + :parent. The record was indeed invalid, but it produced these + validation errors instead: + + * parent: ["must exist"] + + You're getting this error because you've instructed your `belongs_to` + association to add a presence validation to the attribute. *This* + presence validation doesn't use "can't be blank", the usual validation + message, but "must exist" instead. + + With that said, did you know that the `belong_to` matcher can test + this validation for you? Instead of using `validate_presence_of`, try + one of the following instead, depending on your use case: + + it { should belong_to(:parent).optional(false) } + it { should belong_to(:parent).required(true) } + MESSAGE + end + end + end + + context 'declared with required: false' do + context 'and an explicit presence validation is on the association' do + it 'matches' do + record = record_belonging_to( + :parent, + required: false, + validate_presence: true, + ) + + expect { validate_presence_of(:parent) }.to match_against(record) + end + end + + context 'and an explicit presence validation is not on the association' do + it 'does not match' do + record = record_belonging_to( + :parent, + required: false, + validate_presence: false, + model_name: 'Child', + parent_model_name: 'Parent', + ) + + expect { validate_presence_of(:parent) }. + not_to match_against(record). + and_fail_with(<<-MESSAGE) +Expected Child to validate that :parent cannot be empty/falsy, but this +could not be proved. + After setting :parent to ‹nil›, the matcher expected the Child to be + invalid, but it was valid instead. + MESSAGE + end + end + end + + context 'not declared with an optional or required option' do + context 'when belongs_to is configured to be required by default' do + context 'and an explicit presence validation is on the association' do + it 'matches' do + with_belongs_to_as_required_by_default do + record = record_belonging_to( + :parent, + validate_presence: true, + ) + + expect { validate_presence_of(:parent) }. + to match_against(record) + end + end + end + + context 'and an explicit presence validation is not on the association' do + it 'does not match, instructing the user to use belong_to instead' do + with_belongs_to_as_required_by_default do + record = record_belonging_to( + :parent, + validate_presence: false, + model_name: 'Child', + parent_model_name: 'Parent', + ) + + expect { validate_presence_of(:parent) }. + not_to match_against(record). + and_fail_with(<<-MESSAGE) +Expected Child to validate that :parent cannot be empty/falsy, but this +could not be proved. + After setting :parent to ‹nil›, the matcher expected the Child to be + invalid and to produce the validation error "can't be blank" on + :parent. The record was indeed invalid, but it produced these + validation errors instead: + + * parent: ["must exist"] + + You're getting this error because ActiveRecord is configured to add a + presence validation to all `belongs_to` associations, and this + includes yours. *This* presence validation doesn't use "can't be + blank", the usual validation message, but "must exist" instead. + + With that said, did you know that the `belong_to` matcher can test + this validation for you? Instead of using `validate_presence_of`, try + the following instead: + + it { should belong_to(:parent) } + MESSAGE + end + end + end + end + + context 'when belongs_to is configured to be optional by default' do + context 'and an explicit presence validation is on the association' do + it 'matches' do + with_belongs_to_as_optional_by_default do + record = record_belonging_to( + :parent, + validate_presence: true, + ) + + expect { validate_presence_of(:parent) }. + to match_against(record) + end + end + end + + context 'and an explicit presence validation is not on the association' do + it 'does not match' do + with_belongs_to_as_optional_by_default do + record = record_belonging_to( + :parent, + validate_presence: false, + model_name: 'Child', + parent_model_name: 'Parent', + ) + + expect { validate_presence_of(:parent) }. + not_to match_against(record). + and_fail_with(<<-MESSAGE) +Expected Child to validate that :parent cannot be empty/falsy, but this +could not be proved. + After setting :parent to ‹nil›, the matcher expected the Child to be + invalid, but it was valid instead. + MESSAGE + end + end + end + end + end + else + context 'declared with required: true' do + context 'and an explicit presence validation is on the association' do + it 'matches' do + record = record_belonging_to( + :parent, + required: true, + validate_presence: true, + ) + + expect { validate_presence_of(:parent) }.to match_against(record) + end + end + + context 'and an explicit presence validation is not on the association' do + it 'still matches' do + record = record_belonging_to( + :parent, + required: true, + validate_presence: false, + ) + + expect { validate_presence_of(:parent) }.to match_against(record) + end + end + end + + context 'declared with required: false' do + context 'and an explicit presence validation is on the association' do + it 'matches' do + record = record_belonging_to( + :parent, + required: false, + validate_presence: true, + ) + + expect { validate_presence_of(:parent) }.to match_against(record) + end + end + + context 'and an explicit presence validation is not on the association' do + it 'does not match' do + record = record_belonging_to( + :parent, + required: false, + validate_presence: false, + model_name: 'Child', + parent_model_name: 'Parent', + ) + + expect { validate_presence_of(:parent) }. + not_to match_against(record). + and_fail_with(<<-MESSAGE) +Expected Child to validate that :parent cannot be empty/falsy, but this +could not be proved. + After setting :parent to ‹nil›, the matcher expected the Child to be + invalid, but it was valid instead. + MESSAGE + end + end + end + + context 'not declared with a required option' do + context 'and an explicit presence validation is on the association' do + it 'matches' do + record = record_belonging_to(:parent, validate_presence: true) + + expect { validate_presence_of(:parent) }.to match_against(record) + end + end + + context 'and an explicit presence validation is not on the association' do + it 'does not match' do + record = record_belonging_to(:parent, validate_presence: false) + + expect { validate_presence_of(:parent) }. + not_to match_against(record). + and_fail_with(<<-MESSAGE) +Expected Child to validate that :parent cannot be empty/falsy, but this +could not be proved. + After setting :parent to ‹nil›, the matcher expected the Child to be + invalid, but it was valid instead. + MESSAGE + end + end + end + end + + def record_belonging_to( + attribute_name, + model_name: 'Child', + parent_model_name: 'Parent', + column_name: "#{attribute_name}_id", + validate_presence: false, + **association_options, + &block + ) + define_model(parent_model_name) + + child_model = define_model(model_name, column_name => :integer) do + belongs_to(attribute_name, **association_options) + + if validate_presence + validates_presence_of(attribute_name) + end + + if block + instance_eval(&block) + end + end + + child_model.new + end + end + context "an i18n translation containing %{attribute} and %{model}" do before do stub_translation( @@ -272,27 +655,25 @@ def model_creator end end - if active_model_3_2? - context 'a strictly required attribute' do - it 'accepts when the :strict options match' do - expect(validating_presence(strict: true)).to matcher.strict - end + context 'a strictly required attribute' do + it 'accepts when the :strict options match' do + expect(validating_presence(strict: true)).to matcher.strict + end - it 'rejects with the correct failure message when the :strict options do not match' do - assertion = lambda do - expect(validating_presence(strict: false)).to matcher.strict - end + it 'rejects with the correct failure message when the :strict options do not match' do + assertion = lambda do + expect(validating_presence(strict: false)).to matcher.strict + end - message = <<-MESSAGE + message = <<-MESSAGE Expected Example to validate that :attr cannot be empty/falsy, raising a validation exception on failure, but this could not be proved. - After setting :attr to ‹nil›, the matcher expected the Example to be + After setting :attr to ‹""›, the matcher expected the Example to be invalid and to raise a validation exception, but the record produced validation errors instead. - MESSAGE + MESSAGE - expect(&assertion).to fail_with_message(message) - end + expect(&assertion).to fail_with_message(message) end it 'does not override the default message with a blank' do @@ -355,7 +736,7 @@ def model_creator validates_presence_of :foo def foo=(value) - super(Array.wrap(value)) + super([]) end end @@ -363,6 +744,84 @@ def foo=(value) end end + context 'qualified with allow_nil' do + context 'when validating a model with a presence validator' do + context 'and it is specified with allow_nil: true' do + it 'matches in the positive' do + record = validating_presence(allow_nil: true) + expect(record).to matcher.allow_nil + end + + it 'does not match in the negative' do + record = validating_presence(allow_nil: true) + + assertion = -> { expect(record).not_to matcher.allow_nil } + + expect(&assertion).to fail_with_message(<<-MESSAGE) +Expected Example not to validate that :attr cannot be empty/falsy, but +this could not be proved. + After setting :attr to ‹""›, the matcher expected the Example to be + valid, but it was invalid instead, producing these validation errors: + + * attr: ["can't be blank"] + MESSAGE + end + end + + context 'and it is not specified with allow_nil: true' do + it 'does not match in the positive' do + record = validating_presence + + assertion = lambda do + expect(record).to matcher.allow_nil + end + + message = <<-MESSAGE +Expected Example to validate that :attr cannot be empty/falsy, but this +could not be proved. + After setting :attr to ‹nil›, the matcher expected the Example to be + valid, but it was invalid instead, producing these validation errors: + + * attr: ["can't be blank"] + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + + it 'matches in the negative' do + record = validating_presence + + expect(record).not_to matcher.allow_nil + end + end + + context 'when validating a model without a presence validator' do + it 'does not match in the positive' do + record = without_validating_presence + + assertion = lambda do + expect(record).to matcher.allow_nil + end + + message = <<-MESSAGE +Expected Example to validate that :attr cannot be empty/falsy, but this +could not be proved. + After setting :attr to ‹""›, the matcher expected the Example to be + invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + + it 'matches in the negative' do + record = without_validating_presence + + expect(record).not_to matcher.allow_nil + end + end + end + def matcher validate_presence_of(:attr) end @@ -373,6 +832,10 @@ def validating_presence(options = {}) end.new end + def without_validating_presence + define_model(:example, attr: :string).new + end + def active_model(&block) define_active_model_class('Example', accessors: [:attr], &block).new end diff --git a/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb b/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb index f13368c88..3ddf2fcaa 100644 --- a/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb @@ -1,6 +1,8 @@ require 'unit_spec_helper' describe Shoulda::Matchers::ActiveRecord::AssociationMatcher, type: :model do + include UnitTests::ApplicationConfigurationHelpers + context 'belong_to' do it 'accepts a good association with the default foreign key' do expect(belonging_to_parent).to belong_to(:parent) @@ -284,7 +286,7 @@ if active_record_supports_optional_for_associations? context 'when belongs_to is configured to be required by default' do it 'passes' do - configuring_default_belongs_to_requiredness(true) do + with_belongs_to_as_required_by_default do expect(belonging_to_parent).to belong_to(:parent).required(true) end end @@ -292,7 +294,7 @@ context 'when belongs_to is not configured to be required by default' do it 'fails with an appropriate message' do - configuring_default_belongs_to_requiredness(false) do + with_belongs_to_as_optional_by_default do assertion = lambda do expect(belonging_to_parent). to belong_to(:parent).required(true) @@ -333,7 +335,7 @@ if active_record_supports_optional_for_associations? context 'when belongs_to is configured to be required by default' do it 'fails with an appropriate message' do - configuring_default_belongs_to_requiredness(true) do + with_belongs_to_as_required_by_default do assertion = lambda do expect(belonging_to_parent). to belong_to(:parent).required(false) @@ -354,7 +356,7 @@ context 'when belongs_to is not configured to be required by default' do it 'passes' do - configuring_default_belongs_to_requiredness(false) do + with_belongs_to_as_optional_by_default do expect(belonging_to_parent).to belong_to(:parent).required(false) end end @@ -370,7 +372,7 @@ if active_record_supports_optional_for_associations? context 'when belongs_to is configured to be required by default' do it 'fails with an appropriate message' do - configuring_default_belongs_to_requiredness(true) do + with_belongs_to_as_required_by_default do assertion = lambda do expect(belonging_to_parent). to belong_to(:parent).optional @@ -391,7 +393,7 @@ context 'when belongs_to is not configured to be required by default' do it 'passes' do - configuring_default_belongs_to_requiredness(false) do + with_belongs_to_as_optional_by_default do expect(belonging_to_parent).to belong_to(:parent).optional end end @@ -407,7 +409,7 @@ if active_record_supports_optional_for_associations? context 'when belongs_to is configured to be required by default' do it 'passes' do - configuring_default_belongs_to_requiredness(true) do + with_belongs_to_as_required_by_default do expect(belonging_to_parent).to belong_to(:parent) end end @@ -415,14 +417,14 @@ context 'when belongs_to is not configured to be required by default' do it 'passes' do - configuring_default_belongs_to_requiredness(false) do + with_belongs_to_as_optional_by_default do expect(belonging_to_parent).to belong_to(:parent) end end context 'and a presence validation is on the attribute instead of using required: true' do it 'passes' do - configuring_default_belongs_to_requiredness(false) do + with_belongs_to_as_optional_by_default do record = belonging_to_parent do validates_presence_of :parent end @@ -435,7 +437,7 @@ context 'and a presence validation is on the attribute with a condition' do context 'and the condition is true' do it 'passes' do - configuring_default_belongs_to_requiredness(false) do + with_belongs_to_as_optional_by_default do child_model = create_child_model_belonging_to_parent do attr_accessor :condition validates_presence_of :parent, if: :condition @@ -450,7 +452,7 @@ context 'and the condition is false' do it 'passes' do - configuring_default_belongs_to_requiredness(false) do + with_belongs_to_as_optional_by_default do child_model = create_child_model_belonging_to_parent do attr_accessor :condition validates_presence_of :parent, if: :condition @@ -630,7 +632,7 @@ def ensure_parent_is_set end assertion = lambda do - configuring_default_belongs_to_requiredness(true) do + with_belongs_to_as_required_by_default do expect(model.new).to belong_to(:parent) end end @@ -656,7 +658,7 @@ def ensure_parent_is_set end end - configuring_default_belongs_to_requiredness(true) do + with_belongs_to_as_required_by_default do expect(model.new). to belong_to(:parent). without_validating_presence @@ -677,7 +679,7 @@ def ensure_parent_is_set end assertion = lambda do - configuring_default_belongs_to_requiredness(true) do + with_belongs_to_as_required_by_default do expect(model.new).to belong_to(:parent).required end end @@ -703,7 +705,7 @@ def ensure_parent_is_set end end - configuring_default_belongs_to_requiredness(true) do + with_belongs_to_as_required_by_default do expect(model.new). to belong_to(:parent). required. @@ -1765,21 +1767,4 @@ def dependent_options [:destroy, :delete, :nullify, :restrict] end end - - def configuring_default_belongs_to_requiredness(value, &block) - configuring_application( - ActiveRecord::Base, - :belongs_to_required_by_default, - value, - &block - ) - end - - def configuring_application(config, name, value) - previous_value = config.send(name) - config.send("#{name}=", value) - yield - ensure - config.send("#{name}=", previous_value) - end end diff --git a/spec/unit/shoulda/matchers/active_record/define_enum_for_matcher_spec.rb b/spec/unit/shoulda/matchers/active_record/define_enum_for_matcher_spec.rb index ef9ce1518..8b3517f61 100644 --- a/spec/unit/shoulda/matchers/active_record/define_enum_for_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_record/define_enum_for_matcher_spec.rb @@ -9,8 +9,8 @@ column_type: :integer, ) message = format_message(<<-MESSAGE) - Expected Example to define :attrs as an enum, backed by an integer. - However, no such enum exists in Example. + Expected Example to define :attrs as an enum, but no such enum exists on + Example. MESSAGE assertion = lambda do @@ -28,8 +28,8 @@ def self.statuses; end end message = format_message(<<-MESSAGE) - Expected Example to define :attr as an enum, backed by an integer. - However, no such enum exists in Example. + Expected Example to define :attr as an enum, but no such enum exists on + Example. MESSAGE assertion = lambda do @@ -48,8 +48,8 @@ def self.statuses; end attribute_name: :attr, ) message = format_message(<<-MESSAGE) - Expected Example to define :attr as an enum, backed by an integer. - However, no such enum exists in Example. + Expected Example to define :attr as an enum, but no such enum exists + on Example. MESSAGE assertion = lambda do @@ -68,7 +68,7 @@ def self.statuses; end column_type: :string, ) message = format_message(<<-MESSAGE) - Expected Example to define :attr as an enum, backed by an integer. + Expected Example to define :attr as an enum backed by an integer. However, :attr is a string column. MESSAGE @@ -81,30 +81,23 @@ def self.statuses; end end context 'if the attribute is defined as an enum' do - it 'accepts' do + it 'matches' do record = build_record_with_array_values(attribute_name: :attr) - expect(record).to define_enum_for(:attr) + expect { define_enum_for(:attr) }. + to match_against(record). + or_fail_with(<<-MESSAGE, wrap: true) + Expected Example not to define :attr as an enum backed by an + integer, but it did. + MESSAGE end - context 'and the matcher is negated' do - it 'rejects with an appropriate failure message' do - record = build_record_with_array_values( - model_name: 'Example', - attribute_name: :attr, - column_type: :integer, - ) - message = format_message(<<-MESSAGE) - Expected Example not to define :attr as an enum, backed by an integer, - but it did. - MESSAGE + it 'has the right description' do + matcher = define_enum_for(:attr) - assertion = lambda do - expect(record).not_to define_enum_for(:attr) - end - - expect(&assertion).to fail_with_message(message) - end + expect(matcher.description).to eq(<<~MESSAGE.strip) + define :attr as an enum backed by an integer + MESSAGE end end end @@ -118,9 +111,8 @@ def self.statuses; end attribute_name: :attr, ) message = format_message(<<-MESSAGE) - Expected Example to define :attr as an enum, backed by an integer, - with possible values ‹["open", "close"]›. However, no such enum - exists in Example. + Expected Example to define :attr as an enum, but no such enum + exists on Example. MESSAGE assertion = lambda do @@ -142,9 +134,10 @@ def self.statuses; end values: ['published', 'unpublished', 'draft'], ) message = format_message(<<-MESSAGE) - Expected Example to define :attr as an enum, backed by an integer, - with possible values ‹["open", "close"]›. However, the actual - enum values for :attr are ‹["published", "unpublished", "draft"]›. + Expected Example to define :attr as an enum backed by an integer, + mapping ‹"open"› to ‹0› and ‹"close"› to ‹1›. However, :attr + actually maps ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, and + ‹"draft"› to ‹2›. MESSAGE assertion = lambda do @@ -158,14 +151,33 @@ def self.statuses; end end context 'and the enum values match' do - it 'accepts' do + it 'matches' do record = build_record_with_array_values( attribute_name: :attr, values: ['published', 'unpublished', 'draft'], ) - expect(record).to define_enum_for(:attr). + matcher = lambda do + define_enum_for(:attr). + with_values(['published', 'unpublished', 'draft']) + end + + expect(&matcher). + to match_against(record). + or_fail_with(<<-MESSAGE, wrap: true) + Expected Example not to define :attr as an enum backed by an + integer, mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, + and ‹"draft"› to ‹2›, but it did. + MESSAGE + end + + it 'has the right description' do + matcher = define_enum_for(:attr). with_values(['published', 'unpublished', 'draft']) + + expect(matcher.description).to eq(<<~MESSAGE.strip) + define :attr as an enum backed by an integer with values ‹["published", "unpublished", "draft"]› + MESSAGE end end end @@ -179,9 +191,8 @@ def self.statuses; end attribute_name: :attr, ) message = format_message(<<-MESSAGE) - Expected Example to define :attr as an enum, backed by an integer, - with possible values ‹{active: 5, archived: 10}›. However, no such - enum exists in Example. + Expected Example to define :attr as an enum, but no such enum exists + on Example. MESSAGE assertion = lambda do @@ -203,9 +214,9 @@ def self.statuses; end values: { active: 0, archived: 1 }, ) message = format_message(<<-MESSAGE) - Expected Example to define :attr as an enum, backed by an integer, - with possible values ‹{active: 5, archived: 10}›. However, the - actual enum values for :attr are ‹{active: 0, archived: 1}›. + Expected Example to define :attr as an enum backed by an integer, + mapping ‹"active"› to ‹5› and ‹"archived"› to ‹10›. However, :attr + actually maps ‹"active"› to ‹0› and ‹"archived"› to ‹1›. MESSAGE assertion = lambda do @@ -220,28 +231,64 @@ def self.statuses; end context 'and the enum values match' do context 'when expected enum values are a hash' do - it 'accepts' do + it 'matches' do record = build_record_with_hash_values( attribute_name: :attr, values: { active: 0, archived: 1 }, ) - expect(record). - to define_enum_for(:attr). + matcher = lambda do + define_enum_for(:attr). + with_values(active: 0, archived: 1) + end + + expect(&matcher). + to match_against(record). + or_fail_with(<<-MESSAGE, wrap: true) + Expected Example not to define :attr as an enum backed by an + integer, mapping ‹"active"› to ‹0› and ‹"archived"› to ‹1›, + but it did. + MESSAGE + end + + it 'has the right description' do + matcher = define_enum_for(:attr). with_values(active: 0, archived: 1) + + expect(matcher.description).to eq(<<~MESSAGE.strip) + define :attr as an enum backed by an integer with values ‹{active: 0, archived: 1}› + MESSAGE end end context 'when expected enum values are an array' do - it 'accepts' do + it 'matches' do record = build_record_with_hash_values( attribute_name: :attr, values: { active: 0, archived: 1 }, ) - expect(record). - to define_enum_for(:attr). + matcher = lambda do + define_enum_for(:attr). + with_values(['active', 'archived']) + end + + expect(&matcher). + to match_against(record). + or_fail_with(<<-MESSAGE, wrap: true) + Expected Example not to define :attr as an enum backed by an + integer, mapping ‹"active"› to ‹0› and ‹"archived"› to ‹1›, + but it did. + MESSAGE + end + + it 'has the right description' do + matcher = define_enum_for(:attr). with_values(['active', 'archived']) + + expect(matcher.description).to eq(<<~MESSAGE.strip) + define :attr as an enum backed by an integer with values ‹["active", "archived"]› + MESSAGE end end end @@ -276,7 +323,7 @@ def self.statuses; end column_type: :integer, ) message = format_message(<<-MESSAGE) - Expected Example to define :attr as an enum, backed by a string. + Expected Example to define :attr as an enum backed by a string. However, :attr is an integer column. MESSAGE @@ -291,15 +338,30 @@ def self.statuses; end end context 'if the column storing the attribute is of the same type' do - it 'accepts' do + it 'matches' do record = build_record_with_array_values( attribute_name: :attr, column_type: :string, ) - expect(record). - to define_enum_for(:attr). - backed_by_column_of_type(:string) + matcher = lambda do + define_enum_for(:attr).backed_by_column_of_type(:string) + end + + expect(&matcher). + to match_against(record). + or_fail_with(<<-MESSAGE, wrap: true) + Expected Example not to define :attr as an enum backed by a string, + but it did. + MESSAGE + end + + it 'has the right description' do + matcher = define_enum_for(:attr).backed_by_column_of_type(:string) + + expect(matcher.description).to eq(<<~MESSAGE.strip) + define :attr as an enum backed by a string + MESSAGE end end end @@ -324,10 +386,11 @@ def self.statuses; end end message = format_message(<<-MESSAGE) - Expected Example to define :attr as an enum, backed by an integer, - using a prefix of :foo, with possible values ‹[:active, - :archived]›. However, it was defined with either a different - prefix or none at all. + Expected Example to define :attr as an enum backed by an integer, + mapping ‹"active"› to ‹0› and ‹"archived"› to ‹1› and prefixing + accessor methods with "foo_". :attr does map to these values, but + the enum is configured with either a different prefix or no prefix + at all (we can't tell which). MESSAGE expect(&assertion).to fail_with_message(message) @@ -352,10 +415,11 @@ def self.statuses; end end message = format_message(<<-MESSAGE) - Expected Example to define :attr as an enum, backed by an integer, - using a prefix of :bar, with possible values ‹[:active, - :archived]›. However, it was defined with either a different - prefix or none at all. + Expected Example to define :attr as an enum backed by an integer, + mapping ‹"active"› to ‹0› and ‹"archived"› to ‹1› and prefixing + accessor methods with "bar_". :attr does map to these values, but + the enum is configured with either a different prefix or no prefix + at all (we can't tell which). MESSAGE expect(&assertion).to fail_with_message(message) @@ -363,7 +427,7 @@ def self.statuses; end end context 'if the attribute was defined with the same prefix' do - it 'accepts' do + it 'matches' do record = build_record_with_array_values( model_name: 'Example', attribute_name: :attr, @@ -371,10 +435,29 @@ def self.statuses; end prefix: :foo, ) - expect(record). - to define_enum_for(:attr). + matcher = lambda do + define_enum_for(:attr). + with_values([:active, :archived]). + with_prefix(:foo) + end + + expect(&matcher). + to match_against(record). + or_fail_with(<<-MESSAGE, wrap: true) + Expected Example not to define :attr as an enum backed by an + integer, mapping ‹"active"› to ‹0› and ‹"archived"› to ‹1› and + prefixing accessor methods with "foo_", but it did. + MESSAGE + end + + it 'has the right description' do + matcher = define_enum_for(:attr). with_values([:active, :archived]). with_prefix(:foo) + + expect(matcher.description).to eq(<<~MESSAGE.strip) + define :attr as an enum backed by an integer with values ‹[:active, :archived]›, prefix: :foo + MESSAGE end end end @@ -397,10 +480,11 @@ def self.statuses; end end message = format_message(<<-MESSAGE) - Expected Example to define :attr as an enum, backed by an integer, - using a prefix of :attr, with possible values ‹[:active, - :archived]›. However, it was defined with either a different - prefix or none at all. + Expected Example to define :attr as an enum backed by an integer, + mapping ‹"active"› to ‹0› and ‹"archived"› to ‹1› and prefixing + accessor methods with "attr_". :attr does map to these values, but + the enum is configured with either a different prefix or no prefix + at all (we can't tell which). MESSAGE expect(&assertion).to fail_with_message(message) @@ -408,7 +492,7 @@ def self.statuses; end end context 'if the attribute was defined with a prefix' do - it 'accepts' do + it 'matches' do record = build_record_with_array_values( model_name: 'Example', attribute_name: :attr, @@ -416,10 +500,29 @@ def self.statuses; end prefix: true, ) - expect(record). - to define_enum_for(:attr). + matcher = lambda do + define_enum_for(:attr). + with_values([:active, :archived]). + with_prefix + end + + expect(&matcher). + to match_against(record). + or_fail_with(<<-MESSAGE, wrap: true) + Expected Example not to define :attr as an enum backed by an + integer, mapping ‹"active"› to ‹0› and ‹"archived"› to ‹1› and + prefixing accessor methods with "attr_", but it did. + MESSAGE + end + + it 'has the right description' do + matcher = define_enum_for(:attr). with_values([:active, :archived]). with_prefix + + expect(matcher.description).to eq(<<~MESSAGE.strip) + define :attr as an enum backed by an integer with values ‹[:active, :archived]›, prefix: true + MESSAGE end end end @@ -444,10 +547,11 @@ def self.statuses; end end message = format_message(<<-MESSAGE) - Expected Example to define :attr as an enum, backed by an integer, - using a suffix of :foo, with possible values ‹[:active, - :archived]›. However, it was defined with either a different - suffix or none at all. + Expected Example to define :attr as an enum backed by an integer, + mapping ‹"active"› to ‹0› and ‹"archived"› to ‹1› and suffixing + accessor methods with "_foo". :attr does map to these values, but + the enum is configured with either a different suffix or no suffix + at all (we can't tell which). MESSAGE expect(&assertion).to fail_with_message(message) @@ -472,10 +576,11 @@ def self.statuses; end end message = format_message(<<-MESSAGE) - Expected Example to define :attr as an enum, backed by an integer, - using a suffix of :bar, with possible values ‹[:active, - :archived]›. However, it was defined with either a different - suffix or none at all. + Expected Example to define :attr as an enum backed by an integer, + mapping ‹"active"› to ‹0› and ‹"archived"› to ‹1› and suffixing + accessor methods with "_bar". :attr does map to these values, but + the enum is configured with either a different suffix or no suffix + at all (we can't tell which). MESSAGE expect(&assertion).to fail_with_message(message) @@ -483,7 +588,7 @@ def self.statuses; end end context 'if the attribute was defined with the same suffix' do - it 'accepts' do + it 'matches' do record = build_record_with_array_values( model_name: 'Example', attribute_name: :attr, @@ -491,10 +596,29 @@ def self.statuses; end suffix: :foo, ) - expect(record). - to define_enum_for(:attr). + matcher = lambda do + define_enum_for(:attr). + with_values([:active, :archived]). + with_suffix(:foo) + end + + expect(&matcher). + to match_against(record). + or_fail_with(<<-MESSAGE, wrap: true) + Expected Example not to define :attr as an enum backed by an + integer, mapping ‹"active"› to ‹0› and ‹"archived"› to ‹1› and + suffixing accessor methods with "_foo", but it did. + MESSAGE + end + + it 'has the right description' do + matcher = define_enum_for(:attr). with_values([:active, :archived]). with_suffix(:foo) + + expect(matcher.description).to eq(<<~MESSAGE.strip) + define :attr as an enum backed by an integer with values ‹[:active, :archived]›, suffix: :foo + MESSAGE end end end @@ -517,10 +641,11 @@ def self.statuses; end end message = format_message(<<-MESSAGE) - Expected Example to define :attr as an enum, backed by an integer, - using a suffix of :attr, with possible values ‹[:active, - :archived]›. However, it was defined with either a different - suffix or none at all. + Expected Example to define :attr as an enum backed by an integer, + mapping ‹"active"› to ‹0› and ‹"archived"› to ‹1› and suffixing + accessor methods with "_attr". :attr does map to these values, but + the enum is configured with either a different suffix or no suffix + at all (we can't tell which). MESSAGE expect(&assertion).to fail_with_message(message) @@ -528,7 +653,7 @@ def self.statuses; end end context 'if the attribute was defined with a suffix' do - it 'accepts' do + it 'matches' do record = build_record_with_array_values( model_name: 'Example', attribute_name: :attr, @@ -536,10 +661,29 @@ def self.statuses; end suffix: true, ) - expect(record). - to define_enum_for(:attr). + matcher = lambda do + define_enum_for(:attr). + with_values([:active, :archived]). + with_suffix + end + + expect(&matcher). + to match_against(record). + or_fail_with(<<-MESSAGE, wrap: true) + Expected Example not to define :attr as an enum backed by an + integer, mapping ‹"active"› to ‹0› and ‹"archived"› to ‹1› and + suffixing accessor methods with "_attr", but it did. + MESSAGE + end + + it 'has the right description' do + matcher = define_enum_for(:attr). with_values([:active, :archived]). with_suffix + + expect(matcher.description).to eq(<<~MESSAGE.strip) + define :attr as an enum backed by an integer with values ‹[:active, :archived]›, suffix: true + MESSAGE end end end @@ -566,10 +710,12 @@ def self.statuses; end end message = format_message(<<-MESSAGE) - Expected Example to define :attr as an enum, backed by an integer, - using a prefix of :whatever and a suffix of :bar, with possible - values ‹[:active, :archived]›. However, it was defined with either - a different prefix, a different suffix, or neither one at all. + Expected Example to define :attr as an enum backed by an integer, + mapping ‹"active"› to ‹0› and ‹"archived"› to ‹1›, prefixing + accessor methods with "whatever_", and suffixing accessor methods + with "_bar". :attr does map to these values, but the enum is + configured with either a different prefix or suffix, or no prefix or + suffix at all (we can't tell which). MESSAGE expect(&assertion).to fail_with_message(message) @@ -595,11 +741,12 @@ def self.statuses; end end message = format_message(<<-MESSAGE) - Expected Example to define :attr as an enum, backed by an integer, - using a prefix of :foo and a suffix of :whatever, with possible - values ‹[:active, :archived]›. However, it was defined with - either a different prefix, a different suffix, or neither one at - all. + Expected Example to define :attr as an enum backed by an integer, + mapping ‹"active"› to ‹0› and ‹"archived"› to ‹1›, prefixing + accessor methods with "foo_", and suffixing accessor methods with + "_whatever". :attr does map to these values, but the enum is + configured with either a different prefix or suffix, or no prefix + or suffix at all (we can't tell which). MESSAGE expect(&assertion).to fail_with_message(message) @@ -607,7 +754,7 @@ def self.statuses; end end context 'if the attribute was defined with the same prefix and suffix' do - it 'accepts' do + it 'matches' do record = build_record_with_array_values( model_name: 'Example', attribute_name: :attr, @@ -616,11 +763,32 @@ def self.statuses; end suffix: :bar, ) - expect(record). - to define_enum_for(:attr). + matcher = lambda do + define_enum_for(:attr). + with_values([:active, :archived]). + with_prefix(:foo). + with_suffix(:bar) + end + + expect(&matcher). + to match_against(record). + or_fail_with(<<-MESSAGE, wrap: true) + Expected Example not to define :attr as an enum backed by an + integer, mapping ‹"active"› to ‹0› and ‹"archived"› to ‹1›, + prefixing accessor methods with "foo_", and suffixing accessor + methods with "_bar", but it did. + MESSAGE + end + + it 'has the right description' do + matcher = define_enum_for(:attr). with_values([:active, :archived]). with_prefix(:foo). with_suffix(:bar) + + expect(matcher.description).to eq(<<~MESSAGE.strip) + define :attr as an enum backed by an integer with values ‹[:active, :archived]›, prefix: :foo, suffix: :bar + MESSAGE end end end diff --git a/spec/unit/shoulda/matchers/active_record/have_db_index_matcher_spec.rb b/spec/unit/shoulda/matchers/active_record/have_db_index_matcher_spec.rb index 57bfb601c..2c1025c50 100644 --- a/spec/unit/shoulda/matchers/active_record/have_db_index_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_record/have_db_index_matcher_spec.rb @@ -1,85 +1,514 @@ require 'unit_spec_helper' describe Shoulda::Matchers::ActiveRecord::HaveDbIndexMatcher, type: :model do - context 'have_db_index' do - it 'accepts an existing index' do - expect(with_index_on(:age)).to have_db_index(:age) - end + def self.can_test_expression_indexes? + active_record_supports_expression_indexes? && + database_supports_expression_indexes? + end + + describe 'the matcher' do + # rubocop:disable Layout/MultilineBlockLayout + # rubocop:disable Layout/SpaceAroundBlockParameters + shared_examples 'for when the matcher is qualified' do | + index:, + other_index:, + unique:, + qualifier_args:, + columns: { index => :string } + | + # rubocop:enable Layout/MultilineBlockLayout + # rubocop:enable Layout/SpaceAroundBlockParameters + if unique + index_type = 'unique' + inverse_description = 'not unique' + else + index_type = 'non-unique' + inverse_description = 'unique' + end + + context 'when the table has the given index' do + context "when the index is a #{index_type} index" do + it 'matches when used in the positive' do + record = record_with_index_on( + index, + unique: unique, + columns: columns, + ) + + expect(record).to have_db_index(index).unique(*qualifier_args) + end + + it 'does not match when used in the negative' do + record = record_with_index_on( + index, + unique: unique, + model_name: 'Example', + columns: columns, + ) + + assertion = lambda do + expect(record). + not_to have_db_index(index). + unique(*qualifier_args) + end + + expect(&assertion).to fail_with_message(<<-MESSAGE, wrap: true) +Expected the examples table not to have a #{index_type} index on +#{index.inspect}, but it does. + MESSAGE + end + end + + context "when the index is not a #{index_type} index" do + it 'matches when used in the negative' do + record = record_with_index_on( + index, + unique: !unique, + columns: columns, + ) - it 'rejects a nonexistent index' do - expect(define_model(:employee).new).not_to have_db_index(:age) + expect(record).not_to have_db_index(index).unique(*qualifier_args) + end + + it 'does not match when used in the positive' do + record = record_with_index_on( + index, + unique: !unique, + model_name: 'Example', + columns: columns, + ) + + assertion = lambda do + expect(record).to have_db_index(index).unique(*qualifier_args) + end + + expect(&assertion).to fail_with_message(<<-MESSAGE, wrap: true) +Expected the examples table to have an index on #{index.inspect} and for it to +be #{index_type}. The index does exist, but it is #{inverse_description}. + MESSAGE + end + end + end + + context 'when the table does not have the given index' do + it 'does not match in the positive' do + record = record_with_index_on( + index, + unique: unique, + model_name: 'Example', + columns: columns, + ) + + assertion = lambda do + expect(record).to have_db_index(other_index).unique(*qualifier_args) + end + + expect(&assertion).to fail_with_message(<<-MESSAGE, wrap: true) +Expected the examples table to have a #{index_type} index on :#{other_index}, +but it does not. + MESSAGE + end + + it 'matches in the negative' do + expect(record_with_index_on(index, unique: unique, columns: columns)). + not_to have_db_index(other_index). + unique(*qualifier_args) + end + end end - end - context 'have_db_index with unique option' do - it 'accepts an index of correct unique' do - expect(with_index_on(:ssn, unique: true)). - to have_db_index(:ssn).unique(true) + context 'assuming all models are connected to the same database' do + context 'when given one column' do + context 'when qualified with nothing' do + context 'when the table has the given index' do + it 'matches in the positive' do + expect(record_with_index_on(:age)).to have_db_index(:age) + end + + it 'does not match in the negative' do + record = record_with_index_on(:age, model_name: 'Example') + + assertion = lambda do + expect(record).not_to have_db_index(:age) + end + + expect(&assertion).to fail_with_message(<<-MESSAGE) +Expected the examples table not to have an index on :age, but it does. + MESSAGE + end + end + + context 'when the table does not have the given index' do + it 'does not match in the positive' do + assertion = lambda do + record = record_with_index_on(:age, model_name: 'Example') + expect(record).to have_db_index(:name) + end + + expect(&assertion).to fail_with_message(<<-MESSAGE) +Expected the examples table to have an index on :name, but it does not. + MESSAGE + end + + it 'matches in the negative' do + expect(record_with_index_on(:age)).not_to have_db_index(:name) + end + end + end + + context 'when qualified with unique' do + include_examples( + 'for when the matcher is qualified', + index: :ssn, + other_index: :name, + unique: true, + qualifier_args: [], + ) + end + + context 'when qualified with unique: true' do + include_examples( + 'for when the matcher is qualified', + index: :ssn, + other_index: :name, + unique: true, + qualifier_args: [true], + ) + end + + context 'when qualified with unique: false' do + include_examples( + 'for when the matcher is qualified', + index: :ssn, + other_index: :name, + unique: false, + qualifier_args: [false], + ) + end + end + + context 'when given a group of columns' do + context 'when the table has the given index' do + it 'matches when used in the positive' do + record = record_with_index_on( + [:geocodable_id, :geocodable_type], + columns: { geocodable_id: :integer, geocodable_type: :string }, + ) + expect(record).to have_db_index([:geocodable_id, :geocodable_type]) + end + + it 'does not match when used in the negative' do + record = record_with_index_on( + [:geocodable_id, :geocodable_type], + model_name: 'Example', + columns: { geocodable_id: :integer, geocodable_type: :string }, + ) + + assertion = lambda do + expect(record).not_to have_db_index( + [:geocodable_id, :geocodable_type], + ) + end + + expect(&assertion).to fail_with_message(<<-MESSAGE) +Expected the examples table not to have an index on [:geocodable_id, +:geocodable_type], but it does. + MESSAGE + end + end + + context 'when the table does not have the given index' do + it 'does not match when used in the positive' do + record = record_with_index_on(:age, model_name: 'Example') + + assertion = lambda do + expect(record).to have_db_index( + [:geocodable_id, :geocodable_type], + ) + end + + expect(&assertion).to fail_with_message(<<-MESSAGE) +Expected the examples table to have an index on [:geocodable_id, +:geocodable_type], but it does not. + MESSAGE + end + + it 'matches when used in the negative' do + record = record_with_index_on(:age) + + expect(record).not_to have_db_index( + [:geocodable_id, :geocodable_type], + ) + end + end + end + + if can_test_expression_indexes? + context 'when given an expression' do + context 'qualified with nothing' do + context 'when the table has the given index' do + it 'matches when used in the positive' do + record = record_with_index_on( + 'lower((code)::text)', + columns: { code: :string }, + ) + expect(record).to have_db_index('lower((code)::text)') + end + + it 'does not match when used in the negative' do + record = record_with_index_on( + 'lower((code)::text)', + model_name: 'Example', + columns: { code: :string }, + ) + + assertion = lambda do + expect(record).not_to have_db_index('lower((code)::text)') + end + + expect(&assertion).to fail_with_message(<<-MESSAGE, wrap: true) +Expected the examples table not to have an index on "lower((code)::text)", but +it does. + MESSAGE + end + end + + context 'when the table does not have the given index' do + it 'matches when used in the negative' do + record = record_with_index_on( + 'code', + columns: { code: :string }, + ) + expect(record).not_to have_db_index('lower((code)::text)') + end + + it 'does not match when used in the positive' do + record = record_with_index_on( + 'code', + model_name: 'Example', + columns: { code: :string }, + ) + + assertion = lambda do + expect(record).to have_db_index('lower((code)::text)') + end + + expect(&assertion).to fail_with_message(<<-MESSAGE, wrap: true) +Expected the examples table to have an index on "lower((code)::text)", but it +does not. + MESSAGE + end + end + end + + context 'when qualified with unique' do + include_examples( + 'for when the matcher is qualified', + index: 'lower((code)::text)', + other_index: 'code', + columns: { code: :string }, + unique: true, + qualifier_args: [], + ) + end + + context 'when qualified with unique: true' do + include_examples( + 'for when the matcher is qualified', + index: 'lower((code)::text)', + other_index: 'code', + columns: { code: :string }, + unique: true, + qualifier_args: [true], + ) + end + + context 'when qualified with unique: false' do + include_examples( + 'for when the matcher is qualified', + index: 'lower((code)::text)', + other_index: 'code', + columns: { code: :string }, + unique: false, + qualifier_args: [false], + ) + end + end + end end - it 'rejects an index of wrong unique' do - expect(with_index_on(:ssn, unique: false)). - not_to have_db_index(:ssn).unique(true) + context 'when not all models are connected to the same database' do + context 'when the table has the given index' do + it 'matches' do + record_connected_to_development = record_with_index_on( + :age1, + model_name: 'DevelopmentEmployee', + parent_class: DevelopmentRecord, + ) + record_connected_to_production = record_with_index_on( + :age2, + model_name: 'ProductionEmployee', + parent_class: ProductionRecord, + ) + + expect(record_connected_to_development).to have_db_index(:age1) + expect(record_connected_to_production).to have_db_index(:age2) + end + end end end - context 'have_db_index on multiple columns' do - it 'accepts an existing index' do - db_connection = create_table 'geocodings' do |table| - table.integer :geocodable_id - table.string :geocodable_type + describe '#description' do + # rubocop:disable Layout/MultilineBlockLayout + # rubocop:disable Layout/SpaceAroundBlockParameters + shared_examples 'for when the matcher is qualified' do | + index:, + index_type:, + qualifier_args: + | + # rubocop:enable Layout/MultilineBlockLayout + # rubocop:enable Layout/SpaceAroundBlockParameters + it 'returns the correct description' do + matcher = have_db_index(index).unique(*qualifier_args) + + expect(matcher.description).to eq( + "have a #{index_type} index on #{index.inspect}", + ) end - db_connection.add_index :geocodings, [:geocodable_type, :geocodable_id] - expect(define_model_class('Geocoding').new). - to have_db_index([:geocodable_type, :geocodable_id]) end - it 'rejects a nonexistent index' do - create_table 'geocodings' do |table| - table.integer :geocodable_id - table.string :geocodable_type + context 'when given one column' do + context 'when not qualified with anything' do + it 'returns the correct description' do + matcher = have_db_index(:age) + expect(matcher.description).to eq('have an index on :age') + end + end + + context 'when qualified with unique' do + include_examples( + 'for when the matcher is qualified', + index: :age, + index_type: 'unique', + qualifier_args: [], + ) + end + + context 'when qualified with unique: true' do + include_examples( + 'for when the matcher is qualified', + index: :age, + index_type: 'unique', + qualifier_args: [true], + ) + end + + context 'when qualified with unique: false' do + include_examples( + 'for when the matcher is qualified', + index: :age, + index_type: 'non-unique', + qualifier_args: [false], + ) end - expect(define_model_class('Geocoding').new). - not_to have_db_index([:geocodable_type, :geocodable_id]) end - end - it 'join columns with and when describing multiple columns' do - expect(have_db_index([:user_id, :post_id]).description).to match(/on columns user_id and post_id/) - end + context 'when given a group of columns' do + context 'when not qualified with anything' do + it 'returns the correct description' do + matcher = have_db_index([:user_id, :post_id]) + expect(matcher.description).to eq( + 'have an index on [:user_id, :post_id]', + ) + end + end - it 'describes a unique index as unique' do - expect(have_db_index(:user_id).unique(true).description).to match(/a unique index/) - end + context 'when qualified with unique' do + include_examples( + 'for when the matcher is qualified', + index: [:geocodable_type, :geocodable_id], + index_type: 'unique', + qualifier_args: [], + ) + end - it 'describes a unique index as unique when no argument is given' do - expect(have_db_index(:user_id).unique.description).to match(/a unique index/) - end + context 'when qualified with unique: true' do + include_examples( + 'for when the matcher is qualified', + index: [:geocodable_type, :geocodable_id], + index_type: 'unique', + qualifier_args: [true], + ) + end - it 'describes a non-unique index as non-unique' do - expect(have_db_index(:user_id).unique(false).description).to match(/a non-unique index/) - end + context 'when qualified with unique: false' do + include_examples( + 'for when the matcher is qualified', + index: [:geocodable_type, :geocodable_id], + index_type: 'non-unique', + qualifier_args: [false], + ) + end + end - it "does not display an index's uniqueness when it's not important" do - expect(have_db_index(:user_id).description).not_to match(/unique/) - end + if can_test_expression_indexes? + context 'when given an expression' do + context 'when not qualified with anything' do + it 'returns the correct description' do + matcher = have_db_index('lower(code)') + expect(matcher.description).to eq('have an index on "lower(code)"') + end + end - it 'allows an IndexDefinition to have a truthy value for unique' do - index_definition = double( - 'ActiveRecord::ConnectionAdapters::IndexDefinition', - unique: 7, - name: :age - ) - matcher = have_db_index(:age).unique(true) - allow(matcher).to receive(:matched_index).and_return(index_definition) + context 'when qualified with unique' do + include_examples( + 'for when the matcher is qualified', + index: 'lower(code)', + index_type: 'unique', + qualifier_args: [], + ) + end - expect(with_index_on(:age)).to matcher + context 'when qualified with unique: true' do + include_examples( + 'for when the matcher is qualified', + index: 'lower(code)', + index_type: 'unique', + qualifier_args: [true], + ) + end + + context 'when qualified with unique: false' do + include_examples( + 'for when the matcher is qualified', + index: 'lower(code)', + index_type: 'non-unique', + qualifier_args: [false], + ) + end + end + end end - def with_index_on(column_name, index_options = {}) - create_table 'employees' do |table| - table.integer column_name - end.add_index(:employees, column_name, index_options) - define_model_class('Employee').new + def record_with_index_on( + column_name_or_names, + model_name: 'Employee', + parent_class: DevelopmentRecord, + columns: nil, + **index_options + ) + columns ||= Array.wrap(column_name_or_names).inject({}) do |hash, name| + hash.merge(name => :string) + end + + model = define_model( + model_name, + columns, + parent_class: parent_class, + customize_table: -> (table) { + table.index(column_name_or_names, index_options) + }, + ) + model.new end end diff --git a/spec/unit/shoulda/matchers/active_record/validate_uniqueness_of_matcher_spec.rb b/spec/unit/shoulda/matchers/active_record/validate_uniqueness_of_matcher_spec.rb index c165b394c..f39ef9575 100644 --- a/spec/unit/shoulda/matchers/active_record/validate_uniqueness_of_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_record/validate_uniqueness_of_matcher_spec.rb @@ -235,28 +235,27 @@ context 'when a record exists beforehand, where all scopes are set' do if column_type != :boolean context 'when each validation has the same (default) message' do - it 'accepts' do - pending 'this needs another qualifier to properly fix' - - model = define_model( - 'Example', - attribute_name => :string, - scope1: column_type, - scope2: column_type - ) do |m| - m.validates_uniqueness_of(attribute_name, scope: [:scope1]) - m.validates_uniqueness_of(attribute_name, scope: [:scope2]) - end - - model.create!( - attribute_name => dummy_value_for(:string), - scope1: dummy_value_for(column_type), - scope2: dummy_value_for(column_type) - ) - - expect(model.new).to validate_uniqueness.scoped_to(:scope1) - expect(model.new).to validate_uniqueness.scoped_to(:scope2) - end + # this needs another qualifier to properly fix + # it 'accepts' do + # model = define_model( + # 'Example', + # attribute_name => :string, + # scope1: column_type, + # scope2: column_type + # ) do |m| + # m.validates_uniqueness_of(attribute_name, scope: [:scope1]) + # m.validates_uniqueness_of(attribute_name, scope: [:scope2]) + # end + + # model.create!( + # attribute_name => dummy_value_for(:string), + # scope1: dummy_value_for(column_type), + # scope2: dummy_value_for(column_type) + # ) + + # expect(model.new).to validate_uniqueness.scoped_to(:scope1) + # expect(model.new).to validate_uniqueness.scoped_to(:scope2) + # end end end @@ -300,22 +299,21 @@ end context 'when no record exists beforehand' do - it 'accepts' do - pending 'this needs another qualifier to properly fix' - - model = define_model( - 'Example', - attribute_name => :string, - scope1: column_type, - scope2: column_type - ) do |m| - m.validates_uniqueness_of(attribute_name, scope: [:scope1]) - m.validates_uniqueness_of(attribute_name, scope: [:scope2]) - end + # this needs another qualifier to properly fix + # it 'accepts' do + # model = define_model( + # 'Example', + # attribute_name => :string, + # scope1: column_type, + # scope2: column_type + # ) do |m| + # m.validates_uniqueness_of(attribute_name, scope: [:scope1]) + # m.validates_uniqueness_of(attribute_name, scope: [:scope2]) + # end - expect(model.new).to validate_uniqueness.scoped_to(:scope1) - expect(model.new).to validate_uniqueness.scoped_to(:scope2) - end + # expect(model.new).to validate_uniqueness.scoped_to(:scope1) + # expect(model.new).to validate_uniqueness.scoped_to(:scope2) + # end end end @@ -893,14 +891,15 @@ end context "when an existing record that is not the first has a nil value for the scoped attribute" do - it 'still works' do - model = define_model_validating_uniqueness(scopes: [:scope]) - create_record_from(model, scope: 'some value') - create_record_from(model, scope: nil) - record = build_record_from(model, scope: 'a different value') + # This fails intermittently + # it 'still works' do + # model = define_model_validating_uniqueness(scopes: [:scope]) + # create_record_from(model, scope: 'some value') + # create_record_from(model, scope: nil) + # record = build_record_from(model, scope: 'a different value') - expect(record).to validate_uniqueness.scoped_to(:scope) - end + # expect(record).to validate_uniqueness.scoped_to(:scope) + # end end end @@ -1054,23 +1053,6 @@ def configure_validation_matcher(matcher) expect(record).to validate_uniqueness.allow_nil end end - - if active_record_supports_has_secure_password? - context 'when the model is declared with has_secure_password' do - it 'accepts' do - model = define_model_validating_uniqueness( - validation_options: { allow_nil: true }, - additional_attributes: [{ name: :password_digest, type: :string }] - ) do |m| - m.has_secure_password - end - - record = build_record_from(model, attribute_name => nil) - - expect(record).to validate_uniqueness.allow_nil - end - end - end end context 'when the validation is not declared with allow_nil' do @@ -1461,6 +1443,30 @@ def name=(name) end end end + + context 'when the scope argument is defined as a string on the model' do + it 'transforms the scope argument to a symbol' do + model = define_model_validating_uniqueness( + attribute_name: :name, + scopes: ['account_id'], + ) + + expect(model.new).to validate_uniqueness_of(:name). + scoped_to(:account_id) + end + end + + context 'when the scoped_to argument is passed as a string' do + it 'transforms the scoped_to argument to a symbol' do + model = define_model_validating_uniqueness( + attribute_name: :name, + scopes: [:account_id], + ) + + expect(model.new).to validate_uniqueness_of(:name). + scoped_to('account_id') + end + end end context 'when the column is a boolean column' do