mongoid-rspec 4.2.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +132 -3
  3. data/Rakefile +4 -2
  4. data/lib/matchers/accept_nested_attributes.rb +2 -0
  5. data/lib/matchers/allow_mass_assignment.rb +2 -0
  6. data/lib/matchers/associations.rb +27 -9
  7. data/lib/matchers/be_covered_query.rb +75 -0
  8. data/lib/matchers/be_dynamic_document.rb +2 -0
  9. data/lib/matchers/be_mongoid_document.rb +2 -0
  10. data/lib/matchers/be_stored_in.rb +2 -0
  11. data/lib/matchers/execute_mongodb_command.rb +127 -0
  12. data/lib/matchers/have_field.rb +15 -3
  13. data/lib/matchers/have_timestamps.rb +5 -3
  14. data/lib/matchers/indexes/have_index_for.rb +2 -0
  15. data/lib/matchers/indexes/v3/have_index_for.rb +3 -1
  16. data/lib/matchers/indexes/v4/have_index_for.rb +3 -1
  17. data/lib/matchers/validations/absence_of.rb +2 -0
  18. data/lib/matchers/validations/acceptance_of.rb +2 -0
  19. data/lib/matchers/validations/associated.rb +2 -0
  20. data/lib/matchers/validations/confirmation_of.rb +2 -0
  21. data/lib/matchers/validations/custom_validation_of.rb +14 -12
  22. data/lib/matchers/validations/exclusion_of.rb +15 -11
  23. data/lib/matchers/validations/format_of.rb +19 -15
  24. data/lib/matchers/validations/inclusion_of.rb +13 -9
  25. data/lib/matchers/validations/length_of.rb +77 -36
  26. data/lib/matchers/validations/numericality_of.rb +13 -11
  27. data/lib/matchers/validations/presence_of.rb +2 -0
  28. data/lib/matchers/validations/uniqueness_of.rb +19 -17
  29. data/lib/matchers/validations.rb +41 -22
  30. data/lib/mongoid/rspec/version.rb +3 -1
  31. data/lib/mongoid/rspec.rb +4 -0
  32. data/lib/mongoid-rspec.rb +2 -0
  33. metadata +10 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 711f91347b02d3d6982a72b6946bb781ea07dba16f858109da01166c7a768ecf
4
- data.tar.gz: 58e81131678e670e5a7b63385122fa316afadb18d49749c3544a4e5e1898d3fc
3
+ metadata.gz: 2ac3e47b3e606516f4ca239dad1405872db4cd012443a11a9e3c332d47555c27
4
+ data.tar.gz: 8b4d8baff373f7b2c6bc7e194a5a21192c08bdfda17e40693e300f9fd48a111b
5
5
  SHA512:
