From d587ba962dc5cf5c2906e5c25c3318e118c7fced Mon Sep 17 00:00:00 2001 From: Mac Siri Date: Fri, 19 Apr 2024 10:03:04 -0400 Subject: [PATCH] Add AlgoliaSearchable for User (#20869) * Add AlgoliaSearchable::SearchableUser * Update spec * Refactor * Refactor * Add more test * Refactor * Update seeder to clear Algolia index if it exist * All Algolia directly in update_score * Fix broken spec * Remove new queue * Account for more user statuses * Remove unncessary test for now --- app/lib/seeder.rb | 4 ++ app/models/concerns/algolia_searchable.rb | 14 ++++++ .../algolia_searchable/searchable_user.rb | 24 ++++++++++ app/models/user.rb | 2 + .../algolia_search/search_index_worker.rb | 19 ++++++++ spec/models/user_spec.rb | 45 +++++++++++++++++++ spec/rails_helper.rb | 6 +++ spec/support/algolia_mock_requester.rb | 37 +++++++++++++++ .../search_index_worker_spec.rb | 25 +++++++++++ 9 files changed, 176 insertions(+) create mode 100644 app/models/concerns/algolia_searchable.rb create mode 100644 app/models/concerns/algolia_searchable/searchable_user.rb create mode 100644 app/workers/algolia_search/search_index_worker.rb create mode 100644 spec/support/algolia_mock_requester.rb create mode 100644 spec/workers/algolia_search/search_index_worker_spec.rb 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