diff --git a/app/lib/seeder.rb b/app/lib/seeder.rb index 485ec2865..5262c2133 100644 --- a/app/lib/seeder.rb +++ b/app/lib/seeder.rb @@ -26,6 +26,10 @@ class Seeder if klass.none? message = ["Creating", count, plural].compact.join(" ") + if klass.respond_to?(:algolia_search) && Settings::General.algolia_search_enabled? + puts " Algolia search enabled, clearing index for #{klass}..." + klass.clear_index! + end puts " #{@counter}. #{message}." yield else diff --git a/app/models/concerns/algolia_searchable.rb b/app/models/concerns/algolia_searchable.rb new file mode 100644 index 000000000..a53a08836 --- /dev/null +++ b/app/models/concerns/algolia_searchable.rb @@ -0,0 +1,14 @@ +module AlgoliaSearchable + extend ActiveSupport::Concern + + DEFAULT_ALGOLIA_SETTINGS = { + per_environment: true, + disable_indexing: -> { Settings::General.algolia_search_enabled? == false }, + enqueue: :trigger_sidekiq_worker + }.freeze + + included do + include AlgoliaSearch + public_send :include, "AlgoliaSearchable::Searchable#{name}".constantize + end +end diff --git a/app/models/concerns/algolia_searchable/searchable_user.rb b/app/models/concerns/algolia_searchable/searchable_user.rb new file mode 100644 index 000000000..a28ba7c5d --- /dev/null +++ b/app/models/concerns/algolia_searchable/searchable_user.rb @@ -0,0 +1,24 @@ +module AlgoliaSearchable + module SearchableUser + extend ActiveSupport::Concern + + included do + algoliasearch(**DEFAULT_ALGOLIA_SETTINGS, unless: :bad_actor?) do + attribute :name, :username + attribute :profile_image do + profile_image_90 + end + end + end + + class_methods do + def trigger_sidekiq_worker(record, delete) + AlgoliaSearch::SearchIndexWorker.perform_async(record.class.name, record.id, delete) + end + end + + def bad_actor? + score.negative? || banished? || spam_or_suspended? + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index c84c5eebe..584edb205 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -5,6 +5,7 @@ class User < ApplicationRecord include CloudinaryHelper include Images::Profile.for(:profile_image_url) + include AlgoliaSearchable # NOTE: we are using an inline module to keep profile related things together. concerning :Profiles do @@ -315,6 +316,7 @@ class User < ApplicationRecord calculated_score = (badge_achievements_count * 10) + user_reaction_points calculated_score -= 500 if spam? update_column(:score, calculated_score) + AlgoliaSearch::SearchIndexWorker.perform_async(self.class.name, id, false) end def path diff --git a/app/workers/algolia_search/search_index_worker.rb b/app/workers/algolia_search/search_index_worker.rb new file mode 100644 index 000000000..46a91e39f --- /dev/null +++ b/app/workers/algolia_search/search_index_worker.rb @@ -0,0 +1,19 @@ +module AlgoliaSearch + class SearchIndexWorker + include Sidekiq::Worker + sidekiq_options queue: :medium_priority, retry: 5, tags: ["algolia"] + + def perform(klass, id, remove) + return unless Settings::General.algolia_search_enabled? + + record = klass.constantize + + if remove + index = AlgoliaSearch.client.init_index(record.index_name) + index.delete_object(id) + else + record.find(id).index! + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index c9165f3bf..c9c6bc23f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1022,4 +1022,49 @@ RSpec.describe User do end end end + + context "when indexing with Algolia", :algolia do + it "indexes the user on create" do + allow(AlgoliaSearch::SearchIndexWorker).to receive(:perform_async) + create(:user) + expect(AlgoliaSearch::SearchIndexWorker).to have_received(:perform_async).with("User", kind_of(Integer), false) + end + + it "updates user index if user's name has changed" do + user = create(:user) + allow(AlgoliaSearch::SearchIndexWorker).to receive(:perform_async) + user.update(name: "New Name") + expect(AlgoliaSearch::SearchIndexWorker).to have_received(:perform_async).with("User", user.id, false) + end + + describe "#bad_actor?" do + it "returns false to a regular user" do + user = build(:user) + expect(user.bad_actor?).to be(false) + end + + it "returns true if the user has negative score" do + user = build(:user, score: -500) + expect(user.bad_actor?).to be(true) + end + + it "returns true if the user has spam role" do + user = build(:user) + user.add_role(:spam) + expect(user.bad_actor?).to be(true) + end + + it "return true if user is suspended" do + user = build(:user) + user.add_role(:suspended) + expect(user.bad_actor?).to be(true) + end + + it "return true if user is banished" do + user = build(:user) + allow(user).to receive(:banished?).and_return(true) + expect(user.bad_actor?).to be(true) + end + end + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 114feb735..9805ebefd 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -128,6 +128,12 @@ RSpec.configure do |config| end end + config.before(:each, :algolia) do + allow(Settings::General).to receive_messages( + algolia_application_id: "on", algolia_search_only_api_key: "on", algolia_api_key: "on", + ) + end + config.before(:suite) do # Set the TZ ENV variable with the current random timezone from zonebie # which we can then use to properly set the browser time for Capybara specs diff --git a/spec/support/algolia_mock_requester.rb b/spec/support/algolia_mock_requester.rb new file mode 100644 index 000000000..d19f098f6 --- /dev/null +++ b/spec/support/algolia_mock_requester.rb @@ -0,0 +1,37 @@ +# from https://github.com/algolia/algoliasearch-client-ruby/blob/master/test/algolia/integration/mocks/mock_requester.rb +class AlgoliaMockRequester + attr_accessor :requests + + def initialize + @connection = nil + @requests = [] + end + + def send_request(host, method, path, body, headers, timeout, connect_timeout) + request = { + host: host, + method: method, + path: path, + body: body, + headers: headers, + timeout: timeout, + connect_timeout: connect_timeout + } + + @requests.push(request) + + Algolia::Http::Response.new( + status: 200, + body: '{"hits": [], "status": "published"}', + headers: {}, + ) + end + + def get_connection(host) + @connection = host + end + + def build_url(host) + host.protocol + host.url + end +end diff --git a/spec/workers/algolia_search/search_index_worker_spec.rb b/spec/workers/algolia_search/search_index_worker_spec.rb new file mode 100644 index 000000000..e54b06ea7 --- /dev/null +++ b/spec/workers/algolia_search/search_index_worker_spec.rb @@ -0,0 +1,25 @@ +require "rails_helper" +# rubocop:disable RSpec/AnyInstance +RSpec.describe AlgoliaSearch::SearchIndexWorker, :algolia, type: :worker do + let(:user) { create(:user) } + + before do + mock_requester = AlgoliaMockRequester.new + algolia_config = Algolia::Search::Config.new(AlgoliaSearch.configuration) + mock_client = Algolia::Search::Client.new(algolia_config, http_requester: mock_requester) + AlgoliaSearch.instance_variable_set(:@client, mock_client) + end + + it "remove the record from Algolia if record is deleted" do + expect_any_instance_of(Algolia::Search::Index).to receive(:delete_object).with(user.id) + described_class.new.perform(user.class.name, user.id, true) + end + + it "index the record in Algolia if record is created" do + allow(User).to receive(:find).with(user.id).and_return(user) + allow(user).to receive(:index!) + described_class.new.perform(user.class.name, user.id, false) + expect(user).to have_received(:index!) + end +end +# rubocop:enable RSpec/AnyInstance