diff --git a/.rspec b/.rspec
index 4e1e0d2f..83e16f80 100644
--- a/.rspec
+++ b/.rspec
@@ -1 +1,2 @@
--color
+--require spec_helper
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index e3a5568c..e0943d3a 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -1,27 +1,11 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
-# on 2024-04-08 13:44:25 UTC using RuboCop version 1.27.0.
+# on 2024-05-27 18:22:30 UTC using RuboCop version 1.27.0.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.
-# Offense count: 1
-# This cop supports safe auto-correction (--auto-correct).
-# Configuration parameters: TreatCommentsAsGroupSeparators, ConsiderPunctuation, Include.
-# Include: **/*.gemfile, **/Gemfile, **/gems.rb
-Bundler/OrderedGems:
- Exclude:
- - 'Gemfile'
-
-# Offense count: 1
-# This cop supports safe auto-correction (--auto-correct).
-# Configuration parameters: Include.
-# Include: **/*.gemspec
-Gemspec/RequireMFA:
- Exclude:
- - 'meilisearch-rails.gemspec'
-
# Offense count: 1
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyle.
@@ -30,13 +14,12 @@ Layout/EmptyLinesAroundModuleBody:
Exclude:
- 'lib/meilisearch-rails.rb'
-# Offense count: 3
+# Offense count: 1
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyle.
# SupportedStyles: symmetrical, new_line, same_line
Layout/MultilineMethodCallBraceLayout:
Exclude:
- - 'spec/integration_spec.rb'
- 'spec/ms_clean_up_job_spec.rb'
# Offense count: 1
@@ -70,7 +53,7 @@ Lint/UnusedMethodArgument:
# Offense count: 12
# Configuration parameters: IgnoredMethods, CountRepeatedAttributes.
Metrics/AbcSize:
- Max: 104
+ Max: 103
# Offense count: 1
# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods.
@@ -81,7 +64,7 @@ Metrics/BlockLength:
# Offense count: 1
# Configuration parameters: CountComments, CountAsOne.
Metrics/ClassLength:
- Max: 157
+ Max: 169
# Offense count: 8
# Configuration parameters: IgnoredMethods.
@@ -91,12 +74,12 @@ Metrics/CyclomaticComplexity:
# Offense count: 18
# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods.
Metrics/MethodLength:
- Max: 103
+ Max: 102
# Offense count: 1
# Configuration parameters: CountComments, CountAsOne.
Metrics/ModuleLength:
- Max: 449
+ Max: 437
# Offense count: 8
# Configuration parameters: IgnoredMethods.
@@ -123,35 +106,54 @@ Naming/MethodParameterName:
Exclude:
- 'lib/meilisearch-rails.rb'
-# Offense count: 20
+# Offense count: 5
RSpec/BeforeAfterAll:
Exclude:
- - 'spec/integration_spec.rb'
+ - 'spec/integration/active_record/record_has_associations_spec.rb'
+ - 'spec/pagination/kaminari_spec.rb'
+ - 'spec/pagination/will_paginate_spec.rb'
+ - 'spec/settings_spec.rb'
+ - 'spec/system/tech_shop_spec.rb'
+
+# Offense count: 2
+# Configuration parameters: Prefixes.
+# Prefixes: when, with, without
+RSpec/ContextWording:
+ Exclude:
+ - 'spec/options_spec.rb'
+ - 'spec/system/tech_shop_spec.rb'
-# Offense count: 56
+# Offense count: 54
# Configuration parameters: CountAsOne.
RSpec/ExampleLength:
- Max: 19
+ Max: 16
-# Offense count: 3
+# Offense count: 6
# Configuration parameters: Include, CustomTransform, IgnoreMethods, SpecSuffixOnly.
# Include: **/*_spec*rb*, **/spec/**/*
RSpec/FilePath:
Exclude:
- 'spec/configuration_spec.rb'
+ - 'spec/integration/active_record/meilisearch_calls_are_deactivated.rb'
+ - 'spec/meilisearch/activation_spec.rb'
+ - 'spec/safe_index_spec.rb'
- 'spec/settings_spec.rb'
- 'spec/utilities_spec.rb'
-# Offense count: 25
+# Offense count: 29
# Configuration parameters: AssignmentOnly.
RSpec/InstanceVariable:
Exclude:
- - 'spec/integration_spec.rb'
+ - 'spec/system/tech_shop_spec.rb'
# Offense count: 1
RSpec/MultipleDescribes:
Exclude:
- - 'spec/integration_spec.rb'
+ - 'spec/search_spec.rb'
+
+# Offense count: 2
+RSpec/NestedGroups:
+ Max: 4
# Offense count: 1
# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames.
@@ -159,11 +161,6 @@ RSpec/VerifiedDoubles:
Exclude:
- 'spec/configuration_spec.rb'
-# Offense count: 1
-Security/MarshalLoad:
- Exclude:
- - 'spec/integration_spec.rb'
-
# Offense count: 2
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyle.
@@ -172,13 +169,13 @@ Style/Alias:
Exclude:
- 'lib/meilisearch-rails.rb'
-# Offense count: 2
+# Offense count: 3
# Configuration parameters: MinBodyLength.
Style/GuardClause:
Exclude:
- 'lib/meilisearch-rails.rb'
-# Offense count: 9
+# Offense count: 8
# This cop supports safe auto-correction (--auto-correct).
Style/IfUnlessModifier:
Exclude:
@@ -212,26 +209,17 @@ Style/OptionalBooleanParameter:
Exclude:
- 'lib/meilisearch-rails.rb'
-# Offense count: 11
+# Offense count: 5
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline.
# SupportedStyles: single_quotes, double_quotes
Style/StringLiterals:
Exclude:
- - 'spec/integration_spec.rb'
- 'spec/ms_clean_up_job_spec.rb'
-# Offense count: 2
-# This cop supports safe auto-correction (--auto-correct).
-# Configuration parameters: EnforcedStyleForMultiline.
-# SupportedStylesForMultiline: comma, consistent_comma, no_comma
-Style/TrailingCommaInArguments:
- Exclude:
- - 'spec/integration_spec.rb'
-
-# Offense count: 20
+# Offense count: 16
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns.
# URISchemes: http, https
Layout/LineLength:
- Max: 178
+ Max: 170
diff --git a/lib/meilisearch-rails.rb b/lib/meilisearch-rails.rb
index 622843d0..b2577a67 100644
--- a/lib/meilisearch-rails.rb
+++ b/lib/meilisearch-rails.rb
@@ -84,6 +84,20 @@ class IndexSettings
def initialize(options, &block)
@options = options
instance_exec(&block) if block_given?
+ warn_searchable_missing_attributes
+ end
+
+ def warn_searchable_missing_attributes
+ searchables = get_setting(:searchable_attributes)
+ attrs = get_setting(:attributes)&.keys
+
+ if searchables.present? && attrs.present?
+ (searchables.map(&:to_s) - attrs.map(&:to_s)).each do |missing_searchable|
+ MeiliSearch::Rails.logger.warn(
+ "[meilisearch-rails] #{missing_searchable} declared in searchable_attributes but not in attributes. Please add it to attributes if it should be searchable."
+ )
+ end
+ end
end
def use_serializer(serializer)
@@ -464,8 +478,6 @@ def meilisearch(options = {}, &block)
after_destroy_commit { |searchable| searchable.ms_enqueue_remove_from_index!(ms_synchronous?) }
end
end
-
- warn_searchable_missing_attributes
end
def ms_without_auto_index(&block)
@@ -901,19 +913,6 @@ def ms_attribute_changed?(document, attr_name)
# We don't know if the attribute has changed, so conservatively assume it has
true
end
-
- def warn_searchable_missing_attributes
- searchables = meilisearch_settings.get_setting(:searchable_attributes)
- attrs = meilisearch_settings.get_setting(:attributes)&.keys
-
- if searchables.present? && attrs.present?
- (searchables.map(&:to_s) - attrs.map(&:to_s)).each do |missing_searchable|
- MeiliSearch::Rails.logger.warn(
- "[meilisearch-rails] #{name}##{missing_searchable} declared in searchable_attributes but not in attributes. Please add it to attributes if it should be searchable."
- )
- end
- end
- end
end
# these are the instance methods included
diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb
index ebc7e8db..938c637d 100644
--- a/spec/configuration_spec.rb
+++ b/spec/configuration_spec.rb
@@ -1,4 +1,6 @@
require 'spec_helper'
+require 'support/models/book'
+require 'support/models/color'
describe MeiliSearch::Rails::Configuration do
before { stub_const('MeiliSearch::Rails::VERSION', '0.0.1') }
@@ -63,6 +65,20 @@
end
end
+ context 'with per_environment' do
+ # per_environment is already enabled in testing
+ # no setup is required
+
+ it 'adds a Rails env-based index suffix' do
+ expect(Color.index_uid).to eq(safe_index_uid('Color') + "_#{Rails.env}")
+ end
+
+ it 'uses suffix in the additional index as well' do
+ index = Book.index(safe_index_uid('Book'))
+ expect(index.uid).to eq("#{safe_index_uid('Book')}_#{Rails.env}")
+ end
+ end
+
context 'when use Meilisearch without configuration' do
around do |example|
config = MeiliSearch::Rails.configuration
diff --git a/spec/instance_methods_spec.rb b/spec/instance_methods_spec.rb
new file mode 100644
index 00000000..b24c93e7
--- /dev/null
+++ b/spec/instance_methods_spec.rb
@@ -0,0 +1,110 @@
+require 'support/models/book'
+require 'support/models/animals'
+require 'support/models/people'
+require 'support/models/movie'
+require 'support/models/queued_models'
+
+describe 'Instance methods' do
+ describe '#ms_entries' do
+ it 'includes conditionally enabled indexes' do
+ book = Book.create!(
+ name: 'Frankenstein', author: 'Mary Shelley',
+ premium: false, released: true
+ )
+
+ expect(book.ms_entries).to contain_exactly(
+ a_hash_including('index_uid' => /SecuredBook/),
+ a_hash_including('index_uid' => /BookAuthor/),
+ a_hash_including('index_uid' => /Book/)
+ )
+ end
+
+ it 'includes conditionally disabled indexes' do
+ # non public book
+ book = Book.create!(
+ name: 'Frankenstein', author: 'Mary Shelley',
+ premium: false, released: false
+ )
+
+ expect(book.ms_entries).to contain_exactly(
+ a_hash_including('index_uid' => /SecuredBook/),
+ a_hash_including('index_uid' => /BookAuthor/),
+ # also includes book's id as if it was a public book
+ a_hash_including('index_uid' => /Book/)
+ )
+ end
+
+ context 'when models share an index' do
+ it 'does not return instances of other models' do
+ TestUtil.reset_animals!
+
+ toby_dog = Dog.create!(name: 'Toby the Dog')
+ taby_cat = Cat.create!(name: 'Taby the Cat')
+
+ expect(toby_dog.ms_entries).to contain_exactly(
+ a_hash_including('primary_key' => /dog_\d+/)
+ )
+
+ expect(taby_cat.ms_entries).to contain_exactly(
+ a_hash_including('primary_key' => /cat_\d+/)
+ )
+ end
+ end
+ end
+
+ describe '#ms_index!' do
+ it 'returns array with single task with single index' do
+ TestUtil.reset_movies!
+
+ task = Movie.create(title: 'Harry Potter').ms_index!
+
+ expect(task).to contain_exactly(a_hash_including('taskUid'))
+ end
+
+ it 'returns array of tasks with multiple indexes' do
+ TestUtil.reset_books!
+
+ moby_dick = Book.create! name: 'Moby Dick', author: 'Herman Melville', premium: false, released: true
+
+ tasks = moby_dick.ms_index!
+
+ expect(tasks).to contain_exactly(
+ a_hash_including('uid'),
+ a_hash_including('taskUid'),
+ a_hash_including('taskUid')
+ )
+ end
+
+ it 'throws error on non-persisted instances' do
+ expect { Color.new(name: 'purple').index!(true) }.to raise_error(ArgumentError)
+ end
+
+ it 'returns empty array when indexing is disabled' do
+ doc = DisabledEnqueuedDocument.create! name: 'test'
+
+ expect(doc.ms_index!).to be_empty
+ end
+ end
+
+ describe '#ms_remove_from_index!' do
+ it 'throws error on non-persisted instances' do
+ expect { Color.new(name: 'purple').remove_from_index!(true) }.to raise_error(ArgumentError)
+ end
+
+ context 'when :auto_remove is disabled' do
+ it 'is able to remove manually' do
+ TestUtil.reset_people!
+
+ bob = People.create(first_name: 'Bob', last_name: 'Sponge', card_number: 75_801_889)
+
+ result = People.raw_search('Bob')
+ expect(result['hits']).to be_one
+
+ bob.remove_from_index!
+
+ result = People.raw_search('Bob')
+ expect(result['hits']).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/integration/active_record/attributes_have_changed_spec.rb b/spec/integration/active_record/attributes_have_changed_spec.rb
new file mode 100644
index 00000000..f1be37b6
--- /dev/null
+++ b/spec/integration/active_record/attributes_have_changed_spec.rb
@@ -0,0 +1,67 @@
+require 'support/models/color'
+require 'support/models/book'
+
+describe 'When record attributes have changed' do
+ it 'detects attribute changes' do
+ color = Color.new name: 'dark-blue', short_name: 'blue'
+
+ expect(Color.ms_must_reindex?(color)).to be(true)
+ color.save
+ expect(Color.ms_must_reindex?(color)).to be(false)
+
+ color.hex = 123_456
+ expect(Color.ms_must_reindex?(color)).to be(false)
+
+ color.not_indexed = 'strstr'
+ expect(Color.ms_must_reindex?(color)).to be(false)
+ color.name = 'red'
+ expect(Color.ms_must_reindex?(color)).to be(true)
+ color.delete
+ end
+
+ it 'detects attribute changes even in a transaction' do
+ color = Color.new name: 'dark-blue', short_name: 'blue'
+ color.save
+ expect(color.instance_variable_get('@ms_must_reindex')).to be_nil
+ Color.transaction do
+ color.name = 'red'
+ color.save
+ color.not_indexed = 'strstr'
+ color.save
+ expect(color.instance_variable_get('@ms_must_reindex')).to be(true)
+ end
+ expect(color.instance_variable_get('@ms_must_reindex')).to be_nil
+ color.delete
+ end
+
+ it 'detects change with ms_dirty? method' do
+ book = Book.new name: 'My life', author: 'Myself', premium: false, released: true
+
+ allow(book).to receive(:ms_dirty?).and_return(true)
+ expect(Book.ms_must_reindex?(book)).to be(true)
+
+ allow(book).to receive(:ms_dirty?).and_return(false)
+ expect(Book.ms_must_reindex?(book)).to be(false)
+
+ allow(book).to receive(:ms_dirty?).and_return(true)
+ expect(Book.ms_must_reindex?(book)).to be(true)
+ end
+
+ it 'always updates when there is no custom _changed? function' do
+ m = Namespaced::Model.new(another_private_value: 2)
+ m.save
+ results = Namespaced::Model.search('42')
+ expect(results.size).to eq(1)
+ expect(results[0].id).to eq(m.id)
+
+ m.another_private_value = 5
+ m.save
+
+ results = Namespaced::Model.search('42')
+ expect(results.size).to eq(0)
+
+ results = Namespaced::Model.search('45')
+ expect(results.size).to eq(1)
+ expect(results[0].id).to eq(m.id)
+ end
+end
diff --git a/spec/integration/active_record/meilisearch_calls_are_deactivated.rb b/spec/integration/active_record/meilisearch_calls_are_deactivated.rb
new file mode 100644
index 00000000..888ca4c5
--- /dev/null
+++ b/spec/integration/active_record/meilisearch_calls_are_deactivated.rb
@@ -0,0 +1,29 @@
+require 'support/models/task'
+
+describe 'When meilisearch calls are disabled' do
+ it 'does not send requests to meilisearch' do
+ MeiliSearch::Rails.deactivate!
+
+ expect do
+ Task.create(title: 'my task #1')
+ Task.search('task')
+ end.not_to raise_error
+
+ MeiliSearch::Rails.activate!
+ end
+
+ context 'with a block' do
+ it 'does not interfere with prior requests' do
+ Task.destroy_all
+ Task.create!(title: 'deactivated #1')
+
+ MeiliSearch::Rails.deactivate! do
+ # always 0 since the black hole will return the default values
+ expect(Task.search('deactivated').size).to eq(0)
+ end
+
+ expect(MeiliSearch::Rails).to be_active
+ expect(Task.search('#1').size).to eq(1)
+ end
+ end
+end
diff --git a/spec/integration/active_record/model_is_namespaced_spec.rb b/spec/integration/active_record/model_is_namespaced_spec.rb
new file mode 100644
index 00000000..0cadb4f2
--- /dev/null
+++ b/spec/integration/active_record/model_is_namespaced_spec.rb
@@ -0,0 +1,7 @@
+require 'support/models/specialty_models'
+
+describe 'When a record has associations' do
+ it 'has an index name without :: hierarchy' do
+ expect(Namespaced::Model.index_uid.include?('Namespaced_Model')).to be(true)
+ end
+end
diff --git a/spec/integration/active_record/ms_rails_is_included_without_block_spec.rb b/spec/integration/active_record/ms_rails_is_included_without_block_spec.rb
new file mode 100644
index 00000000..691f998b
--- /dev/null
+++ b/spec/integration/active_record/ms_rails_is_included_without_block_spec.rb
@@ -0,0 +1,9 @@
+require 'support/models/specialty_models'
+
+describe 'When MeiliSearch::Rails is included but not called' do
+ it 'raises an error' do
+ expect do
+ MisconfiguredBlock.reindex!
+ end.to raise_error(ArgumentError)
+ end
+end
diff --git a/spec/integration/active_record/record_has_associations_spec.rb b/spec/integration/active_record/record_has_associations_spec.rb
new file mode 100644
index 00000000..3da2fdf6
--- /dev/null
+++ b/spec/integration/active_record/record_has_associations_spec.rb
@@ -0,0 +1,23 @@
+require 'support/models/post'
+
+describe 'When a record has associations' do
+ before(:all) do
+ Post.clear_index!(true)
+ end
+
+ it 'eagerly loads associations' do
+ post1 = Post.new(title: 'foo')
+ post1.comments << Comment.new(body: 'one')
+ post1.comments << Comment.new(body: 'two')
+ post1.save!
+
+ post2 = Post.new(title: 'bar')
+ post2.comments << Comment.new(body: 'three')
+ post2.comments << Comment.new(body: 'four')
+ post2.save!
+
+ assert_queries(2) do
+ Post.reindex!
+ end
+ end
+end
diff --git a/spec/integration/active_record/record_is_updated_spec.rb b/spec/integration/active_record/record_is_updated_spec.rb
new file mode 100644
index 00000000..ffbed0bc
--- /dev/null
+++ b/spec/integration/active_record/record_is_updated_spec.rb
@@ -0,0 +1,45 @@
+require 'support/models/book'
+require 'support/models/color'
+
+describe 'When record is updated' do
+ it 'updates the changed attributes on the index' do
+ purple = Color.create!(name: 'purple', short_name: 'p')
+ expect(Color.search('purple')).to be_one
+ expect(Color.search('pink')).to be_empty
+
+ purple.update name: 'pink'
+ expect(Color.search('purple')).to be_empty
+ expect(Color.search('pink')).to be_one
+ end
+
+ it 'automatically removes document from conditional indexes' do
+ TestUtil.reset_books!
+
+ # add a new public book which is public (not premium but released)
+ book = Book.create! name: 'Public book', author: 'me', premium: false, released: true
+
+ # should be searchable in the 'Book' index
+ index = Book.index(safe_index_uid('Book'))
+ results = index.search('Public book')
+ expect(results['hits']).to be_one
+
+ # update the book and make it non-public anymore (not premium, not released)
+ book.update released: false
+
+ # should be removed from the index
+ results = index.search('Public book')
+ expect(results['hits']).to be_empty
+ end
+
+ context 'when attributes have not changed' do
+ it 'does not call the API' do
+ TestUtil.reset_people!
+
+ jane = People.create(first_name: 'Jane', last_name: 'Doe', card_number: 75_801_887)
+
+ expect do
+ jane.update(first_name: 'Jane')
+ end.not_to change(People.index.tasks['results'], :count)
+ end
+ end
+end
diff --git a/spec/integration_spec.rb b/spec/integration_spec.rb
deleted file mode 100644
index 0cfa8af9..00000000
--- a/spec/integration_spec.rb
+++ /dev/null
@@ -1,1273 +0,0 @@
-require 'spec_helper'
-
-if defined?(ActiveModel::Serializer)
- describe 'SerializedDocument' do
- before(:all) do
- SerializedDocument.clear_index!(true)
- end
-
- it 'pushes the name but not the other attribute' do
- o = SerializedDocument.new name: 'test', skip: 'skip me'
- attributes = SerializedDocument.meilisearch_settings.get_attributes(o)
- expect(attributes).to eq({ name: 'test' })
- end
- end
-end
-describe 'Encoding' do
- before(:all) do
- EncodedString.clear_index!(true)
- end
-
- it 'converts to utf-8' do
- EncodedString.create!
- results = EncodedString.raw_search ''
- expect(results['hits'].size).to eq(1)
- expect(results['hits'].first['value']).to eq("\xC2\xA0\xE2\x80\xA2\xC2\xA0".force_encoding('utf-8'))
- end
-end
-
-describe 'Settings change detection' do
- it 'detects settings changes' do
- expect(Color.send(:meilisearch_settings_changed?, nil, {})).to be(true)
- expect(Color.send(:meilisearch_settings_changed?, {}, { 'searchable_attributes' => ['name'] })).to be(true)
- expect(Color.send(:meilisearch_settings_changed?, { 'searchable_attributes' => ['name'] },
- { 'searchable_attributes' => %w[name hex] })).to be(true)
- expect(Color.send(:meilisearch_settings_changed?, { 'searchable_attributes' => ['name'] },
- { 'ranking_rules' => ['words', 'typo', 'proximity', 'attribute', 'sort', 'exactness', 'hex:asc'] })).to be(true)
- end
-
- it 'does not detect settings changes' do
- expect(Color.send(:meilisearch_settings_changed?, {}, {})).to be(false)
- expect(Color.send(:meilisearch_settings_changed?, { 'searchableAttributes' => ['name'] },
- { searchable_attributes: ['name'] })).to be(false)
- expect(Color.send(:meilisearch_settings_changed?,
- { 'searchableAttributes' => ['name'], 'rankingRules' => ['words', 'typo', 'proximity', 'attribute', 'sort', 'exactness', 'hex:asc'] },
- { 'ranking_rules' => ['words', 'typo', 'proximity', 'attribute', 'sort', 'exactness', 'hex:asc'] })).to be(false)
- end
-end
-
-describe 'Attributes change detection' do
- it 'detects attribute changes' do
- color = Color.new name: 'dark-blue', short_name: 'blue'
-
- expect(Color.ms_must_reindex?(color)).to be(true)
- color.save
- expect(Color.ms_must_reindex?(color)).to be(false)
-
- color.hex = 123_456
- expect(Color.ms_must_reindex?(color)).to be(false)
-
- color.not_indexed = 'strstr'
- expect(Color.ms_must_reindex?(color)).to be(false)
- color.name = 'red'
- expect(Color.ms_must_reindex?(color)).to be(true)
- color.delete
- end
-
- it 'detects attribute changes even in a transaction' do
- color = Color.new name: 'dark-blue', short_name: 'blue'
- color.save
- expect(color.instance_variable_get('@ms_must_reindex')).to be_nil
- Color.transaction do
- color.name = 'red'
- color.save
- color.not_indexed = 'strstr'
- color.save
- expect(color.instance_variable_get('@ms_must_reindex')).to be(true)
- end
- expect(color.instance_variable_get('@ms_must_reindex')).to be_nil
- color.delete
- end
-
- it 'detects change with ms_dirty? method' do
- ebook = Ebook.new name: 'My life', author: 'Myself', premium: false, released: true
- expect(Ebook.ms_must_reindex?(ebook)).to be(true) # Because it's defined in ms_dirty? method
- ebook.current_time = 10
- ebook.published_at = 8
- expect(Ebook.ms_must_reindex?(ebook)).to be(true)
- ebook.published_at = 12
- expect(Ebook.ms_must_reindex?(ebook)).to be(false)
- end
-end
-
-describe 'Namespaced::Model' do
- before(:all) do
- Namespaced::Model.index.delete_all_documents!
- end
-
- it 'has an index name without :: hierarchy' do
- expect(Namespaced::Model.index_uid.include?('Namespaced_Model')).to be(true)
- end
-
- it 'uses the block to determine attribute\'s value' do
- m = Namespaced::Model.new(another_private_value: 2)
- attributes = Namespaced::Model.meilisearch_settings.get_attributes(m)
- expect(attributes['customAttr']).to eq(42)
- expect(attributes['myid']).to eq(m.id)
- end
-
- it 'always updates when there is no custom _changed? function' do
- m = Namespaced::Model.new(another_private_value: 2)
- m.save
- results = Namespaced::Model.search('42')
- expect(results.size).to eq(1)
- expect(results[0].id).to eq(m.id)
-
- m.another_private_value = 5
- m.save
-
- results = Namespaced::Model.search('42')
- expect(results.size).to eq(0)
-
- results = Namespaced::Model.search('45')
- expect(results.size).to eq(1)
- expect(results[0].id).to eq(m.id)
- end
-end
-
-describe 'NestedItem' do
- before(:all) do
- NestedItem.clear_index!(true)
- rescue StandardError
- # not fatal
- end
-
- it 'fetches attributes unscoped' do
- i1 = NestedItem.create hidden: false
- i2 = NestedItem.create hidden: true
-
- i1.children << NestedItem.create(hidden: true) << NestedItem.create(hidden: true)
- NestedItem.where(id: [i1.id, i2.id]).reindex!(MeiliSearch::Rails::IndexSettings::DEFAULT_BATCH_SIZE, true)
-
- result = NestedItem.index.get_document(i1.id)
- expect(result['nb_children']).to eq(2)
-
- result = NestedItem.raw_search('')
- expect(result['hits'].size).to eq(1)
-
- if i2.respond_to? :update_attributes
- i2.update_attributes hidden: false # rubocop:disable Rails/ActiveRecordAliases
- else
- i2.update hidden: false
- end
-
- result = NestedItem.raw_search('')
- expect(result['hits'].size).to eq(2)
- end
-end
-
-describe 'Posts' do
- before(:all) do
- Post.clear_index!(true)
- end
-
- it 'eagerly loads associations' do
- post1 = Post.new(title: 'foo')
- post1.comments << Comment.new(body: 'one')
- post1.comments << Comment.new(body: 'two')
- post1.save!
-
- post2 = Post.new(title: 'bar')
- post2.comments << Comment.new(body: 'three')
- post2.comments << Comment.new(body: 'four')
- post2.save!
-
- assert_queries(2) do
- Post.reindex!
- end
- end
-end
-
-describe 'Colors' do
- before do
- Color.clear_index!(true)
- Color.delete_all
- end
-
- it 'is synchronous' do
- c = Color.new
- c.valid?
- expect(c.send(:ms_synchronous?)).to be(true)
- end
-
- it 'auto indexes' do
- blue = Color.create!(name: 'blue', short_name: 'b', hex: 0xFF0000)
- results = Color.search('blue')
- expect(results.size).to eq(1)
- expect(results).to include(blue)
- end
-
- it 'returns facets distribution' do
- Color.create!(name: 'blue', short_name: 'b', hex: 0xFF0000)
- results = Color.search('', { facets: ['short_name'] })
- expect(results.raw_answer).not_to be_nil
- expect(results.facets_distribution).not_to be_nil
- expect(results.facets_distribution.size).to eq(1)
- expect(results.facets_distribution['short_name']['b']).to eq(1)
- end
-
- it 'is raw searchable' do
- Color.create!(name: 'blue', short_name: 'b', hex: 0xFF0000)
- results = Color.raw_search('blue')
- expect(results['hits'].size).to eq(1)
- expect(results['estimatedTotalHits']).to eq(1)
- end
-
- it 'is able to temporarily disable auto-indexing' do
- Color.without_auto_index do
- Color.create!(name: 'blue', short_name: 'b', hex: 0xFF0000)
- end
- expect(Color.search('blue').size).to eq(0)
- Color.reindex!(MeiliSearch::Rails::IndexSettings::DEFAULT_BATCH_SIZE, true)
- expect(Color.search('blue').size).to eq(1)
- end
-
- it 'is not searchable with non-searchable fields' do
- Color.create!(name: 'blue', short_name: 'x', hex: 0xFF0000)
- results = Color.search('x')
- expect(results.size).to eq(0)
- end
-
- it 'ranks with custom hex' do
- Color.create!(name: 'red', short_name: 'r3', hex: 3)
- Color.create!(name: 'red', short_name: 'r1', hex: 1)
- Color.create!(name: 'red', short_name: 'r2', hex: 2)
- results = Color.search('red')
- expect(results.size).to eq(3)
- expect(results[0].hex).to eq(1)
- expect(results[1].hex).to eq(2)
- expect(results[2].hex).to eq(3)
- end
-
- it 'updates the index if the attribute changed' do
- purple = Color.create!(name: 'purple', short_name: 'p')
- expect(Color.search('purple').size).to eq(1)
- expect(Color.search('pink').size).to eq(0)
- purple.name = 'pink'
- purple.save
- expect(Color.search('purple').size).to eq(0)
- expect(Color.search('pink').size).to eq(1)
- end
-
- it 'uses the specified scope' do
- Color.create!(name: 'red', short_name: 'r3', hex: 3)
- Color.create!(name: 'red', short_name: 'r1', hex: 1)
- Color.create!(name: 'red', short_name: 'r2', hex: 2)
- Color.create!(name: 'purple', short_name: 'p')
- Color.clear_index!(true)
- Color.where(name: 'red').reindex!(MeiliSearch::Rails::IndexSettings::DEFAULT_BATCH_SIZE, true)
- expect(Color.search('').size).to eq(3)
- Color.clear_index!(true)
- Color.where(id: Color.first.id).reindex!(MeiliSearch::Rails::IndexSettings::DEFAULT_BATCH_SIZE, true)
- expect(Color.search('').size).to eq(1)
- end
-
- it 'has a Rails env-based index name' do
- expect(Color.index_uid).to eq(safe_index_uid('Color') + "_#{Rails.env}")
- end
-
- it 'includes _formatted object' do
- Color.create!(name: 'green', short_name: 'b', hex: 0xFF0000)
- results = Color.search('gre')
- expect(results.size).to eq(1)
- expect(results[0].formatted).not_to be_nil
- end
-
- it 'indexes an array of documents' do
- json = Color.raw_search('')
- Color.index_documents Color.limit(1), true # reindex last color, `limit` is incompatible with the reindex! method
- expect(json['hits'].count).to eq(Color.raw_search('')['hits'].count)
- end
-
- it 'does not index non-saved document' do
- expect { Color.new(name: 'purple').index!(true) }.to raise_error(ArgumentError)
- expect { Color.new(name: 'purple').remove_from_index!(true) }.to raise_error(ArgumentError)
- end
-
- it 'searches with filter' do
- Color.create!(name: 'blue', short_name: 'blu', hex: 0x0000FF)
- black = Color.create!(name: 'black', short_name: 'bla', hex: 0x000000)
- Color.create!(name: 'green', short_name: 'gre', hex: 0x00FF00)
- facets = Color.search('bl', { filter: ['short_name = bla'] })
- expect(facets.size).to eq(1)
- expect(facets).to include(black)
- end
-
- it 'searches with sorting' do
- Color.delete_all
-
- blue = Color.create!(name: 'blue', short_name: 'blu', hex: 0x0000FF)
- black = Color.create!(name: 'black', short_name: 'bla', hex: 0x000000)
- green = Color.create!(name: 'green', short_name: 'gre', hex: 0x00FF00)
-
- facets = Color.search('*', { sort: ['name:asc'] })
-
- expect(facets).to eq([black, blue, green])
- end
-
- it 'has maxValuesPerFacet set' do
- expect(Color.ms_index.get_settings.dig('faceting', 'maxValuesPerFacet')).to eq(20)
- end
-end
-
-describe 'An imaginary store' do
- before(:all) do
- Product.clear_index!(true)
-
- # Google products
- @blackberry = Product.create!(name: 'blackberry', href: 'google', tags: ['decent', 'businessmen love it'])
- @nokia = Product.create!(name: 'nokia', href: 'google', tags: ['decent'])
-
- # Amazon products
- @android = Product.create!(name: 'android', href: 'amazon', tags: ['awesome'])
- @samsung = Product.create!(name: 'samsung', href: 'amazon', tags: ['decent'])
- @motorola = Product.create!(name: 'motorola', href: 'amazon', tags: ['decent'],
- description: 'Not sure about features since I\'ve never owned one.')
-
- # Ebay products
- @palmpre = Product.create!(name: 'palmpre', href: 'ebay', tags: ['discontinued', 'worst phone ever'])
- @palm_pixi_plus = Product.create!(name: 'palm pixi plus', href: 'ebay', tags: ['terrible'])
- @lg_vortex = Product.create!(name: 'lg vortex', href: 'ebay', tags: ['decent'])
- @t_mobile = Product.create!(name: 't mobile', href: 'ebay', tags: ['terrible'])
-
- # Yahoo products
- @htc = Product.create!(name: 'htc', href: 'yahoo', tags: ['decent'])
- @htc_evo = Product.create!(name: 'htc evo', href: 'yahoo', tags: ['decent'])
- @ericson = Product.create!(name: 'ericson', href: 'yahoo', tags: ['decent'])
-
- # Apple products
- @iphone = Product.create!(name: 'iphone', href: 'apple', tags: ['awesome', 'poor reception'],
- description: 'Puts even more features at your fingertips')
- @macbook = Product.create!(name: 'macbookpro', href: 'apple')
-
- # Unindexed products
- @sekrit = Product.create!(name: 'super sekrit', href: 'amazon', release_date: Time.now + 1.day)
- @no_href = Product.create!(name: 'super sekrit too; missing href')
-
- # Subproducts
- @camera = Camera.create!(name: 'canon eos rebel t3', href: 'canon')
-
- 100.times { Product.create!(name: 'crapoola', href: 'crappy', tags: ['crappy']) }
-
- @products_in_database = Product.all
-
- Product.reindex!(MeiliSearch::Rails::IndexSettings::DEFAULT_BATCH_SIZE, true)
- end
-
- it 'is not synchronous' do
- p = Product.new
- p.valid?
-
- expect(p).not_to be_ms_synchronous
- end
-
- it 'is able to reindex manually' do
- results_before_clearing = Product.raw_search('')
- expect(results_before_clearing['hits'].size).not_to be(0)
- Product.clear_index!(true)
- results = Product.raw_search('')
- expect(results['hits'].size).to be(0)
- Product.reindex!(MeiliSearch::Rails::IndexSettings::DEFAULT_BATCH_SIZE, true)
- results_after_reindexing = Product.raw_search('')
- expect(results_after_reindexing['hits'].size).not_to be(0)
- expect(results_before_clearing['hits'].size).to be(results_after_reindexing['hits'].size)
- end
-
- describe 'basic searching' do
- it 'finds the iphone' do
- results = Product.search('iphone')
- expect(results.size).to eq(1)
- expect(results).to include(@iphone)
- end
-
- it 'searches case insensitively' do
- results = Product.search('IPHONE')
- expect(results.size).to eq(1)
- expect(results).to include(@iphone)
- end
-
- it 'finds all amazon products' do
- results = Product.search('amazon')
- expect(results.size).to eq(3)
- expect(results).to include(@android, @samsung, @motorola)
- end
-
- it 'finds all "palm" phones with wildcard word search' do
- results = Product.search('pal')
- expect(results.size).to eq(2)
- expect(results).to include(@palmpre, @palm_pixi_plus)
- end
-
- it 'searches multiple words from the same field' do
- results = Product.search('palm pixi plus')
- expect(results.size).to eq(1)
- expect(results).to include(@palm_pixi_plus)
- end
-
- it 'finds using phrase search' do
- results = Product.search('coco "palm"')
- expect(results.size).to eq(1)
- expect(results).to include(@palm_pixi_plus)
- end
-
- it 'narrows the results by searching across multiple fields' do
- results = Product.search('apple iphone')
- expect(results.size).to eq(2)
- expect(results).to include(@iphone)
- end
-
- it 'does not search on non-indexed fields' do
- results = Product.search('features')
- expect(results.size).to eq(0)
- end
-
- it 'deletes the associated record' do
- ipad = Product.create!(name: 'ipad', href: 'apple', tags: ['awesome', 'great battery'],
- description: 'Big screen')
-
- ipad.index!(true)
- results = Product.search('ipad')
- expect(results.size).to eq(1)
-
- ipad.destroy
- results = Product.search('ipad')
- expect(results.size).to eq(0)
- end
-
- context 'when a document cannot be found in ActiveRecord' do
- it 'does not throw an exception' do
- Product.index.add_documents!(@palmpre.attributes.merge(id: -1))
- expect { Product.search('pal').to_json }.not_to raise_error
- Product.index.delete_document!(-1)
- end
-
- it 'returns the other results if those are still available locally' do
- Product.index.add_documents!(@palmpre.attributes.merge(id: -1))
- expect(JSON.parse(Product.search('pal').to_json).size).to eq(2)
- Product.index.delete_document!(-1)
- end
- end
-
- it 'does not duplicate an already indexed record' do
- expect(Product.search('nokia').size).to eq(1)
- @nokia.index!
- expect(Product.search('nokia').size).to eq(1)
- @nokia.index!
- @nokia.index!
- expect(Product.search('nokia').size).to eq(1)
- end
-
- it 'does not return products that are not indexable' do
- @sekrit.index!
- @no_href.index!
- results = Product.search('sekrit')
- expect(results.size).to eq(0)
- end
-
- it 'includes items belong to subclasses' do
- @camera.index!
- results = Product.search('eos rebel')
- expect(results.size).to eq(1)
- expect(results).to include(@camera)
- end
-
- it 'deletes a not-anymore-indexable product' do
- results = Product.search('sekrit')
- expect(results.size).to eq(0)
-
- @sekrit.release_date = Time.now - 1.day
- @sekrit.save!
- @sekrit.index!(true)
- results = Product.search('sekrit')
- expect(results.size).to eq(1)
-
- @sekrit.release_date = Time.now + 1.day
- @sekrit.save!
- @sekrit.index!(true)
- results = Product.search('sekrit')
- expect(results.size).to eq(0)
- end
-
- it 'finds using synonyms' do
- expect(Product.search('pomme').size).to eq(Product.search('apple').size)
- expect(Product.search('m_b_p').size).to eq(Product.search('macbookpro').size)
- end
- end
-end
-
-describe 'MongoDocument' do
- it 'does not have method conflicts' do
- expect { MongoDocument.reindex! }.to raise_error(NameError)
- expect { MongoDocument.new.index! }.to raise_error(NameError)
- MongoDocument.ms_reindex!
- MongoDocument.create(name: 'mongo').ms_index!
- end
-end
-
-describe 'Book' do
- before do
- Book.clear_index!(true)
- Book.index(safe_index_uid('BookAuthor')).delete_all_documents
- Book.index(safe_index_uid('Book')).delete_all_documents
- end
-
- it 'returns array of tasks on #ms_index!' do
- moby_dick = Book.create! name: 'Moby Dick', author: 'Herman Melville', premium: false, released: true
-
- tasks = moby_dick.ms_index!
-
- expect(tasks).to contain_exactly(
- a_hash_including('uid'),
- a_hash_including('taskUid'),
- a_hash_including('taskUid')
- )
- end
-
- it 'indexes the book in 2 indexes of 3' do
- steve_jobs = Book.create! name: 'Steve Jobs', author: 'Walter Isaacson', premium: true, released: true
- results = Book.search('steve')
- expect(results.size).to eq(1)
- expect(results).to include(steve_jobs)
-
- index_author = Book.index(safe_index_uid('BookAuthor'))
- expect(index_author).not_to be_nil
- results = index_author.search('steve')
- expect(results['hits'].length).to eq(0)
- results = index_author.search('walter')
- expect(results['hits'].length).to eq(1)
-
- # premium -> not part of the public index
- index_book = Book.index(safe_index_uid('Book'))
- expect(index_book).not_to be_nil
- results = index_book.search('steve')
- expect(results['hits'].length).to eq(0)
- end
-
- it 'sanitizes attributes' do
- _hack = Book.create! name: '"> hack0r',
- author: '', premium: true, released: true
- b = Book.raw_search('hack', { attributes_to_highlight: ['*'] })
- expect(b['hits'].length).to eq(1)
- begin
- expect(b['hits'][0]['name']).to eq('"> hack0r').and_raise(StandardError)
- expect(b['hits'][0]['author']).to eq('alert(1)')
- expect(b['hits'][0]['_formatted']['name']).to eq('"> hack0r')
- rescue StandardError
- # rails 4.2's sanitizer
- begin
- expect(b['hits'][0]['name']).to eq('"> hack0r').and_raise(StandardError)
- expect(b['hits'][0]['author']).to eq('')
- expect(b['hits'][0]['_formatted']['name']).to eq('"> hack0r')
- rescue StandardError
- # jruby
- expect(b['hits'][0]['name']).to eq('"> hack0r')
- expect(b['hits'][0]['author']).to eq('')
- expect(b['hits'][0]['_formatted']['name']).to eq('"> hack0r')
- end
- end
- end
-
- it 'handles removal in an extra index' do
- # add a new public book which (not premium but released)
- book = Book.create! name: 'Public book', author: 'me', premium: false, released: true
-
- # should be searchable in the 'Book' index
- index = Book.index(safe_index_uid('Book'))
- results = index.search('Public book')
- expect(results['hits'].size).to eq(1)
-
- # update the book and make it non-public anymore (not premium, not released)
- book.update released: false
-
- # should be removed from the index
- results = index.search('Public book')
- expect(results['hits'].size).to eq(0)
- end
-
- it 'uses the per_environment option in the additional index as well' do
- index = Book.index(safe_index_uid('Book'))
- expect(index.uid).to eq("#{safe_index_uid('Book')}_#{Rails.env}")
- end
-
- it 'searches with one typo min size' do
- Book.create! name: 'The Lord of the Rings', author: 'me', premium: false, released: true
- results = Book.search('Lrod')
- expect(results.size).to eq(0)
-
- results = Book.search('Rnigs')
- expect(results.size).to eq(1)
- end
-
- it 'searches with two typo min size' do
- Book.create! name: 'Dracula', author: 'me', premium: false, released: true
- results = Book.search('Darclua')
- expect(results.size).to eq(0)
-
- Book.create! name: 'Frankenstein', author: 'me', premium: false, released: true
- results = Book.search('Farnkenstien')
- expect(results.size).to eq(1)
- end
-
- describe '#ms_entries' do
- it 'returns all 3 indexes for a public book' do
- book = Book.create!(
- name: 'Frankenstein', author: 'Mary Shelley',
- premium: false, released: true
- )
-
- expect(book.ms_entries).to contain_exactly(
- a_hash_including("index_uid" => "#{safe_index_uid('SecuredBook')}_test"),
- a_hash_including("index_uid" => "#{safe_index_uid('BookAuthor')}_test"),
- a_hash_including("index_uid" => "#{safe_index_uid('Book')}_test"),
- )
- end
-
- it 'returns all 3 indexes for a non-public book' do
- book = Book.create!(
- name: 'Frankenstein', author: 'Mary Shelley',
- premium: false, released: false
- )
-
- expect(book.ms_entries).to contain_exactly(
- a_hash_including("index_uid" => "#{safe_index_uid('SecuredBook')}_test"),
- a_hash_including("index_uid" => "#{safe_index_uid('BookAuthor')}_test"),
- a_hash_including("index_uid" => "#{safe_index_uid('Book')}_test"),
- )
- end
- end
-
- it 'returns facets using max values per facet' do
- 10.times do
- Book.create! name: Faker::Book.title, author: Faker::Book.author, genre: Faker::Book.genre
- end
-
- genres = Book.distinct.pluck(:genre)
-
- results = Book.search('', { facets: ['genre'] })
-
- expect(genres.size).to be > 3
- expect(results.facets_distribution['genre'].size).to eq(3)
- end
-
- it 'does not error on facet_search' do
- genres = %w[Legend Fiction Crime].cycle
- authors = %w[A B C].cycle
-
- 5.times do
- Book.create! name: Faker::Book.title, author: authors.next, genre: genres.next
- end
-
- expect do
- Book.index.facet_search('genre', 'Fic', filter: 'author = A')
- Book.index.facet_search('genre', filter: 'author = A')
- Book.index.facet_search('genre')
- end.not_to raise_error
- end
-
- context 'with Marshal serialization' do
- let(:found_books) { Book.search('*') }
- let(:marshaled_books) { Marshal.dump(found_books) }
-
- it 'returns all books in the marshaled format' do
- # Perform the search and marshal the results
- expect(marshaled_books).to be_present
-
- # Load the marshaled data and check the content
- loaded_books = Marshal.load(marshaled_books)
- expect(loaded_books).to match_array(found_books)
- end
- end
-
- context 'with Rails caching' do
- let(:memory_store) { ActiveSupport::Cache.lookup_store(:memory_store) }
- let(:cache) { Rails.cache }
-
- let(:search_query) { '*' }
- let(:cache_key) { "book_search:#{search_query}" }
-
- before do
- allow(Rails).to receive(:cache).and_return(memory_store)
- Rails.cache.clear
- end
-
- it 'caches the search results' do
- # Ensure the cache is empty before the test
- expect(Rails.cache.read(cache_key)).to be_nil
-
- # Perform the search and cache the results
- Rails.cache.fetch(cache_key) do
- Book.search(search_query)
- end
-
- # Check if the search result is cached
- not_cached_books = Book.search(search_query)
- expect(Rails.cache.read(cache_key)).to match_array(not_cached_books)
- end
- end
-end
-
-describe 'Movie' do
- before(:all) do
- Movie.clear_index!(true)
- end
-
- it 'returns array of single task hash on #ms_index!' do
- movie = Movie.create(title: 'Harry Potter')
-
- task = movie.ms_index!
-
- expect(task).to contain_exactly(a_hash_including('taskUid'))
- end
-
- it 'does not return any record with typo' do
- Movie.create(title: 'Harry Potter')
-
- expect(Movie.search('harry pottr', matching_strategy: 'all').size).to eq(0)
- end
-end
-
-describe 'Kaminari' do
- before(:all) do
- require 'kaminari'
- MeiliSearch::Rails.configuration[:pagination_backend] = :kaminari
- Restaurant.clear_index!(true)
-
- 10.times do
- Restaurant.create(
- name: Faker::Restaurant.name,
- kind: Faker::Restaurant.type,
- description: Faker::Restaurant.description
- )
- end
-
- Restaurant.reindex!(MeiliSearch::Rails::IndexSettings::DEFAULT_BATCH_SIZE, true)
- end
-
- after(:all) { MeiliSearch::Rails.configuration[:pagination_backend] = nil }
-
- it 'paginates' do
- hits = Restaurant.search ''
- expect(hits.total_count).to eq(Restaurant.raw_search('')['hits'].size)
-
- p1 = Restaurant.search '', page: 1, hits_per_page: 1
- expect(p1.size).to eq(1)
- expect(p1[0]).to eq(hits[0])
- expect(p1.total_count).to eq(Restaurant.raw_search('')['hits'].count)
-
- p2 = Restaurant.search '', page: 2, hits_per_page: 1
- expect(p2.size).to eq(1)
- expect(p2[0]).to eq(hits[1])
- expect(p2.total_count).to eq(Restaurant.raw_search('')['hits'].count)
- end
-
- it 'respects both camelCase and snake_case options' do
- expect(Restaurant.count).to be > 1
-
- # TODO: deprecate all camelcase attributes on v1.
- %i[hits_per_page hitsPerPage].each do |method|
- restaurants = Restaurant.search '', { page: 1, method => 1 }
-
- expect(restaurants.size).to eq(1)
- end
- end
-
- it 'does not return error if pagination params are strings' do
- p1 = Restaurant.search '', page: '1', hits_per_page: '1'
- expect(p1.size).to eq(1)
- expect(p1.total_count).to eq(Restaurant.raw_search('')['hits'].count)
-
- p2 = Restaurant.search '', page: '2', hits_per_page: '1'
- expect(p2.size).to eq(1)
- expect(p2.total_count).to eq(Restaurant.raw_search('')['hits'].count)
- end
-
- it 'returns records less than or equal to max_total_hits' do
- expect(Restaurant.search('*').size).to eq(5)
- end
-end
-
-describe 'Will_paginate' do
- before(:all) do
- require 'will_paginate'
- MeiliSearch::Rails.configuration[:pagination_backend] = :will_paginate
- Movie.clear_index!(true)
-
- 10.times { Movie.create(title: Faker::Movie.title) }
-
- Movie.reindex!(MeiliSearch::Rails::IndexSettings::DEFAULT_BATCH_SIZE, true)
- end
-
- after(:all) { MeiliSearch::Rails.configuration[:pagination_backend] = nil }
-
- it 'paginates' do
- hits = Movie.search '', hits_per_page: 2
- expect(hits.per_page).to eq(2)
- expect(hits.total_pages).to eq(3)
- expect(hits.total_entries).to eq(Movie.raw_search('')['hits'].count)
- end
-
- it 'returns most relevant elements in the first page' do
- hits = Movie.search '', hits_per_page: 2
- raw_hits = Movie.raw_search ''
- expect(hits[0]['id']).to eq(raw_hits['hits'][0]['id'].to_i)
-
- hits = Movie.search '', hits_per_page: 2, page: 2
- raw_hits = Movie.raw_search ''
- expect(hits[0]['id']).to eq(raw_hits['hits'][2]['id'].to_i)
- end
-
- it 'does not return error if pagination params are strings' do
- hits = Movie.search '', hits_per_page: '5'
- expect(hits.per_page).to eq(5)
- expect(hits.total_pages).to eq(1)
- expect(hits.current_page).to eq(1)
-
- hits = Movie.search '', hits_per_page: '5', page: '2'
- expect(hits.current_page).to eq(2)
- end
-
- it 'returns records less than or equal to max_total_hits' do
- expect(Movie.search('*').size).to eq(5)
- end
-end
-
-describe 'with pagination by pagy' do
- before(:all) do
- MeiliSearch::Rails.configuration[:pagination_backend] = :pagy
- MeiliSearch::Rails.configuration[:per_environment] = false
- end
-
- after(:all) do
- MeiliSearch::Rails.configuration[:pagination_backend] = nil
- MeiliSearch::Rails.configuration[:per_environment] = true
- end
-
- it 'has meaningful error when pagy is set as the pagination_backend' do
- Movie.create(title: 'Harry Potter').index!(true)
-
- logger = double
-
- allow(logger).to receive(:warn)
- allow(MeiliSearch::Rails).to receive(:logger).and_return(logger)
-
- Movie.search('')
-
- expect(logger).to have_received(:warn)
- .with('[meilisearch-rails] Remove `pagination_backend: :pagy` from your initializer, `pagy` it is not required for `pagy`')
- end
-end
-
-describe 'attributes_to_crop' do
- before(:all) do
- MeiliSearch::Rails.configuration[:per_environment] = false
-
- 10.times do
- Restaurant.create(
- name: Faker::Restaurant.name,
- kind: Faker::Restaurant.type,
- description: Faker::Restaurant.description
- )
- end
-
- Restaurant.reindex!(MeiliSearch::Rails::IndexSettings::DEFAULT_BATCH_SIZE, true)
- end
-
- after(:all) { MeiliSearch::Rails.configuration[:per_environment] = true }
-
- it 'includes _formatted object' do
- results = Restaurant.search('')
- raw_search_results = Restaurant.raw_search('')
- expect(results[0].formatted).not_to be_nil
- expect(results[0].formatted).to eq(raw_search_results['hits'].first['_formatted'])
- expect(results.first.formatted['description'].length).to be < results.first['description'].length
- expect(results.first.formatted['description']).to eq(raw_search_results['hits'].first['_formatted']['description'])
- expect(results.first.formatted['description']).not_to eq(results.first['description'])
- end
-end
-
-describe 'Disabled' do
- before(:all) do
- DisabledBoolean.index.delete_all_documents!
- DisabledProc.index.delete_all_documents!
- DisabledSymbol.index.delete_all_documents!
- end
-
- it 'disables the indexing using a boolean' do
- DisabledBoolean.create name: 'foo'
- expect(DisabledBoolean.search('').size).to eq(0)
- end
-
- it 'disables the indexing using a proc' do
- DisabledProc.create name: 'foo'
- expect(DisabledProc.search('').size).to eq(0)
- end
-
- it 'disables the indexing using a symbol' do
- DisabledSymbol.create name: 'foo'
- expect(DisabledSymbol.search('').size).to eq(0)
- end
-end
-
-unless OLD_RAILS
- describe 'EnqueuedDocument' do
- it 'enqueues a job' do
- expect do
- EnqueuedDocument.create! name: 'hellraiser'
- end.to raise_error('enqueued hellraiser')
- end
-
- it 'does not enqueue a job inside no index block' do
- expect do
- EnqueuedDocument.without_auto_index do
- EnqueuedDocument.create! name: 'test'
- end
- end.not_to raise_error
- end
- end
-
- describe 'DisabledEnqueuedDocument' do
- it '#ms_index! returns an empty array' do
- doc = DisabledEnqueuedDocument.create! name: 'test'
-
- expect(doc.ms_index!).to be_empty
- end
-
- it 'does not try to enqueue a job' do
- expect do
- DisabledEnqueuedDocument.create! name: 'test'
- end.not_to raise_error
- end
- end
-
- describe 'ConditionallyEnqueuedDocument' do
- before do
- allow(MeiliSearch::Rails::MSJob).to receive(:perform_later).and_return(nil)
- allow(MeiliSearch::Rails::MSCleanUpJob).to receive(:perform_later).and_return(nil)
- end
-
- it 'does not try to enqueue an index job when :if option resolves to false' do
- doc = ConditionallyEnqueuedDocument.create! name: 'test', is_public: false
-
- expect(MeiliSearch::Rails::MSJob).not_to have_received(:perform_later).with(doc, 'ms_index!')
- end
-
- it 'enqueues an index job when :if option resolves to true' do
- doc = ConditionallyEnqueuedDocument.create! name: 'test', is_public: true
-
- expect(MeiliSearch::Rails::MSJob).to have_received(:perform_later).with(doc, 'ms_index!')
- end
-
- it 'does enqueue a remove_from_index despite :if option' do
- doc = ConditionallyEnqueuedDocument.create!(name: 'test', is_public: true)
- expect(MeiliSearch::Rails::MSJob).to have_received(:perform_later).with(doc, 'ms_index!')
-
- doc.destroy!
-
- expect(MeiliSearch::Rails::MSCleanUpJob).to have_received(:perform_later).with(doc.ms_entries)
- end
- end
-end
-
-describe 'Misconfigured Block' do
- it 'forces the meilisearch block' do
- expect do
- MisconfiguredBlock.reindex!
- end.to raise_error(ArgumentError)
- end
-end
-
-describe 'People' do
- before do
- People.clear_index!(true)
- People.delete_all
- end
-
- before(:all) { MeiliSearch::Rails.configuration[:per_environment] = false }
-
- after(:all) { MeiliSearch::Rails.configuration[:per_environment] = true }
-
- it 'adds custom complex attribute' do
- People.create(first_name: 'Jane', last_name: 'Doe', card_number: 75_801_887)
- result = People.raw_search('Jane')
- expect(result['hits'][0]['full_name']).to eq('Jane Doe')
- end
-
- it 'has as uid the custom name specified' do
- People.create(first_name: 'Jane', last_name: 'Doe', card_number: 75_801_887)
- expect(People.index.uid).to eq(safe_index_uid('MyCustomPeople'))
- end
-
- it 'has the chosen field as custom primary key' do
- People.create(first_name: 'Jane', last_name: 'Doe', card_number: 75_801_887)
- index = MeiliSearch::Rails.client.fetch_index(safe_index_uid('MyCustomPeople'))
- expect(index.primary_key).to eq('card_number')
- end
-
- it 'does not call the API if there has been no attribute change' do
- People.create(first_name: 'Jane', last_name: 'Doe', card_number: 75_801_887)
-
- person = People.search('Jane').first
-
- expect do
- person.update(first_name: 'Jane')
- end.not_to change(People.index.tasks['results'], :size)
- end
-
- it 'does not auto-remove' do
- People.create(first_name: 'Joanna', last_name: 'Banana', card_number: 75_801_888)
- joanna = People.search('Joanna')[0]
- joanna.destroy
- result = People.raw_search('Joanna')
- expect(result['hits'].size).to eq(1)
- end
-
- it 'is able to remove manually' do
- bob = People.create(first_name: 'Bob', last_name: 'Sponge', card_number: 75_801_889)
- result = People.raw_search('Bob')
- expect(result['hits'].size).to eq(1)
- bob.remove_from_index!
- result = People.raw_search('Bob')
- expect(result['hits'].size).to eq(0)
- end
-
- it 'clears index manually' do
- People.create(first_name: 'Jane', last_name: 'Doe', card_number: 75_801_887)
- results = People.raw_search('')
- expect(results['hits'].size).not_to eq(0)
- People.clear_index!(true)
- results = People.raw_search('')
- expect(results['hits'].size).to eq(0)
- end
-end
-
-describe 'Animals' do
- it 'returns only the requested type' do
- Dog.create!([{ name: 'Toby the Dog' }, { name: 'Felix the Dog' }])
- Cat.create!([{ name: 'Toby the Cat' }, { name: 'Felix the Cat' }, { name: 'roar' }])
-
- expect(Dog.count).to eq(2)
- expect(Cat.count).to eq(3)
-
- expect(Cat.search('felix').size).to eq(1)
- expect(Cat.search('felix').first.name).to eq('Felix the Cat')
- expect(Dog.search('toby').size).to eq(1)
- expect(Dog.search('Toby').first.name).to eq('Toby the Dog')
- end
-
- it 'shares a single index' do
- cat_index = Cat.index.instance_variable_get('@index').uid
- dog_index = Dog.index.instance_variable_get('@index').uid
-
- expect(cat_index).to eq(dog_index)
- end
-
- describe '#ms_entries' do
- it 'returns the correct entry for each animal' do
- toby_dog = Dog.create!(name: 'Toby the Dog')
- taby_cat = Cat.create!(name: 'Taby the Cat')
-
- expect(toby_dog.ms_entries).to contain_exactly(
- a_hash_including('primary_key' => /dog_\d+/))
-
- expect(taby_cat.ms_entries).to contain_exactly(
- a_hash_including('primary_key' => /cat_\d+/))
- end
- end
-end
-
-describe 'Songs' do
- before(:all) { MeiliSearch::Rails.configuration[:per_environment] = false }
-
- after(:all) { MeiliSearch::Rails.configuration[:per_environment] = true }
-
- it 'targets multiple indices' do
- Song.create!(name: 'Coconut nut', artist: 'Smokey Mountain', premium: false, released: true) # Only song supposed to be added to Songs index
- Song.create!(name: 'Smoking hot', artist: 'Cigarettes before lunch', premium: true, released: true)
- Song.create!(name: 'Floor is lava', artist: 'Volcano', premium: true, released: false)
- Song.index.wait_for_task(Song.index.tasks['results'].first['uid'])
- MeiliSearch::Rails.client.index(safe_index_uid('PrivateSongs')).wait_for_task(MeiliSearch::Rails.client.index(safe_index_uid('PrivateSongs')).tasks['results'].first['uid'])
- results = Song.search('', index: safe_index_uid('Songs'))
- expect(results.size).to eq(1)
- raw_results = Song.raw_search('', index: safe_index_uid('Songs'))
- expect(raw_results['hits'].size).to eq(1)
- results = Song.search('', index: safe_index_uid('PrivateSongs'))
- expect(results.size).to eq(3)
- raw_results = Song.raw_search('', index: safe_index_uid('PrivateSongs'))
- expect(raw_results['hits'].size).to eq(3)
- end
-end
-
-describe 'Raise on failure' do
- before { Vegetable.instance_variable_set('@ms_indexes', nil) }
-
- it 'raises on failure' do
- expect do
- Fruit.search('', { filter: 'title = Nightshift' })
- end.to raise_error(MeiliSearch::ApiError)
- end
-
- it 'does not raise on failure' do
- expect do
- Vegetable.search('', { filter: 'title = Kale' })
- end.not_to raise_error
- end
-
- context 'when Meilisearch server take too long to answer' do
- let(:index_instance) { instance_double(MeiliSearch::Index, settings: nil, update_settings: nil) }
- let(:slow_client) { instance_double(MeiliSearch::Client, index: index_instance) }
-
- before do
- allow(slow_client).to receive(:create_index)
- allow(MeiliSearch::Rails).to receive(:client).and_return(slow_client)
- end
-
- it 'does not raise error timeouts on reindex' do
- allow(index_instance).to receive(:add_documents).and_raise(MeiliSearch::TimeoutError)
-
- expect do
- Vegetable.create(name: 'potato')
- end.not_to raise_error
- end
-
- it 'does not raise error timeouts on data addition' do
- allow(index_instance).to receive(:add_documents).and_return(nil)
-
- expect do
- Vegetable.ms_reindex!
- end.not_to raise_error
- end
- end
-end
-
-context 'when a searchable attribute is not an attribute' do
- let(:other_people_class) do
- Class.new(People) do
- def self.name
- 'People'
- end
- end
- end
-
- let(:logger) { instance_double('Logger', warn: nil) }
-
- before do
- allow(MeiliSearch::Rails).to receive(:logger).and_return(logger)
-
- other_people_class.meilisearch index_uid: safe_index_uid('Others'), primary_key: :card_number do
- attribute :first_name
- searchable_attributes %i[first_name last_name]
- end
- end
-
- it 'warns the user' do
- expect(logger).to have_received(:warn).with(/meilisearch-rails.+last_name/)
- end
-end
-
-context "when have a internal class defined in the app's scope" do
- it 'does not raise NoMethodError' do
- Task.create(title: 'my task #1')
-
- expect do
- Task.search('task')
- end.not_to raise_error
- end
-end
-
-context 'when MeiliSearch calls are deactivated' do
- it 'is active by default' do
- expect(MeiliSearch::Rails).to be_active
- end
-
- describe '#deactivate!' do
- context 'without block' do
- before { MeiliSearch::Rails.deactivate! }
-
- after { MeiliSearch::Rails.activate! }
-
- it 'deactivates the requests and keep the state' do
- expect(MeiliSearch::Rails).not_to be_active
- end
-
- it 'responds with a black hole' do
- expect(MeiliSearch::Rails.client.foo.bar.now.nil.item.issue).to be_nil
- end
-
- it 'deactivates requests' do
- expect do
- Task.create(title: 'my task #1')
- Task.search('task')
- end.not_to raise_error
- end
- end
-
- context 'with a block' do
- it 'disables only around call' do
- MeiliSearch::Rails.deactivate! do
- expect(MeiliSearch::Rails).not_to be_active
- end
-
- expect(MeiliSearch::Rails).to be_active
- end
-
- it 'works even when the instance made calls earlier' do
- Task.destroy_all
- Task.create!(title: 'deactivated #1')
-
- MeiliSearch::Rails.deactivate! do
- # always 0 since the black hole will return the default values
- expect(Task.search('deactivated').size).to eq(0)
- end
-
- expect(MeiliSearch::Rails).to be_active
- expect(Task.search('#1').size).to eq(1)
- end
-
- it 'works in multi-threaded environments' do
- Threads.new(5, log: $stdout).assert(20) do |_i, _r|
- MeiliSearch::Rails.deactivate! do
- expect(MeiliSearch::Rails).not_to be_active
- end
-
- expect(MeiliSearch::Rails).to be_active
- end
- end
- end
- end
-end
-
-describe 'proximity_precision' do
- before do
- stub_const(
- 'OtherColor',
- Class.new do
- include ActiveModel::Model
- include MeiliSearch::Rails
- end
- )
- end
-
- context 'when the value is byWord' do
- before do
- OtherColor.meilisearch synchronize: true, index_uid: safe_index_uid('OtherColors') do
- proximity_precision 'byWord'
- end
- end
-
- it 'sets the value byWord to proximity precision' do
- expect(OtherColor.index.get_settings['proximityPrecision']).to eq('byWord')
- end
- end
-
- context 'when the value is byAttribute' do
- before do
- OtherColor.meilisearch synchronize: true, index_uid: safe_index_uid('OtherColors') do
- proximity_precision 'byAttribute'
- end
- end
-
- it 'sets the value byAttribute to proximity precision' do
- expect(OtherColor.index.get_settings['proximityPrecision']).to eq('byAttribute')
- end
- end
-end
diff --git a/spec/meilisearch/activation_spec.rb b/spec/meilisearch/activation_spec.rb
new file mode 100644
index 00000000..e81a08a0
--- /dev/null
+++ b/spec/meilisearch/activation_spec.rb
@@ -0,0 +1,41 @@
+describe MeiliSearch::Rails do
+ it 'is active by default' do
+ expect(described_class).to be_active
+ end
+
+ describe '#deactivate!' do
+ context 'without block' do
+ before { described_class.deactivate! }
+
+ after { described_class.activate! }
+
+ it 'deactivates the requests until activate!-ed' do
+ expect(described_class).not_to be_active
+ end
+
+ it 'responds with a black hole' do
+ expect(described_class.client.foo.bar.now.nil.item.issue).to be_nil
+ end
+ end
+
+ context 'with a block' do
+ it 'disables only around call' do
+ described_class.deactivate! do
+ expect(described_class).not_to be_active
+ end
+
+ expect(described_class).to be_active
+ end
+
+ it 'works in multi-threaded environments' do
+ Threads.new(5, log: $stdout).assert(20) do |_i, _r|
+ described_class.deactivate! do
+ expect(described_class).not_to be_active
+ end
+
+ expect(described_class).to be_active
+ end
+ end
+ end
+ end
+end
diff --git a/spec/model_methods_spec.rb b/spec/model_methods_spec.rb
new file mode 100644
index 00000000..9853aafb
--- /dev/null
+++ b/spec/model_methods_spec.rb
@@ -0,0 +1,85 @@
+require 'support/async_helper'
+require 'support/models/color'
+require 'support/models/book'
+require 'support/models/people'
+
+describe 'Model methods' do
+ describe '.reindex!' do
+ it 'uses the specified scope' do
+ TestUtil.reset_colors!
+
+ Color.create!(name: 'red', short_name: 'r3', hex: 3)
+ Color.create!(name: 'red', short_name: 'r1', hex: 1)
+ Color.create!(name: 'purple', short_name: 'p')
+
+ Color.clear_index!(true)
+
+ Color.where(name: 'red').reindex!(3, true)
+ expect(Color.search('').size).to eq(2)
+
+ Color.clear_index!(true)
+ Color.where(id: Color.first.id).reindex!(3, true)
+ expect(Color.search('').size).to eq(1)
+ end
+ end
+
+ describe '.clear_index!' do
+ context 'when :auto_remove is disabled' do
+ it 'clears index manually' do
+ TestUtil.reset_people!
+
+ People.create(first_name: 'Jane', last_name: 'Doe', card_number: 75_801_887)
+ AsyncHelper.await_last_task
+
+ results = People.raw_search('')
+ expect(results['hits']).not_to be_empty
+
+ People.clear_index!(true)
+
+ results = People.raw_search('')
+ expect(results['hits']).to be_empty
+ end
+ end
+ end
+
+ describe '.without_auto_index' do
+ it 'disables auto indexing for the model' do
+ TestUtil.reset_colors!
+
+ Color.without_auto_index do
+ Color.create!(name: 'blue', short_name: 'b', hex: 0xFF0000)
+ end
+
+ expect(Color.search('blue')).to be_empty
+
+ Color.reindex!(2, true)
+ expect(Color.search('blue')).to be_one
+ end
+
+ it 'does not disable auto indexing for other models' do
+ TestUtil.reset_books!
+
+ Color.without_auto_index do
+ Book.create!(
+ name: 'Frankenstein', author: 'Mary Shelley',
+ premium: false, released: true
+ )
+ end
+
+ expect(Book.search('Frankenstein')).to be_one
+ end
+ end
+
+ describe '.index_documents' do
+ it 'updates existing documents' do
+ TestUtil.reset_colors!
+
+ _blue = Color.create!(name: 'blue', short_name: 'blu', hex: 0x0000FF)
+ _black = Color.create!(name: 'black', short_name: 'bla', hex: 0x000000)
+
+ json = Color.raw_search('')
+ Color.index_documents Color.limit(1), true # reindex last color, `limit` is incompatible with the reindex! method
+ expect(json['hits'].count).to eq(Color.raw_search('')['hits'].count)
+ end
+ end
+end
diff --git a/spec/options_spec.rb b/spec/options_spec.rb
new file mode 100644
index 00000000..41fbc744
--- /dev/null
+++ b/spec/options_spec.rb
@@ -0,0 +1,281 @@
+require 'support/async_helper'
+require 'support/models/color'
+require 'support/models/book'
+require 'support/models/animals'
+require 'support/models/people'
+require 'support/models/vegetable'
+require 'support/models/fruit'
+require 'support/models/disabled_models'
+require 'support/models/queued_models'
+
+describe 'meilisearch_options' do
+ describe ':index_uid' do
+ it 'sets the index uid specified' do
+ TestUtil.reset_people!
+ People.create(first_name: 'Jane', last_name: 'Doe', card_number: 75_801_887)
+ expect(People.index.uid).to eq("#{safe_index_uid('MyCustomPeople')}_test")
+ end
+ end
+
+ describe ':primary_key' do
+ it 'sets the primary key specified' do
+ TestUtil.reset_people!
+ People.create(first_name: 'Jane', last_name: 'Doe', card_number: 75_801_887)
+ expect(People.index.fetch_info.primary_key).to eq('card_number')
+ end
+ end
+
+ describe ':index_uid and :primary_key (shared index)' do
+ it 'index uid is the same' do
+ cat_index = Cat.index_uid
+ dog_index = Dog.index_uid
+
+ expect(cat_index).to eq(dog_index)
+ end
+
+ it 'searching a type only returns its own documents' do
+ TestUtil.reset_animals!
+
+ Dog.create!([{ name: 'Toby the Dog' }, { name: 'Felix the Dog' }])
+ Cat.create!([{ name: 'Toby the Cat' }, { name: 'Felix the Cat' }, { name: 'roar' }])
+
+ expect(Cat.search('felix')).to be_one
+ expect(Cat.search('felix').first.name).to eq('Felix the Cat')
+ expect(Dog.search('toby')).to be_one
+ expect(Dog.search('Toby').first.name).to eq('Toby the Dog')
+ end
+ end
+
+ describe ':if' do
+ it 'only indexes the record in the valid indexes' do
+ TestUtil.reset_books!
+
+ Book.create! name: 'Steve Jobs', author: 'Walter Isaacson',
+ premium: true, released: true
+
+ results = Book.search('steve')
+ expect(results).to be_one
+
+ results = Book.index(safe_index_uid('BookAuthor')).search('walter')
+ expect(results['hits']).to be_one
+
+ # premium -> not part of the public index
+ results = Book.index(safe_index_uid('Book')).search('steve')
+ expect(results['hits']).to be_empty
+ end
+ end
+
+ describe ':unless' do
+ it 'only indexes the record if it evaluates to false' do
+ NestedItem.clear_index!(true)
+
+ i1 = NestedItem.create hidden: false
+ i2 = NestedItem.create hidden: true
+
+ i1.children << NestedItem.create(hidden: true) << NestedItem.create(hidden: true)
+ NestedItem.where(id: [i1.id, i2.id]).reindex!(MeiliSearch::Rails::IndexSettings::DEFAULT_BATCH_SIZE, true)
+
+ result = NestedItem.index.get_document(i1.id)
+ expect(result['nb_children']).to eq(2)
+
+ result = NestedItem.raw_search('')
+ expect(result['hits'].size).to eq(1)
+
+ if i2.respond_to? :update_attributes
+ i2.update_attributes hidden: false # rubocop:disable Rails/ActiveRecordAliases
+ else
+ i2.update hidden: false
+ end
+
+ result = NestedItem.raw_search('')
+ expect(result['hits'].size).to eq(2)
+ end
+ end
+
+ describe ':auto_index' do
+ it 'is enabled by default' do
+ TestUtil.reset_colors!
+
+ Color.create!(name: 'blue', short_name: 'b', hex: 0xFF0000)
+ results = Color.raw_search('blue')
+ expect(results['hits'].size).to eq(1)
+ expect(results['estimatedTotalHits']).to eq(1)
+ end
+ end
+
+ describe ':auto_remove' do
+ context 'when false' do
+ it 'does not remove document on destroy' do
+ TestUtil.reset_people!
+
+ joanna = People.create(first_name: 'Joanna', last_name: 'Mason', card_number: 75_801_888)
+ AsyncHelper.await_last_task
+
+ result = People.raw_search('Joanna')
+ expect(result['hits']).to be_one
+
+ joanna.destroy
+ AsyncHelper.await_last_task
+
+ result = People.raw_search('Joanna')
+ expect(result['hits']).to be_one
+ end
+ end
+ end
+
+ describe ':disable_indexing' do
+ it 'prevents indexing when disabled with a boolean' do
+ # manually trigger index creation since indexing is disabled
+ DisabledBoolean.index
+ AsyncHelper.await_last_task
+
+ DisabledBoolean.create name: 'foo'
+ expect(DisabledBoolean.search('')).to be_empty
+ end
+
+ it 'prevents indexing when disabled with a proc' do
+ # manually trigger index creation since indexing is disabled
+ DisabledProc.index
+ AsyncHelper.await_last_task
+
+ DisabledProc.create name: 'foo'
+ expect(DisabledProc.search('')).to be_empty
+ end
+
+ it 'prevents indexing when disabled with a symbol (method)' do
+ # manually trigger index creation since indexing is disabled
+ DisabledSymbol.index
+ AsyncHelper.await_last_task
+
+ DisabledSymbol.create name: 'foo'
+ expect(DisabledSymbol.search('')).to be_empty
+ end
+ end
+
+ describe ':enqueue' do
+ context 'when configured with a proc' do
+ it 'runs proc when created' do
+ expect do
+ EnqueuedDocument.create! name: 'hellraiser'
+ end.to raise_error('enqueued hellraiser')
+ end
+
+ it 'does not run proc in without_auto_index block' do
+ expect do
+ EnqueuedDocument.without_auto_index do
+ EnqueuedDocument.create! name: 'test'
+ end
+ end.not_to raise_error
+ end
+
+ it 'does not run proc when auto_index is disabled' do
+ expect do
+ DisabledEnqueuedDocument.create! name: 'test'
+ end.not_to raise_error
+ end
+
+ context 'when :if is configured' do
+ before do
+ allow(MeiliSearch::Rails::MSJob).to receive(:perform_later).and_return(nil)
+ allow(MeiliSearch::Rails::MSCleanUpJob).to receive(:perform_later).and_return(nil)
+ end
+
+ it 'does not try to enqueue an index job when :if option resolves to false' do
+ doc = ConditionallyEnqueuedDocument.create! name: 'test', is_public: false
+
+ expect(MeiliSearch::Rails::MSJob).not_to have_received(:perform_later).with(doc, 'ms_index!')
+ end
+
+ it 'enqueues an index job when :if option resolves to true' do
+ doc = ConditionallyEnqueuedDocument.create! name: 'test', is_public: true
+
+ expect(MeiliSearch::Rails::MSJob).to have_received(:perform_later).with(doc, 'ms_index!')
+ end
+
+ it 'does enqueue a remove_from_index despite :if option' do
+ doc = ConditionallyEnqueuedDocument.create!(name: 'test', is_public: true)
+ expect(MeiliSearch::Rails::MSJob).to have_received(:perform_later).with(doc, 'ms_index!')
+
+ doc.destroy!
+
+ expect(MeiliSearch::Rails::MSCleanUpJob).to have_received(:perform_later).with(doc.ms_entries)
+ end
+ end
+ end
+ end
+
+ describe ':sanitize' do
+ context 'when true' do
+ it 'sanitizes attributes' do
+ TestUtil.reset_books!
+
+ Book.create! name: '"> hack0r',
+ author: '', premium: true, released: true
+
+ b = Book.raw_search('hack')
+
+ expect(b['hits'][0]).to include(
+ 'name' => '"> hack0r',
+ 'author' => ''
+ )
+ end
+
+ it 'keeps _formatted emphasis' do
+ TestUtil.reset_books!
+
+ Book.create! name: '"> hack0r',
+ author: '', premium: true, released: true
+
+ b = Book.raw_search('hack', { attributes_to_highlight: ['*'] })
+
+ expect(b['hits'][0]['_formatted']).to include(
+ 'name' => '"> hack0r'
+ )
+ end
+ end
+ end
+
+ describe ':raise_on_failure' do
+ context 'when true' do
+ it 'raises exception on failure' do
+ expect do
+ Fruit.search('', { filter: 'title = Nightshift' })
+ end.to raise_error(MeiliSearch::ApiError)
+ end
+ end
+
+ context 'when set to false' do
+ it 'fails without an exception' do
+ expect do
+ Vegetable.search('', { filter: 'title = Kale' })
+ end.not_to raise_error
+ end
+
+ context 'in case of timeout' do
+ let(:index_instance) { instance_double(MeiliSearch::Index, settings: nil, update_settings: nil) }
+ let(:slow_client) { instance_double(MeiliSearch::Client, index: index_instance) }
+
+ before do
+ allow(slow_client).to receive(:create_index)
+ allow(MeiliSearch::Rails).to receive(:client).and_return(slow_client)
+ end
+
+ it 'does not raise error timeouts on reindex' do
+ allow(index_instance).to receive(:add_documents).and_raise(MeiliSearch::TimeoutError)
+
+ expect do
+ Vegetable.create(name: 'potato')
+ end.not_to raise_error
+ end
+
+ it 'does not raise error timeouts on data addition' do
+ allow(index_instance).to receive(:add_documents).and_return(nil)
+
+ expect do
+ Vegetable.ms_reindex!
+ end.not_to raise_error
+ end
+ end
+ end
+ end
+end
diff --git a/spec/pagination/kaminari_spec.rb b/spec/pagination/kaminari_spec.rb
new file mode 100644
index 00000000..d42dc1aa
--- /dev/null
+++ b/spec/pagination/kaminari_spec.rb
@@ -0,0 +1,62 @@
+require 'kaminari'
+require 'support/async_helper'
+require 'support/models/restaurant'
+
+describe 'Pagination with kaminari' do
+ before(:all) do
+ MeiliSearch::Rails.configuration[:pagination_backend] = :kaminari
+ Restaurant.clear_index!
+
+ 3.times do
+ Restaurant.create(
+ name: Faker::Restaurant.name,
+ kind: Faker::Restaurant.type,
+ description: Faker::Restaurant.description
+ )
+ end
+
+ AsyncHelper.await_last_task
+ end
+
+ it 'paginates' do
+ first, second = Restaurant.search ''
+
+ p1 = Restaurant.search '', page: 1, hits_per_page: 1
+ expect(p1).to be_one
+ expect(p1).to contain_exactly(first)
+
+ p2 = Restaurant.search '', page: 2, hits_per_page: 1
+ expect(p2).to be_one
+ expect(p2).to contain_exactly(second)
+ end
+
+ it 'returns number of total results' do
+ hits = Restaurant.search ''
+ expect(hits.total_count).to eq(2)
+
+ p1 = Restaurant.search '', page: 1, hits_per_page: 1
+ expect(p1.total_count).to eq(2)
+ end
+
+ it 'respects both camelCase options' do
+ # TODO: deprecate all camelcase attributes on v1.
+ restaurants = Restaurant.search '', { page: 1, hitsPerPage: 1 }
+
+ expect(restaurants).to be_one
+ expect(restaurants.total_count).to be > 1
+ end
+
+ it 'accepts string options' do
+ p1 = Restaurant.search '', page: '1', hits_per_page: '1'
+ expect(p1).to be_one
+ expect(p1.total_count).to eq(Restaurant.raw_search('')['hits'].count)
+
+ p2 = Restaurant.search '', page: '2', hits_per_page: '1'
+ expect(p2.size).to eq(1)
+ expect(p2.total_count).to eq(Restaurant.raw_search('')['hits'].count)
+ end
+
+ it 'respects max_total_hits' do
+ expect(Restaurant.search('*').count).to eq(2)
+ end
+end
diff --git a/spec/pagination/pagy_spec.rb b/spec/pagination/pagy_spec.rb
new file mode 100644
index 00000000..8d78f49d
--- /dev/null
+++ b/spec/pagination/pagy_spec.rb
@@ -0,0 +1,19 @@
+require 'support/models/movie'
+
+describe 'Pagination with pagy' do
+ it 'has meaningful error when pagy is set as the pagination_backend' do
+ Movie.create(title: 'Harry Potter').index!(true)
+
+ logger = double
+
+ allow(logger).to receive(:warn)
+ allow(MeiliSearch::Rails).to receive(:logger).and_return(logger)
+
+ MeiliSearch::Rails.configuration[:pagination_backend] = :pagy
+
+ Movie.search('')
+
+ expect(logger).to have_received(:warn)
+ .with('[meilisearch-rails] Remove `pagination_backend: :pagy` from your initializer, `pagy` it is not required for `pagy`')
+ end
+end
diff --git a/spec/pagination/will_paginate_spec.rb b/spec/pagination/will_paginate_spec.rb
new file mode 100644
index 00000000..87045076
--- /dev/null
+++ b/spec/pagination/will_paginate_spec.rb
@@ -0,0 +1,45 @@
+require 'will_paginate'
+require 'support/async_helper'
+require 'support/models/movie'
+
+describe 'Pagination with will_paginate' do
+ before(:all) do
+ MeiliSearch::Rails.configuration[:pagination_backend] = :will_paginate
+ Movie.clear_index!
+
+ 6.times { Movie.create(title: Faker::Movie.title) }
+
+ AsyncHelper.await_last_task
+ end
+
+ it 'paginates with sort' do
+ unpaged_hits = Movie.search ''
+
+ hits = Movie.search '', hits_per_page: 2
+ expect(hits).to eq(unpaged_hits[0..1])
+
+ hits = Movie.search '', hits_per_page: 2, page: 2
+ expect(hits).to eq(unpaged_hits[2..3])
+ end
+
+ it 'returns paging metadata' do
+ hits = Movie.search '', hits_per_page: 2
+ expect(hits.per_page).to eq(2)
+ expect(hits.total_pages).to eq(3)
+ expect(hits.total_entries).to eq(5)
+ end
+
+ it 'accepts string options' do
+ hits = Movie.search '', hits_per_page: '5'
+ expect(hits.per_page).to eq(5)
+ expect(hits.total_pages).to eq(1)
+ expect(hits.current_page).to eq(1)
+
+ hits = Movie.search '', hits_per_page: '5', page: '2'
+ expect(hits.current_page).to eq(2)
+ end
+
+ it 'respects max_total_hits' do
+ expect(Movie.search('*').count).to eq(5)
+ end
+end
diff --git a/spec/safe_index_spec.rb b/spec/safe_index_spec.rb
new file mode 100644
index 00000000..9fa0fc4c
--- /dev/null
+++ b/spec/safe_index_spec.rb
@@ -0,0 +1,22 @@
+require 'support/models/book'
+
+describe MeiliSearch::Rails::SafeIndex do
+ describe '#facet_search' do
+ it 'accepts all params without error' do
+ TestUtil.reset_books!
+
+ genres = %w[Legend Fiction Crime].cycle
+ authors = %w[A B C].cycle
+
+ 5.times do
+ Book.create! name: Faker::Book.title, author: authors.next, genre: genres.next
+ end
+
+ expect do
+ Book.index.facet_search('genre', 'Fic', filter: 'author = A')
+ Book.index.facet_search('genre', filter: 'author = A')
+ Book.index.facet_search('genre')
+ end.not_to raise_error
+ end
+ end
+end
diff --git a/spec/search_spec.rb b/spec/search_spec.rb
new file mode 100644
index 00000000..d07a0961
--- /dev/null
+++ b/spec/search_spec.rb
@@ -0,0 +1,68 @@
+require 'support/models/color'
+
+describe 'Search' do
+ before do
+ Color.clear_index!(true)
+ Color.delete_all
+ end
+
+ it 'respects non-searchable attributes' do
+ Color.create!(name: 'blue', short_name: 'x', hex: 0xFF0000)
+ expect(Color.search('x')).to be_empty
+ end
+
+ it 'respects ranking rules' do
+ third = Color.create!(hex: 3)
+ first = Color.create!(hex: 1)
+ second = Color.create!(hex: 2)
+
+ expect(Color.search('')).to eq([first, second, third])
+ end
+
+ it 'applies filter' do
+ _blue = Color.create!(name: 'blue', short_name: 'blu', hex: 0x0000FF)
+ black = Color.create!(name: 'black', short_name: 'bla', hex: 0x000000)
+ _green = Color.create!(name: 'green', short_name: 'gre', hex: 0x00FF00)
+
+ results = Color.search('bl', { filter: ['short_name = bla'] })
+ expect(results).to contain_exactly(black)
+ end
+
+ it 'applies sorting' do
+ blue = Color.create!(name: 'blue', short_name: 'blu', hex: 0x0000FF)
+ black = Color.create!(name: 'black', short_name: 'bla', hex: 0x000000)
+ green = Color.create!(name: 'green', short_name: 'gre', hex: 0x00FF00)
+
+ results = Color.search('*', { sort: ['name:asc'] })
+
+ expect(results).to eq([black, blue, green])
+ end
+
+ it 'makes facets distribution accessible' do
+ Color.create!(name: 'blue', short_name: 'b', hex: 0xFF0000)
+ results = Color.search('', { facets: ['short_name'] })
+
+ expect(results.facets_distribution).to match(
+ { 'short_name' => { 'b' => 1 } }
+ )
+ end
+
+ it 'results include #formatted object' do
+ Color.create!(name: 'green', short_name: 'b', hex: 0xFF0000)
+ results = Color.search('gre')
+ expect(results[0].formatted).to include('name' => 'green')
+ end
+end
+
+describe '#raw_search' do
+ it 'allows for access to meilisearch-ruby search' do
+ TestUtil.reset_colors!
+
+ Color.create!(name: 'blue', short_name: 'b', hex: 0xFF0000)
+
+ raw_results = Color.raw_search('blue')
+ ms_ruby_results = Color.index.search('blue')
+
+ expect(raw_results.keys).to match ms_ruby_results.keys
+ end
+end
diff --git a/spec/settings_spec.rb b/spec/settings_spec.rb
index ed5a7a8f..0e7a3dfd 100644
--- a/spec/settings_spec.rb
+++ b/spec/settings_spec.rb
@@ -1,6 +1,184 @@
-require 'spec_helper'
+require 'support/async_helper'
+require 'support/models/book'
+require 'support/models/people'
+require 'support/models/restaurant'
+require 'support/models/specialty_models'
+require 'support/models/song'
+require 'support/models/color'
describe MeiliSearch::Rails::IndexSettings do
+ describe 'attribute' do
+ context 'when passed a block' do
+ it 'uses the block to determine attribute\'s value' do
+ m = Namespaced::Model.new(another_private_value: 2)
+ attributes = Namespaced::Model.meilisearch_settings.get_attributes(m)
+ expect(attributes).to include('customAttr' => 42, 'myid' => m.id)
+ end
+ end
+ end
+
+ describe 'add_attribute' do
+ context 'with a symbol' do
+ it 'calls method for new attribute' do
+ TestUtil.reset_people!
+
+ People.create(first_name: 'Jane', last_name: 'Doe', card_number: 75_801_887)
+ AsyncHelper.await_last_task
+
+ result = People.raw_search('Jane')
+ expect(result['hits'][0]['full_name']).to eq('Jane Doe')
+ end
+ end
+ end
+
+ describe 'faceting' do
+ it 'respects max values per facet' do
+ TestUtil.reset_books!
+
+ 4.times do
+ Book.create! name: Faker::Book.title, author: Faker::Book.author,
+ genre: Faker::Book.unique.genre
+ end
+
+ genres = Book.distinct.pluck(:genre)
+
+ results = Book.search('', { facets: ['genre'] })
+
+ expect(genres.size).to be > 3
+ expect(results.facets_distribution['genre'].size).to eq(3)
+ end
+ end
+
+ describe 'typo_tolerance' do
+ it 'does not return any record with type when disabled' do
+ TestUtil.reset_movies!
+
+ Movie.create(title: 'Harry Potter')
+
+ expect(Movie.search('harry pottr', matching_strategy: 'all')).to be_empty
+ end
+
+ it 'searches with one typo min size' do
+ TestUtil.reset_books!
+
+ Book.create! name: 'The Lord of the Rings', author: 'me', premium: false, released: true
+ results = Book.search('Lrod')
+ expect(results).to be_empty
+
+ results = Book.search('Rnigs')
+ expect(results).to be_one
+ end
+
+ it 'searches with two typo min size' do
+ TestUtil.reset_books!
+
+ Book.create! name: 'Dracula', author: 'me', premium: false, released: true
+ results = Book.search('Darclua')
+ expect(results).to be_empty
+
+ Book.create! name: 'Frankenstein', author: 'me', premium: false, released: true
+ results = Book.search('Farnkenstien')
+ expect(results).to be_one
+ end
+ end
+
+ describe 'attributes_to_crop' do
+ before(:all) do
+ 10.times do
+ Restaurant.create(
+ name: Faker::Restaurant.name,
+ kind: Faker::Restaurant.type,
+ description: Faker::Restaurant.description
+ )
+ end
+
+ Restaurant.reindex!(MeiliSearch::Rails::IndexSettings::DEFAULT_BATCH_SIZE, true)
+ end
+
+ it 'includes _formatted object' do
+ results = Restaurant.search('')
+ raw_search_results = Restaurant.raw_search('')
+ expect(results[0].formatted).not_to be_nil
+ expect(results[0].formatted).to eq(raw_search_results['hits'].first['_formatted'])
+ expect(results.first.formatted['description'].length).to be < results.first['description'].length
+ expect(results.first.formatted['description']).to eq(raw_search_results['hits'].first['_formatted']['description'])
+ expect(results.first.formatted['description']).not_to eq(results.first['description'])
+ end
+ end
+
+ describe 'use_serializer' do
+ it 'only uses the attributes from the serializer' do
+ o = SerializedDocument.new name: 'test', skip: 'skip me'
+ attributes = SerializedDocument.meilisearch_settings.get_attributes(o)
+ expect(attributes).to eq({ name: 'test' })
+ end
+ end
+
+ describe 'proximity_precision' do
+ it 'can be set to byWord' do
+ expect(Color.index.get_settings['proximityPrecision']).to eq('byWord')
+ end
+
+ it 'can be set to byAttribute' do
+ Song.create!(name: 'Coconut nut', artist: 'Smokey Mountain', premium: false, released: true)
+ AsyncHelper.await_last_task
+ expect(Song.index.get_settings['proximityPrecision']).to eq('byAttribute')
+ end
+ end
+
+ describe 'force_utf8_encoding' do
+ it 'converts to utf8' do
+ EncodedString.create!
+ results = EncodedString.raw_search ''
+ expect(results['hits'].first).to include('value' => ' • ')
+ end
+ end
+
+ describe 'searchable_attributes' do
+ # TODO: Add more searchable_attributes tests
+ context 'when a searchable attribute is not an attribute' do
+ let(:logger) { instance_double('Logger', warn: nil) }
+
+ before do
+ allow(MeiliSearch::Rails).to receive(:logger).and_return(logger)
+ end
+
+ it 'warns the user' do
+ People.meilisearch_settings.add_index(safe_index_uid('searchable_attr_spec')) do
+ attribute :first_name
+ searchable_attributes %i[first_name last_name]
+ end
+
+ expect(logger).to have_received(:warn).with(/meilisearch-rails.+last_name/)
+ end
+ end
+ end
+
+ describe 'add_index' do
+ let(:private_songs_index) { safe_index_uid('PrivateSongs') }
+ let(:public_songs_index) { safe_index_uid('Songs') }
+
+ it 'targets multiple indexes' do
+ Song.clear_index!(true)
+ songs =
+ [
+ Song.create!(name: 'Coconut nut', artist: 'Smokey Mountain', premium: false, released: true),
+ Song.create!(name: 'Smoking hot', artist: 'Cigarettes before lunch', premium: true, released: true),
+ Song.create!(name: 'Floor is lava', artist: 'Volcano', premium: true, released: false)
+ ]
+
+ public_song = songs.first
+
+ AsyncHelper.await_last_task
+
+ public_search = Song.search('', index: public_songs_index)
+ expect(public_search).to contain_exactly(public_song)
+
+ results = Song.search('', index: private_songs_index)
+ expect(results).to match(songs)
+ end
+ end
+
describe 'settings change detection' do
let(:record) { Color.create name: 'dark-blue', short_name: 'blue' }
diff --git a/spec/support/active_record_classes.rb b/spec/support/active_record_classes.rb
deleted file mode 100644
index bb16768b..00000000
--- a/spec/support/active_record_classes.rb
+++ /dev/null
@@ -1,70 +0,0 @@
-require 'support/active_record_schema'
-Dir["#{File.dirname(__FILE__)}/models/*.rb"].sort.each { |file| require file }
-
-ar_schema.instance_exec do
- create_table :uniq_users, id: false do |t|
- t.string :name
- end
- create_table :nullable_ids
- create_table :mongo_documents do |t|
- t.string :name
- end
- create_table :ebooks do |t|
- t.string :name
- t.string :author
- t.boolean :premium
- t.boolean :released
- end
-end
-
-class UniqUser < ActiveRecord::Base
- include MeiliSearch::Rails
-
- meilisearch synchronous: true, index_uid: safe_index_uid('UniqUser'), primary_key: :name
-end
-
-class NullableId < ActiveRecord::Base
- include MeiliSearch::Rails
-
- meilisearch synchronous: true, index_uid: safe_index_uid('NullableId'), primary_key: :custom_id,
- if: :never
-
- def custom_id
- nil
- end
-
- def never
- false
- end
-end
-
-class MongoDocument < ActiveRecord::Base
- include MeiliSearch::Rails
-
- meilisearch index_uid: safe_index_uid('MongoDocument')
-
- def self.reindex!
- raise NameError, 'never reached'
- end
-
- def index!
- raise NameError, 'never reached'
- end
-end
-
-class Ebook < ActiveRecord::Base
- include MeiliSearch::Rails
- attr_accessor :current_time, :published_at
-
- meilisearch synchronous: true, index_uid: safe_index_uid('eBooks') do
- searchable_attributes [:name]
- end
-
- def ms_dirty?
- return true if published_at.nil? || current_time.nil?
-
- # Consider dirty if published date is in the past
- # This doesn't make so much business sense but it's easy to test.
- published_at < current_time
- end
-end
diff --git a/spec/support/active_record_schema.rb b/spec/support/active_record_schema.rb
index 8673a122..832e0212 100644
--- a/spec/support/active_record_schema.rb
+++ b/spec/support/active_record_schema.rb
@@ -26,3 +26,5 @@
def ar_schema
@ar_schema ||= ActiveRecord::Schema.new
end
+
+ar_schema.verbose = false
diff --git a/spec/support/async_helper.rb b/spec/support/async_helper.rb
new file mode 100644
index 00000000..27a8be63
--- /dev/null
+++ b/spec/support/async_helper.rb
@@ -0,0 +1,6 @@
+module AsyncHelper
+ def self.await_last_task
+ task = MeiliSearch::Rails.client.tasks['results'].first
+ MeiliSearch::Rails.client.wait_for_task task['uid']
+ end
+end
diff --git a/spec/support/models/animals.rb b/spec/support/models/animals.rb
index a177298e..c9c8ceaa 100644
--- a/spec/support/models/animals.rb
+++ b/spec/support/models/animals.rb
@@ -31,3 +31,12 @@ def ms_id
"dog_#{id}"
end
end
+
+module TestUtil
+ def self.reset_animals!
+ Cat.clear_index!(true)
+ Cat.delete_all
+ Dog.clear_index!(true)
+ Dog.delete_all
+ end
+end
diff --git a/spec/support/models/book.rb b/spec/support/models/book.rb
index 5b92db30..ad956606 100644
--- a/spec/support/models/book.rb
+++ b/spec/support/models/book.rb
@@ -32,3 +32,11 @@ def public?
released && !premium
end
end
+
+module TestUtil
+ def self.reset_books!
+ Book.clear_index!(true)
+ Book.index(safe_index_uid('BookAuthor')).delete_all_documents
+ Book.index(safe_index_uid('Book')).delete_all_documents
+ end
+end
diff --git a/spec/support/models/color.rb b/spec/support/models/color.rb
index 96807212..c242fed7 100644
--- a/spec/support/models/color.rb
+++ b/spec/support/models/color.rb
@@ -25,6 +25,7 @@ class Color < ActiveRecord::Base
]
attributes_to_highlight [:name]
faceting max_values_per_facet: 20
+ proximity_precision 'byWord'
end
def will_save_change_to_hex?
@@ -35,3 +36,10 @@ def will_save_change_to_short_name?
false
end
end
+
+module TestUtil
+ def self.reset_colors!
+ Color.clear_index!(true)
+ Color.delete_all
+ end
+end
diff --git a/spec/support/models/movie.rb b/spec/support/models/movie.rb
index 5198a286..0f6338ec 100644
--- a/spec/support/models/movie.rb
+++ b/spec/support/models/movie.rb
@@ -12,3 +12,10 @@ class Movie < ActiveRecord::Base
typo_tolerance enabled: false
end
end
+
+module TestUtil
+ def self.reset_movies!
+ Movie.clear_index!(true)
+ Movie.delete_all
+ end
+end
diff --git a/spec/support/models/people.rb b/spec/support/models/people.rb
index 7c7c18da..590aeaf6 100644
--- a/spec/support/models/people.rb
+++ b/spec/support/models/people.rb
@@ -22,3 +22,10 @@ def will_save_change_to_full_name?
will_save_change_to_first_name? || will_save_change_to_last_name?
end
end
+
+module TestUtil
+ def self.reset_people!
+ People.clear_index!(true)
+ People.delete_all
+ end
+end
diff --git a/spec/support/models/restaurant.rb b/spec/support/models/restaurant.rb
index d39f4071..6c36211e 100644
--- a/spec/support/models/restaurant.rb
+++ b/spec/support/models/restaurant.rb
@@ -13,6 +13,6 @@ class Restaurant < ActiveRecord::Base
meilisearch index_uid: safe_index_uid('Restaurant') do
attributes_to_crop [:description]
crop_length 10
- pagination max_total_hits: 5
+ pagination max_total_hits: 2
end
end
diff --git a/spec/support/models/song.rb b/spec/support/models/song.rb
index be164576..81b1c3c2 100644
--- a/spec/support/models/song.rb
+++ b/spec/support/models/song.rb
@@ -19,6 +19,8 @@ class Song < ActiveRecord::Base
add_index PUBLIC_INDEX_UID, if: :public? do
searchable_attributes %i[name artist]
end
+
+ proximity_precision 'byAttribute'
end
private
diff --git a/spec/system/tech_shop_spec.rb b/spec/system/tech_shop_spec.rb
new file mode 100644
index 00000000..680be3ac
--- /dev/null
+++ b/spec/system/tech_shop_spec.rb
@@ -0,0 +1,180 @@
+require 'support/async_helper'
+require 'support/models/product'
+
+describe 'Tech shop' do
+ before(:all) do
+ Product.delete_all
+ Product.index.delete_all_documents!
+
+ # Google products
+ @blackberry = Product.create!(name: 'blackberry', href: 'google', tags: ['decent', 'businessmen love it'])
+ @nokia = Product.create!(name: 'nokia', href: 'google', tags: ['decent'])
+
+ # Amazon products
+ @android = Product.create!(name: 'android', href: 'amazon', tags: ['awesome'])
+ @samsung = Product.create!(name: 'samsung', href: 'amazon', tags: ['decent'])
+ @motorola = Product.create!(name: 'motorola', href: 'amazon', tags: ['decent'],
+ description: 'Not sure about features since I\'ve never owned one.')
+
+ # Ebay products
+ @palmpre = Product.create!(name: 'palmpre', href: 'ebay', tags: ['discontinued', 'worst phone ever'])
+ @palm_pixi_plus = Product.create!(name: 'palm pixi plus', href: 'ebay', tags: ['terrible'])
+ @lg_vortex = Product.create!(name: 'lg vortex', href: 'ebay', tags: ['decent'])
+ @t_mobile = Product.create!(name: 't mobile', href: 'ebay', tags: ['terrible'])
+
+ # Yahoo products
+ @htc = Product.create!(name: 'htc', href: 'yahoo', tags: ['decent'])
+ @htc_evo = Product.create!(name: 'htc evo', href: 'yahoo', tags: ['decent'])
+ @ericson = Product.create!(name: 'ericson', href: 'yahoo', tags: ['decent'])
+
+ # Apple products
+ @iphone = Product.create!(name: 'iphone', href: 'apple', tags: ['awesome', 'poor reception'],
+ description: 'Puts even more features at your fingertips')
+ @macbook = Product.create!(name: 'macbookpro', href: 'apple')
+
+ # Unindexed products
+ @sekrit = Product.create!(name: 'super sekrit', href: 'amazon', release_date: Time.now + 1.day)
+ @no_href = Product.create!(name: 'super sekrit too; missing href')
+
+ # Subproducts
+ @camera = Camera.create!(name: 'canon eos rebel t3', href: 'canon')
+
+ Product.reindex!(MeiliSearch::Rails::IndexSettings::DEFAULT_BATCH_SIZE, true)
+ end
+
+ context 'product' do
+ it 'defaults to asynchronous' do
+ p = Product.new
+
+ expect(p).not_to be_ms_synchronous
+ end
+
+ it 'supports manual indexing' do
+ products_before_clear = Product.raw_search('')['hits']
+ expect(products_before_clear).not_to be_empty
+
+ Product.clear_index!(true)
+
+ products_after_clear = Product.raw_search('')['hits']
+ expect(products_after_clear).to be_empty
+ Product.reindex!(MeiliSearch::Rails::IndexSettings::DEFAULT_BATCH_SIZE, true)
+
+ products_after_reindex = Product.raw_search('')['hits']
+ expect(products_after_reindex).not_to be_empty
+ expect(products_before_clear).to eq(products_after_reindex)
+ end
+ end
+
+ describe 'basic searching' do
+ it 'finds the iphone' do
+ results = Product.search('iphone')
+ expect(results).to contain_exactly(@iphone)
+ end
+
+ it 'searches case insensitively' do
+ results = Product.search('IPHONE')
+ expect(results).to contain_exactly(@iphone)
+ end
+
+ it 'finds all amazon products' do
+ results = Product.search('amazon')
+ expect(results).to contain_exactly(@android, @samsung, @motorola)
+ end
+
+ it 'finds all "palm" phones with wildcard word search' do
+ results = Product.search('pal')
+ expect(results).to contain_exactly(@palmpre, @palm_pixi_plus)
+ end
+
+ it 'searches multiple words from the same field' do
+ results = Product.search('palm pixi plus')
+ expect(results).to contain_exactly(@palm_pixi_plus)
+ end
+
+ it 'finds using phrase search' do
+ results = Product.search('coco "palm"')
+ expect(results).to contain_exactly(@palm_pixi_plus)
+ end
+
+ it 'narrows the results by searching across multiple fields' do
+ results = Product.search('apple iphone')
+ expect(results).to include(@iphone, @macbook)
+ end
+
+ it 'does not search on non-indexed fields' do
+ expect(Product.search('features')).to be_empty
+ end
+
+ it 'deletes associated document on #destroy' do
+ ipad = Product.create!(name: 'ipad', href: 'apple', tags: ['awesome', 'great battery'],
+ description: 'Big screen')
+
+ ipad.index!(true)
+ results = Product.search('ipad')
+ expect(results).to contain_exactly(ipad)
+
+ ipad.destroy
+ AsyncHelper.await_last_task
+
+ results = Product.raw_search('ipad')['hits']
+ expect(results).to be_empty
+ end
+
+ context 'when a document cannot be found in ActiveRecord' do
+ it 'does not throw an exception' do
+ Product.index.add_documents!(@palmpre.attributes.merge(id: -1))
+ expect { Product.search('pal') }.not_to raise_error
+ Product.index.delete_document!(-1)
+ end
+
+ it 'returns other available results' do
+ Product.index.add_documents!(@palmpre.attributes.merge(id: -1))
+ expect(Product.search('pal').size).to eq(2)
+ Product.index.delete_document!(-1)
+ end
+ end
+
+ it 'reindexing does not duplicate record' do
+ expect(Product.search('nokia')).to contain_exactly(@nokia)
+ @nokia.index!
+ expect(Product.search('nokia')).to contain_exactly(@nokia)
+ @nokia.index!
+ @nokia.index!
+ expect(Product.search('nokia')).to contain_exactly(@nokia)
+ end
+
+ it 'does not return products that are not indexable' do
+ @sekrit.index!
+ @no_href.index!
+ results = Product.search('sekrit')
+ expect(results).to be_empty
+ end
+
+ it 'includes instances of subclasses' do
+ @camera.index!
+ results = Product.search('eos rebel')
+ expect(results).to contain_exactly(@camera)
+ end
+
+ it 'deletes a document that is no longer indexable' do
+ results = Product.search('sekrit')
+ expect(results).to be_empty
+
+ @sekrit.update(release_date: Time.now - 1.day)
+ @sekrit.index!(true)
+ results = Product.search('sekrit')
+ expect(results).to contain_exactly(@sekrit)
+
+ @sekrit.update(release_date: Time.now + 1.day)
+ @sekrit.save!
+ @sekrit.index!(true)
+ results = Product.search('sekrit')
+ expect(results).to be_empty
+ end
+
+ it 'supports synonyms' do
+ expect(Product.search('pomme')).to eq(Product.search('apple'))
+ expect(Product.search('m_b_p')).to eq(Product.search('macbookpro'))
+ end
+ end
+end