6
- metadata.gz: 7aab1843f0f0b8778916039f3ef8b0f89e815bbc9cfafe0d7244a903df8c524ec6e990914478d6cd403b5b9e81e885b007850fe58cbf78841d989a472e98cdb2
7
- data.tar.gz: 3e068496db19d24acfd05ff8ac630ecd41ccab1606e6597ddf0d0bf5969296dd7e1f2f5cf3a7a847801b44c84363e784908024a392317b7d1a95825f2c66bf9f
6
+ metadata.gz: 51157b3c68734d119c2880e7d071f52f244322bf517bfd8cab697d0e2f2e0662877b455acf704923dbed3f24034ab341c4c84ad6a9443230370052f805c6db0e
7
+ data.tar.gz: 0ed7c718a2862d4a1bb5abfd03e430dff67b1589612abbf50b2f449dd0584ecaff99b42b8e204e6281eab65f1b404b1bbf710da65550bd4fb369ef157395b241
data/README.md CHANGED
@@ -1,12 +1,15 @@
1
1
  # [mongoid-rspec](https://github.com/mongoid/mongoid-rspec "A collection of RSpec-compatible matchers that help to test Mongoid documents.")
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/mongoid-rspec.svg)](https://badge.fury.io/rb/mongoid-rspec)
4
- [![Test Status](https://github.com/mongoid/mongoid-rspec/workflows/Test/badge.svg)](https://github.com/mongoid/mongoid-rspec/actions)
5
- [![Rubocop Status](https://github.com/mongoid/mongoid-rspec/workflows/Rubocop/badge.svg)](https://github.com/mongoid/mongoid-rspec/actions)
4
+ [![Test Status](https://github.com/mongoid/mongoid-rspec/actions/workflows/rspec.yml/badge.svg?branch=master)](https://github.com/mongoid/mongoid-rspec/actions/workflows/rspec.yml?query=branch%3Amaster)
5
+ [![Rubocop Status](https://github.com/mongoid/mongoid-rspec/actions/workflows/rubocop.yml/badge.svg?branch=master)](https://github.com/mongoid/mongoid-rspec/actions/workflows/rubocop.yml?query=branch%3Amaster)
6
+ [![Coverage Status](https://coveralls.io/repos/github/mongoid/mongoid-rspec/badge.svg?branch=master)](https://coveralls.io/github/mongoid/mongoid-rspec?branch=master)
7
+
8
+ CI collects SimpleCov coverage on Ruby 3.4 with Mongoid 9 and MongoDB 8.0 and submits one report to Coveralls using GitHub's workflow token. To generate a local report, run `COVERAGE=true BUNDLE_GEMFILE=gemfiles/mongoid_9.gemfile bundle install` followed by `COVERAGE=true BUNDLE_GEMFILE=gemfiles/mongoid_9.gemfile bundle exec rake spec`. Reports are written to `coverage/`.
6
9
 
7
10
  The mongoid-rspec library provides a collection of RSpec-compatible matchers that help to test Mongoid documents.
8
11
 
9
- [Tested](https://github.com/mongoid/mongoid-locker/actions) against:
12
+ [Tested](https://github.com/mongoid/mongoid-rspec/actions/workflows/rspec.yml) against:
10
13
  - MRI: `2.6.x`, `2.7.x`, `3.0.x`, `3.1.x`, `3.2.x`
11
14
  - Mongoid: `4`, `5`, `6`, `7`, `8`, `9`
12
15
 
@@ -41,6 +44,33 @@ RSpec.configure do |config|
41
44
  end
42
45
  ```
43
46
 
47
+ ### Using Mongoid and ActiveRecord with shoulda-matchers
48
+
49
+ Both libraries define matcher names such as `validate_length_of` and `belong_to`, but their chain methods and implementations differ. In an application with both Mongoid and ActiveRecord models, include Mongoid matchers only for a custom `:mongoid_model` type instead of all `:model` examples. Keep Shoulda's ActiveModel and ActiveRecord matchers scoped to `:model`:
50
+
51
+ ```ruby
52
+ require 'mongoid-rspec'
53
+ require 'shoulda/matchers'
54
+
55
+ RSpec.configure do |config|
56
+ config.include Mongoid::Matchers, type: :mongoid_model
57
+ config.include Shoulda::Matchers::ActiveModel, type: :model
58
+ config.include Shoulda::Matchers::ActiveRecord, type: :model
59
+ end
60
+
61
+ RSpec.describe MongoidAddress, type: :mongoid_model do
62
+ it { is_expected.to validate_length_of(:name).with_maximum(255) }
63
+ it { is_expected.to belong_to(:city).as_inverse_of(:addresses) }
64
+ end
65
+
66
+ RSpec.describe ActiveRecordAddress, type: :model do
67
+ it { is_expected.to validate_length_of(:name).is_at_most(255) }
68
+ it { is_expected.to belong_to(:city).inverse_of(:addresses) }
69
+ end
70
+ ```
71
+
72
+ Replace the existing unscoped or `type: :model` inclusion of `Mongoid::Matchers`; do not keep it alongside the scoped configuration. Explicitly tag Mongoid example groups with `type: :mongoid_model`, including those under `spec/models`, where rspec-rails would otherwise infer `:model`. Do not include Shoulda's model matcher modules globally or directly in those Mongoid groups. This avoids depending on module inclusion order and preserves each library's existing matcher API.
73
+
44
74
  ### Other
45
75
 
46
76
  Add to your `spec_helper.rb` file.
@@ -204,8 +234,74 @@ RSpec.describe Article, type: :model do
204
234
  end
205
235
  ```
206
236
 
237
+ ### execute_mongodb_command
238
+
239
+ Use `execute_mongodb_command` with a block expectation to verify that code issues a MongoDB command. Without an argument, the matcher passes when any command is issued on the block's thread. An expected command matches the entire application command document exactly, not just a subset or query selector; nested String and Symbol keys are normalized to Strings. Driver-added top-level `$db`, `lsid`, and `$clusterTime` fields are excluded from comparison, while all other command fields and values must match. The matcher ignores unrelated background-thread activity and raises `NotImplementedError` when the driver does not expose command monitoring, including the legacy driver used with Mongoid 4.
240
+
241
+ ```ruby
242
+ RSpec.describe FooService do
243
+ it 'uses the cached result without querying MongoDB' do
244
+ expect { FooService.recent_foos }.not_to execute_mongodb_command
245
+ end
246
+ end
247
+
248
+ RSpec.describe Article do
249
+ it 'executes the expected MongoDB command' do
250
+ expected_command = {
251
+ find: 'articles',
252
+ filter: { 'title.en' => 'cached' },
253
+ limit: 10,
254
+ sort: { created_at: -1 }
255
+ }
256
+
257
+ expect do
258
+ Article.where(title: 'cached').desc(:created_at).limit(10).to_a
259
+ end.to execute_mongodb_command(expected_command)
260
+ end
261
+ end
262
+ ```
263
+
264
+ ### be_covered_query
265
+
266
+ Check that MongoDB's winning plan uses an index without fetching documents:
267
+
268
+ ```ruby
269
+ class Article
270
+ index({ title: 1, _id: 1 })
271
+ end
272
+
273
+ RSpec.describe Article do
274
+ it { expect(Article.where(title: 'Example').only(:title)).to be_covered_query }
275
+ end
276
+ ```
277
+
278
+ Mongoid includes `_id` in an inclusive projection by default, so the index must cover it as well. The matcher examines all stages in the winning plan, including nested shard plans, and rejects plans containing `FETCH` or `COLLSCAN`. A covered plan does not guarantee that a query is fast; this matcher checks plan coverage, not execution time.
279
+
280
+ Use `.using_index('title_1__id_1')` or `.using_indexes('first_index', 'second_index')` to additionally require the exact set of index names used by the winning plan. Index names accept nonempty strings or symbols; order and repeated uses across shards do not matter. Rejected plans are ignored, and the query must still be covered.
281
+
282
+ ```ruby
283
+ expect(Article.where(title: 'Example').only(:title)).to be_covered_query.using_index('title_1__id_1')
284
+ ```
285
+
207
286
  ### Field Matchers
208
287
 
288
+ Chain `.readonly` to check fields declared with Mongoid's `attr_readonly`. This checks the declaration, not whether a new document currently allows writes. Like other field matcher options, it applies to every requested field and accepts class or instance subjects.
289
+
290
+ ```ruby
291
+ class Article
292
+ include Mongoid::Document
293
+
294
+ field :slug, type: String
295
+ field :external_id, type: String
296
+ attr_readonly :slug, :external_id
297
+ end
298
+
299
+ RSpec.describe Article do
300
+ it { is_expected.to have_field(:slug).of_type(String).readonly }
301
+ it { is_expected.to have_fields(:slug, :external_id).readonly }
302
+ end
303
+ ```
304
+
209
305
  ```ruby
210
306
  RSpec.describe Article do
211
307
  it { is_expected.to have_field(:published).of_type(Mongoid::Boolean).with_default_value_of(false) }
@@ -223,6 +319,31 @@ end
223
319
 
224
320
  ### Association Matchers
225
321
 
322
+ Use `with_primary_key` to check an association's primary key, including the default `_id`. Like `with_foreign_key`, it accepts a symbol or string and can be chained with other association options:
323
+
324
+ ```ruby
325
+ class Author
326
+ include Mongoid::Document
327
+
328
+ field :code, type: String
329
+ has_many :posts, primary_key: :code, foreign_key: :author_code
330
+ end
331
+
332
+ class Post
333
+ include Mongoid::Document
334
+
335
+ belongs_to :author, primary_key: :code, foreign_key: :author_code
336
+ end
337
+
338
+ RSpec.describe Author do
339
+ it { is_expected.to have_many(:posts).with_primary_key(:code).with_foreign_key(:author_code) }
340
+ end
341
+
342
+ RSpec.describe Post do
343
+ it { is_expected.to belong_to(:author).with_primary_key('code') }
344
+ end
345
+ ```
346
+
226
347
  ```ruby
227
348
  RSpec.describe User do
228
349
  it { is_expected.to have_many(:articles).with_foreign_key(:author_id).ordered_by(:title) }
@@ -244,6 +365,8 @@ RSpec.describe User do
244
365
 
245
366
  it { is_expected.to have_and_belong_to_many(:children) }
246
367
  it { is_expected.to have_and_belong_to_many(:children).of_type(User) }
368
+ # nonconflicting alternative when using shoulda-matchers
369
+ it { is_expected.to have_and_belong_to_many_related(:children).of_type(User) }
247
370
  end
248
371
 
249
372
  RSpec.describe Profile do
@@ -297,6 +420,10 @@ end
297
420
 
298
421
  ### Validation Matchers
299
422
 
423
+ `validate_length_of` searches all applicable length validators for one matching the requested options. For example, a maximum-length validator added by `has_secure_password` does not hide a separately declared minimum-length validator. Chained length, message, and `to_allow` options must match a single validator; options from separate validators are not combined.
424
+
425
+ Use `validate_length_of(:name).within(1..100).to_allow(nil: true)` to check `allow_nil: true`, or `.to_allow(blank: true)` to check `allow_blank: true`. Both options can be combined, and the full keys `allow_nil` and `allow_blank` are also accepted. Values must be `true` or `false`; `false` requires an explicitly configured `false` (an omitted option does not match). Omitting `to_allow` leaves these options unchecked. Empty hashes, unknown keys, non-boolean values, and duplicate aliases are rejected with `ArgumentError`.
426
+
300
427
  ```ruby
301
428
  RSpec.describe Site do
302
429
  it { is_expected.to validate_presence_of(:name) }
@@ -393,6 +520,8 @@ end
393
520
 
394
521
  You're encouraged to contribute to this library. See [CONTRIBUTING](CONTRIBUTING.md) for details.
395
522
 
523
+ `bundle exec rake` runs the unit suite without third-party integration dependencies. Each integration has its own Appraisal bundle and directory under `spec/integration/`. For example, run `BUNDLE_GEMFILE=gemfiles/shoulda_matchers.gemfile bundle install`, then `BUNDLE_GEMFILE=gemfiles/shoulda_matchers.gemfile bundle exec rspec spec/integration/shoulda_matchers`. To add an integration, define its dependencies in Appraisals, generate its gemfile, add its specs under a matching directory, and add its name to the integration matrix in `.github/workflows/rspec.yml`. CI runs each listed integration in a separate process and bundle.
524
+
396
525
  ## Copyright and License
397
526
 
398
527
  Copyright (c) 2009-2018 Evan Sagge and Contributors.
data/Rakefile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  $LOAD_PATH.push File.expand_path('../lib', __FILE__)
2
4
 
3
5
  require 'bundler'
@@ -6,11 +8,11 @@ Bundler::GemHelper.install_tasks
6
8
  require 'rspec/core/rake_task'
7
9
 
8
10
  RSpec::Core::RakeTask.new(:spec) do |spec|
9
- spec.pattern = './spec/**/*_spec.rb'
11
+ spec.pattern = './spec/unit/**/*_spec.rb'
10
12
  end
11
13
 
12
14
  RSpec::Core::RakeTask.new(:rcov) do |spec|
13
- spec.pattern = './spec/**/*_spec.rb'
15
+ spec.pattern = './spec/unit/**/*_spec.rb'
14
16
  spec.rcov = true
15
17
  end
16
18
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mongoid
2
4
  module Matchers # :nodoc:
3
5
  # Ensures that the model can accept nested attributes for the specified
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # this code is totally extracted from shoulda-matchers gem.
2
4
  module Mongoid
3
5
  module Matchers
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  if Mongoid::Compatibility::Version.mongoid7_or_newer?
2
4
  require 'mongoid/association'
3
5
  else
@@ -30,7 +32,7 @@ module Mongoid
30
32
  @association = {}
31
33
  @association[:name] = name.to_s
32
34
  @association[:type] = association_type
33
- @expectation_message = "#{type_description} #{@association[:name].inspect}"
35
+ @expectation_message = +"#{type_description} #{@association[:name].inspect}"
34
36
  @expectation_message << " of type #{@association[:class].inspect}" unless @association[:class].nil?
35
37
  end
36
38
 
@@ -113,6 +115,12 @@ module Mongoid
113
115
  self
114
116
  end
115
117
 
118
+ def with_primary_key(primary_key)
119
+ @association[:primary_key] = primary_key.to_s
120
+ @expectation_message << " using primary key #{@association[:primary_key].inspect}"
121
+ self
122
+ end
123
+
116
124
  def with_counter_cache
117
125
  @association[:counter_cache] = true
118
126
  @expectation_message << " which specifies counter_cache as #{@association[:counter_cache]}"
@@ -269,6 +277,15 @@ module Mongoid
269
277
  end
270
278
  end
271
279
 
280
+ if @association[:primary_key]
281
+ if metadata.primary_key != @association[:primary_key]
282
+ @negative_result_message = "#{@positive_result_message} with primary key #{metadata.primary_key.inspect}"
283
+ return false
284
+ else
285
+ @positive_result_message = "#{@positive_result_message} with primary key #{metadata.primary_key.inspect}"
286
+ end
287
+ end
288
+
272
289
  if @association[:counter_cache]
273
290
  if metadata.counter_cached? != true
274
291
  @negative_result_message = "#{@positive_result_message} which did not set counter_cache"
@@ -318,19 +335,19 @@ module Mongoid
318
335
  type ||= @association[:type]
319
336
  case type.name
320
337
  when EMBEDS_ONE.name
321
- (passive ? 'embed' : 'embeds') << ' one'
338
+ +(passive ? 'embed' : 'embeds') << ' one'
322
339
  when EMBEDS_MANY.name
323
- (passive ? 'embed' : 'embeds') << ' many'
340
+ +(passive ? 'embed' : 'embeds') << ' many'
324
341
  when EMBEDDED_IN.name
325
- (passive ? 'be' : 'is') << ' embedded in'
342
+ +(passive ? 'be' : 'is') << ' embedded in'
326
343
  when HAS_ONE.name
327
- (passive ? 'reference' : 'references') << ' one'
344
+ +(passive ? 'reference' : 'references') << ' one'
328
345
  when HAS_MANY.name
329
- (passive ? 'reference' : 'references') << ' many'
346
+ +(passive ? 'reference' : 'references') << ' many'
330
347
  when HAS_AND_BELONGS_TO_MANY.name
331
- (passive ? 'reference' : 'references') << ' and referenced in many'
348
+ +(passive ? 'reference' : 'references') << ' and referenced in many'
332
349
  when BELONGS_TO.name
333
- (passive ? 'be referenced in' : 'referenced in')
350
+ +(passive ? 'be referenced in' : 'referenced in')
334
351
  else
335
352
  raise format("Unknown association type '%s'", type)
336
353
  end
@@ -369,9 +386,10 @@ module Mongoid
369
386
  end
370
387
  alias have_many have_many_related
371
388
 
372
- def have_and_belong_to_many(association_name)
389
+ def have_and_belong_to_many_related(association_name)
373
390
  HaveAssociationMatcher.new(association_name, HAS_AND_BELONGS_TO_MANY)
374
391
  end
392
+ alias have_and_belong_to_many have_and_belong_to_many_related
375
393
 
376
394
  def belong_to_related(association_name)
377
395
  HaveAssociationMatcher.new(association_name, BELONGS_TO)
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Matchers
5
+ def be_covered_query
6
+ BeCoveredQuery.new
7
+ end
8
+
9
+ class BeCoveredQuery
10
+ PLAN_CHILD_KEYS = %w[inputStage inputStages outerStage innerStage queryPlan winningPlan shards].freeze
11
+
12
+ def using_index(name)
13
+ using_indexes(name)
14
+ end
15
+
16
+ def using_indexes(*names)
17
+ raise ArgumentError, 'index names must be nonempty strings or symbols' unless names.any? && names.all? { |name| (name.is_a?(String) || name.is_a?(Symbol)) && !name.to_s.empty? }
18
+
19
+ @expected_indexes = names.map(&:to_s).uniq.sort
20
+ self
21
+ end
22
+
23
+ def matches?(actual)
24
+ @stages = []
25
+ @indexes = []
26
+ return false unless actual.respond_to?(:explain)
27
+
28
+ explain = actual.explain
29
+ planner = value_for(explain, :queryPlanner)
30
+ winning_plan = value_for(planner, :winningPlan)
31
+ return false unless winning_plan
32
+
33
+ collect_plan(winning_plan)
34
+ covered = @stages.include?('IXSCAN') && (@stages & %w[FETCH COLLSCAN]).empty?
35
+ covered && (!@expected_indexes || ((@indexes.uniq - @expected_indexes).empty? && (@expected_indexes - @indexes).empty?))
36
+ end
37
+
38
+ def description
39
+ desc = +'be a covered query'
40
+ desc << " using indexes #{@expected_indexes.inspect}" if @expected_indexes
41
+ desc
42
+ end
43
+
44
+ def failure_message
45
+ detail = @stages.empty? ? 'no winning plan was found' : "winning plan stages were #{@stages.inspect}"
46
+ detail += " and indexes were #{@indexes.uniq.inspect}" if @expected_indexes && @stages.any?
47
+ "Expected query to #{description}, but #{detail}"
48
+ end
49
+
50
+ def failure_message_when_negated
51
+ "Expected query not to #{description}"
52
+ end
53
+
54
+ private
55
+
56
+ def value_for(hash, key)
57
+ return unless hash.respond_to?(:[])
58
+
59
+ hash[key.to_s] || hash[key.to_sym]
60
+ end
61
+
62
+ def collect_plan(plan)
63
+ case plan
64
+ when Hash
65
+ stage = value_for(plan, :stage)
66
+ @stages << stage.to_s if stage
67
+ @indexes << value_for(plan, :indexName) if stage.to_s == 'IXSCAN'
68
+ PLAN_CHILD_KEYS.each { |key| collect_plan(value_for(plan, key)) }
69
+ when Array
70
+ plan.each { |value| collect_plan(value) }
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mongoid
2
4
  module Matchers
3
5
  def be_dynamic_document
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mongoid
2
4
  module Matchers
3
5
  def be_mongoid_document
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mongoid
2
4
  module Matchers
3
5
  def be_stored_in(options)
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Matchers
5
+ def execute_mongodb_command(*expected_commands)
6
+ raise ArgumentError, 'execute_mongodb_command accepts at most one command' if expected_commands.length > 1
7
+
8
+ if expected_commands.empty?
9
+ ExecuteMongodbCommand.new
10
+ else
11
+ ExecuteMongodbCommand.new(expected_commands.first)
12
+ end
13
+ end
14
+
15
+ class ExecuteMongodbCommand
16
+ DRIVER_METADATA_KEYS = %w[$db lsid $clusterTime].freeze
17
+ UNSPECIFIED = Object.new.freeze
18
+
19
+ def initialize(expected_command = UNSPECIFIED)
20
+ @expected_command = expected_command.equal?(UNSPECIFIED) ? nil : normalize_command(expected_command)
21
+ end
22
+
23
+ def supports_block_expectations?
24
+ true
25
+ end
26
+
27
+ def matches?(block)
28
+ raise ArgumentError, 'execute_mongodb_command expects a block' unless block
29
+ raise NotImplementedError, 'execute_mongodb_command requires MongoDB driver command monitoring' unless
30
+ defined?(Mongo::Monitoring::Global) && defined?(Mongo::Monitoring::COMMAND)
31
+ raise NotImplementedError, 'execute_mongodb_command requires the Mongoid client registry' unless
32
+ defined?(Mongoid::Clients) && Mongoid::Clients.respond_to?(:clients)
33
+
34
+ @command_executed = false
35
+ topic = Mongo::Monitoring::COMMAND
36
+ global_monitor = Mongo::Monitoring::Global
37
+ subscriber = CommandSubscriber.new(Thread.current, @expected_command, method(:normalize_command)) do
38
+ @command_executed = true
39
+ end
40
+ clients = mongoid_clients
41
+ subscribed = false
42
+
43
+ begin
44
+ clients.each { |client| client.subscribe(topic, subscriber) }
45
+ global_monitor.subscribe(topic, subscriber)
46
+ subscribed = true
47
+ block.call
48
+ ensure
49
+ current_clients = mongoid_clients
50
+ global_monitor.unsubscribe(topic, subscriber) if subscribed
51
+ (clients + current_clients).uniq.each { |client| client.unsubscribe(topic, subscriber) }
52
+ end
53
+
54
+ @command_executed
55
+ end
56
+
57
+ def failure_message
58
+ message = +'Expected block to execute a MongoDB command'
59
+ message << " matching #{@expected_command.inspect}" if @expected_command
60
+ message
61
+ end
62
+
63
+ def failure_message_when_negated
64
+ message = +'Expected block not to execute a MongoDB command'
65
+ message << " matching #{@expected_command.inspect}" if @expected_command
66
+ message
67
+ end
68
+
69
+ private
70
+
71
+ def normalize_command(command)
72
+ raise ArgumentError, 'expected MongoDB command must be a non-empty Hash' unless command.is_a?(Hash) && !command.empty?
73
+
74
+ normalize_document(command, top_level: true)
75
+ end
76
+
77
+ def normalize_document(document, top_level: false)
78
+ document.each_with_object({}) do |(key, value), normalized|
79
+ normalized_key = normalize_key(key)
80
+ next if top_level && DRIVER_METADATA_KEYS.include?(normalized_key)
81
+ raise ArgumentError, "duplicate MongoDB command key after normalization: #{normalized_key.inspect}" if normalized.key?(normalized_key)
82
+
83
+ normalized[normalized_key] = normalize_value(value)
84
+ end
85
+ end
86
+
87
+ def normalize_key(key)
88
+ return key if key.is_a?(String)
89
+ return key.to_s if key.is_a?(Symbol)
90
+
91
+ raise ArgumentError, "MongoDB command keys must be Strings or Symbols, got #{key.class}"
92
+ end
93
+
94
+ def normalize_value(value)
95
+ case value
96
+ when Hash then normalize_document(value)
97
+ when Array then value.map { |item| normalize_value(item) }
98
+ else value
99
+ end
100
+ end
101
+
102
+ def mongoid_clients
103
+ Mongoid::Clients.clients.values
104
+ end
105
+
106
+ class CommandSubscriber
107
+ def initialize(thread, expected_command, normalizer, &on_command)
108
+ @thread = thread
109
+ @expected_command = expected_command
110
+ @normalizer = normalizer
111
+ @on_command = on_command
112
+ end
113
+
114
+ def started(event)
115
+ return unless Thread.current.equal?(@thread)
116
+
117
+ command = @normalizer.call(event.command)
118
+ @on_command.call if @expected_command.nil? || command == @expected_command
119
+ end
120
+
121
+ def succeeded(_event); end
122
+
123
+ def failed(_event); end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mongoid
2
4
  module Matchers
3
5
  class HaveField # :nodoc:
@@ -10,6 +12,11 @@ module Mongoid
10
12
  self
11
13
  end
12
14
 
15
+ def readonly
16
+ @readonly = true
17
+ self
18
+ end
19
+
13
20
  def of_type(type)
14
21
  @type = type
15
22
  self
@@ -30,7 +37,7 @@ module Mongoid
30
37
  @errors = []
31
38
  @attributes.each do |attr|
32
39
  if @klass.fields.include?(attr)
33
- error = ''
40
+ error = +''
34
41
  if @type && (@klass.fields[attr].type != @type)
35
42
  error << " of type #{@klass.fields[attr].type}"
36
43
  end
@@ -47,7 +54,11 @@ module Mongoid
47
54
  error << " with alias #{@klass.fields[attr].options[:as]}"
48
55
  end
49
56
 
50
- @errors.push("field #{attr.inspect}" << error) unless error.blank?
57
+ if @readonly && !@klass.readonly_attributes.include?(attr)
58
+ error << ' that is not readonly'
59
+ end
60
+
61
+ @errors.push("field #{attr.inspect}#{error}") unless error.blank?
51
62
 
52
63
  if @localized
53
64
  unless @klass.fields[attr].localized?
@@ -74,10 +85,11 @@ module Mongoid
74
85
  alias failure_message_when_negated failure_message_for_should_not
75
86
 
76
87
  def description
77
- desc = "have #{@attributes.size > 1 ? 'fields' : 'field'} named #{@attributes.collect(&:inspect).to_sentence}"
88
+ desc = +"have #{@attributes.size > 1 ? 'fields' : 'field'} named #{@attributes.collect(&:inspect).to_sentence}"
78
89
  desc << " of type #{@type.inspect}" if @type
79
90
  desc << " with alias #{@field_alias}" if @field_alias
80
91
  desc << " with default value of #{@default.inspect}" unless @default.nil?
92
+ desc << ' marked readonly' if @readonly
81
93
  desc
82
94
  end
83
95
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mongoid
2
4
  module Matchers
3
5
  def have_timestamps
@@ -6,7 +8,7 @@ module Mongoid
6
8
 
7
9
  class HaveTimestamps
8
10
  def initialize
9
- @root_module = 'Mongoid::Timestamps'
11
+ @root_module = +'Mongoid::Timestamps'
10
12
  end
11
13
 
12
14
  def matches?(actual)
@@ -15,7 +17,7 @@ module Mongoid
15
17
  end
16
18
 
17
19
  def for(phase)
18
- raise('You\'ve already declared timetamp\'s sub-module via "for" clause') if @submodule
20
+ raise('You\'ve already declared timestamp\'s sub-module via "for" clause') if @submodule
19
21
 
20
22
  case @phase = phase.to_sym
21
23
  when :creating then @submodule = 'Created'
@@ -33,7 +35,7 @@ module Mongoid
33
35
  end
34
36
 
35
37
  def description
36
- desc = 'be a Mongoid document with'
38
+ desc = +'be a Mongoid document with'
37
39
  desc << ' shorted' if @shortened
38
40
  desc << " #{@phase}" if @phase
39
41
  desc << ' timestamps'
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mongoid
2
4
  module Matchers
3
5
  class HaveIndexForBase
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mongoid
2
4
  module Matchers
3
5
  def have_index_for(index_key)
@@ -36,7 +38,7 @@ module Mongoid
36
38
  alias failure_message_when_negated failure_message_for_should_not
37
39
 
38
40
  def description
39
- desc = "have an index for #{index_key.inspect}"
41
+ desc = +"have an index for #{index_key.inspect}"
40
42
  desc << " with options of #{index_options.inspect}" if index_options
41
43
  desc
42
44
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mongoid
2
4
  module Matchers
3
5
  def have_index_for(index_key)
@@ -15,7 +17,7 @@ module Mongoid
15
17
  end
16
18
 
17
19
  def failure_message
18
- message = "Expected #{model.inspect} to #{description},"
20
+ message = +"Expected #{model.inspect} to #{description},"
19
21
  message << if actual_index.nil?
20
22
  ' found no index'
21
23
  else
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  if Mongoid::Compatibility::Version.mongoid4_or_newer?
2
4
  module Mongoid
3
5
  module Matchers
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mongoid
2
4
  module Matchers
3
5
  module Validations