From 393ba0022144b5b20b2e1996d6f07c7acf987ec9 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 3 May 2021 11:09:45 -0400 Subject: [PATCH] =?UTF-8?q?Remove=20Elasticsearch=20=E2=9C=82=EF=B8=8F=20?= =?UTF-8?q?=E2=9C=82=EF=B8=8F=20=E2=9C=82=EF=B8=8F=20=E2=9C=82=EF=B8=8F=20?= =?UTF-8?q?=E2=9C=82=EF=B8=8F=20=E2=9C=82=EF=B8=8F=20=E2=9C=82=EF=B8=8F=20?= =?UTF-8?q?=E2=9C=82=EF=B8=8F=20=E2=9C=82=EF=B8=8F=20=E2=9C=82=EF=B8=8F=20?= =?UTF-8?q?=E2=9C=82=EF=B8=8F=20=E2=9C=82=EF=B8=8F=20=E2=9C=82=EF=B8=8F=20?= =?UTF-8?q?=E2=9C=82=EF=B8=8F=20=20(#13606)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Attempt number 1 * Fix rack_attack specs * Fix users_searches_users spec * Fix display_users_search_spec * Fix comment typo * Remove search:destroy task from cypress * Remove port 9300 from gitpod * Stub response in attack_spec --- .env_sample | 5 +- .gitpod.dockerfile | 9 - .gitpod.yml | 5 - .travis.yml | 9 - Gemfile | 3 +- Gemfile.lock | 9 - README.md | 1 - .../api/v0/health_checks_controller.rb | 8 - app/controllers/chat_channels_controller.rb | 1 - app/controllers/search_controller.rb | 8 - app/lib/acts_as_taggable_on/tag.rb | 11 - app/lib/forem_stats_drivers/datadog_driver.rb | 1 - app/models/article.rb | 9 - app/models/chat_channel_membership.rb | 7 - app/models/comment.rb | 13 - app/models/concerns/searchable.rb | 32 --- app/models/listing.rb | 8 - app/models/message.rb | 1 - app/models/organization.rb | 5 - app/models/podcast_episode.rb | 11 - app/models/tag.rb | 11 - app/models/user.rb | 19 +- .../data_sync/elasticsearch/article.rb | 16 -- app/services/data_sync/elasticsearch/base.rb | 33 --- .../data_sync/elasticsearch/organization.rb | 17 -- app/services/data_sync/elasticsearch/tag.rb | 30 --- app/services/data_sync/elasticsearch/user.rb | 30 --- app/services/edge_cache/bust_commentable.rb | 1 - app/services/podcasts/create_episode.rb | 1 - app/services/search/base.rb | 143 ----------- .../search/chat_channel_membership.rb | 28 --- app/services/search/client.rb | 65 ----- app/services/search/cluster.rb | 72 ------ app/services/search/feed_content.rb | 83 ------- app/services/search/listing.rb | 29 --- .../query_builders/chat_channel_membership.rb | 57 ----- .../search/query_builders/feed_content.rb | 202 ---------------- app/services/search/query_builders/listing.rb | 91 ------- .../search/query_builders/query_base.rb | 80 ------- app/services/search/query_builders/user.rb | 52 ---- app/services/search/reading_list.rb | 78 ------ app/services/search/tag.rb | 45 ---- app/services/search/user.rb | 80 ------- app/services/users/delete_activity.rb | 2 - app/services/users/delete_articles.rb | 2 - app/services/users/delete_comments.rb | 1 - app/workers/articles/score_calc_worker.rb | 1 - .../indexes_memberships_worker.rb | 12 - .../metrics/record_data_counts_worker.rb | 10 - .../rating_votes/assign_rating_worker.rb | 1 - app/workers/search/bulk_index_worker.rb | 18 -- app/workers/search/index_worker.rb | 12 - .../search/remove_from_index_worker.rb | 17 -- bin/setup | 3 - .../mappings/chat_channel_memberships.json | 61 ----- .../elasticsearch/mappings/feed_content.json | 186 --------------- config/elasticsearch/mappings/listings.json | 87 ------- config/elasticsearch/mappings/reactions.json | 96 -------- config/elasticsearch/mappings/tags.json | 31 --- config/elasticsearch/mappings/users.json | 87 ------- config/initializers/cypress_rails.rb | 1 - config/routes.rb | 2 - container-compose.yml | 33 +-- docker-compose.yml | 38 +-- docs/admin/admin-search.md | 4 +- docs/backend/data-update-scripts.md | 4 +- docs/backend/elasticsearch.md | 92 ------- docs/backend/readme.md | 1 - docs/installation/containers.md | 6 - docs/installation/linux.md | 15 -- docs/installation/mac.md | 164 ++----------- docs/installation/windows.md | 62 +---- docs/maintainers/deploying.md | 17 +- docs/technical-overview/architecture.md | 8 +- docs/technical-overview/stack.md | 4 +- docs/tests/readme.md | 5 - docs/tests/test-flags.md | 46 +--- docs/troubleshooting.md | 60 ----- ...at_channel_memberships_to_elasticsearch.rb | 2 +- ...10152018_resync_elasticsearch_documents.rb | 10 +- ...feed_content_and_users_to_elasticsearch.rb | 30 +-- ...803142830_reindex_listing_search_column.rb | 2 +- ...articles_with_duplicate_feed_source_url.rb | 126 +++++----- ...h_duplicate_user_id_title_body_markdown.rb | 2 +- ...0911045602_reindex_articles_with_videos.rb | 4 +- ...924140813_remove_reaction_index_by_name.rb | 6 +- ...34117_reindex_users_for_username_search.rb | 6 +- ...rticles_comments_elasticsearch_document.rb | 6 +- lib/tasks/app_initializer.rake | 3 - lib/tasks/search.rake | 26 -- release-tasks.sh | 3 +- spec/factories/tags.rb | 4 - spec/initializers/rack/attack_spec.rb | 9 +- spec/lib/acts_as_taggable_on/tag_spec.rb | 31 --- ...annel_memberships_to_elasticsearch_spec.rb | 11 - ...content_and_users_to_elasticsearch_spec.rb | 28 --- .../reindex_listing_search_column_spec.rb | 11 - .../reindex_users_for_username_search_spec.rb | 12 - .../remove_reaction_index_by_name_spec.rb | 13 - .../resync_elasticsearch_documents_spec.rb | 56 ----- ...es_comments_elasticsearch_document_spec.rb | 17 -- spec/models/article_spec.rb | 32 --- spec/models/chat_channel_membership_spec.rb | 18 -- spec/models/comment_spec.rb | 17 -- spec/models/concerns/searchable_spec.rb | 71 ------ spec/models/listing_spec.rb | 18 -- spec/models/message_spec.rb | 9 - spec/models/organization_spec.rb | 13 - spec/models/podcast_episode_spec.rb | 22 -- spec/models/tag_spec.rb | 35 --- spec/models/user_spec.rb | 23 -- spec/rails_helper.rb | 24 -- spec/requests/api/v0/health_checks_spec.rb | 15 -- spec/requests/search_spec.rb | 12 - .../search/article_serializer_spec.rb | 6 - .../search/comment_serializer_spec.rb | 6 - .../search/user_serializer_spec.rb | 22 -- .../data_sync/elasticsearch/article_spec.rb | 8 - .../data_sync/elasticsearch/base_spec.rb | 24 -- .../elasticsearch/organization_spec.rb | 8 - .../data_sync/elasticsearch/tag_spec.rb | 8 - .../data_sync/elasticsearch/user_spec.rb | 8 - .../edge_cache/bust_commentable_spec.rb | 6 - spec/services/moderator/banish_user_spec.rb | 2 +- spec/services/moderator/merge_user_spec.rb | 14 -- spec/services/podcasts/create_episode_spec.rb | 5 - spec/services/search/base_spec.rb | 170 ------------- .../search/chat_channel_membership_spec.rb | 148 ------------ spec/services/search/cluster_spec.rb | 88 ------- spec/services/search/feed_content_spec.rb | 224 ------------------ spec/services/search/listing_spec.rb | 178 -------------- .../chat_channel_membership_spec.rb | 103 -------- .../query_builders/feed_content_spec.rb | 130 ---------- .../search/query_builders/listing_spec.rb | 114 --------- .../search/query_builders/user_spec.rb | 70 ------ spec/services/search/reading_list_spec.rb | 115 --------- spec/services/search/tag_spec.rb | 55 ----- spec/services/search/user_spec.rb | 99 -------- spec/services/users/delete_articles_spec.rb | 21 -- spec/services/users/delete_comments_spec.rb | 8 - spec/services/users/delete_spec.rb | 19 -- spec/support/elasticsearch_helpers.rb | 19 -- spec/support/initializers/vcr.rb | 3 +- .../user_visits_articles_by_tag_spec.rb | 8 +- .../user_visits_articles_by_timeframe_spec.rb | 2 +- .../comments/user_delete_a_comment_spec.rb | 2 +- .../user_visits_homepage_articles_spec.rb | 6 +- .../homepage/user_visits_homepage_spec.rb | 2 +- ..._visits_homepage_with_announcement_spec.rb | 2 +- .../user_views_an_organization_spec.rb | 6 +- .../search/display_articles_search_spec.rb | 2 +- .../search/display_comments_search_spec.rb | 2 +- .../search/display_users_search_spec.rb | 11 +- .../system/search/user_searches_users_spec.rb | 5 +- spec/system/user/view_user_comments_spec.rb | 4 +- spec/system/user/view_user_index_spec.rb | 2 +- spec/system/user_uses_the_editor_spec.rb | 2 +- .../articles/score_calc_worker_spec.rb | 7 - .../indexes_memberships_worker_spec.rb | 18 -- .../metrics/record_data_counts_worker_spec.rb | 9 - .../rating_votes/assign_rating_worker_spec.rb | 7 - spec/workers/search/bulk_index_worker_spec.rb | 22 -- spec/workers/search/index_worker_spec.rb | 19 -- .../search/remove_from_index_worker_spec.rb | 20 -- vendor/cache/elasticsearch-7.12.0.gem | Bin 13824 -> 0 bytes vendor/cache/elasticsearch-api-7.12.0.gem | Bin 90624 -> 0 bytes .../cache/elasticsearch-transport-7.12.0.gem | Bin 55296 -> 0 bytes 167 files changed, 178 insertions(+), 4996 deletions(-) delete mode 100644 app/lib/acts_as_taggable_on/tag.rb delete mode 100644 app/models/concerns/searchable.rb delete mode 100644 app/services/data_sync/elasticsearch/article.rb delete mode 100644 app/services/data_sync/elasticsearch/base.rb delete mode 100644 app/services/data_sync/elasticsearch/organization.rb delete mode 100644 app/services/data_sync/elasticsearch/tag.rb delete mode 100644 app/services/data_sync/elasticsearch/user.rb delete mode 100644 app/services/search/base.rb delete mode 100644 app/services/search/chat_channel_membership.rb delete mode 100644 app/services/search/client.rb delete mode 100644 app/services/search/cluster.rb delete mode 100644 app/services/search/feed_content.rb delete mode 100644 app/services/search/listing.rb delete mode 100644 app/services/search/query_builders/chat_channel_membership.rb delete mode 100644 app/services/search/query_builders/feed_content.rb delete mode 100644 app/services/search/query_builders/listing.rb delete mode 100644 app/services/search/query_builders/query_base.rb delete mode 100644 app/services/search/query_builders/user.rb delete mode 100644 app/services/search/reading_list.rb delete mode 100644 app/services/search/tag.rb delete mode 100644 app/services/search/user.rb delete mode 100644 app/workers/chat_channels/indexes_memberships_worker.rb delete mode 100644 app/workers/search/bulk_index_worker.rb delete mode 100644 app/workers/search/index_worker.rb delete mode 100644 app/workers/search/remove_from_index_worker.rb delete mode 100644 config/elasticsearch/mappings/chat_channel_memberships.json delete mode 100644 config/elasticsearch/mappings/feed_content.json delete mode 100644 config/elasticsearch/mappings/listings.json delete mode 100644 config/elasticsearch/mappings/reactions.json delete mode 100644 config/elasticsearch/mappings/tags.json delete mode 100644 config/elasticsearch/mappings/users.json delete mode 100644 docs/backend/elasticsearch.md delete mode 100644 lib/tasks/search.rake delete mode 100644 spec/lib/acts_as_taggable_on/tag_spec.rb delete mode 100644 spec/lib/data_update_scripts/index_chat_channel_memberships_to_elasticsearch_spec.rb delete mode 100644 spec/lib/data_update_scripts/re_index_feed_content_and_users_to_elasticsearch_spec.rb delete mode 100644 spec/lib/data_update_scripts/reindex_listing_search_column_spec.rb delete mode 100644 spec/lib/data_update_scripts/reindex_users_for_username_search_spec.rb delete mode 100644 spec/lib/data_update_scripts/remove_reaction_index_by_name_spec.rb delete mode 100644 spec/lib/data_update_scripts/resync_elasticsearch_documents_spec.rb delete mode 100644 spec/lib/data_update_scripts/resync_unpublished_articles_comments_elasticsearch_document_spec.rb delete mode 100644 spec/models/concerns/searchable_spec.rb delete mode 100644 spec/services/data_sync/elasticsearch/article_spec.rb delete mode 100644 spec/services/data_sync/elasticsearch/base_spec.rb delete mode 100644 spec/services/data_sync/elasticsearch/organization_spec.rb delete mode 100644 spec/services/data_sync/elasticsearch/tag_spec.rb delete mode 100644 spec/services/data_sync/elasticsearch/user_spec.rb delete mode 100644 spec/services/search/base_spec.rb delete mode 100644 spec/services/search/chat_channel_membership_spec.rb delete mode 100644 spec/services/search/cluster_spec.rb delete mode 100644 spec/services/search/feed_content_spec.rb delete mode 100644 spec/services/search/listing_spec.rb delete mode 100644 spec/services/search/query_builders/chat_channel_membership_spec.rb delete mode 100644 spec/services/search/query_builders/feed_content_spec.rb delete mode 100644 spec/services/search/query_builders/listing_spec.rb delete mode 100644 spec/services/search/query_builders/user_spec.rb delete mode 100644 spec/services/search/reading_list_spec.rb delete mode 100644 spec/services/search/tag_spec.rb delete mode 100644 spec/services/search/user_spec.rb delete mode 100644 spec/support/elasticsearch_helpers.rb delete mode 100644 spec/workers/chat_channels/indexes_memberships_worker_spec.rb delete mode 100644 spec/workers/search/bulk_index_worker_spec.rb delete mode 100644 spec/workers/search/index_worker_spec.rb delete mode 100644 spec/workers/search/remove_from_index_worker_spec.rb delete mode 100644 vendor/cache/elasticsearch-7.12.0.gem delete mode 100644 vendor/cache/elasticsearch-api-7.12.0.gem delete mode 100644 vendor/cache/elasticsearch-transport-7.12.0.gem diff --git a/.env_sample b/.env_sample index 1a9fcf4c3..0645a5ca0 100644 --- a/.env_sample +++ b/.env_sample @@ -33,9 +33,6 @@ REDIS_SIDEKIQ_URL="redis://localhost:6379" # Redis Devices/Rpush storage REDIS_RPUSH_URL="redis://localhost:6379" -# Elasticsearch -ELASTICSEARCH_URL="http://localhost:9200" - # OpenResty OPENRESTY_URL="" @@ -164,4 +161,4 @@ SENDGRID_API_KEY="Optional, Production only" # Disable simplecov coverage testing when running rspec locally -COVERAGE="false" \ No newline at end of file +COVERAGE="false" diff --git a/.gitpod.dockerfile b/.gitpod.dockerfile index a8a6f369d..c05178468 100644 --- a/.gitpod.dockerfile +++ b/.gitpod.dockerfile @@ -18,12 +18,3 @@ RUN sudo apt-get update \ && sudo apt-get install -y \ redis-server \ && sudo rm -rf /var/lib/apt/lists/* - -# Install Elasticsearch -ARG ES_REPO=https://artifacts.elastic.co/downloads/elasticsearch -ARG ES_ARCHIVE=elasticsearch-oss-7.5.2-linux-x86_64.tar.gz -RUN wget "${ES_REPO}/${ES_ARCHIVE}" \ - && wget "${ES_REPO}/${ES_ARCHIVE}.sha512" \ - && shasum -a 512 -c ${ES_ARCHIVE}.sha512 \ - && tar -xzf ${ES_ARCHIVE} \ - && rm ${ES_ARCHIVE} ${ES_ARCHIVE}.sha512 diff --git a/.gitpod.yml b/.gitpod.yml index 3e081f489..181d298fc 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -10,16 +10,11 @@ ports: onOpen: ignore - port: 6379 onOpen: ignore - - port: 9200 - onOpen: ignore - - port: 9300 - onOpen: ignore tasks: - name: Forem Server before: | redis-server & - /home/gitpod/elasticsearch-7.5.2/bin/elasticsearch & init: > cp .env_sample .env && gem install solargraph && diff --git a/.travis.yml b/.travis.yml index 3321bfce3..d1b273bcf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -39,20 +39,11 @@ env: - KNAPSACK_PRO_CI_NODE_TOTAL=3 - COVERAGE_REPORTS_TOTAL=4 - FOREM_OWNER_SECRET="secret" # test secret so e2e tests can run properly. - - ELASTICSEARCH_URL="http://localhost:9200" before_install: - gem install bundler:"<2.3" install: - date --rfc-3339=seconds - nvm install - - pkill -9 -f elasticsearch || true - - curl -s -O https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.5.2-amd64.deb - - sudo dpkg -i --force-confnew elasticsearch-7.5.2-amd64.deb - - sudo sed -i.old 's/-Xms1g/-Xms128m/' /etc/elasticsearch/jvm.options - - sudo sed -i.old 's/-Xmx1g/-Xmx128m/' /etc/elasticsearch/jvm.options - - echo -e '-XX:+DisableExplicitGC\n-Djdk.io.permissionsUseCanonicalPath=true\n-Dlog4j.skipJansi=true\n-server\n' | sudo tee -a /etc/elasticsearch/jvm.options - - sudo chown -R elasticsearch:elasticsearch /etc/default/elasticsearch - - sudo systemctl start elasticsearch - bundle config set path 'vendor/bundle' - cp .env_sample .env - bin/ci-bundle diff --git a/Gemfile b/Gemfile index e521a9b11..9197f872c 100644 --- a/Gemfile +++ b/Gemfile @@ -28,7 +28,6 @@ gem "devise" # Flexible authentication solution for Rails gem "devise_invitable", "~> 2.0.5" # Allows invitations to be sent for joining gem "dogstatsd-ruby", "~> 4.8" # A client for DogStatsD, an extension of the StatsD metric server for Datadog gem "doorkeeper", "~> 5.5" # Oauth 2 provider -gem "elasticsearch", "~> 7.12" # Powers DEVs core search functionality gem "email_validator", "~> 2.2" # Email validator for Rails and ActiveModel gem "emoji_regex", "~> 3.2" # A pair of Ruby regular expressions for matching Unicode Emoji symbols gem "fastly", "~> 3.0" # Client library for the Fastly acceleration system @@ -65,7 +64,7 @@ gem "omniauth-github", "~> 2.0" # OmniAuth strategy for GitHub gem "omniauth-rails_csrf_protection", "~> 1.0" # Provides CSRF protection on OmniAuth request endpoint on Rails application. gem "omniauth-twitter", "~> 1.4" # OmniAuth strategy for Twitter gem "parallel", "~> 1.20" # Run any kind of code in parallel processes -gem "patron", "~> 0.13.3" # HTTP client library based on libcurl, used with Elasticsearch to support http keep-alive connections +gem "patron", "~> 0.13.3" # HTTP client library based on libcurl, used with GitHub OAuth client gem "pg", "~> 1.2" # Pg is the Ruby interface to the PostgreSQL RDBMS gem "pg_search", "~> 2.3.5" # PgSearch builds Active Record named scopes that take advantage of PostgreSQL's full text search gem "puma", "~> 5.2.2" # Puma is a simple, fast, threaded, and highly concurrent HTTP 1.1 server diff --git a/Gemfile.lock b/Gemfile.lock index 9799ecc0c..097690b86 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -223,14 +223,6 @@ GEM dotenv-rails (2.7.6) dotenv (= 2.7.6) railties (>= 3.2) - elasticsearch (7.12.0) - elasticsearch-api (= 7.12.0) - elasticsearch-transport (= 7.12.0) - elasticsearch-api (7.12.0) - multi_json - elasticsearch-transport (7.12.0) - faraday (~> 1) - multi_json em-websocket (0.5.2) eventmachine (>= 0.12.9) http_parser.rb (~> 0.6.0) @@ -880,7 +872,6 @@ DEPENDENCIES dogstatsd-ruby (~> 4.8) doorkeeper (~> 5.5) dotenv-rails (~> 2.7.6) - elasticsearch (~> 7.12) email_validator (~> 2.2) emoji_regex (~> 3.2) erb_lint (~> 0.0.37) diff --git a/README.md b/README.md index 83def2cda..e0dcfe3ec 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,6 @@ A more complete overview of our stack is available in - [ImageMagick](https://imagemagick.org/): please refer to ImageMagick's [installation instructions](https://imagemagick.org/script/download.php). - [Redis](https://redis.io/) 4 or higher. -- [Elasticsearch](https://www.elastic.co) 7 or higher. #### Containers diff --git a/app/controllers/api/v0/health_checks_controller.rb b/app/controllers/api/v0/health_checks_controller.rb index 26a5abc3e..31e1b719f 100644 --- a/app/controllers/api/v0/health_checks_controller.rb +++ b/app/controllers/api/v0/health_checks_controller.rb @@ -7,14 +7,6 @@ module Api render json: { message: "App is up!" }, status: :ok end - def search - if Search::Client.ping - render json: { message: "Search ping succeeded!" }, status: :ok - else - render json: { message: "Search ping failed!" }, status: :internal_server_error - end - end - def database if ActiveRecord::Base.connected? render json: { message: "Database connected" }, status: :ok diff --git a/app/controllers/chat_channels_controller.rb b/app/controllers/chat_channels_controller.rb index ecc56f786..91e153160 100644 --- a/app/controllers/chat_channels_controller.rb +++ b/app/controllers/chat_channels_controller.rb @@ -159,7 +159,6 @@ class ChatChannelsController < ApplicationController authorize chat_channel chat_channel.status = "blocked" chat_channel.save - chat_channel.chat_channel_memberships.map(&:index_to_elasticsearch) render json: { status: "success", message: "chat channel blocked" }, status: :ok end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 27ff8f0af..1cd2fd519 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -93,10 +93,6 @@ class SearchController < ApplicationController render json: { result: result } end - def users - render json: { result: user_search } - end - def usernames result = Search::Postgres::Username.search_documents(params[:username]) @@ -197,10 +193,6 @@ class SearchController < ApplicationController ) end - def user_search - Search::User.search_documents(params: user_params.to_h) - end - def chat_channel_params params.permit(CHAT_CHANNEL_PARAMS) end diff --git a/app/lib/acts_as_taggable_on/tag.rb b/app/lib/acts_as_taggable_on/tag.rb deleted file mode 100644 index 715cb1eee..000000000 --- a/app/lib/acts_as_taggable_on/tag.rb +++ /dev/null @@ -1,11 +0,0 @@ -module ActsAsTaggableOn - class Tag - after_commit on: %i[create update] do - ::Tag.find(id).index_to_elasticsearch - end - - after_commit on: :update do - DataSync::Elasticsearch::Tag.new(self).call - end - end -end diff --git a/app/lib/forem_stats_drivers/datadog_driver.rb b/app/lib/forem_stats_drivers/datadog_driver.rb index ab4280196..cba2c38fa 100644 --- a/app/lib/forem_stats_drivers/datadog_driver.rb +++ b/app/lib/forem_stats_drivers/datadog_driver.rb @@ -9,7 +9,6 @@ module ForemStatsDrivers c.tracer enabled: ENV["DD_API_KEY"].present? c.tracer partial_flush: true c.tracer priority_sampling: true - c.use :elasticsearch c.use :sidekiq c.use :redis, service_name: "redis", describes: { url: ENV["REDIS_URL"] } c.use :redis, service_name: "redis-sessions", describes: { url: ENV["REDIS_SESSIONS_URL"] } diff --git a/app/models/article.rb b/app/models/article.rb index 098ba2997..cc840b6ea 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -3,14 +3,9 @@ class Article < ApplicationRecord include ActionView::Helpers include Storext.model include Reactable - include Searchable include UserSubscriptionSourceable include PgSearch::Model - SEARCH_SERIALIZER = Search::ArticleSerializer - SEARCH_CLASS = Search::FeedContent - DATA_SYNC_CLASS = DataSync::Elasticsearch::Article - acts_as_taggable_on :tags resourcify @@ -112,9 +107,6 @@ class Article < ApplicationRecord } after_commit :async_score_calc, :touch_collection, on: %i[create update] - after_commit :index_to_elasticsearch, on: %i[create update] - after_commit :sync_related_elasticsearch_docs, on: %i[update] - after_commit :remove_from_elasticsearch, on: [:destroy] # The trigger `update_reading_list_document` is used to keep the `articles.reading_list_document` column updated. # @@ -361,7 +353,6 @@ class Article < ApplicationRecord def touch_by_reaction async_score_calc - index_to_elasticsearch end def comments_blob diff --git a/app/models/chat_channel_membership.rb b/app/models/chat_channel_membership.rb index 83d8220bf..6396dd36c 100644 --- a/app/models/chat_channel_membership.rb +++ b/app/models/chat_channel_membership.rb @@ -1,10 +1,6 @@ class ChatChannelMembership < ApplicationRecord attr_accessor :invitation_usernames - include Searchable - SEARCH_SERIALIZER = Search::ChatChannelMembershipSerializer - SEARCH_CLASS = Search::ChatChannelMembership - ROLES = %w[member mod].freeze STATUSES = %w[active inactive pending rejected left_channel removed_from_channel joining_request].freeze @@ -18,9 +14,6 @@ class ChatChannelMembership < ApplicationRecord validate :permission - after_commit :index_to_elasticsearch, on: %i[create update] - after_commit :remove_from_elasticsearch, on: [:destroy] - delegate :channel_type, to: :chat_channel scope :eager_load_serialized_data, -> { includes(:user, :channel) } diff --git a/app/models/comment.rb b/app/models/comment.rb index 4d5c0fda6..b395a0197 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -4,10 +4,6 @@ class Comment < ApplicationRecord include PgSearch::Model include Reactable - include Searchable - - SEARCH_SERIALIZER = Search::CommentSerializer - SEARCH_CLASS = Search::FeedContent BODY_MARKDOWN_SIZE_RANGE = (1..25_000).freeze @@ -63,18 +59,9 @@ class Comment < ApplicationRecord after_create_commit :send_to_moderator after_commit :calculate_score, on: %i[create update] - after_commit :index_to_elasticsearch, on: %i[create update] after_update_commit :update_notifications, if: proc { |comment| comment.saved_changes.include? "body_markdown" } - after_commit :remove_from_elasticsearch, on: [:destroy] - - # [@atsmith813] this is adapted from the `search_field` property in - # `config/elasticsearch/mappings/feed_content.json` and - # `app/serializers/search/comment_serializer.rb` - # - # highlighter settings are taken from - # Search::QueryBuildersFeedContent#add_highlight_fields pg_search_scope :search_comments, against: %i[body_markdown], using: { diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb deleted file mode 100644 index fb0dc774d..000000000 --- a/app/models/concerns/searchable.rb +++ /dev/null @@ -1,32 +0,0 @@ -module Searchable - def search_id - id - end - - def index_to_elasticsearch - Search::IndexWorker.perform_async(self.class.name, id) - end - - def index_to_elasticsearch_inline - self.class::SEARCH_CLASS.index(search_id, serialized_search_hash) - end - - def remove_from_elasticsearch - # Callbacks can cause index and removal jobs to be enqueued at the same time - # to avoid indexing a document after removing it we delay the removal job by 5 seconds to - # ensure it is run last - Search::RemoveFromIndexWorker.perform_in(5.seconds, self.class::SEARCH_CLASS.to_s, search_id) - end - - def serialized_search_hash - self.class::SEARCH_SERIALIZER.new(self).serializable_hash.dig(:data, :attributes) - end - - def elasticsearch_doc - self.class::SEARCH_CLASS.find_document(search_id) - end - - def sync_related_elasticsearch_docs - self.class::DATA_SYNC_CLASS.new(self).call - end -end diff --git a/app/models/listing.rb b/app/models/listing.rb index eee54211c..adadc1170 100644 --- a/app/models/listing.rb +++ b/app/models/listing.rb @@ -3,12 +3,8 @@ class Listing < ApplicationRecord # We standardized on the latter, but keeping the table name was easier. self.table_name = "classified_listings" - include Searchable include PgSearch::Model - SEARCH_SERIALIZER = Search::ListingSerializer - SEARCH_CLASS = Search::Listing - attr_accessor :action # NOTE: categories were hardcoded at first and the model was only added later. @@ -19,8 +15,6 @@ class Listing < ApplicationRecord before_validation :modify_inputs before_save :evaluate_markdown before_create :create_slug - after_commit :index_to_elasticsearch, on: %i[create update] - after_commit :remove_from_elasticsearch, on: [:destroy] acts_as_taggable_on :tags has_many :credits, as: :purchase, inverse_of: :purchase, dependent: :nullify @@ -33,8 +27,6 @@ class Listing < ApplicationRecord validate :restrict_markdown_input validate :validate_tags - # [@atsmith813] this is adapted from the `listing_search` property in - # `config/elasticsearch/mappings/listings.json` pg_search_scope :search_listings, against: %i[body_markdown cached_tag_list location slug title], using: { tsearch: { prefix: true } } diff --git a/app/models/message.rb b/app/models/message.rb index 1f964156e..6a6848f44 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -31,7 +31,6 @@ class Message < ApplicationRecord def update_chat_channel_last_message_at chat_channel.touch(:last_message_at) - ChatChannels::IndexesMembershipsWorker.perform_async(chat_channel.id) end def update_all_has_unopened_messages_statuses diff --git a/app/models/organization.rb b/app/models/organization.rb index 5ab97ddb7..0e77ea9dd 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -30,7 +30,6 @@ class Organization < ApplicationRecord # This callback will eventually invoke Article.update_cached_user to update the organization.name # only when it has been changed, thus invoking the trigger on Article.reading_list_document after_update_commit :update_articles_cached_organization - after_update_commit :sync_related_elasticsearch_docs after_destroy_commit :bust_cache, :article_sync has_many :articles, dependent: :nullify @@ -173,10 +172,6 @@ class Organization < ApplicationRecord errors.add(:slug, "is taken.") if slug_taken end - def sync_related_elasticsearch_docs - DataSync::Elasticsearch::Organization.new(self).call - end - def article_sync # Syncs article cached organization and updates Elasticsearch docs Article.where(id: cached_article_ids).find_each(&:save) diff --git a/app/models/podcast_episode.rb b/app/models/podcast_episode.rb index 464c93b35..78accaab6 100644 --- a/app/models/podcast_episode.rb +++ b/app/models/podcast_episode.rb @@ -4,10 +4,6 @@ class PodcastEpisode < ApplicationRecord ] include PgSearch::Model - include Searchable - - SEARCH_SERIALIZER = Search::PodcastEpisodeSerializer - SEARCH_CLASS = Search::FeedContent acts_as_taggable @@ -38,13 +34,6 @@ class PodcastEpisode < ApplicationRecord after_destroy :purge, :purge_all after_save :bust_cache - after_commit :index_to_elasticsearch, on: %i[update] - after_commit :remove_from_elasticsearch, on: [:destroy] - - # [@atsmith813] this is adapted from the `search_field` property in - # `config/elasticsearch/mappings/feed_content.json` and - # `app/serializers/search/podcast_episode_serializer.rb` with a couple of - # extras pg_search_scope :search_podcast_episodes, against: %i[body subtitle title], using: { tsearch: { prefix: true } } diff --git a/app/models/tag.rb b/app/models/tag.rb index 0842898d6..07e0d7d40 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -1,5 +1,3 @@ -require_relative "../lib/acts_as_taggable_on/tag" - class Tag < ActsAsTaggableOn::Tag attr_accessor :points, :tag_moderator_id, :remove_moderator_id @@ -8,8 +6,6 @@ class Tag < ActsAsTaggableOn::Tag # This model doesn't inherit from ApplicationRecord so this has to be included include Purgeable - include Searchable - include PgSearch::Model ALLOWED_CATEGORIES = %w[uncategorized language library tool site_mechanic location subcommunity].freeze @@ -39,9 +35,6 @@ class Tag < ActsAsTaggableOn::Tag before_save :mark_as_updated after_commit :bust_cache - after_commit :index_to_elasticsearch, on: %i[create update] - after_commit :sync_related_elasticsearch_docs, on: [:update] - after_commit :remove_from_elasticsearch, on: [:destroy] pg_search_scope :search_by_name, against: :name, @@ -49,10 +42,6 @@ class Tag < ActsAsTaggableOn::Tag scope :eager_load_serialized_data, -> {} - SEARCH_SERIALIZER = Search::TagSerializer - SEARCH_CLASS = Search::Tag - DATA_SYNC_CLASS = DataSync::Elasticsearch::Tag - # possible social previews templates for articles with a particular tag def self.social_preview_templates Rails.root.join("app/views/social_previews/articles").children.map { |ch| File.basename(ch, ".html.erb") } diff --git a/app/models/user.rb b/app/models/user.rb index d4b47bc89..8b27e6e0b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,8 +1,8 @@ class User < ApplicationRecord resourcify + rolify include CloudinaryHelper - include Searchable include Storext.model # @citizen428 Preparing to drop profile columns from the users table @@ -100,12 +100,6 @@ class User < ApplicationRecord :add_credits, :remove_credits, :add_org_credits, :remove_org_credits, :ip_address, :current_password - rolify after_add: :index_roles, after_remove: :index_roles - - SEARCH_SERIALIZER = Search::UserSerializer - SEARCH_CLASS = Search::User - DATA_SYNC_CLASS = DataSync::Elasticsearch::User - acts_as_followable acts_as_follower @@ -310,9 +304,6 @@ class User < ApplicationRecord after_create_commit :send_welcome_notification after_commit :bust_cache - after_commit :index_to_elasticsearch, on: %i[create update] - after_commit :sync_related_elasticsearch_docs, on: %i[create update] - after_commit :remove_from_elasticsearch, on: [:destroy] def self.dev_account find_by(id: Settings::Community.staff_user_id) @@ -676,14 +667,6 @@ class User < ApplicationRecord follows.destroy_all end - def index_roles(_role) - return unless persisted? - - index_to_elasticsearch_inline - rescue StandardError => e - Honeybadger.notify(e, context: { user_id: id }) - end - def can_send_confirmation_email return if changes[:email].blank? || id.blank? diff --git a/app/services/data_sync/elasticsearch/article.rb b/app/services/data_sync/elasticsearch/article.rb deleted file mode 100644 index abc03c709..000000000 --- a/app/services/data_sync/elasticsearch/article.rb +++ /dev/null @@ -1,16 +0,0 @@ -module DataSync - module Elasticsearch - class Article < Base - RELATED_DOCS = %i[ - comments - ].freeze - - SHARED_FIELDS = %i[ - published - title - ].freeze - - delegate :comments, to: :@updated_record - end - end -end diff --git a/app/services/data_sync/elasticsearch/base.rb b/app/services/data_sync/elasticsearch/base.rb deleted file mode 100644 index b20054a97..000000000 --- a/app/services/data_sync/elasticsearch/base.rb +++ /dev/null @@ -1,33 +0,0 @@ -module DataSync - module Elasticsearch - class Base - attr_reader :updated_record - - def initialize(updated_record) - @updated_record = updated_record - end - - def call - return unless sync_needed? - - sync_related_documents - end - - private - - def sync_needed? - updated_fields.slice(*self.class::SHARED_FIELDS).any? - end - - def sync_related_documents - self.class::RELATED_DOCS.each do |relation_name| - __send__(relation_name).find_each(&:index_to_elasticsearch) - end - end - - def updated_fields - updated_record.saved_changes - end - end - end -end diff --git a/app/services/data_sync/elasticsearch/organization.rb b/app/services/data_sync/elasticsearch/organization.rb deleted file mode 100644 index 94e06e684..000000000 --- a/app/services/data_sync/elasticsearch/organization.rb +++ /dev/null @@ -1,17 +0,0 @@ -module DataSync - module Elasticsearch - class Organization < Base - RELATED_DOCS = %i[ - articles - ].freeze - - SHARED_FIELDS = %i[ - slug - name - profile_image - ].freeze - - delegate :articles, to: :@updated_record - end - end -end diff --git a/app/services/data_sync/elasticsearch/tag.rb b/app/services/data_sync/elasticsearch/tag.rb deleted file mode 100644 index 2b486856d..000000000 --- a/app/services/data_sync/elasticsearch/tag.rb +++ /dev/null @@ -1,30 +0,0 @@ -module DataSync - module Elasticsearch - class Tag < Base - RELATED_DOCS = %i[ - articles - podcast_episodes - ].freeze - - SHARED_FIELDS = %i[ - keywords_for_search - ].freeze - - private - - def articles - ::Article.cached_tagged_with(updated_record.name) - end - - def reactions - ::Reaction.readinglist.where(reactable: articles) - end - - def podcast_episodes - ::PodcastEpisode.where( - id: updated_record.taggings.where(taggable_type: "PodcastEpisode").select(:taggable_id), - ) - end - end - end -end diff --git a/app/services/data_sync/elasticsearch/user.rb b/app/services/data_sync/elasticsearch/user.rb deleted file mode 100644 index de33ef574..000000000 --- a/app/services/data_sync/elasticsearch/user.rb +++ /dev/null @@ -1,30 +0,0 @@ -module DataSync - module Elasticsearch - class User < Base - RELATED_DOCS = %i[ - articles - podcast_episodes - chat_channel_memberships - comments - ].freeze - - SHARED_FIELDS = %i[ - username - name - profile_image_url - ].freeze - - delegate :articles, :chat_channel_memberships, :comments, to: :@updated_record - - private - - def reactions - updated_record.reactions.readinglist - end - - def podcast_episodes - PodcastEpisode.for_user(updated_record) - end - end - end -end diff --git a/app/services/edge_cache/bust_commentable.rb b/app/services/edge_cache/bust_commentable.rb index 08a24fdb4..5887c3716 100644 --- a/app/services/edge_cache/bust_commentable.rb +++ b/app/services/edge_cache/bust_commentable.rb @@ -7,7 +7,6 @@ module EdgeCache cache_bust = EdgeCache::Bust.new cache_bust.call("#{commentable.path}/comments") - commentable.index_to_elasticsearch_inline end end end diff --git a/app/services/podcasts/create_episode.rb b/app/services/podcasts/create_episode.rb index 531188fd4..71995b2aa 100644 --- a/app/services/podcasts/create_episode.rb +++ b/app/services/podcasts/create_episode.rb @@ -69,7 +69,6 @@ module Podcasts def finalize(episode) episode.purge_all - episode.index_to_elasticsearch episode.save if episode.processed_html.blank? end end diff --git a/app/services/search/base.rb b/app/services/search/base.rb deleted file mode 100644 index 16d7ab4ce..000000000 --- a/app/services/search/base.rb +++ /dev/null @@ -1,143 +0,0 @@ -module Search - class Base - class << self - def index(doc_id, serialized_data) - Search::Client.index( - id: doc_id, - index: self::INDEX_ALIAS, - body: serialized_data.merge(last_indexed_at: Time.current), - ) - end - - def bulk_index(data_hashes) - indexing_hashes = data_hashes.map do |data_hash| - model_hash = data_hash.with_indifferent_access - model_hash[:last_indexed_at] = Time.current - { - index: { - _index: self::INDEX_ALIAS, - _id: model_hash[:id], - data: model_hash - } - } - end - - process_hashes(indexing_hashes) - end - - def find_document(doc_id) - Search::Client.get(id: doc_id, index: self::INDEX_ALIAS) - end - - def delete_document(doc_id) - Search::Client.delete(id: doc_id, index: self::INDEX_ALIAS) - end - - def index_exists?(index_name: self::INDEX_NAME) - Search::Client.indices.exists(index: index_name) - end - - def create_index(index_name: self::INDEX_NAME) - Search::Client.indices.create(index: index_name, body: settings) - end - - def update_index(index_name: self::INDEX_NAME) - return unless dynamic_index_settings.any? - - Search::Client.indices.put_settings(index: index_name, body: dynamic_settings) - end - - def delete_index(index_name: self::INDEX_NAME) - Search::Client.indices.delete(index: index_name) - end - - def refresh_index(index_name: self::INDEX_ALIAS) - Search::Client.indices.refresh(index: index_name) - end - - def add_alias(index_name: self::INDEX_NAME, index_alias: self::INDEX_ALIAS) - Search::Client.indices.put_alias(index: index_name, name: index_alias) - end - - def update_mappings(index_alias: self::INDEX_ALIAS) - Search::Client.indices.put_mapping(index: index_alias, body: self::MAPPINGS) - end - - def document_count - Search::Client.count(index: self::INDEX_ALIAS)["count"] - end - - def search_documents(params:) - set_query_size(params) - query_hash = "Search::QueryBuilders::#{name.demodulize}".safe_constantize.new(params: params).as_hash - - results = search(body: query_hash) - hits = results.dig("hits", "hits").map { |hit| prepare_doc(hit) } - paginate_hits(hits, params) - end - - private - - def prepare_doc(hit) - hit["_source"] - end - - def search(body:) - Search::Client.search(index: self::INDEX_ALIAS, body: body) - end - - def set_query_size(params) - params[:page] ||= self::DEFAULT_PAGE - params[:per_page] ||= self::DEFAULT_PER_PAGE - - # pages start at 0 - params[:size] = params[:per_page].to_i * (params[:page].to_i + 1) - end - - def paginate_hits(hits, params) - start = params[:per_page] * params[:page] - hits[start, params[:per_page]] || [] - end - - def settings - { settings: { index: index_settings } } - end - - def index_settings - raise "Search classes must implement their own index settings" - end - - def dynamic_settings - { settings: { index: dynamic_index_settings } } - end - - def dynamic_index_settings - {} - end - - def process_hashes(indexing_hashes) - indexing_hashes.in_groups_of(1000, false).flat_map do |hashes| - indexing_chunks(hashes).filter_map { |chunk| Search::Client.bulk(body: chunk) } - end - end - - def indexing_chunks(hashes) - return [] unless hashes.any? - return to_enum(__method__, hashes) unless block_given? - - size = 0 - chunk = [] - hashes.each do |hash| - chunk << hash - size += hash.to_s.bytesize - next unless size > 5.megabytes - - yield chunk - size = 0 - chunk = [] - end - yield chunk - end - end - end -end diff --git a/app/services/search/chat_channel_membership.rb b/app/services/search/chat_channel_membership.rb deleted file mode 100644 index ef6533332..000000000 --- a/app/services/search/chat_channel_membership.rb +++ /dev/null @@ -1,28 +0,0 @@ -module Search - class ChatChannelMembership < Base - INDEX_NAME = "chat_channel_memberships_#{Rails.env}".freeze - INDEX_ALIAS = "chat_channel_memberships_#{Rails.env}_alias".freeze - MAPPINGS = JSON.parse(File.read("config/elasticsearch/mappings/chat_channel_memberships.json"), - symbolize_names: true).freeze - DEFAULT_PAGE = 0 - DEFAULT_PER_PAGE = 30 - - class << self - private - - def index_settings - if Rails.env.production? - { - number_of_shards: 3, - number_of_replicas: 1 - } - else - { - number_of_shards: 1, - number_of_replicas: 0 - } - end - end - end - end -end diff --git a/app/services/search/client.rb b/app/services/search/client.rb deleted file mode 100644 index 985ea3b11..000000000 --- a/app/services/search/client.rb +++ /dev/null @@ -1,65 +0,0 @@ -module Search - # Search client (uses Elasticsearch as a backend) - class Client - class << self - # adapted from https://api.rubyonrails.org/classes/Module.html#method-i-delegate_missing_to - def method_missing(method, *args, &block) - return super unless target.respond_to?(method, false) - - # define for re-use - self.class.define_method(method) do |*new_args, &new_block| - request do - target.public_send(method, *new_args, &new_block) - end - end - - # call the original method, this will only be called the first time - # as in subsequent calls, the newly defined method will prevail - request do - target.public_send(method, *args, &block) - end - end - - # adapted from https://api.rubyonrails.org/classes/Module.html#method-i-delegate_missing_to - def respond_to_missing?(method, _include_all = false) - target.respond_to?(method, false) || super - end - - private - - TRANSPORT_EXCEPTIONS = [ - Elasticsearch::Transport::Transport::Errors::BadRequest, - Elasticsearch::Transport::Transport::Errors::NotFound, - ].freeze - - def request - yield - rescue *TRANSPORT_EXCEPTIONS => e - class_name = e.class.name.demodulize - record_error(e.message, class_name) - - # raise specific error if known, generic one if unknown - error_class = "::Search::Errors::Transport::#{class_name}".safe_constantize - raise error_class, e.message if error_class - - raise ::Search::Errors::TransportError, e.message - end - - def record_error(error_message, class_name) - Honeycomb.add_field("elasticsearch.result", "error") - Honeycomb.add_field("elasticsearch.error", class_name) - ForemStatsClient.increment("elasticsearch.errors", tags: ["error:#{class_name}", "message:#{error_message}"]) - end - - def target - @target ||= Elasticsearch::Client.new( - url: ApplicationConfig["ELASTICSEARCH_URL"], - retry_on_failure: 5, - request_timeout: 30, - adapter: :patron, - log: Rails.env.development?, - ) - end - end - end -end diff --git a/app/services/search/cluster.rb b/app/services/search/cluster.rb deleted file mode 100644 index f1457be23..000000000 --- a/app/services/search/cluster.rb +++ /dev/null @@ -1,72 +0,0 @@ -module Search - class Cluster - SEARCH_CLASSES = [ - Search::ChatChannelMembership, - Search::Listing, - Search::FeedContent, - Search::Tag, - Search::User, - ].freeze - - class << self - def recreate_indexes - delete_indexes - setup_indexes - end - - def setup_indexes - update_settings - create_indexes - update_indexes - add_aliases - update_mappings - end - - def update_settings - Search::Client.cluster.put_settings(body: default_settings) - end - - def create_indexes - SEARCH_CLASSES.each do |search_class| - next if search_class.index_exists? - - search_class.create_index - end - end - - def update_indexes - SEARCH_CLASSES.each(&:update_index) - end - - def add_aliases - SEARCH_CLASSES.each(&:add_alias) - end - - def update_mappings - SEARCH_CLASSES.each(&:update_mappings) - end - - def delete_indexes - return if Rails.env.production? - - SEARCH_CLASSES.each do |search_class| - next unless Search::Client.indices.exists(index: search_class::INDEX_NAME) - - search_class.delete_index - end - end - - private - - def default_settings - { - persistent: { - action: { - auto_create_index: false - } - } - } - end - end - end -end diff --git a/app/services/search/feed_content.rb b/app/services/search/feed_content.rb deleted file mode 100644 index f098202de..000000000 --- a/app/services/search/feed_content.rb +++ /dev/null @@ -1,83 +0,0 @@ -module Search - class FeedContent < Base - INDEX_NAME = "feed_content_#{Rails.env}".freeze - INDEX_ALIAS = "feed_content_#{Rails.env}_alias".freeze - MAPPINGS = JSON.parse(File.read("config/elasticsearch/mappings/feed_content.json"), symbolize_names: true).freeze - DEFAULT_PAGE = 0 - DEFAULT_PER_PAGE = 60 - - INCLUDED_CLASS_NAMES = %w[Article Comment PodcastEpisode].freeze - - class << self - INCLUDED_CLASS_NAMES.each do |class_name| - define_method("#{class_name.underscore.pluralize}_document_count") do - Search::Client.count( - index: self::INDEX_ALIAS, body: count_filter(class_name), - )["count"] - end - end - - private - - def prepare_doc(hit) - source = hit["_source"] - source["tag_list"] = hit["tags"]&.map { |t| t["name"] } || [] - source["id"] = source["id"].split("_").last.to_i - source["tag_list"] = source["tags"]&.map { |t| t["name"] } || [] - source["flare_tag"] = source["flare_tag_hash"] - source["user_id"] = source.dig("user", "id") - source["highlight"] = hit["highlight"] - source["readable_publish_date"] = source["readable_publish_date_string"] - source["podcast"] = { - "slug" => source["slug"], - "image_url" => source["main_image"], - "title" => source["title"] - } - source["_score"] = hit["_score"] - - source.merge(timestamps_hash(hit)) - end - - def timestamps_hash(hit) - published_at = hit.dig("_source", "published_at") - published_at_timestamp = Time.zone.parse(published_at || "") - { - "published_at_int" => published_at_timestamp.to_i, - "published_timestamp" => published_at - } - end - - def count_filter(class_name) - { - query: { - bool: { - filter: { term: { class_name: class_name } } - } - } - } - end - - def index_settings - if Rails.env.production? - { - number_of_shards: 10, - number_of_replicas: 1 - } - else - { - number_of_shards: 1, - number_of_replicas: 0 - } - end - end - - def dynamic_index_settings - if Rails.env.production? - { refresh_interval: "5s" } - else - { refresh_interval: "1s" } - end - end - end - end -end diff --git a/app/services/search/listing.rb b/app/services/search/listing.rb deleted file mode 100644 index a93d6aaad..000000000 --- a/app/services/search/listing.rb +++ /dev/null @@ -1,29 +0,0 @@ -module Search - class Listing < Base - # We used to use both "classified listing" and "listing" throughout the app. - # We standardized on the latter in most places, but kept the index name here. - INDEX_NAME = "classified_listings_#{Rails.env}".freeze - INDEX_ALIAS = "classified_listings_#{Rails.env}_alias".freeze - MAPPINGS = JSON.parse(File.read("config/elasticsearch/mappings/listings.json"), symbolize_names: true).freeze - DEFAULT_PAGE = 0 - DEFAULT_PER_PAGE = 75 - - class << self - private - - def index_settings - if Rails.env.production? - { - number_of_shards: 2, - number_of_replicas: 1 - } - else - { - number_of_shards: 1, - number_of_replicas: 0 - } - end - end - end - end -end diff --git a/app/services/search/query_builders/chat_channel_membership.rb b/app/services/search/query_builders/chat_channel_membership.rb deleted file mode 100644 index 5158d6429..000000000 --- a/app/services/search/query_builders/chat_channel_membership.rb +++ /dev/null @@ -1,57 +0,0 @@ -module Search - module QueryBuilders - class ChatChannelMembership < QueryBase - FILTER_KEYS = %i[ - channel_status - channel_type - status - viewable_by - ].freeze - - TERMS_FILTER_KEYS = %i[viewable_by status].freeze - - QUERY_KEYS = %i[ - channel_text - ].freeze - - DEFAULT_PARAMS = { - sort_by: "channel_last_message_at", - sort_direction: "desc", - size: 0 - }.freeze - - def initialize(params:) - super() - - @params = params.deep_symbolize_keys - @params[:viewable_by] = @params[:user_id] - - # TODO: @mstruve: When we want to allow people like admins to - # search ALL memberships this will need to change - @params[:status] = %w[active joining_request] - - build_body - end - - private - - def build_queries - @body[:query] = {} - @body[:query][:bool] = { filter: filter_conditions } - @body[:query][:bool][:must] = query_conditions if query_keys_present? - end - - def filter_conditions - FILTER_KEYS.filter_map do |filter_key| - next if @params[filter_key].blank? || @params[filter_key] == "all" - - if TERMS_FILTER_KEYS.include?(filter_key) - { terms: { filter_key => @params[filter_key] } } - else - { term: { filter_key => @params[filter_key] } } - end - end - end - end - end -end diff --git a/app/services/search/query_builders/feed_content.rb b/app/services/search/query_builders/feed_content.rb deleted file mode 100644 index f81bec168..000000000 --- a/app/services/search/query_builders/feed_content.rb +++ /dev/null @@ -1,202 +0,0 @@ -module Search - module QueryBuilders - class FeedContent < QueryBase - # In order for highlighting to work properly we have to search the fields we want to highlight - QUERY_KEYS = { - search_fields: [ - "tags.keywords_for_search", - "tags.name^3", - "body_text^2", - "title^6", - "user.name", - "user.username", - "organization.name", - ] - }.freeze - - # Search keys from our controllers may not match what we have stored in Elasticsearch so we map them here, - # this allows us to change our Elasticsearch docs without worrying about the frontend - TERM_KEYS = { - id: "id", # NOTE: FeedContent ids are formatted article_#, podcast_episode_#, comment_# - tag_names: "tags.name", - approved: "approved", - user_id: "user.id", - class_name: "class_name", - published: "published", - organization_id: "organization.id" - }.freeze - - RANGE_KEYS = %i[ - published_at - ].freeze - - DEFAULT_PARAMS = { - sort: [ - :_score, - { score: "desc" }, - { hotness_score: "desc" }, - { comments_count: "desc" }, - ], - size: 0 - }.freeze - - HIGHLIGHT_FIELDS = %w[ - body_text - ].freeze - - SOURCE = %i[ - id - class_name - cloudinary_video_url - comments_count - flare_tag_hash - main_image - path - public_reactions_count - published_at - readable_publish_date_string - reading_time - slug - tags - title - video_duration_in_minutes - video_duration_string - user - organization - ].freeze - - attr_accessor :params, :body - - def initialize(params:) - super() - - @params = params.deep_symbolize_keys - - # Default to only showing published articles to start - @params[:published] = true - - build_body - add_function_scoring unless sort_params_present? - end - - private - - def add_sort - return @body[:sort] = DEFAULT_PARAMS[:sort] unless sort_params_present? - - @body[:sort] = { @params[:sort_by] => @params[:sort_direction] } - end - - def add_highlight_fields - highlight_fields = { encoder: "html", pre_tags: "", post_tags: "", fields: {} } - HIGHLIGHT_FIELDS.each do |field_name| - # This hash can be filled with options to further customize our highlighting - # https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-body.html#request-body-search-highlighting - highlight_fields[:fields][field_name] = { order: :score, number_of_fragments: 2, fragment_size: 75 } - end - @body[:highlight] = highlight_fields - end - - def filter_source - @body[:_source] = SOURCE - end - - def build_queries - @body[:query] = { bool: {} } - @body[:query][:bool][:filter] = filter_conditions if filter_keys_present? - return unless query_keys_present? - - @body[:query][:bool][:must] = query_conditions - # Boost the score of queries that match these conditions but if they dont match any, - # minimum_should_match: 0, then that is OK - @body[:query][:bool][:should] = match_phrase_conditions - @body[:query][:bool][:minimum_should_match] = 0 - end - - def filter_conditions - filter_conditions = [] - - filter_conditions.concat(term_keys) if terms_keys_present? - filter_conditions.concat(range_keys) if range_keys_present? - - filter_conditions - end - - def filter_keys_present? - range_keys_present? || terms_keys_present? - end - - def terms_keys_present? - TERM_KEYS.detect { |key, _| @params[key].present? } - end - - def range_keys_present? - RANGE_KEYS.detect { |key| @params[key].present? } - end - - def term_keys - TERM_KEYS.filter_map do |term_key, search_key| - next unless @params.key? term_key - - values = Array.wrap(@params[term_key]) - - if params[:tag_boolean_mode] == "all" && term_key == :tag_names - values.map { |val| { term: { search_key => val } } } - else - { terms: { search_key => values } } - end - end.compact - end - - def range_keys - RANGE_KEYS.filter_map do |range_key| - next unless @params.key? range_key - - { range: { range_key => @params[range_key] } } - end - end - - def query_hash(key, fields) - { - simple_query_string: { - query: key.downcase, - fields: fields, - lenient: true, - analyze_wildcard: true, - minimum_should_match: 2 - } - } - end - - def add_function_scoring - @body[:query] = { function_score: { query: @body[:query] } } - @body[:query][:function_score][:functions] = scoring_functions - end - - def scoring_functions - [ - scoring_filter(1000, 2.5), - scoring_filter(500, 2), - scoring_filter(100, 1.5), - ] - end - - def scoring_filter(score, weight) - { - filter: { - range: { - score: { - gte: score - } - } - }, - weight: weight - } - end - - def sort_params_present? - @params[:sort_by] && @params[:sort_direction] - end - end - end -end diff --git a/app/services/search/query_builders/listing.rb b/app/services/search/query_builders/listing.rb deleted file mode 100644 index 4b5332fdf..000000000 --- a/app/services/search/query_builders/listing.rb +++ /dev/null @@ -1,91 +0,0 @@ -module Search - module QueryBuilders - class Listing < QueryBase - TERM_KEYS = %i[ - category - contact_via_connect - location - published - slug - tags - title - user_id - ].freeze - - RANGE_KEYS = %i[ - bumped_at - expires_at - ].freeze - - QUERY_KEYS = %i[ - listing_search - ].freeze - - DEFAULT_PARAMS = { - published: true, - sort_by: "bumped_at", - sort_direction: "desc", - size: 0 - }.freeze - - def initialize(params:) - super() - - @params = params.deep_symbolize_keys - - # For now, we're not allowing searches for Listings that are - # not published. If we want to change this in the future we can do: - # @params[:published] = DEFAULT_PARAMS[:published] unless @params.key?(:published) - @params[:published] = DEFAULT_PARAMS[:published] - - build_body - end - - private - - def build_queries - @body[:query] = { bool: {} } - @body[:query][:bool][:filter] = filter_conditions - @body[:query][:bool][:must] = query_conditions if query_keys_present? - end - - def filter_conditions - filter_conditions = [] - - # term_keys are always present because we are setting a filter of - # published: true by default - filter_conditions.concat term_keys - - filter_conditions.concat range_keys if range_keys_present? - - filter_conditions - end - - def term_keys - TERM_KEYS.flat_map do |term_key| - next unless @params.key? term_key - - values = Array.wrap(@params[term_key]) - - if params[:tag_boolean_mode] == "all" && term_key == :tags - values.map { |tag| { terms: { term_key => Array.wrap(tag) } } } - else - { terms: { term_key => values } } - end - end.compact - end - - def range_keys - RANGE_KEYS.filter_map do |range_key| - next unless @params.key? range_key - - { range: { range_key => @params[range_key] } } - end - end - - def range_keys_present? - RANGE_KEYS.detect { |key| @params[key].present? } - end - end - end -end diff --git a/app/services/search/query_builders/query_base.rb b/app/services/search/query_builders/query_base.rb deleted file mode 100644 index 9f9600ef7..000000000 --- a/app/services/search/query_builders/query_base.rb +++ /dev/null @@ -1,80 +0,0 @@ -module Search - module QueryBuilders - class QueryBase - attr_accessor :params, :body - - def as_hash - @body - end - - private - - def build_body - @body = ActiveSupport::HashWithIndifferentAccess.new - build_queries - add_sort - set_size - add_highlight_fields - filter_source - end - - def add_highlight_fields; end - - def filter_source; end - - def add_sort - sort_key = @params[:sort_by] || self.class::DEFAULT_PARAMS[:sort_by] - sort_direction = @params[:sort_direction] || self.class::DEFAULT_PARAMS[:sort_direction] - @body[:sort] = { - sort_key => sort_direction - } - end - - def set_size - # By default we will return 0 documents if size is not specified - @body[:size] = @params[:size] || self.class::DEFAULT_PARAMS[:size] - end - - def query_keys_present? - self.class::QUERY_KEYS.detect { |key, _| @params[key].present? } - end - - def query_conditions - self.class::QUERY_KEYS.filter_map do |query_key, query_fields| - next if @params[query_key].blank? - - fields = query_fields.presence || [query_key] - - query_hash(@params[query_key], fields) - end - end - - def match_phrase_conditions - self.class::QUERY_KEYS.filter_map do |query_key, _fields| - next if @params[query_key].blank? - - match_phrase(@params[query_key], query_key) - end - end - - def match_phrase(phrase, query_key) - { - match_phrase: { - query_key => { query: phrase, slop: 0 } - } - } - end - - def query_hash(key, fields) - { - simple_query_string: { - query: "#{key}*", - fields: fields, - lenient: true, - analyze_wildcard: true - } - } - end - end - end -end diff --git a/app/services/search/query_builders/user.rb b/app/services/search/query_builders/user.rb deleted file mode 100644 index e832edba0..000000000 --- a/app/services/search/query_builders/user.rb +++ /dev/null @@ -1,52 +0,0 @@ -module Search - module QueryBuilders - class User < QueryBase - QUERY_KEYS = %i[ - search_fields - ].freeze - - # In the event we want to search for documents that do NOT contain certain values - EXCLUDED_TERM_KEYS = { - exclude_roles: "roles" - }.freeze - - DEFAULT_PARAMS = { - sort_by: "hotness_score", - sort_direction: "desc", - size: 0 - }.freeze - - def initialize(params:) - super() - - @params = params.deep_symbolize_keys - - # default to excluding users who are suspended - # TODO: [@jacobherrington] banned can be removed once the data scripts have succesfully run on all Forems - @params[:exclude_roles] = %w[suspended banned] - - build_body - end - - private - - def build_queries - @body[:query] = { bool: {} } - @body[:query][:bool][:must] = query_conditions if query_keys_present? - @body[:query][:bool][:must_not] = excluded_term_keys if excluded_term_keys_present? - end - - def excluded_term_keys_present? - self.class::EXCLUDED_TERM_KEYS.detect { |key, _| @params[key].present? } - end - - def excluded_term_keys - EXCLUDED_TERM_KEYS.filter_map do |term_key, search_key| - next unless @params.key? term_key - - { terms: { search_key => Array.wrap(@params[term_key]) } } - end - end - end - end -end diff --git a/app/services/search/reading_list.rb b/app/services/search/reading_list.rb deleted file mode 100644 index cc696a438..000000000 --- a/app/services/search/reading_list.rb +++ /dev/null @@ -1,78 +0,0 @@ -module Search - # This class does not inherit from Search::Base like our other search classes bc it - # is using the FeedContent index to search for ReadingList reaction articles rather than its - # own separate index. The primary function of this class is to help combine and parse reaction - # data with article Elasticsearch data to populate the Reading List view. - class ReadingList - DEFAULT_PAGE = 0 - DEFAULT_PER_PAGE = 60 - DEFAULT_STATUS = %w[valid confirmed].freeze - - # Using a class method here to follow the pattern of the other Search classes - def self.search_documents(params:, user:) - new(params: params, user: user).reading_list_reactions - end - - def initialize(params:, user:) - self.status = params.delete(:status) || DEFAULT_STATUS - self.view_page = params.delete(:page) || DEFAULT_PAGE - self.view_per_page = params.delete(:per_page) || DEFAULT_PER_PAGE - self.user = user - self.search_params = params - end - - def reading_list_reactions - ordered_articles = parse_and_order_articles(article_docs) - { "reactions" => paginate_articles(ordered_articles), "total" => total } - end - - private - - attr_accessor :search_params, :user, :status, :view_page, :view_per_page, :total - - def paginate_articles(ordered_articles) - start = view_per_page * view_page - ordered_articles[start, view_per_page] || [] - end - - def article_docs - return @article_docs if @article_docs - - # Gather articles from Elasticsearch based on search criteria containing - # tags, text search, status, and the list of IDs of all articles in a user's - # reading list - docs = FeedContent.search_documents( - params: search_params.merge( - id: search_ids, - class_name: "Article", - page: 0, - per_page: reading_list_article_ids.count, - ), - ) - self.total = docs.count - @article_docs = docs.index_by { |doc| doc["id"] } - end - - def reading_list_article_ids - # Collect all reading list IDs and article IDs for a user - @reading_list_article_ids ||= user.reactions.readinglist.where(status: status).order(id: :desc).pluck( - :reactable_id, :id - ).to_h - end - - def search_ids - reading_list_article_ids.keys.map { |id| "article_#{id}" } - end - - def parse_and_order_articles(articles) - # Combines reaction and article data to create hashes that contain the fields - # the reading list view needs. Ensures articles are returned in order of reaction ID - reading_list_article_ids.filter_map do |article_id, reaction_id| - found_article_doc = articles[article_id] - next unless found_article_doc - - { "id" => reaction_id, "user_id" => user.id, "reactable" => articles[article_id] } - end - end - end -end diff --git a/app/services/search/tag.rb b/app/services/search/tag.rb deleted file mode 100644 index 53c965b45..000000000 --- a/app/services/search/tag.rb +++ /dev/null @@ -1,45 +0,0 @@ -module Search - class Tag < Base - INDEX_NAME = "tags_#{Rails.env}".freeze - INDEX_ALIAS = "tags_#{Rails.env}_alias".freeze - MAPPINGS = JSON.parse(File.read("config/elasticsearch/mappings/tags.json"), symbolize_names: true).freeze - - class << self - def search_documents(query_string) - results = search(body: query(query_string)) - results.dig("hits", "hits").map { |tag_doc| tag_doc["_source"] } - end - - private - - def query(query_string) - { - query: { - query_string: { - query: query_string, - analyze_wildcard: true, - allow_leading_wildcard: false - } - }, - sort: { - hotness_score: "desc" - } - } - end - - def index_settings - if Rails.env.production? - { - number_of_shards: 1, - number_of_replicas: 1 - } - else - { - number_of_shards: 1, - number_of_replicas: 0 - } - end - end - end - end -end diff --git a/app/services/search/user.rb b/app/services/search/user.rb deleted file mode 100644 index ea9ce87b4..000000000 --- a/app/services/search/user.rb +++ /dev/null @@ -1,80 +0,0 @@ -module Search - class User < Base - INDEX_NAME = "users_#{Rails.env}".freeze - INDEX_ALIAS = "users_#{Rails.env}_alias".freeze - MAPPINGS = JSON.parse(File.read("config/elasticsearch/mappings/users.json"), symbolize_names: true).freeze - DEFAULT_PAGE = 0 - DEFAULT_PER_PAGE = 20 - - class << self - def search_usernames(username) - results = search(body: username_query(username)) - results.dig("hits", "hits").map do |doc| - source = doc["_source"] - { - "username" => source["username"], - "name" => source["name"], - "profile_image_90" => source["profile_image_90"] - } - end - end - - private - - def username_query(username) - { - query: { - query_string: { - query: "username:#{username}*", - analyze_wildcard: true, - allow_leading_wildcard: false - } - }, - size: Search::Postgres::Username::MAX_RESULTS # Limit the number of results we return to the frontend - } - end - - def prepare_doc(hit) - source = hit["_source"] - { - "user" => { - "username" => source["username"], - "name" => source["username"], - "profile_image_90" => source["profile_image_90"] - }, - "title" => source["name"], - "path" => source["path"], - "id" => source["id"], - "class_name" => "User", - "public_reactions_count" => source["public_reactions_count"], - "comments_count" => source["comments_count"], - "badge_achievements_count" => source["badge_achievements_count"], - "last_comment_at" => source["last_comment_at"], - "user_id" => source["id"] - } - end - - def index_settings - if Rails.env.production? - { - number_of_shards: 10, - number_of_replicas: 1 - } - else - { - number_of_shards: 1, - number_of_replicas: 0 - } - end - end - - def dynamic_index_settings - if Rails.env.production? - { refresh_interval: "10s" } - else - { refresh_interval: "1s" } - end - end - end - end -end diff --git a/app/services/users/delete_activity.rb b/app/services/users/delete_activity.rb index cb6f9dd24..83054c027 100644 --- a/app/services/users/delete_activity.rb +++ b/app/services/users/delete_activity.rb @@ -2,8 +2,6 @@ module Users module DeleteActivity module_function - # If you're removing data that is in Elasticsearch, make sure to use - # .destroy_all to trigger the callback to remove the document(s) def call(user) delete_social_media(user) delete_profile_info(user) diff --git a/app/services/users/delete_articles.rb b/app/services/users/delete_articles.rb index 33d8945e1..64853fa2b 100644 --- a/app/services/users/delete_articles.rb +++ b/app/services/users/delete_articles.rb @@ -12,10 +12,8 @@ module Users comment.reactions.delete_all EdgeCache::BustComment.call(comment.commentable) EdgeCache::BustUser.call(comment.user) - comment.remove_from_elasticsearch comment.delete end - article.remove_from_elasticsearch article.delete article.purge end diff --git a/app/services/users/delete_comments.rb b/app/services/users/delete_comments.rb index 7659dfc77..273d30bed 100644 --- a/app/services/users/delete_comments.rb +++ b/app/services/users/delete_comments.rb @@ -9,7 +9,6 @@ module Users comment.reactions.delete_all EdgeCache::BustComment.call(comment.commentable) comment.remove_notifications - comment.remove_from_elasticsearch comment.delete end EdgeCache::BustUser.call(user) diff --git a/app/workers/articles/score_calc_worker.rb b/app/workers/articles/score_calc_worker.rb index b6fa3c573..dddf0cce3 100644 --- a/app/workers/articles/score_calc_worker.rb +++ b/app/workers/articles/score_calc_worker.rb @@ -9,7 +9,6 @@ module Articles return unless article article.update_score - article.index_to_elasticsearch_inline end end end diff --git a/app/workers/chat_channels/indexes_memberships_worker.rb b/app/workers/chat_channels/indexes_memberships_worker.rb deleted file mode 100644 index 31f0fbcea..000000000 --- a/app/workers/chat_channels/indexes_memberships_worker.rb +++ /dev/null @@ -1,12 +0,0 @@ -module ChatChannels - class IndexesMembershipsWorker - include Sidekiq::Worker - - sidekiq_options queue: :high_priority, lock: :until_executing - - def perform(chat_channel_id) - chat_channel = ChatChannel.find(chat_channel_id) - chat_channel.chat_channel_memberships.each(&:index_to_elasticsearch) - end - end -end diff --git a/app/workers/metrics/record_data_counts_worker.rb b/app/workers/metrics/record_data_counts_worker.rb index 2e900646f..575932fe2 100644 --- a/app/workers/metrics/record_data_counts_worker.rb +++ b/app/workers/metrics/record_data_counts_worker.rb @@ -14,16 +14,6 @@ module Metrics Rails.logger.info(message: "db_table_size", table_info: { table_name: model.table_name, table_size: db_count }) ForemStatsClient.gauge("postgres.db_table_size", db_count, tags: ["table_name:#{model.table_name}"]) - - next unless model.const_defined?(:SEARCH_CLASS) - - document_count = if model::SEARCH_CLASS.respond_to?("#{model.to_s.underscore.pluralize}_document_count") - model::SEARCH_CLASS.public_send("#{model.to_s.underscore.pluralize}_document_count") - else - model::SEARCH_CLASS.document_count - end - ForemStatsClient.gauge("elasticsearch.document_count", document_count, - tags: ["table_name:#{model.table_name}"]) end end end diff --git a/app/workers/rating_votes/assign_rating_worker.rb b/app/workers/rating_votes/assign_rating_worker.rb index 2a1a55167..9a13a54e0 100644 --- a/app/workers/rating_votes/assign_rating_worker.rb +++ b/app/workers/rating_votes/assign_rating_worker.rb @@ -16,7 +16,6 @@ module RatingVotes experience_level_rating_distribution: ratings.max - ratings.min, last_experience_level_rating_at: Time.current, ) - article.index_to_elasticsearch_inline end end end diff --git a/app/workers/search/bulk_index_worker.rb b/app/workers/search/bulk_index_worker.rb deleted file mode 100644 index 9d81ba0f1..000000000 --- a/app/workers/search/bulk_index_worker.rb +++ /dev/null @@ -1,18 +0,0 @@ -module Search - class BulkIndexWorker - include Sidekiq::Worker - - sidekiq_options queue: :high_priority, lock: :until_executing - - def perform(object_class, ids) - data_hashes = object_class.constantize - .eager_load_serialized_data - .where(id: ids) - .find_each.map(&:serialized_search_hash) - - search_class = object_class.constantize::SEARCH_CLASS - - search_class.bulk_index(data_hashes) - end - end -end diff --git a/app/workers/search/index_worker.rb b/app/workers/search/index_worker.rb deleted file mode 100644 index 247f7f846..000000000 --- a/app/workers/search/index_worker.rb +++ /dev/null @@ -1,12 +0,0 @@ -module Search - class IndexWorker - include Sidekiq::Worker - - sidekiq_options queue: :high_priority, lock: :until_executing - - def perform(object_class, id) - object = object_class.constantize.find_by(id: id) - object&.index_to_elasticsearch_inline - end - end -end diff --git a/app/workers/search/remove_from_index_worker.rb b/app/workers/search/remove_from_index_worker.rb deleted file mode 100644 index fecf9db5d..000000000 --- a/app/workers/search/remove_from_index_worker.rb +++ /dev/null @@ -1,17 +0,0 @@ -module Search - class RemoveFromIndexWorker - include Sidekiq::Worker - - sidekiq_options queue: :medium_priority, lock: :until_executing - - def perform(search_class, id) - search_class.safe_constantize.delete_document(id) - rescue Search::Errors::Transport::NotFound - # Often race conditions cause us never to index a document and - # we end up with an error when trying to remove it. Because - # we have count checks that run every hour to track the counts in - # Elasticsearch and db to ensure they match we can ignore this error - nil - end - end -end diff --git a/bin/setup b/bin/setup index 98ecea7bf..e0fb2b846 100755 --- a/bin/setup +++ b/bin/setup @@ -30,9 +30,6 @@ FileUtils.chdir APP_ROOT do FileUtils.cp "config/database.yml.sample", "config/database.yml" end - puts "\n== Preparing Test Elasticsearch ==" - system! 'RAILS_ENV="test" bin/rails search:setup' - puts "\n== Initializing the application ==" system! "bin/rails app_initializer:setup forem:setup" diff --git a/config/elasticsearch/mappings/chat_channel_memberships.json b/config/elasticsearch/mappings/chat_channel_memberships.json deleted file mode 100644 index 1e188ea99..000000000 --- a/config/elasticsearch/mappings/chat_channel_memberships.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "dynamic": "strict", - "properties": { - "id": { - "type": "keyword" - }, - "status": { - "type": "keyword" - }, - "viewable_by": { - "type": "keyword" - }, - "chat_channel_id": { - "type": "keyword" - }, - "last_opened_at": { - "type": "date" - }, - "channel_text": { - "type": "text" - }, - "channel_last_message_at": { - "type": "date" - }, - "channel_status": { - "type": "keyword" - }, - "channel_status": { - "type": "keyword" - }, - "channel_type": { - "type": "keyword" - }, - "channel_username": { - "type": "keyword" - }, - "channel_name": { - "type": "text" - }, - "channel_image": { - "type": "keyword" - }, - "channel_modified_slug": { - "type": "text", - "fields": { - "raw": { - "type": "keyword" - } - } - }, - "channel_messages_count": { - "type": "integer" - }, - "channel_discoverable": { - "type": "boolean" - }, - "last_indexed_at": { - "type": "date" - } - } -} diff --git a/config/elasticsearch/mappings/feed_content.json b/config/elasticsearch/mappings/feed_content.json deleted file mode 100644 index 04124a792..000000000 --- a/config/elasticsearch/mappings/feed_content.json +++ /dev/null @@ -1,186 +0,0 @@ -{ - "dynamic": "strict", - "properties": { - "id": { - "type": "keyword" - }, - "approved": { - "type": "boolean" - }, - "body_text": { - "type": "text", - "copy_to": "search_fields" - }, - "class_name": { - "type": "keyword" - }, - "cloudinary_video_url": { - "type": "keyword" - }, - "comments_count": { - "type": "integer" - }, - "experience_level_rating": { - "type": "integer" - }, - "experience_level_rating_distribution": { - "type": "integer" - }, - "featured": { - "type": "boolean" - }, - "featured_number": { - "type": "integer" - }, - "flare_tag_hash": { - "dynamic": "strict", - "properties": { - "name": { - "type": "keyword" - }, - "bg_color_hex": { - "type": "keyword" - }, - "text_color_hex": { - "type": "keyword" - } - } - }, - "hotness_score": { - "type": "integer" - }, - "last_indexed_at": { - "type": "date" - }, - "main_image": { - "type": "keyword" - }, - "organization": { - "dynamic": "strict", - "properties": { - "slug": { - "type": "keyword" - }, - "name": { - "type": "text", - "copy_to": "search_fields", - "fields": { - "raw": { - "type": "keyword" - } - } - }, - "id": { - "type": "keyword" - }, - "profile_image_90": { - "type": "keyword" - } - } - }, - "path": { - "type": "keyword" - }, - // deprecated in favor of public_reactions_count, to be removed - "positive_reactions_count": { - "type": "integer" - }, - "public_reactions_count": { - "type": "integer" - }, - "published": { - "type": "boolean" - }, - "published_at": { - "type": "date" - }, - "quote": { - "type": "text" - }, - "reactions_count": { - "type": "integer" - }, - "readable_publish_date": { - "type": "date" - }, - "readable_publish_date_string": { - "type": "keyword" - }, - "reading_time": { - "type": "integer" - }, - "score": { - "type": "integer" - }, - "search_fields": { - "type": "text" - }, - "search_score": { - "type": "integer" - }, - "slug": { - "type": "keyword" - }, - "subtitle": { - "type": "text" - }, - "summary": { - "type": "text" - }, - "tags": { - "dynamic": "strict", - "properties": { - "name": { - "type": "keyword", - "copy_to": "search_fields" - }, - "keywords_for_search": { - "type": "keyword", - "copy_to": "search_fields" - } - } - }, - "title": { - "type": "text", - "copy_to": "search_fields", - "fields": { - "raw": { - "type": "keyword" - } - } - }, - "user": { - "dynamic": "strict", - "properties": { - "username": { - "type": "keyword", - "copy_to": "search_fields" - }, - "name": { - "type": "text", - "copy_to": "search_fields", - "fields": { - "raw": { - "type": "keyword" - } - } - }, - "id": { - "type": "keyword" - }, - "profile_image_90": { - "type": "keyword" - } - } - }, - "video_duration_in_minutes": { - "type": "integer" - }, - "video_duration_string": { - "type": "keyword" - }, - "website_url": { - "type": "keyword" - } - } -} diff --git a/config/elasticsearch/mappings/listings.json b/config/elasticsearch/mappings/listings.json deleted file mode 100644 index e832e30af..000000000 --- a/config/elasticsearch/mappings/listings.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "dynamic": "strict", - "properties": { - "id": { - "type": "keyword" - }, - "author": { - "dynamic": "strict", - "properties": { - "username": { - "type": "keyword" - }, - "name": { - "type": "keyword" - }, - "profile_image_90": { - "type": "keyword" - } - } - }, - "body_markdown": { - "type": "text", - "copy_to": "listing_search" - }, - "originally_published_at": { - "type": "date" - }, - "bumped_at": { - "type": "date" - }, - "category": { - "type": "keyword" - }, - "listing_search": { - "type": "text" - }, - "contact_via_connect": { - "type": "boolean" - }, - "expires_at": { - "type": "date" - }, - "last_indexed_at": { - "type": "date" - }, - "location": { - "type": "text", - "copy_to": "listing_search", - "fields": { - "raw": { - "type": "keyword" - } - } - }, - "processed_html": { - "type": "keyword" - }, - "published": { - "type": "boolean" - }, - "slug": { - "type": "text", - "copy_to": "listing_search", - "fields": { - "raw": { - "type": "keyword" - } - } - }, - "tags": { - "type": "keyword", - "copy_to": "listing_search" - }, - "title": { - "type": "text", - "copy_to": "listing_search", - "fields": { - "raw": { - "type": "keyword" - } - } - }, - "user_id": { - "type": "keyword" - } - } -} diff --git a/config/elasticsearch/mappings/reactions.json b/config/elasticsearch/mappings/reactions.json deleted file mode 100644 index 30dab30fd..000000000 --- a/config/elasticsearch/mappings/reactions.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "dynamic": "strict", - "properties": { - "id": { - "type": "keyword" - }, - "category": { - "type": "keyword" - }, - "created_at": { - "type": "date" - }, - "last_indexed_at": { - "type": "date" - }, - "reactable": { - "dynamic": "strict", - "properties": { - "id": { - "type": "keyword" - }, - "body_text": { - "type": "text", - "copy_to": "search_fields" - }, - "class_name": { - "type": "keyword" - }, - "path": { - "type": "keyword" - }, - "published_date_string": { - "type": "keyword" - }, - "reading_time": { - "type": "integer" - }, - "tags": { - "dynamic": "strict", - "properties": { - "name": { - "type": "keyword", - "copy_to": "search_fields" - }, - "keywords_for_search": { - "type": "keyword", - "copy_to": "search_fields" - } - } - }, - "title": { - "type": "text", - "copy_to": "search_fields", - "fields": { - "raw": { - "type": "keyword" - } - } - }, - "user": { - "dynamic": "strict", - "properties": { - "username": { - "type": "keyword", - "copy_to": "search_fields" - }, - "name": { - "type": "text", - "copy_to": "search_fields", - "fields": { - "raw": { - "type": "keyword" - } - } - }, - "id": { - "type": "keyword" - }, - "profile_image_90": { - "type": "keyword" - } - } - } - } - }, - "search_fields": { - "type": "text" - }, - "status": { - "type": "keyword" - }, - "user_id": { - "type": "keyword" - } - } -} diff --git a/config/elasticsearch/mappings/tags.json b/config/elasticsearch/mappings/tags.json deleted file mode 100644 index ce5374ef4..000000000 --- a/config/elasticsearch/mappings/tags.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "dynamic": "strict", - "properties": { - "id": { - "type": "keyword" - }, - "last_indexed_at": { - "type": "date" - }, - "name": { - "type": "text", - "fields": { - "raw": { - "type": "keyword" - } - } - }, - "hotness_score": { - "type": "integer" - }, - "supported": { - "type": "boolean" - }, - "short_summary": { - "type": "text" - }, - "rules_html": { - "type": "text" - } - } -} diff --git a/config/elasticsearch/mappings/users.json b/config/elasticsearch/mappings/users.json deleted file mode 100644 index c7f6e594a..000000000 --- a/config/elasticsearch/mappings/users.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "dynamic": "strict", - "properties": { - "id": { - "type": "keyword" - }, - "available_for": { - "type": "keyword", - "copy_to": "search_fields" - }, - "badge_achievements_count": { - "type": "integer" - }, - "comments_count": { - "type": "integer" - }, - "employer_name": { - "type": "keyword", - "copy_to": "search_fields" - }, - "hotness_score": { - "type": "integer" - }, - "last_comment_at": { - "type": "date" - }, - "last_indexed_at": { - "type": "date" - }, - "mostly_work_with": { - "type": "keyword", - "copy_to": "search_fields" - }, - "name": { - "type": "text", - "copy_to": "search_fields" - }, - "path": { - "type": "keyword" - }, - // deprecated in favor of public_reactions_count, to be removed - "positive_reactions_count": { - "type": "integer" - }, - "public_reactions_count": { - "type": "integer" - }, - "profile_image_90": { - "type": "keyword" - }, - "reactions_count": { - "type": "integer" - }, - "roles": { - "type": "keyword" - }, - "search_fields": { - "type": "text" - }, - "username": { - "type": "keyword", - "copy_to": "search_fields" - }, - "profile_fields": { - "type": "nested", - "properties": { - "name": { - "type": "keyword" - }, - "value": { - "type": "keyword" - } - } - }, - "custom_profile_fields": { - "type": "nested", - "properties": { - "name": { - "type": "keyword" - }, - "value": { - "type": "keyword" - } - } - } - } -} diff --git a/config/initializers/cypress_rails.rb b/config/initializers/cypress_rails.rb index 14f2b96a7..857fb06ea 100644 --- a/config/initializers/cypress_rails.rb +++ b/config/initializers/cypress_rails.rb @@ -22,7 +22,6 @@ end CypressRails.hooks.before_server_stop do # Called once, at_exit puts "Cleaning up and stopping server for end to end tests." - Rake::Task["search:destroy"].invoke Rake::Task["db:truncate_all"].invoke puts "The end to end test server shutdown gracefully." end diff --git a/config/routes.rb b/config/routes.rb index 6834c380b..5095553f4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -89,7 +89,6 @@ Rails.application.routes.draw do resources :health_checks, only: [] do collection do get :app - get :search get :database get :cache end @@ -212,7 +211,6 @@ Rails.application.routes.draw do get "/search/tags", to: "search#tags" get "/search/chat_channels", to: "search#chat_channels" get "/search/listings", to: "search#listings" - get "/search/users", to: "search#users" get "/search/usernames", to: "search#usernames" get "/search/feed_content", to: "search#feed_content" get "/search/reactions", to: "search#reactions" diff --git a/container-compose.yml b/container-compose.yml index f21c99aca..00d9d608f 100644 --- a/container-compose.yml +++ b/container-compose.yml @@ -8,13 +8,11 @@ services: depends_on: - bundle - db - - elasticsearch - redis - yarn environment: RAILS_ENV: development DATABASE_URL: postgresql://forem:forem@db:5432/PracticalDeveloper_development - ELASTICSEARCH_URL: http://elasticsearch:9200 REDIS_SESSIONS_URL: redis://redis:6379 REDIS_SIDEKIQ_URL: redis://redis:6379 REDIS_URL: redis://redis:6379 @@ -24,7 +22,7 @@ services: APP_DOMAIN: rails volumes: - .:/opt/apps/forem:z - entrypoint: ["dockerize", "-wait", "tcp://db:5432", "-wait", "http://elasticsearch:9200", "-wait", "tcp://redis:6379", "-wait", "file:///opt/apps/forem/vendor/bundle/.bundle_finished", "-timeout", "2700s"] + entrypoint: ["dockerize", "-wait", "tcp://db:5432", "-wait", "tcp://redis:6379", "-wait", "file:///opt/apps/forem/vendor/bundle/.bundle_finished", "-timeout", "2700s"] command: [ "bash", "-c", "./scripts/entrypoint.sh bootstrap && bundle exec rails server -b 0.0.0.0 -p 3000"] bundle: @@ -36,7 +34,6 @@ services: REDIS_SIDEKIQ_URL: redis://redis:6379 REDIS_URL: redis://redis:6379 DATABASE_URL: postgresql://forem:forem@db:5432/PracticalDeveloper_development - ELASTICSEARCH_URL: http://elasticsearch:9200 volumes: - .:/opt/apps/forem:z command: ["./scripts/bundle.sh"] @@ -50,7 +47,6 @@ services: REDIS_SIDEKIQ_URL: redis://redis:6379 REDIS_URL: redis://redis:6379 DATABASE_URL: postgresql://forem:forem@db:5432/PracticalDeveloper_development - ELASTICSEARCH_URL: http://elasticsearch:9200 volumes: - .:/opt/apps/forem:z command: [ "bash", "-c", "yarn install --dev"] @@ -67,7 +63,6 @@ services: REDIS_SIDEKIQ_URL: redis://redis:6379 REDIS_URL: redis://redis:6379 DATABASE_URL: postgresql://forem:forem@db:5432/PracticalDeveloper_development - ELASTICSEARCH_URL: http://elasticsearch:9200 volumes: - .:/opt/apps/forem:z entrypoint: ["dockerize", "-wait", "file:///opt/apps/forem/node_modules/.bin/webpack-dev-server", "-timeout", "300s"] @@ -80,17 +75,15 @@ services: - rails - redis - db - - elasticsearch environment: RAILS_ENV: development REDIS_SESSIONS_URL: redis://redis:6379 REDIS_SIDEKIQ_URL: redis://redis:6379 REDIS_URL: redis://redis:6379 DATABASE_URL: postgresql://forem:forem@db:5432/PracticalDeveloper_development - ELASTICSEARCH_URL: http://elasticsearch:9200 volumes: - .:/opt/apps/forem:z - entrypoint: ["dockerize", "-wait", "tcp://db:5432", "-wait", "http://elasticsearch:9200", "-wait", "tcp://redis:6379", "-wait", "http://rails:3000", "-timeout", "300s"] + entrypoint: ["dockerize", "-wait", "tcp://db:5432", "-wait", "tcp://redis:6379", "-wait", "http://rails:3000", "-timeout", "300s"] command: ["bundle", "exec", "rake","db:seed"] sidekiq: @@ -100,17 +93,15 @@ services: - rails - redis - db - - elasticsearch environment: RAILS_ENV: development REDIS_SESSIONS_URL: redis://redis:6379 REDIS_SIDEKIQ_URL: redis://redis:6379 REDIS_URL: redis://redis:6379 DATABASE_URL: postgresql://forem:forem@db:5432/PracticalDeveloper_development - ELASTICSEARCH_URL: http://elasticsearch:9200 volumes: - .:/opt/apps/forem:z - entrypoint: ["dockerize", "-wait", "tcp://db:5432", "-wait", "http://elasticsearch:9200", "-wait", "tcp://redis:6379", "-wait", "http://rails:3000", "-timeout", "300s"] + entrypoint: ["dockerize", "-wait", "tcp://db:5432", "-wait", "tcp://redis:6379", "-wait", "http://rails:3000", "-timeout", "300s"] command: ["bundle", "exec", "sidekiq","-c","2"] db: @@ -131,23 +122,5 @@ services: ports: - "6379:6379" - elasticsearch: - image: docker.elastic.co/elasticsearch/elasticsearch:7.5.2 - container_name: forem_elasticsearch - environment: - - cluster.name=forem - - bootstrap.memory_lock=true - - "ES_JAVA_OPTS=-Xms512m -Xmx512m" - - "discovery.type=single-node" - - xpack.security.enabled=false - - xpack.monitoring.enabled=false - - xpack.graph.enabled=false - - xpack.watcher.enabled=false - ports: - - "9200:9200" - # volumes: - # - es_data:/usr/share/elasticsearch/data:Z - volumes: db_data: - # es_data: diff --git a/docker-compose.yml b/docker-compose.yml index 9fd4a60c9..6f9ecdc1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,13 +9,11 @@ services: depends_on: - bundle - db - - elasticsearch - redis - yarn environment: RAILS_ENV: development DATABASE_URL: postgresql://forem:forem@db:5432/PracticalDeveloper_development - ELASTICSEARCH_URL: http://elasticsearch:9200 REDIS_SESSIONS_URL: redis://redis:6379 REDIS_SIDEKIQ_URL: redis://redis:6379 REDIS_URL: redis://redis:6379 @@ -25,7 +23,7 @@ services: APP_DOMAIN: rails volumes: - .:/opt/apps/forem:delegated - entrypoint: ["dockerize", "-wait", "tcp://db:5432", "-wait", "http://elasticsearch:9200", "-wait", "tcp://redis:6379", "-wait", "file:///opt/apps/forem/vendor/bundle/.bundle_finished", "-timeout", "2700s", "-wait-retry-interval", "10s"] + entrypoint: ["dockerize", "-wait", "tcp://db:5432", "-wait", "tcp://redis:6379", "-wait", "file:///opt/apps/forem/vendor/bundle/.bundle_finished", "-timeout", "2700s", "-wait-retry-interval", "10s"] command: [ "bash", "-c", "./scripts/entrypoint.sh bootstrap && bundle exec rails server -b 0.0.0.0 -p 3000"] bundle: @@ -38,7 +36,6 @@ services: REDIS_SIDEKIQ_URL: redis://redis:6379 REDIS_URL: redis://redis:6379 DATABASE_URL: postgresql://forem:forem@db:5432/PracticalDeveloper_development - ELASTICSEARCH_URL: http://elasticsearch:9200 volumes: - .:/opt/apps/forem:delegated command: ["./scripts/bundle.sh"] @@ -53,7 +50,6 @@ services: REDIS_SIDEKIQ_URL: redis://redis:6379 REDIS_URL: redis://redis:6379 DATABASE_URL: postgresql://forem:forem@db:5432/PracticalDeveloper_development - ELASTICSEARCH_URL: http://elasticsearch:9200 volumes: - .:/opt/apps/forem:delegated command: yarn install --dev @@ -72,7 +68,6 @@ services: REDIS_SIDEKIQ_URL: redis://redis:6379 REDIS_URL: redis://redis:6379 DATABASE_URL: postgresql://forem:forem@db:5432/PracticalDeveloper_development - ELASTICSEARCH_URL: http://elasticsearch:9200 volumes: - .:/opt/apps/forem:delegated entrypoint: ["dockerize", "-wait", "file:///opt/apps/forem/node_modules/.bin/webpack-dev-server", "-wait", "file:///opt/apps/forem/vendor/bundle/.bundle_finished", "-timeout", "2700s", "-wait-retry-interval", "10s"] @@ -86,17 +81,15 @@ services: - rails - redis - db - - elasticsearch environment: RAILS_ENV: development REDIS_SESSIONS_URL: redis://redis:6379 REDIS_SIDEKIQ_URL: redis://redis:6379 REDIS_URL: redis://redis:6379 DATABASE_URL: postgresql://forem:forem@db:5432/PracticalDeveloper_development - ELASTICSEARCH_URL: http://elasticsearch:9200 volumes: - .:/opt/apps/forem:delegated - entrypoint: ["dockerize", "-wait", "tcp://db:5432", "-wait", "http://elasticsearch:9200", "-wait", "tcp://redis:6379", "-wait", "http://rails:3000", "-timeout", "2700s", "-wait-retry-interval", "20s"] + entrypoint: ["dockerize", "-wait", "tcp://db:5432", "-wait", "tcp://redis:6379", "-wait", "http://rails:3000", "-timeout", "2700s", "-wait-retry-interval", "20s"] command: ["bundle", "exec", "rake","db:seed"] sidekiq: @@ -107,17 +100,15 @@ services: - rails - redis - db - - elasticsearch environment: RAILS_ENV: development REDIS_SESSIONS_URL: redis://redis:6379 REDIS_SIDEKIQ_URL: redis://redis:6379 REDIS_URL: redis://redis:6379 DATABASE_URL: postgresql://forem:forem@db:5432/PracticalDeveloper_development - ELASTICSEARCH_URL: http://elasticsearch:9200 volumes: - .:/opt/apps/forem:delegated - entrypoint: ["dockerize", "-wait", "tcp://db:5432", "-wait", "http://elasticsearch:9200", "-wait", "tcp://redis:6379", "-wait", "http://rails:3000", "-timeout", "2700s", "-wait-retry-interval", "20s"] + entrypoint: ["dockerize", "-wait", "tcp://db:5432", "-wait", "tcp://redis:6379", "-wait", "http://rails:3000", "-timeout", "2700s", "-wait-retry-interval", "20s"] command: ["bundle", "exec", "sidekiq","-c","2"] db: @@ -138,28 +129,5 @@ services: ports: - "6379:6379" - elasticsearch: - image: docker.elastic.co/elasticsearch/elasticsearch:7.5.2 - container_name: forem_elasticsearch - environment: - - cluster.name=forem - - bootstrap.memory_lock=true - - "ES_JAVA_OPTS=-Xms512m -Xmx512m" - - "discovery.type=single-node" - - xpack.security.enabled=false - - xpack.monitoring.enabled=false - - xpack.graph.enabled=false - - xpack.watcher.enabled=false - volumes: - - es_data:/usr/share/elasticsearch/data:delegated - ports: - - "9200:9200" - ulimits: - memlock: - soft: -1 - hard: -1 - volumes: db_data: - es_data: - diff --git a/docs/admin/admin-search.md b/docs/admin/admin-search.md index 2beff7180..3d671f795 100644 --- a/docs/admin/admin-search.md +++ b/docs/admin/admin-search.md @@ -16,9 +16,9 @@ The view responsible for managing user reports can be found at action of the [`admin/feedback_messages_controller`][feedback_messages]. For Forem, Ransack is being used exclusively in admin, for search problems in -other parts of the app we use [Elasticsearch][elasticsearch]. +other parts of the app we use [PostgreSQL Full Text Search][postgres_fts]. [feedback_messages]: https://github.com/forem/forem/blob/4e41e4a2ac893fa2a6c36990cfe475858ffb086a/app/controllers/admin/feedback_messages_controller.rb#L4 [ransack]: https://github.com/activerecord-hackery/ransack -[elasticsearch]: /backend/elasticsearch +[postgres_fts]: https://www.postgresql.org/docs/11/textsearch.html diff --git a/docs/backend/data-update-scripts.md b/docs/backend/data-update-scripts.md index 8e78d5f8f..7143f8676 100644 --- a/docs/backend/data-update-scripts.md +++ b/docs/backend/data-update-scripts.md @@ -8,9 +8,7 @@ Data Update Scripts were introduced in [this PR](https://github.com/forem/forem/pull/6025) and allow us to run any data updates we might need. For example, if we added a column to the database and then wanted to backfill that column with data, rather than going and manually -doing it in a console, we would use a DataUpdateScript. Another example might be -adding a new attribute to Elasticsearch. We could then use a DataUpdateScript to -reindex all of our models. +doing it in a console, we would use a DataUpdateScript. ## How it works diff --git a/docs/backend/elasticsearch.md b/docs/backend/elasticsearch.md deleted file mode 100644 index 6a68c6735..000000000 --- a/docs/backend/elasticsearch.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: Elasticsearch ---- - -# What is [Elasticsearch](https://www.elastic.co/what-is/elasticsearch)? - -> Elasticsearch is a distributed, open-source search and analytics engine for -> all types of data, including textual, numerical, geospatial, structured, and -> unstructured. Elasticsearch is built on Apache Lucene and was first released -> in 2010 by Elastic. - -# How Forem uses Elasticsearch - -### Searchable Models - -At Forem we use Elasticsearch for all of our user-facing searching needs. Models -that are searched using Elasticsearch are: - -- Tags -- Listings -- Chat Channels -- Users -- Podcast Episodes -- Comments -- Articles - -### Index Setup - -The above models are organized in Elasticsearch in different -[indexes](https://www.elastic.co/blog/what-is-an-elasticsearch-index) to make -searching more accurate and performant. The index breakdown is: - -- Tags index -> Tags -- Listings index -> Listings -- Chat Channel Membership index -> Chat Channels -- User's index -> Users -- Feed Content index -> Podcast Episodes, Comments, and Articles - -You will notice that Podcast Episodes, Comments, and Articles all share the same -index. This allows us to search and aggregate across all of these documents at -the same time. These documents also share a lot of similar fields so putting -them in the same index made sense. In the future, depending on our search needs -this index structure might change. - -Each index that is listed above has a corresponding -[mapping](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping.html) -that defines what fields can and cannot be indexed to it. These mappings are -similar to a db schema. In our case, all of our index mappings are defined as -[`strict`](https://www.elastic.co/guide/en/elasticsearch/reference/current/dynamic.html#dynamic) -which means if we try to index a field that is not defined it will throw an -error. Mappings for each index can be found in -`app/config/elasticsearch/mappings`. - -### Indexing documents into Elasticsearch - -In order to index the data from Postgres into Elasticsearch, we rely on -callbacks in our ActiveRecord models. Whenever a model is created or updated we -use an `after_commit` callback to enqueue a `Search::IndexWorker` which handles -indexing the document into Elasticsearch. In order to translate our ActiveRecord -data to Elasticsearch we use serializers. Each model has its own serializer -which can be found in `app/serializers/search` - -### Searching Elasticsearch - -Once the documents are in Elasticsearch then we can search for them. This is -handled by code in our `app/services/search` directory. Here you will see a -collection of classes used to help us index and search documents in -Elasticsearch. The nested `query_builders` directory contains all the logic we -need to help us build complex search queries to send to Elasticsearch. - -# Working With Elasticsearch - -## How to add a new field to Elasticsearch - -When adding a new field to Elasticsearch here are the steps you have to follow: - -1. Add field to mapping by editing the appropriate JSON file in - `app/config/elasticsearch/mappings`. If you plan to test locally you will - need to run `bin/setup` to update your local Elasticsearch mappings. -2. Add field to serializer for model located in `app/serializers/search`. -3. If the new field needs to be backfilled write a DataUpdateScript to reindex - all affected models so the field is available and populated in Elasticsearch -4. DEPLOY - Before you can start using a new field you need to ensure that it is - added to Elasticsearch and populated properly. The worker that runs the - DataUpdateScript will run 5 min after a deploy completes. This means you have - to deploy the code for adding the field separate from the code that will use - it. -5. Add code to search for your new field. You likely will need to update the - allowed params in the searches controller as well as the pertinent query - builder. -6. DEPLOY new search code -7. DONE! diff --git a/docs/backend/readme.md b/docs/backend/readme.md index a1b917644..553924150 100644 --- a/docs/backend/readme.md +++ b/docs/backend/readme.md @@ -10,7 +10,6 @@ items: - authorization.md - configuration.md - data-update-scripts.md - - elasticsearch.md - emails.md - fastly.md - internationalization.md diff --git a/docs/installation/containers.md b/docs/installation/containers.md index b784e479d..2d4891b16 100644 --- a/docs/installation/containers.md +++ b/docs/installation/containers.md @@ -108,12 +108,6 @@ The script executes the following steps: ### Docker on Mac -- Should you experience problems with the Elasticsearch container, try to - increase the memory and/or swap allocation for Docker. On macOS this can be - done via the GUI: - - ![docker gui](https://user-images.githubusercontent.com/47985/74210448-b63b7c80-4c83-11ea-959b-02249b2a6952.png) - - In case `rails server` starts with the following message: ```shell diff --git a/docs/installation/linux.md b/docs/installation/linux.md index d20f7fb69..aeaf0ae64 100644 --- a/docs/installation/linux.md +++ b/docs/installation/linux.md @@ -66,21 +66,6 @@ We recommend following Digital Ocean's extensive guides [How to Install and Secure Redis](https://www.digitalocean.com/community/tutorial_collections/how-to-install-and-secure-redis) to setup Redis. -### Elasticsearch - -Forem requires a version of Elasticsearch between 7.1 and 7.5. Version 7.6 is -not supported. We recommend version 7.5.2. - -We recommend following -[Elasticsearch's guide for installing on Linux](https://www.elastic.co/guide/en/elasticsearch/reference/7.5/targz.html#install-linux). - -Elasticsearch is also available as as -[Debian package](https://www.elastic.co/guide/en/elasticsearch/reference/7.5/deb.html) -or a -[RPM package](https://www.elastic.co/guide/en/elasticsearch/reference/7.5/rpm.html). - -NOTE: Make sure to download **the OSS version**, `elasticsearch-oss`. - ## Installing Forem 1. Fork Forem's repository, e.g. diff --git a/docs/installation/mac.md b/docs/installation/mac.md index 19f0ddb33..471a38a27 100644 --- a/docs/installation/mac.md +++ b/docs/installation/mac.md @@ -8,18 +8,25 @@ title: macOS ### Ruby -1. **Note:** MacOS ships with a version of Ruby, needed for various operating systems. - To avoid causing an issue with your operating system you should use a version manager for Ruby. +1. **Note:** MacOS ships with a version of Ruby, needed for various operating + systems. To avoid causing an issue with your operating system you should use + a version manager for Ruby. + + If you don't already have a Ruby version manager, we highly recommend + [rbenv](https://github.com/rbenv/rbenv). This will allow you to have + different versions running on a per project basis. The MacOS system version + of Ruby will stay intact while giving you the ability to use the version + needed for this Forem project. Please follow their + [installation guide](https://github.com/rbenv/rbenv#installation). - If you don't already have a Ruby version manager, we highly recommend [rbenv](https://github.com/rbenv/rbenv). This will allow you to have different versions running on a per project basis. The MacOS system version of Ruby will stay intact while giving you the ability to use the version needed for this Forem project. -Please follow their [installation guide](https://github.com/rbenv/rbenv#installation). 2. With the Ruby version manager, install the Ruby version listed on our badge. (i.e. with rbenv: `rbenv install $(cat .ruby-version)`) - - **Note:** The repository must be forked and cloned before running the `rbenv install $(cat .ruby-version)` command. - + + **Note:** The repository must be forked and cloned before running the + `rbenv install $(cat .ruby-version)` command. ### Yarn + Please refer to their [installation guide](https://yarnpkg.com/en/docs/install). ### PostgreSQL @@ -63,146 +70,6 @@ You can test if it's up and running by issuing the following command: redis-cli ping ``` -### Elasticsearch - -Forem requires Elasticsearch 7.x to run. We recommend version 7.5.2. - -You have the option of installing Elasticsearch with Homebrew or through an -archive. We **recommend** installing from archive on Mac. - -### Installing Elasticsearch from the archive - -We recommend that you **do not** install Elasticsearch in the app directory. -Instead, we recommend installing it in your home directory (for example, -`cd $HOME`). (This also ensures that we don't accidentally commit Elasticsearch -code to the project's repository!) - -The following directions were -[taken from the Elasticsearch docs themselves](https://www.elastic.co/guide/en/elasticsearch/reference/7.5/targz.html#install-macos), -so check those out if you run into any issues or want further information. Make -sure to download **the OSS version** of Elasticsearch, `elasticsearch-oss`. - -Please note that you will need `wget` in order to proceed with this installation -(`brew install wget`). - -```shell -wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-oss-7.5.2-darwin-x86_64.tar.gz -wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-oss-7.5.2-darwin-x86_64.tar.gz.sha512 -shasum -a 512 -c elasticsearch-oss-7.5.2-darwin-x86_64.tar.gz.sha512 -tar -xzf elasticsearch-oss-7.5.2-darwin-x86_64.tar.gz -``` - -To start elasticsearch, make sure you are in the correct directory: - -```shell -cd elasticsearch-7.5.2 -``` - -You can then start it by running: - -```shell -./bin/elasticsearch -``` - -To start elasticsearch as a daemonized process: - -```shell -./bin/elasticsearch -d -``` - -### Installing Elasticsearch with Homebrew - -To install Elasticsearch with Homebrew we will use the following commands to: - -- tap the Elastic Homebrew repository -- install the latest OSS distribution -- pin the latest OSS distribution. - -```shell -brew tap elastic/tap -brew install elastic/tap/elasticsearch-oss -brew pin elasticsearch-oss -``` - -After installation you can manually test if the Elasticsearch server starts by -issuing the command `elasticsearch` in the shell. You can then start the server -as a service with `brew services start elasticsearch-oss`. - -You can find further info on your local Elasticsearch installation by typing -`brew info elastic/tap/elasticsearch-oss`. - -#### Troubleshooting startup issues - -Two possible startup issues you might encounter: - -- `java.nio.file.FileSystemLoopException`: - -```text -Exception in thread "main" org.elasticsearch.bootstrap.BootstrapException: java.nio.file.FileSystemLoopException: /usr/local/etc/elasticsearch/elasticsearch -Likely root cause: java.nio.file.FileSystemLoopException: /usr/local/etc/elasticsearch/elasticsearch -``` - -This happens because the installation of Elasticsearch might have a recursive -link in the configuration directory causing the infinite loop: - -```shell -> ll /usr/local/etc/elasticsearch -elasticsearch -> /usr/local/etc/elasticsearch -``` - -By manually removing the link with -`rm -i /usr/local/etc/elasticsearch/elasticsearch` the issue should be fixed. - -- `java.lang.IllegalStateException`: - -```text -java.lang.IllegalStateException: Could not load plugin descriptor for plugin directory [plugins] -Likely root cause: java.nio.file.NoSuchFileException: /usr/local/Cellar/elasticsearch-oss/7.6.0/libexec/plugins/plugins/plugin-descriptor.properties -``` - -This happens for a similar reason as the previous error, the installation might -create a recursive link in the plugins directory. - -```shell -> ll /usr/local/var/elasticsearch/plugins -plugins -> /usr/local/var/elasticsearch/plugins -``` - -By manually removing the link with -`rm -i /usr/local/var/elasticsearch/plugins/plugins` the issue should be fixed. - -### Testing if Elasticsearch is running - -Once installed and started you can test if it's up and running correctly by -issuing the following command: - -```shell -curl http://localhost:9200 -``` - -You should receive in response a JSON document containing some information about -your local Elasticsearch installation, for example: - -```json -{ - "name": "hostname", - "cluster_name": "elasticsearch_...", - "cluster_uuid": "...", - "version": { - "number": "7.5.2", - "build_flavor": "oss", - "build_type": "tar", - "build_hash": "8bec50e1e0ad29dad5653712cf3bb580cd1afcdf", - "build_date": "2020-01-15T12:11:52.313576Z", - "build_snapshot": false, - "lucene_version": "8.3.0", - "minimum_wire_compatibility_version": "6.8.0", - "minimum_index_compatibility_version": "6.0.0-beta1" - }, - "tagline": "You Know, for Search" -} -``` - ## Installing Forem 1. Fork Forem's repository, e.g. @@ -240,8 +107,7 @@ your local Elasticsearch installation, for example: ### Possible error messages -**Error:** -`rbenv install hangs at ruby-build: using readline from homebrew` +**Error:** `rbenv install hangs at ruby-build: using readline from homebrew` **_Solution:_** [Stackoverflow answer](https://stackoverflow.com/questions/63599818/rbenv-install-hangs-at-ruby-build-using-readline-from-homebrew) diff --git a/docs/installation/windows.md b/docs/installation/windows.md index 686675c7d..e849d83f9 100644 --- a/docs/installation/windows.md +++ b/docs/installation/windows.md @@ -176,60 +176,6 @@ We recommend to follow [this guide](https://redislabs.com/blog/redis-on-windows-10/) to run Redis under WSL. -### Elasticsearch - -Forem requires a version of Elasticsearch between 7.1 and 7.5. Version 7.6 is -not supported. We recommend version 7.5.2. - -We recommend that you **do not** install Elasticsearch in the app directory. -Instead, we recommend installing it in your home directory (for example, -`cd $HOME`). (This also ensures that we don't accidentally commit Elasticsearch -code to the project's repository!) - -The following directions were taken from -[the Elasticsearch docs](https://www.elastic.co/guide/en/elasticsearch/reference/7.5/targz.html#install-linux), -check them out to learn more about the installation process and troubleshooting -issues. Make sure to refer to **the OSS version**, `elasticsearch-oss` while -going through the Elasticsearch docs. - -To install Elasticsearch perform the following steps: - -1. Execute the following commands: - - ```shell - cd - wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-oss-7.5.2-linux-x86_64.tar.gz - wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-oss-7.5.2-linux-x86_64.tar.gz.sha512 - shasum -a 512 -c elasticsearch-oss-7.5.2-linux-x86_64.tar.gz.sha512 - tar -xzf elasticsearch-oss-7.5.2-linux-x86_64.tar.gz - ``` - -2. Next, switch to the correct directory with: - - ```shell - cd elasticsearch-7.5.2/ - ``` - -3. To start Elasticsearch, run the following command: - - ```shell - ./bin/elasticsearch - ``` - - or, start it as a daemonized process with: - - ```shell - ./bin/elasticsearch -d - ``` - -4. Once Elasticsearch is running, - [verify Elasticsearch's installation](https://www.elastic.co/guide/en/elasticsearch/reference/7.5/targz.html#_checking_that_elasticsearch_is_running) - by executing the `cURL` command as follows: - - ```shell - curl -X GET "localhost:9200/?pretty" - ``` - ## Installing Forem 1. Fork Forem's repository, eg. @@ -277,8 +223,8 @@ To install Elasticsearch perform the following steps: - You do not need "real" keys for basic development. Some features require certain keys, so you may be able to add them as you go. -1. After ensuring that Elasticsearch, the PostgreSQL server, and the Redis - server are running, run `bin/setup`. +1. After ensuring that the PostgreSQL server and the Redis server are running, + run `bin/setup`. > The `bin/setup` script is responsible for installing a varienty of > dependencies. One can find it inside the `bin` folder by the name of @@ -290,8 +236,8 @@ To install Elasticsearch perform the following steps: > - It then installs JavaScript dependencies using the script in `bin/yarn` > file. These dependencies are located in `package.json` in the root of the > repository. - > - Next, it uses various Rake files located inside the `lib` folder to setup - > ElasticSearch environment, PostgreSQL database creation and updation. + > - Next, it uses various Rake files located inside the `lib` folder for the + > PostgreSQL database creation and updation. > - Finally it cleans up all the log files and restarts the Puma server. ### Possible error messages diff --git a/docs/maintainers/deploying.md b/docs/maintainers/deploying.md index ad0449d65..35e50e207 100644 --- a/docs/maintainers/deploying.md +++ b/docs/maintainers/deploying.md @@ -57,10 +57,10 @@ stage will do nothing. If the branch has been merged into main, then this stage will kick off a deploy. The deploy will run in its own job deploying our application to Heroku. -Prior to deploying the code, Heroku will run database migrations, Elasticsearch -updates, and do some final checks (more information on that below) to make sure -everything is working as expected. If these all succeed, then the deploy -completes and our team is notified. +Prior to deploying the code, Heroku will run database migrations and do some +final checks (more information on that below) to make sure everything is working +as expected. If these all succeed, then the deploy completes and our team is +notified. ## Deploying to Heroku @@ -84,13 +84,8 @@ directory. During this release step we do a few checks. successfully before the code that uses it goes live. 3. We run any data update scripts that need to be run. A data update script is one that allows us to update data in the background separate from a - migration. For example, if we add a new field to Elasticsearch and need to - reindex all of our documents we would use a data update script. -4. We update Elasticsearch. Elasticsearch contains indexes which have mappings. - Mappings are similar to database schema. The same way we run a migration to - update our database we have to run a setup task to update any Elasticsearch - mappings. -5. Following updating all of our datastores we use the Rails runner to output a + migration. +4. Following updating all of our datastores we use the Rails runner to output a simple string. Executing a Rails runner command ensures that we can boot up the entire app successfully before it is deployed. We deploy asynchronously, so the website is running the new code a few minutes after deploy. A new diff --git a/docs/technical-overview/architecture.md b/docs/technical-overview/architecture.md index c6ed9a2fa..6c0bee3cf 100644 --- a/docs/technical-overview/architecture.md +++ b/docs/technical-overview/architecture.md @@ -72,10 +72,10 @@ Rails][fastly_rails] docs, but we bust specific URLs via `EdgeCache::Bust#call`. The home feed is based on a combination of recent collective posts that are cached and delivered the same to everyone in the HTML, and additional articles -fetched from an Elasticsearch index after page load. To determine which posts a -user sees, they are ranked based on the user's followed tags, followed users, -and relative weights for each tag. Additional fetched articles also follow this -general pattern. +fetched from a PostgreSQL Full Text Search index after page load. To determine +which posts a user sees, they are ranked based on the user's followed tags, +followed users, and relative weights for each tag. Additional fetched articles +also follow this general pattern. Currently, the top post on the home feed, which must have a cover image, is shared among all users. diff --git a/docs/technical-overview/stack.md b/docs/technical-overview/stack.md index 20b4bf877..78a0a406d 100644 --- a/docs/technical-overview/stack.md +++ b/docs/technical-overview/stack.md @@ -7,7 +7,8 @@ title: Stack For the Forem tech stack we use: - [_Puma_](https://github.com/puma/puma) as the web server -- [_PostgreSQL_](https://www.postgresql.org/) as the primary database +- [_PostgreSQL_](https://www.postgresql.org/) as the primary database and for + Full Text Search - [_Redis_](https://redis.io/) to store cached data - [_Fastly_](https://www.fastly.com/) for [edge caching](https://dev.to/ben/making-devto-insanely-fast) @@ -18,7 +19,6 @@ For the Forem tech stack we use: - [_Sidekiq_](https://github.com/mperham/sidekiq) and [_Active Job_](https://guides.rubyonrails.org/active_job_basics.html) for background workers -- [_Elasticsearch_](https://www.elastic.co/elasticsearch/) for in app searching - [Ransack](https://github.com/activerecord-hackery/ransack) for internal search - [_Redcarpet_](https://github.com/vmg/redcarpet) and [_Rouge_](https://github.com/jneen/rouge) to parse Markdown diff --git a/docs/tests/readme.md b/docs/tests/readme.md index b4b59aad2..efae0084e 100644 --- a/docs/tests/readme.md +++ b/docs/tests/readme.md @@ -69,8 +69,3 @@ retry the individual job that failed rather than the entire suite in order to save some time. When you retry the individual job, make sure to also retry the Deploy job. Even though you may not be deploying that job must complete for the entire build to pass. - -Please note that you will need to have Elasticsearch installed and running for -certain tests in our test suite. You can find instructions on how to install and -run Elasticsearch specific to your environment in the -[Installation Guide](/installation). diff --git a/docs/tests/test-flags.md b/docs/tests/test-flags.md index 93f1f6774..6342ee4f2 100644 --- a/docs/tests/test-flags.md +++ b/docs/tests/test-flags.md @@ -11,59 +11,15 @@ test example. Some flags that we use are: - `js: true` -- `elasticsearch_reset: true` -- `elasticsearch: ` -- `stub_elasticsearch: true` - `throttle: true` - `type: ` -### Elasticsearch Flags - -Two Elasticsearch flags that can be used when dealing with specs that will be -hitting Elasticsearch. When running a spec that is going to interact with -Elasticsearch, you want Elasticsearch to be clean like your database. Since we -don't have a "database cleaner" for Elasticsearch, we have to do it manually. -There are two ways to do this: - -1. `elasticsearch: reset` - This will trigger a complete tear down and rebuild - of Elasticsearch via a block. This takes time so it's not something you want - to be doing unless you absolutely have to. - -```ruby - config.around(:each, elasticsearch_reset: true) do |example| - Search::Cluster.recreate_indexes - example.run - Search::Cluster.recreate_indexes - end -``` - -2. `elasticsearch: ` - This will clear the data for the given - search class index. For example, if you passed "User" as your search class - name it would clear out the user index data using code from this block. - -```ruby - config.around(:each, :elasticsearch) do |ex| - klasses = Array.wrap(ex.metadata[:elasticsearch]).map do |search_class| - Search.const_get(search_class) - end - klasses.each { |klass| clear_elasticsearch_data(klass) } - ex.run - end -``` - ### `js: true` Flag `js: true` indicates that we want the JavaScript on the page to be executed when the page is rendered, and a headless chrome instance will be initialized to do so (instead of the default -[rack_test](https://github.com/teamcapybara/capybara#racktest) driver). One side -effect of running our JavaScript in our specs is that a lot of pages will hit -Elasticsearch. Since we don't clean out Elasticsearch between every single spec -(because it is very costly) this can lead to unexpected data being loaded for a -spec. To prevent this from happening, we can use the `:stub_elasticsearch` flag. -The `:stub_elasticsearch` flag will stub all index and search requests made to -Elasticsearch and return an empty response. This will ensure that no unwanted -data shows up on your spec's page. +[rack_test](https://github.com/teamcapybara/capybara#racktest) driver). If you are debugging a `js: true` spec and want to see the browser, you can set `HEADLESS=false` before running a spec: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 60c1700cb..cf550dbfe 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -33,66 +33,6 @@ The first command creates the user **ec2-user** and the second one creates the database for this user because every user needs its database. Even if the first command fails, run the second command to create the missing database. -## Elasticsearch - -### Index read-only error - -If you encounter an error similar to the following: - -```shell -{"error":{"root_cause":[{"type":"cluster_block_exception","reason":"index [tags_development] blocked by: [FORBIDDEN/12/index read-only / allow delete (api)];"}],"type":"cluster_block_exception","reason":"index [tags_development] blocked by: [FORBIDDEN/12/index read-only / allow delete (api)];"},"status":403} -``` - -it means that Elasticsearch went into read only mode because its disk allocator -noticed how the disk drive is nearly full: - -> Elasticsearch enforces a read-only index block -> (`index.blocks.read_only_allow_delete`) on every index that has one or more -> shards allocated on the node that has at least one disk exceeding the flood -> stage. - -This is an indication that you might not have enough space for ES to work -correctly. - -If you want to disable the threshold check on your local machine you can open a -Rails console (with `rails console`) and issue the following command: - -```ruby -Search::Client.cluster.put_settings(body: { - persistent: { - "cluster.routing.allocation.disk.threshold_enabled" => false, - } -}) -``` - -Alternatively you can use the following command in a terminal session: - -```shell -curl -XPUT -H "Content-Type: application/json" http://localhost:9200/_all/_settings -d '{"index.blocks.read_only_allow_delete": null}' -``` - -To disable the "read only" mode to allow operations on the Elasticsearch indexes -you can issue the following command, similary in the Rails console: - -```ruby -Search::Client.indices.get(index: "*").keys.each do |index_name| - Search::Client.indices.put_settings( - index: index_name, - body: { "index.blocks.read_only_allow_delete" => nil } - ) -end -``` - -Or alternatively: - -```shell -curl -XPUT -H "Content-Type: application/json" http://localhost:9200/_cluster/settings -d '{ "transient": { "cluster.routing.allocation.disk.threshold_enabled": false } }' -``` - -If instead you want to tune the Elasticsearch disk allocator's settings, please -refer to -[Disk-based shard allocation](https://www.elastic.co/guide/en/elasticsearch/reference/current/disk-allocator.html#disk-allocator). - ## CORS If you are experiencing CORS issues locally or need to display more information diff --git a/lib/data_update_scripts/20200218195023_index_chat_channel_memberships_to_elasticsearch.rb b/lib/data_update_scripts/20200218195023_index_chat_channel_memberships_to_elasticsearch.rb index 73d5d464e..35fa1babb 100644 --- a/lib/data_update_scripts/20200218195023_index_chat_channel_memberships_to_elasticsearch.rb +++ b/lib/data_update_scripts/20200218195023_index_chat_channel_memberships_to_elasticsearch.rb @@ -1,7 +1,7 @@ module DataUpdateScripts class IndexChatChannelMembershipsToElasticsearch def run - ChatChannelMembership.find_each(&:index_to_elasticsearch_inline) + # ChatChannelMembership.find_each(&:index_to_elasticsearch_inline) end end end diff --git a/lib/data_update_scripts/20200410152018_resync_elasticsearch_documents.rb b/lib/data_update_scripts/20200410152018_resync_elasticsearch_documents.rb index 2adea1ffc..dd1c1afae 100644 --- a/lib/data_update_scripts/20200410152018_resync_elasticsearch_documents.rb +++ b/lib/data_update_scripts/20200410152018_resync_elasticsearch_documents.rb @@ -2,13 +2,13 @@ module DataUpdateScripts class ResyncElasticsearchDocuments def run # Previous bug: Not getting removed properly - sync_docs(Article.ids, "Article") - sync_docs(Comment.ids, "Comment") - sync_docs(User.ids, "User") + # sync_docs(Article.ids, "Article") + # sync_docs(Comment.ids, "Comment") + # sync_docs(User.ids, "User") # Previous bug: Not getting indexed on creation properly - index_docs(PodcastEpisode.ids, "PodcastEpisode") - index_docs(Tag.ids, "Tag") + # index_docs(PodcastEpisode.ids, "PodcastEpisode") + # index_docs(Tag.ids, "Tag") end private diff --git a/lib/data_update_scripts/20200519142908_re_index_feed_content_and_users_to_elasticsearch.rb b/lib/data_update_scripts/20200519142908_re_index_feed_content_and_users_to_elasticsearch.rb index 2131cc3cf..bb54a90a5 100644 --- a/lib/data_update_scripts/20200519142908_re_index_feed_content_and_users_to_elasticsearch.rb +++ b/lib/data_update_scripts/20200519142908_re_index_feed_content_and_users_to_elasticsearch.rb @@ -1,21 +1,21 @@ module DataUpdateScripts class ReIndexFeedContentAndUsersToElasticsearch def run - Article.select(:id).in_batches(of: 100) do |batch| - Search::BulkIndexWorker.set(queue: :default).perform_async( - "Article", batch.ids - ) - end - Comment.select(:id).in_batches(of: 100) do |batch| - Search::BulkIndexWorker.set(queue: :default).perform_async( - "Comment", batch.ids - ) - end - PodcastEpisode.select(:id).in_batches(of: 100) do |batch| - Search::BulkIndexWorker.set(queue: :default).perform_async( - "PodcastEpisode", batch.ids - ) - end + # Article.select(:id).in_batches(of: 100) do |batch| + # Search::BulkIndexWorker.set(queue: :default).perform_async( + # "Article", batch.ids + # ) + # end + # Comment.select(:id).in_batches(of: 100) do |batch| + # Search::BulkIndexWorker.set(queue: :default).perform_async( + # "Comment", batch.ids + # ) + # end + # PodcastEpisode.select(:id).in_batches(of: 100) do |batch| + # Search::BulkIndexWorker.set(queue: :default).perform_async( + # "PodcastEpisode", batch.ids + # ) + # end # See: https://github.com/forem/forem/pull/10313#discussion_r487646864 # User.select(:id).in_batches(of: 200) do |batch| diff --git a/lib/data_update_scripts/20200803142830_reindex_listing_search_column.rb b/lib/data_update_scripts/20200803142830_reindex_listing_search_column.rb index 0e9a8c4dc..2ac808345 100644 --- a/lib/data_update_scripts/20200803142830_reindex_listing_search_column.rb +++ b/lib/data_update_scripts/20200803142830_reindex_listing_search_column.rb @@ -3,7 +3,7 @@ module DataUpdateScripts def run # Choose to do inline so development envs are ready immediately after # this is run - Listing.find_each(&:index_to_elasticsearch_inline) + # Listing.find_each(&:index_to_elasticsearch_inline) end end end diff --git a/lib/data_update_scripts/20200904132553_remove_draft_articles_with_duplicate_feed_source_url.rb b/lib/data_update_scripts/20200904132553_remove_draft_articles_with_duplicate_feed_source_url.rb index 742792069..3b0ad601f 100644 --- a/lib/data_update_scripts/20200904132553_remove_draft_articles_with_duplicate_feed_source_url.rb +++ b/lib/data_update_scripts/20200904132553_remove_draft_articles_with_duplicate_feed_source_url.rb @@ -5,76 +5,76 @@ module DataUpdateScripts # This statement deletes all draft articles in excess found to be duplicate over feed_source_url, # excluding those whose body_markdown is different from the other duplicate occurrences - result_same_body = ActiveRecord::Base.connection.execute( - <<-SQL, - WITH duplicates_draft_articles AS - (SELECT id - FROM - (SELECT id, - published, - body_markdown, - LAG(body_markdown, 1) OVER(PARTITION BY feed_source_url - ORDER BY id ASC) AS previous_body_markdown, - ROW_NUMBER() OVER(PARTITION BY feed_source_url - ORDER BY id ASC) AS row_number - FROM articles - WHERE feed_source_url IS NOT NULL ) duplicates - WHERE duplicates.row_number > 1 - AND published = 'f' -- drafts - AND body_markdown = previous_body_markdown -- with the same body - ) - DELETE - FROM articles - WHERE id IN (SELECT id FROM duplicates_draft_articles) RETURNING id; - SQL - ) - + # result_same_body = ActiveRecord::Base.connection.execute( + # <<-SQL, + # WITH duplicates_draft_articles AS + # (SELECT id + # FROM + # (SELECT id, + # published, + # body_markdown, + # LAG(body_markdown, 1) OVER(PARTITION BY feed_source_url + # ORDER BY id ASC) AS previous_body_markdown, + # ROW_NUMBER() OVER(PARTITION BY feed_source_url + # ORDER BY id ASC) AS row_number + # FROM articles + # WHERE feed_source_url IS NOT NULL ) duplicates + # WHERE duplicates.row_number > 1 + # AND published = 'f' -- drafts + # AND body_markdown = previous_body_markdown -- with the same body + # ) + # DELETE + # FROM articles + # WHERE id IN (SELECT id FROM duplicates_draft_articles) RETURNING id; + # SQL + # ) + # # Now that all duplicates with the same body are gone, we need to deal with duplicate feed source URLs # with different bodies. # We thus select the oldest for removal preserving the most recent one - result_different_bodies = ActiveRecord::Base.connection.execute( - <<-SQL, - WITH duplicates_draft_articles AS - (SELECT id - FROM - (SELECT id, - published, - body_markdown, - LAG(body_markdown, 1) OVER(PARTITION BY feed_source_url - ORDER BY created_at DESC) AS previous_body_markdown, - ROW_NUMBER() OVER(PARTITION BY feed_source_url - ORDER BY created_at DESC) AS row_number - FROM articles - WHERE feed_source_url IS NOT NULL ) duplicates - WHERE duplicates.row_number > 1 - AND published = 'f' -- drafts - AND body_markdown != previous_body_markdown -- with different bodies - ) - DELETE - FROM articles - WHERE id IN (SELECT id FROM duplicates_draft_articles) RETURNING id; - SQL - ) - - result_same_body_ids = result_same_body.map { |row| row["id"] } - result_different_bodies_ids = result_different_bodies.map { |row| row["id"] } + # result_different_bodies = ActiveRecord::Base.connection.execute( + # <<-SQL, + # WITH duplicates_draft_articles AS + # (SELECT id + # FROM + # (SELECT id, + # published, + # body_markdown, + # LAG(body_markdown, 1) OVER(PARTITION BY feed_source_url + # ORDER BY created_at DESC) AS previous_body_markdown, + # ROW_NUMBER() OVER(PARTITION BY feed_source_url + # ORDER BY created_at DESC) AS row_number + # FROM articles + # WHERE feed_source_url IS NOT NULL ) duplicates + # WHERE duplicates.row_number > 1 + # AND published = 'f' -- drafts + # AND body_markdown != previous_body_markdown -- with different bodies + # ) + # DELETE + # FROM articles + # WHERE id IN (SELECT id FROM duplicates_draft_articles) RETURNING id; + # SQL + # ) + # + # result_same_body_ids = result_same_body.map { |row| row["id"] } + # result_different_bodies_ids = result_different_bodies.map { |row| row["id"] } # Remove all articles from Elasticsearch - (result_same_body_ids + result_different_bodies_ids).each do |id| - Search::RemoveFromIndexWorker.perform_in(5.seconds, "Search::FeedContent", "article_#{id}") - end + # (result_same_body_ids + result_different_bodies_ids).each do |id| + # Search::RemoveFromIndexWorker.perform_in(5.seconds, "Search::FeedContent", "article_#{id}") + # end # Store deleted IDs temporarily in Redis for safe keeping - Rails.cache.write( - "DataUpdateScripts::RemoveDraftArticlesWithDuplicateFeedSourceUrl::SameBody", - result_same_body_ids, - expires_in: 2.weeks, - ) - Rails.cache.write( - "DataUpdateScripts::RemoveDraftArticlesWithDuplicateFeedSourceUrl::DifferentBodies", - result_same_body_ids, - expires_in: 2.weeks, - ) + # Rails.cache.write( + # "DataUpdateScripts::RemoveDraftArticlesWithDuplicateFeedSourceUrl::SameBody", + # result_same_body_ids, + # expires_in: 2.weeks, + # ) + # Rails.cache.write( + # "DataUpdateScripts::RemoveDraftArticlesWithDuplicateFeedSourceUrl::DifferentBodies", + # result_same_body_ids, + # expires_in: 2.weeks, + # ) end end end diff --git a/lib/data_update_scripts/20200910140109_cleanup_published_articles_with_duplicate_user_id_title_body_markdown.rb b/lib/data_update_scripts/20200910140109_cleanup_published_articles_with_duplicate_user_id_title_body_markdown.rb index 23ff5a6f1..0a938f6db 100644 --- a/lib/data_update_scripts/20200910140109_cleanup_published_articles_with_duplicate_user_id_title_body_markdown.rb +++ b/lib/data_update_scripts/20200910140109_cleanup_published_articles_with_duplicate_user_id_title_body_markdown.rb @@ -34,7 +34,7 @@ module DataUpdateScripts # save all the magic article.save - article.index_to_elasticsearch_inline + # article.index_to_elasticsearch_inline articles_to_delete_ids += articles_to_graft.map { |a| a["id"] } end diff --git a/lib/data_update_scripts/20200911045602_reindex_articles_with_videos.rb b/lib/data_update_scripts/20200911045602_reindex_articles_with_videos.rb index 1ec9484aa..31e7caa4f 100644 --- a/lib/data_update_scripts/20200911045602_reindex_articles_with_videos.rb +++ b/lib/data_update_scripts/20200911045602_reindex_articles_with_videos.rb @@ -1,8 +1,8 @@ module DataUpdateScripts class ReindexArticlesWithVideos def run - articles = Article.where.not(video: nil).or(Article.where.not(video: "")) - articles.find_each(&:index_to_elasticsearch_inline) + # articles = Article.where.not(video: nil).or(Article.where.not(video: "")) + # articles.find_each(&:index_to_elasticsearch_inline) end end end diff --git a/lib/data_update_scripts/20200924140813_remove_reaction_index_by_name.rb b/lib/data_update_scripts/20200924140813_remove_reaction_index_by_name.rb index 77e46414e..9e5415954 100644 --- a/lib/data_update_scripts/20200924140813_remove_reaction_index_by_name.rb +++ b/lib/data_update_scripts/20200924140813_remove_reaction_index_by_name.rb @@ -1,10 +1,10 @@ module DataUpdateScripts class RemoveReactionIndexByName def run - index_name = "reactions_#{Rails.env}" - return unless Search::Client.indices.exists(index: index_name) + # index_name = "reactions_#{Rails.env}" + # return unless Search::Client.indices.exists(index: index_name) - Search::Client.indices.delete(index: index_name) + # Search::Client.indices.delete(index: index_name) end end end diff --git a/lib/data_update_scripts/20201030134117_reindex_users_for_username_search.rb b/lib/data_update_scripts/20201030134117_reindex_users_for_username_search.rb index e131bef58..47d03cd2c 100644 --- a/lib/data_update_scripts/20201030134117_reindex_users_for_username_search.rb +++ b/lib/data_update_scripts/20201030134117_reindex_users_for_username_search.rb @@ -1,9 +1,9 @@ module DataUpdateScripts class ReindexUsersForUsernameSearch def run - User.select(:id).in_batches(of: 200) do |batch| - Search::BulkIndexWorker.set(queue: :default).perform_async("User", batch.ids) - end + # User.select(:id).in_batches(of: 200) do |batch| + # Search::BulkIndexWorker.set(queue: :default).perform_async("User", batch.ids) + # end end end end diff --git a/lib/data_update_scripts/20210118194138_resync_unpublished_articles_comments_elasticsearch_document.rb b/lib/data_update_scripts/20210118194138_resync_unpublished_articles_comments_elasticsearch_document.rb index 5160419ff..4c102122b 100644 --- a/lib/data_update_scripts/20210118194138_resync_unpublished_articles_comments_elasticsearch_document.rb +++ b/lib/data_update_scripts/20210118194138_resync_unpublished_articles_comments_elasticsearch_document.rb @@ -1,9 +1,9 @@ module DataUpdateScripts class ResyncUnpublishedArticlesCommentsElasticsearchDocument def run - Article.unpublished.where.not(comments_count: 0).each do |article| - article.comments.each(&:index_to_elasticsearch) - end + # Article.unpublished.where.not(comments_count: 0).each do |article| + # article.comments.each(&:index_to_elasticsearch) + # end end end end diff --git a/lib/tasks/app_initializer.rake b/lib/tasks/app_initializer.rake index bbc000885..fb7d6f333 100644 --- a/lib/tasks/app_initializer.rake +++ b/lib/tasks/app_initializer.rake @@ -1,9 +1,6 @@ namespace :app_initializer do desc "Prepare Application on Boot Up" task setup: :environment do - puts "\n== Preparing Elasticsearch ==" - Rake::Task["search:setup"].execute - puts "\n== Preparing database ==" system("bin/rails db:prepare") || exit!(1) Rake::Task["db:migrate"].execute # it'll re-alphabetize the columns in `schema.rb` diff --git a/lib/tasks/search.rake b/lib/tasks/search.rake deleted file mode 100644 index 5c7abd766..000000000 --- a/lib/tasks/search.rake +++ /dev/null @@ -1,26 +0,0 @@ -namespace :search do - desc "set up Elasticsearch indexes" - task setup: :environment do - Search::Cluster.setup_indexes - end - - desc "update Elasticsearch index mappings" - task update_mappings: :environment do - Search::Cluster.update_mappings - end - - desc "tear down Elasticsearch indexes" - task destroy: :environment do - if Rails.env.production? - puts "Will NOT destroy indexes in production" - next - end - - Search::Cluster.delete_indexes - end -end - -if Rails.env.development? - Rake::Task["db:create"].enhance(["search:setup"]) - Rake::Task["db:drop"].enhance(["search:destroy"]) -end diff --git a/release-tasks.sh b/release-tasks.sh index f76338ad4..f77240619 100755 --- a/release-tasks.sh +++ b/release-tasks.sh @@ -14,8 +14,7 @@ set -Eex # abort release if deploy status equals "blocked" [[ $DEPLOY_STATUS = "blocked" ]] && echo "Deploy blocked" && exit 1 -# runs migration for Postgres, setups/updates Elasticsearch -# and boots the app to check there are no errors +# runs migration for Postgres and boots the app to check there are no errors STATEMENT_TIMEOUT=4500000 bundle exec rails app_initializer:setup bundle exec rake fastly:update_configs bundle exec rails runner "puts 'app load success'" diff --git a/spec/factories/tags.rb b/spec/factories/tags.rb index 5c395d5f2..9e82608b8 100644 --- a/spec/factories/tags.rb +++ b/spec/factories/tags.rb @@ -4,9 +4,5 @@ FactoryBot.define do factory :tag do name { generate :name } supported { true } - - trait :search_indexed do - after(:create, &:index_to_elasticsearch_inline) - end end end diff --git a/spec/initializers/rack/attack_spec.rb b/spec/initializers/rack/attack_spec.rb index d02eb099f..9102d14f4 100644 --- a/spec/initializers/rack/attack_spec.rb +++ b/spec/initializers/rack/attack_spec.rb @@ -11,12 +11,13 @@ describe Rack::Attack, type: :request, throttle: true do describe "search_throttle" do it "throttles /search endpoints based on IP" do Timecop.freeze do - allow(Search::User).to receive(:search_documents).and_return({}) + allow(Search::Postgres::Username).to receive(:search_documents).and_return({}) + valid_responses = Array.new(5).map do - get "/search/users", headers: { "HTTP_FASTLY_CLIENT_IP" => "5.6.7.8" } + get "/search/usernames", headers: { "HTTP_FASTLY_CLIENT_IP" => "5.6.7.8" } end - throttled_response = get "/search/users", headers: { "HTTP_FASTLY_CLIENT_IP" => "5.6.7.8" } - new_ip_response = get "/search/users", headers: { "HTTP_FASTLY_CLIENT_IP" => "1.1.1.1" } + throttled_response = get "/search/usernames", headers: { "HTTP_FASTLY_CLIENT_IP" => "5.6.7.8" } + new_ip_response = get "/search/usernames", headers: { "HTTP_FASTLY_CLIENT_IP" => "1.1.1.1" } valid_responses.each { |r| expect(r).not_to eq(429) } expect(throttled_response).to eq(429) diff --git a/spec/lib/acts_as_taggable_on/tag_spec.rb b/spec/lib/acts_as_taggable_on/tag_spec.rb deleted file mode 100644 index 312d47309..000000000 --- a/spec/lib/acts_as_taggable_on/tag_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -require "rails_helper" - -RSpec.describe ActsAsTaggableOn::Tag, type: :lib do - describe "#after_commit" do - it "on create indexes tag to elasticsearch" do - tag_name = "muffintag" - create(:article, body_markdown: "---\ntitle: Me#{rand(1000)}\ntags: #{tag_name}\n---\n\nMeMeMe") - sidekiq_perform_enqueued_jobs - tag = Tag.find_by(name: tag_name) - expect(tag.elasticsearch_doc).not_to be_nil - end - - it "syncs related elasticsearch documents" do - article = create(:article) - podcast_episode = create(:podcast_episode) - tag = article.tags.first - podcast_episode.tags << tag - new_keywords = "keyword1, keyword2, keyword3" - sidekiq_perform_enqueued_jobs - - tag.update(keywords_for_search: new_keywords) - sidekiq_perform_enqueued_jobs - expect(collect_keywords(article)).to include(new_keywords) - expect(collect_keywords(podcast_episode)).to include(new_keywords) - end - end - - def collect_keywords(record) - record.elasticsearch_doc.dig("_source", "tags").flat_map { |t| t["keywords_for_search"] } - end -end diff --git a/spec/lib/data_update_scripts/index_chat_channel_memberships_to_elasticsearch_spec.rb b/spec/lib/data_update_scripts/index_chat_channel_memberships_to_elasticsearch_spec.rb deleted file mode 100644 index 137af509c..000000000 --- a/spec/lib/data_update_scripts/index_chat_channel_memberships_to_elasticsearch_spec.rb +++ /dev/null @@ -1,11 +0,0 @@ -require "rails_helper" -require Rails.root.join("lib/data_update_scripts/20200218195023_index_chat_channel_memberships_to_elasticsearch.rb") - -describe DataUpdateScripts::IndexChatChannelMembershipsToElasticsearch, elasticsearch: "ChatChannelMembership" do - it "indexes chat channel memberships to Elasticsearch" do - chat_channel_membership = FactoryBot.create(:chat_channel_membership) - expect { chat_channel_membership.elasticsearch_doc }.to raise_error(Search::Errors::Transport::NotFound) - described_class.new.run - expect(chat_channel_membership.elasticsearch_doc).not_to be_nil - end -end diff --git a/spec/lib/data_update_scripts/re_index_feed_content_and_users_to_elasticsearch_spec.rb b/spec/lib/data_update_scripts/re_index_feed_content_and_users_to_elasticsearch_spec.rb deleted file mode 100644 index 98bf55ca9..000000000 --- a/spec/lib/data_update_scripts/re_index_feed_content_and_users_to_elasticsearch_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -require "rails_helper" -require Rails.root.join("lib/data_update_scripts/20200519142908_re_index_feed_content_and_users_to_elasticsearch.rb") - -describe DataUpdateScripts::ReIndexFeedContentAndUsersToElasticsearch do - after do - Search::FeedContent.refresh_index - end - - it "indexes feed content(articles, comments, podcast episodes) and users to Elasticsearch", :aggregate_failures do - article = create(:article) - podcast_episode = create(:podcast_episode) - comment = create(:comment) - - expect { article.elasticsearch_doc }.to raise_error(Search::Errors::Transport::NotFound) - expect { podcast_episode.elasticsearch_doc }.to raise_error(Search::Errors::Transport::NotFound) - expect { comment.elasticsearch_doc }.to raise_error(Search::Errors::Transport::NotFound) - - sidekiq_perform_enqueued_jobs { described_class.new.run } - - expect(article.elasticsearch_doc).not_to be_nil - expect(podcast_episode.elasticsearch_doc).not_to be_nil - expect(comment.elasticsearch_doc).not_to be_nil - - expect(article.elasticsearch_doc["_source"].keys).to include "public_reactions_count" - expect(podcast_episode.elasticsearch_doc["_source"].keys).to include "public_reactions_count" - expect(comment.elasticsearch_doc["_source"].keys).to include "public_reactions_count" - end -end diff --git a/spec/lib/data_update_scripts/reindex_listing_search_column_spec.rb b/spec/lib/data_update_scripts/reindex_listing_search_column_spec.rb deleted file mode 100644 index 967558231..000000000 --- a/spec/lib/data_update_scripts/reindex_listing_search_column_spec.rb +++ /dev/null @@ -1,11 +0,0 @@ -require "rails_helper" -require Rails.root.join("lib/data_update_scripts/20200803142830_reindex_listing_search_column.rb") - -describe DataUpdateScripts::ReindexListingSearchColumn, elasticsearch: "Listing" do - it "indexes listings to Elasticsearch" do - listing = create(:listing) - expect { listing.elasticsearch_doc }.to raise_error(Search::Errors::Transport::NotFound) - described_class.new.run - expect(listing.elasticsearch_doc).not_to be_nil - end -end diff --git a/spec/lib/data_update_scripts/reindex_users_for_username_search_spec.rb b/spec/lib/data_update_scripts/reindex_users_for_username_search_spec.rb deleted file mode 100644 index bd9826833..000000000 --- a/spec/lib/data_update_scripts/reindex_users_for_username_search_spec.rb +++ /dev/null @@ -1,12 +0,0 @@ -require "rails_helper" -require Rails.root.join("lib/data_update_scripts/20201030134117_reindex_users_for_username_search.rb") - -describe DataUpdateScripts::ReindexUsersForUsernameSearch do - let(:user) { create :user } - - it "reindexes users" do - sidekiq_assert_enqueued_with(job: Search::BulkIndexWorker, args: ["User", [user.id]], queue: "default") do - described_class.new.run - end - end -end diff --git a/spec/lib/data_update_scripts/remove_reaction_index_by_name_spec.rb b/spec/lib/data_update_scripts/remove_reaction_index_by_name_spec.rb deleted file mode 100644 index 48fb7cb15..000000000 --- a/spec/lib/data_update_scripts/remove_reaction_index_by_name_spec.rb +++ /dev/null @@ -1,13 +0,0 @@ -require "rails_helper" -require Rails.root.join("lib/data_update_scripts/20200924140813_remove_reaction_index_by_name.rb") - -describe DataUpdateScripts::RemoveReactionIndexByName do - it "removes reaction index" do - index_name = "reactions_#{Rails.env}" - Search::Client.indices.create(index: index_name) - expect(Search::Client.indices.get(index: "*").keys).to include(index_name) - - described_class.new.run - expect(Search::Client.indices.get(index: "*").keys).not_to include(index_name) - end -end diff --git a/spec/lib/data_update_scripts/resync_elasticsearch_documents_spec.rb b/spec/lib/data_update_scripts/resync_elasticsearch_documents_spec.rb deleted file mode 100644 index 89063cfef..000000000 --- a/spec/lib/data_update_scripts/resync_elasticsearch_documents_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -require "rails_helper" -require Rails.root.join("lib/data_update_scripts/20200410152018_resync_elasticsearch_documents.rb") - -describe DataUpdateScripts::ResyncElasticsearchDocuments, elasticsearch: %w[FeedContent User Tag] do - after do - Article::SEARCH_CLASS.refresh_index - User::SEARCH_CLASS.refresh_index - end - - it "indexes podcast episodes and tags to Elasticsearch" do - tag = create(:tag) - podcast_episode = create(:podcast_episode) - Sidekiq::Worker.clear_all - - expect { tag.elasticsearch_doc }.to raise_error(Search::Errors::Transport::NotFound) - expect { podcast_episode.elasticsearch_doc }.to raise_error(Search::Errors::Transport::NotFound) - - sidekiq_perform_enqueued_jobs { described_class.new.run } - expect(tag.elasticsearch_doc).not_to be_nil - expect(podcast_episode.elasticsearch_doc).not_to be_nil - end - - it "syncs articles, comments, and users to Elasticsearch", :aggregate_failures do - article = create(:article) - comment = create(:comment) - user = create(:user) - index_real_and_mock_documents(article, comment, user) - - expect(Article::SEARCH_CLASS.articles_document_count).not_to eq(Article.count) - expect(Comment::SEARCH_CLASS.comments_document_count).not_to eq(Comment.count) - expect(User::SEARCH_CLASS.document_count).not_to eq(User.count) - - sidekiq_perform_enqueued_jobs { described_class.new.run } - refresh_indexes - - expect(article.elasticsearch_doc).not_to be_nil - expect(user.elasticsearch_doc).not_to be_nil - expect(comment.elasticsearch_doc).not_to be_nil - expect(Article::SEARCH_CLASS.articles_document_count).to eq(Article.count) - expect(Comment::SEARCH_CLASS.comments_document_count).to eq(Comment.count) - expect(User::SEARCH_CLASS.document_count).to eq(User.count) - end - - def index_real_and_mock_documents(article, comment, user) - sidekiq_perform_enqueued_jobs - Article::SEARCH_CLASS.index("article_#{article.id + 100}", class_name: "Article") - Comment::SEARCH_CLASS.index("comment_#{comment.id + 100}", class_name: "Comment") - User::SEARCH_CLASS.index(user.id + 100, name: "User") - refresh_indexes - end - - def refresh_indexes - User::SEARCH_CLASS.refresh_index - Article::SEARCH_CLASS.refresh_index - end -end diff --git a/spec/lib/data_update_scripts/resync_unpublished_articles_comments_elasticsearch_document_spec.rb b/spec/lib/data_update_scripts/resync_unpublished_articles_comments_elasticsearch_document_spec.rb deleted file mode 100644 index 60c5705f3..000000000 --- a/spec/lib/data_update_scripts/resync_unpublished_articles_comments_elasticsearch_document_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -require "rails_helper" -require Rails.root.join( - "lib/data_update_scripts/20210118194138_resync_unpublished_articles_comments_elasticsearch_document.rb", -) - -describe DataUpdateScripts::ResyncUnpublishedArticlesCommentsElasticsearchDocument do - it "works" do - article = create(:article) - comment = create(:comment, commentable: article) - sidekiq_perform_enqueued_jobs - article.update_column(:published, false) - - sidekiq_assert_enqueued_with(job: Search::IndexWorker, args: ["Comment", comment.id]) do - described_class.new.run - end - end -end diff --git a/spec/models/article_spec.rb b/spec/models/article_spec.rb index 7cfa6d9fa..df51653d2 100644 --- a/spec/models/article_spec.rb +++ b/spec/models/article_spec.rb @@ -137,30 +137,6 @@ RSpec.describe Article, type: :model do end end - describe "#after_commit" do - it "on update enqueues job to index article to elasticsearch" do - article.save - sidekiq_assert_enqueued_with(job: Search::IndexWorker, args: [described_class.to_s, article.id]) do - article.save - end - end - - it "on destroy enqueues job to delete article from elasticsearch" do - article = create(:article) - - sidekiq_assert_enqueued_with(job: Search::RemoveFromIndexWorker, - args: [described_class::SEARCH_CLASS.to_s, article.search_id]) do - article.destroy - end - end - - it "on update syncs elasticsearch data" do - allow(article).to receive(:sync_related_elasticsearch_docs) - article.save - expect(article).to have_received(:sync_related_elasticsearch_docs) - end - end - context "when published" do before do # rubocop:disable RSpec/NamedSubject @@ -1204,14 +1180,6 @@ RSpec.describe Article, type: :model do end end - describe "#touch_by_reaction" do - it "reindexes elasticsearch doc" do - sidekiq_assert_enqueued_with(job: Search::IndexWorker, args: [described_class.to_s, article.id]) do - article.touch_by_reaction - end - end - end - describe "co_author_ids_list=" do it "correctly sets co author ids from a comma separated list of ids" do co_author1 = create(:user) diff --git a/spec/models/chat_channel_membership_spec.rb b/spec/models/chat_channel_membership_spec.rb index 204a30d51..1b3f60e11 100644 --- a/spec/models/chat_channel_membership_spec.rb +++ b/spec/models/chat_channel_membership_spec.rb @@ -34,22 +34,4 @@ RSpec.describe ChatChannelMembership, type: :model do expect(chat_channel_membership.channel_text).to eq(expected_text) end end - - describe "#after_commit" do - it "on update enqueues job to index chat_channel_membership to elasticsearch" do - chat_channel_membership.save - sidekiq_assert_enqueued_with(job: Search::IndexWorker, - args: [described_class.to_s, chat_channel_membership.id]) do - chat_channel_membership.save - end - end - - it "on destroy enqueues job to delete chat_channel_membership from elasticsearch" do - chat_channel_membership.save - sidekiq_assert_enqueued_with(job: Search::RemoveFromIndexWorker, - args: [described_class::SEARCH_CLASS.to_s, chat_channel_membership.id]) do - chat_channel_membership.destroy - end - end - end end diff --git a/spec/models/comment_spec.rb b/spec/models/comment_spec.rb index b5063531e..049a9a484 100644 --- a/spec/models/comment_spec.rb +++ b/spec/models/comment_spec.rb @@ -118,23 +118,6 @@ RSpec.describe Comment, type: :model do end # rubocop:enable RSpec/NamedSubject - describe "#after_commit" do - it "on update enqueues job to index comment to elasticsearch" do - sidekiq_assert_enqueued_with(job: Search::IndexWorker, args: [described_class.to_s, comment.id]) do - comment.save - end - end - - it "on destroy enqueues job to delete comment from elasticsearch" do - comment = create(:comment) - - sidekiq_assert_enqueued_with(job: Search::RemoveFromIndexWorker, - args: [described_class::SEARCH_CLASS.to_s, comment.search_id]) do - comment.destroy - end - end - end - describe "#search_id" do it "returns comment_ID" do expect(comment.search_id).to eq("comment_#{comment.id}") diff --git a/spec/models/concerns/searchable_spec.rb b/spec/models/concerns/searchable_spec.rb deleted file mode 100644 index 6c6f77483..000000000 --- a/spec/models/concerns/searchable_spec.rb +++ /dev/null @@ -1,71 +0,0 @@ -require "rails_helper" - -class SearchableModel - include Searchable - SEARCH_CLASS = Search::Tag - SEARCH_SERIALIZER = Search::TagSerializer - - def id - 1 - end -end - -RSpec.describe Searchable do - let(:model_class) { SearchableModel } - let(:searchable_model) { model_class.new } - let(:serialized_hash) { { data: { attributes: { id: searchable_model.search_id } } } } - - before do - mock_serializer = instance_double("MockSerializer", :serializable_hash) - allow(model_class::SEARCH_SERIALIZER).to receive(:new).and_return(mock_serializer) - allow(mock_serializer).to receive(:serializable_hash).and_return( - serialized_hash, - ) - end - - describe "#search_id" do - it "defaults to id" do - expect(searchable_model.search_id).to equal(searchable_model.id) - end - end - - describe "#remove_from_elasticsearch" do - it "enqueues job to delete model document from elasticsearch" do - sidekiq_assert_enqueued_with(job: Search::RemoveFromIndexWorker, - args: [SearchableModel::SEARCH_CLASS.to_s, searchable_model.search_id]) do - searchable_model.remove_from_elasticsearch - end - end - end - - describe "#index_to_elasticsearch" do - it "enqueues job to index document to elasticsearch" do - sidekiq_assert_enqueued_with(job: Search::IndexWorker, args: ["SearchableModel", searchable_model.id]) do - searchable_model.index_to_elasticsearch - end - end - end - - describe "#index_to_elasticsearch_inline" do - it "indexes a document to elasticsearch inline" do - allow(model_class::SEARCH_CLASS).to receive(:index) - searchable_model.index_to_elasticsearch_inline - expect(model_class::SEARCH_CLASS).to have_received(:index).with(searchable_model.search_id, - id: searchable_model.search_id) - end - end - - describe "#serialized_search_hash" do - it "creates a valid serialized hash to send to elasticsearch" do - expect(searchable_model.serialized_search_hash.symbolize_keys.keys).to eq([:id]) - end - end - - describe "#elasticsearch_doc" do - it "finds document in elasticsearch" do - allow(model_class::SEARCH_CLASS).to receive(:find_document) - searchable_model.elasticsearch_doc - expect(model_class::SEARCH_CLASS).to have_received(:find_document) - end - end -end diff --git a/spec/models/listing_spec.rb b/spec/models/listing_spec.rb index 98eeb193a..248082e64 100644 --- a/spec/models/listing_spec.rb +++ b/spec/models/listing_spec.rb @@ -71,22 +71,4 @@ RSpec.describe Listing, type: :model do expect(credit.reload.purchase).to be_nil end end - - describe "#after_commit" do - it "on update enqueues worker to index tag to elasticsearch" do - listing.save - - sidekiq_assert_enqueued_with(job: Search::IndexWorker, args: [described_class.to_s, listing.id]) do - listing.save - end - end - - it "on destroy enqueues job to delete listing from elasticsearch" do - listing.save - sidekiq_assert_enqueued_with(job: Search::RemoveFromIndexWorker, - args: [described_class::SEARCH_CLASS.to_s, listing.id]) do - listing.destroy - end - end - end end diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index c58d3c807..625dfde2f 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -158,15 +158,6 @@ RSpec.describe Message, type: :model do end describe "#after_create" do - it "enqueues ChatChannels::IndexesMembershipsWorker" do - chat_channel.add_users([user]) - allow(ChatChannels::IndexesMembershipsWorker).to receive(:perform_async) - - create(:message, chat_channel: chat_channel, user: user) - - expect(ChatChannels::IndexesMembershipsWorker).to have_received(:perform_async) - end - context "when chat_action is left_channel" do it "does not update unopened message statuses" do chat_channel.add_users([user, user2]) diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index f9e1e44f1..bfac8f1ae 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -72,22 +72,9 @@ RSpec.describe Organization, type: :model do end context "when callbacks are triggered after commit" do - it "on update syncs elasticsearch data" do - article = create(:article, organization: organization) - sidekiq_perform_enqueued_jobs - new_org_name = "#{organization.name}+NEW" - organization.update(name: new_org_name) - sidekiq_perform_enqueued_jobs - expect(article.elasticsearch_doc.dig("_source", "organization", "name")).to eq(new_org_name) - end - it "on destroy updates related article data" do article = create(:article, organization: organization) - drain_all_sidekiq_jobs - expect(article.elasticsearch_doc.dig("_source", "organization", "id")).to eq(organization.id) organization.destroy - sidekiq_perform_enqueued_jobs - expect(article.elasticsearch_doc.dig("_source", "organization")).to be_nil expect(article.reload.cached_organization).to be_nil end end diff --git a/spec/models/podcast_episode_spec.rb b/spec/models/podcast_episode_spec.rb index 1431a52f4..cb657b273 100644 --- a/spec/models/podcast_episode_spec.rb +++ b/spec/models/podcast_episode_spec.rb @@ -20,11 +20,6 @@ RSpec.describe PodcastEpisode, type: :model do it { is_expected.to validate_presence_of(:title) } end - # Couldn't use shoulda matchers for these tests because: - # Shoulda uses `save(validate: false)` which skips validations, but runs callbacks - # So an invalid record is saved and the elasticsearch callback fails because there's no associated podcast - # https://git.io/fjg2g - it "validates guid uniqueness" do ep2 = build(:podcast_episode, guid: podcast_episode.guid) @@ -40,23 +35,6 @@ RSpec.describe PodcastEpisode, type: :model do end end - describe "#after_commit" do - it "on update enqueues job to index podcast_episode to elasticsearch" do - podcast_episode.save - sidekiq_assert_enqueued_with(job: Search::IndexWorker, args: [described_class.to_s, podcast_episode.id]) do - podcast_episode.save - end - end - - it "on destroy enqueues job to delete podcast_episode from elasticsearch" do - podcast_episode.save - sidekiq_assert_enqueued_with(job: Search::RemoveFromIndexWorker, - args: [described_class::SEARCH_CLASS.to_s, podcast_episode.search_id]) do - podcast_episode.destroy - end - end - end - describe "#search_id" do it "returns podcast_episode_ID" do expect(podcast_episode.search_id).to eq("podcast_episode_#{podcast_episode.id}") diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index 0b0d7c656..b5ca689a7 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -128,37 +128,6 @@ RSpec.describe Tag, type: :model do expect(tag.mod_chat_channel).to eq(channel) end - describe "#after_commit" do - it "on update enqueues job to index tag to elasticsearch" do - tag.save - sidekiq_assert_enqueued_with(job: Search::IndexWorker, args: [described_class.to_s, tag.id]) do - tag.save - end - end - - it "on destroy enqueues job to delete tag from elasticsearch" do - tag.save - sidekiq_assert_enqueued_with(job: Search::RemoveFromIndexWorker, - args: [described_class::SEARCH_CLASS.to_s, tag.id]) do - tag.destroy - end - end - - it "syncs related elasticsearch documents" do - article = create(:article) - podcast_episode = create(:podcast_episode) - tag = described_class.find(article.tags.first.id) - podcast_episode.tags << tag - new_keywords = "keyword1, keyword2, keyword3" - sidekiq_perform_enqueued_jobs - - tag.update(keywords_for_search: new_keywords) - sidekiq_perform_enqueued_jobs - expect(collect_keywords(article)).to include(new_keywords) - expect(collect_keywords(podcast_episode)).to include(new_keywords) - end - end - describe "::aliased_name" do it "returns the preferred alias tag" do preferred_tag = create(:tag, name: "rails") @@ -187,8 +156,4 @@ RSpec.describe Tag, type: :model do expect(described_class.find_preferred_alias_for("something")).to eq("something") end end - - def collect_keywords(record) - record.elasticsearch_doc.dig("_source", "tags").flat_map { |t| t["keywords_for_search"] } - end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index d39b50c55..ddcf21998 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -298,29 +298,6 @@ RSpec.describe User, type: :model do end end - describe "#after_commit" do - it "on update enqueues job to index user to elasticsearch" do - user.save - sidekiq_assert_enqueued_with(job: Search::IndexWorker, args: [described_class.to_s, user.id]) do - user.save - end - end - - it "on update syncs elasticsearch data" do - allow(user).to receive(:sync_related_elasticsearch_docs) - user.save - expect(user).to have_received(:sync_related_elasticsearch_docs) - end - - it "on destroy enqueues job to delete user from elasticsearch" do - user.save - sidekiq_assert_enqueued_with(job: Search::RemoveFromIndexWorker, - args: [described_class::SEARCH_CLASS.to_s, user.id]) do - user.destroy - end - end - end - context "when callbacks are triggered before validation" do let(:user) { build(:user) } diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index a48ff8378..d3b87d759 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -48,7 +48,6 @@ allowed_sites = [ "selenium-release.storage.googleapis.com", "developer.microsoft.com/en-us/microsoft-edge/tools/webdriver", "api.knapsackpro.com", - "elasticsearch", ] WebMock.disable_net_connect!(allow_localhost: true, allow: allowed_sites) @@ -74,7 +73,6 @@ RSpec.configure do |config| config.include FactoryBot::Syntax::Methods config.include OmniauthHelpers config.include SidekiqTestHelpers - config.include ElasticsearchHelpers config.after(:each, type: :system) do Warden::Manager._on_request.clear @@ -89,8 +87,6 @@ RSpec.configure do |config| # which we can then use to properly set the browser time for Capybara specs ENV["TZ"] = Time.zone.tzinfo.name - Search::Cluster.recreate_indexes - # NOTE: @citizen428 needed while we delegate from User to Profile to keep # spec changes limited for the time being. csv = Rails.root.join("lib/data/dev_profile_fields.csv") @@ -109,30 +105,10 @@ RSpec.configure do |config| # rubocop:enable RSpec/AnyInstance end - config.before(:each, stub_elasticsearch: true) do |_example| - stubbed_search_response = { "hits" => { "hits" => [] } } - allow(Search::Client).to receive(:search).and_return(stubbed_search_response) - allow(Search::Client).to receive(:index).and_return({ "_source" => {} }) - end - config.around(:each, :flaky) do |ex| ex.run_with_retry retry: 3 end - config.around(:each, elasticsearch_reset: true) do |example| - Search::Cluster.recreate_indexes - example.run - Search::Cluster.recreate_indexes - end - - config.around(:each, :elasticsearch) do |ex| - klasses = Array.wrap(ex.metadata[:elasticsearch]).map do |search_class| - Search.const_get(search_class) - end - klasses.each { |klass| clear_elasticsearch_data(klass) } - ex.run - end - config.around(:each, throttle: true) do |example| Rack::Attack.enabled = true example.run diff --git a/spec/requests/api/v0/health_checks_spec.rb b/spec/requests/api/v0/health_checks_spec.rb index b6df5a71e..6d7a5e811 100644 --- a/spec/requests/api/v0/health_checks_spec.rb +++ b/spec/requests/api/v0/health_checks_spec.rb @@ -22,21 +22,6 @@ RSpec.describe "HealthCheck", type: :request do end end - describe "GET /api/health_checks/search" do - it "returns json success if ping succeeds" do - get search_api_health_checks_path, headers: headers - expect(response.status).to eq(200) - expect(response.parsed_body["message"]).to eq("Search ping succeeded!") - end - - it "returns json failure if ping fails" do - allow(Search::Client).to receive(:ping).and_return(false) - get search_api_health_checks_path, headers: headers - expect(response.status).to eq(500) - expect(response.parsed_body["message"]).to eq("Search ping failed!") - end - end - describe "GET /api/health_checks/database" do it "returns json success if connection check succeeds" do get database_api_health_checks_path, headers: headers diff --git a/spec/requests/search_spec.rb b/spec/requests/search_spec.rb index 27d40aa33..f6bff8bdd 100644 --- a/spec/requests/search_spec.rb +++ b/spec/requests/search_spec.rb @@ -59,18 +59,6 @@ RSpec.describe "Search", type: :request, proper_status: true do end end - describe "GET /search/users" do - let(:mock_documents) { [{ "username" => "firstlast" }] } - - it "returns json" do - allow(Search::User).to receive(:search_documents).and_return( - mock_documents, - ) - get "/search/users" - expect(response.parsed_body).to eq("result" => mock_documents) - end - end - describe "GET /search/usernames" do before do sign_in authorized_user diff --git a/spec/serializers/search/article_serializer_spec.rb b/spec/serializers/search/article_serializer_spec.rb index d6b2fb08d..5cb168596 100644 --- a/spec/serializers/search/article_serializer_spec.rb +++ b/spec/serializers/search/article_serializer_spec.rb @@ -16,12 +16,6 @@ RSpec.describe Search::ArticleSerializer do expect(data_hash.keys).to include(:id, :body_text, :hotness_score) end - it "creates valid json for Elasticsearch", elasticsearch: "FeedContent" do - data_hash = described_class.new(article).serializable_hash.dig(:data, :attributes) - result = Article::SEARCH_CLASS.index(article.id, data_hash) - expect(result["result"]).to eq("created") - end - it "correctly serializes video duration in minutes when video_duration_in_seconds is nil" do data_hash = described_class.new(article).serializable_hash.dig(:data, :attributes) expect(data_hash[:video_duration_in_minutes]).to eq(0) diff --git a/spec/serializers/search/comment_serializer_spec.rb b/spec/serializers/search/comment_serializer_spec.rb index 10a624e43..65b81694f 100644 --- a/spec/serializers/search/comment_serializer_spec.rb +++ b/spec/serializers/search/comment_serializer_spec.rb @@ -11,10 +11,4 @@ RSpec.describe Search::CommentSerializer do expect(data_hash[:user]).to eq(user_data) expect(data_hash.keys).to include(:id, :body_text, :hotness_score, :title) end - - it "creates valid json for Elasticsearch", elasticsearch: "FeedContent" do - data_hash = described_class.new(comment).serializable_hash.dig(:data, :attributes) - result = Comment::SEARCH_CLASS.index(comment.id, data_hash) - expect(result["result"]).to eq("created") - end end diff --git a/spec/serializers/search/user_serializer_spec.rb b/spec/serializers/search/user_serializer_spec.rb index 12451dd47..efa891ba8 100644 --- a/spec/serializers/search/user_serializer_spec.rb +++ b/spec/serializers/search/user_serializer_spec.rb @@ -7,26 +7,4 @@ RSpec.describe Search::UserSerializer do data_hash = described_class.new(user).serializable_hash.dig(:data, :attributes) expect(data_hash.keys).to include(:id, :name, :path, :username, :roles) end - - it "creates valid json for Elasticsearch", elasticsearch: "User" do - data_hash = described_class.new(user).serializable_hash.dig(:data, :attributes) - result = User::SEARCH_CLASS.index(user.id, data_hash) - expect(result["result"]).to eq("created") - end - - it "indexes profile fields as a nested field", elasticsearch: "User", aggregate_failures: true do - data_hash = described_class.new(user).serializable_hash.dig(:data, :attributes) - result = User::SEARCH_CLASS.index(user.id, data_hash) - expect(result["result"]).to eq("created") - indexed_profile_fields = user.reload.elasticsearch_doc.dig("_source", "profile_fields") - expect(indexed_profile_fields).to be_an_instance_of(Array) - end - - it "indexes custom profile fields as a nested field", elasticsearch: "User", aggregate_failures: true do - data_hash = described_class.new(user).serializable_hash.dig(:data, :attributes) - result = User::SEARCH_CLASS.index(user.id, data_hash) - expect(result["result"]).to eq("created") - indexed_custom_fields = user.reload.elasticsearch_doc.dig("_source", "custom_profile_fields") - expect(indexed_custom_fields).to be_an_instance_of(Array) - end end diff --git a/spec/services/data_sync/elasticsearch/article_spec.rb b/spec/services/data_sync/elasticsearch/article_spec.rb deleted file mode 100644 index f28ae30b8..000000000 --- a/spec/services/data_sync/elasticsearch/article_spec.rb +++ /dev/null @@ -1,8 +0,0 @@ -require "rails_helper" - -RSpec.describe DataSync::Elasticsearch::Article, type: :service do - it "defines necessary constants" do - expect(described_class::RELATED_DOCS).not_to be_nil - expect(described_class::SHARED_FIELDS).not_to be_nil - end -end diff --git a/spec/services/data_sync/elasticsearch/base_spec.rb b/spec/services/data_sync/elasticsearch/base_spec.rb deleted file mode 100644 index e965793d6..000000000 --- a/spec/services/data_sync/elasticsearch/base_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -require "rails_helper" - -RSpec.describe DataSync::Elasticsearch::Base, type: :service do - let!(:updated_record) { instance_double("Record", saved_changes: { name: %w[name1 name2] }) } - let(:syncer) { described_class.new(updated_record) } - - describe "#call" do - before do - stub_const("#{described_class}::SHARED_FIELDS", %i[name]) - allow(syncer).to receive(:sync_related_documents) - end - - it "syncs related_documents when sync is needed " do - syncer.call - expect(syncer).to have_received(:sync_related_documents) - end - - it "does not sync related_documents when sync is not needed" do - allow(updated_record).to receive(:saved_changes).and_return(twitter_url: %w[url1 url2]) - syncer.call - expect(syncer).not_to have_received(:sync_related_documents) - end - end -end diff --git a/spec/services/data_sync/elasticsearch/organization_spec.rb b/spec/services/data_sync/elasticsearch/organization_spec.rb deleted file mode 100644 index cab5d2e6d..000000000 --- a/spec/services/data_sync/elasticsearch/organization_spec.rb +++ /dev/null @@ -1,8 +0,0 @@ -require "rails_helper" - -RSpec.describe DataSync::Elasticsearch::Organization, type: :service do - it "defines necessary constants" do - expect(described_class::RELATED_DOCS).not_to be_nil - expect(described_class::SHARED_FIELDS).not_to be_nil - end -end diff --git a/spec/services/data_sync/elasticsearch/tag_spec.rb b/spec/services/data_sync/elasticsearch/tag_spec.rb deleted file mode 100644 index 589f39743..000000000 --- a/spec/services/data_sync/elasticsearch/tag_spec.rb +++ /dev/null @@ -1,8 +0,0 @@ -require "rails_helper" - -RSpec.describe DataSync::Elasticsearch::Tag, type: :service do - it "defines necessary constants" do - expect(described_class::RELATED_DOCS).not_to be_nil - expect(described_class::SHARED_FIELDS).not_to be_nil - end -end diff --git a/spec/services/data_sync/elasticsearch/user_spec.rb b/spec/services/data_sync/elasticsearch/user_spec.rb deleted file mode 100644 index 3320d7a93..000000000 --- a/spec/services/data_sync/elasticsearch/user_spec.rb +++ /dev/null @@ -1,8 +0,0 @@ -require "rails_helper" - -RSpec.describe DataSync::Elasticsearch::User, type: :service do - it "defines necessary constants" do - expect(described_class::RELATED_DOCS).not_to be_nil - expect(described_class::SHARED_FIELDS).not_to be_nil - end -end diff --git a/spec/services/edge_cache/bust_commentable_spec.rb b/spec/services/edge_cache/bust_commentable_spec.rb index 71e3ddfaa..2fbcc2a7f 100644 --- a/spec/services/edge_cache/bust_commentable_spec.rb +++ b/spec/services/edge_cache/bust_commentable_spec.rb @@ -15,10 +15,4 @@ RSpec.describe EdgeCache::BustCommentable, type: :service do expect(EdgeCache::BustComment).to have_received(:call).with(commentable).once expect(cache_bust).to have_received(:call).with("#{commentable.path}/comments").once end - - it "indexes commentable to Elasticsearch" do - allow(commentable).to receive(:index_to_elasticsearch_inline) - described_class.call(commentable) - expect(commentable).to have_received(:index_to_elasticsearch_inline).once - end end diff --git a/spec/services/moderator/banish_user_spec.rb b/spec/services/moderator/banish_user_spec.rb index 2f615417b..d2464ae82 100644 --- a/spec/services/moderator/banish_user_spec.rb +++ b/spec/services/moderator/banish_user_spec.rb @@ -27,7 +27,7 @@ RSpec.describe Moderator::BanishUser, type: :service do create(:comment, user: user, commentable: article) sidekiq_perform_enqueued_jobs - sidekiq_perform_enqueued_jobs(except: Search::IndexWorker) do + sidekiq_perform_enqueued_jobs do described_class.call(user: user, admin: admin) end expect(user.comments.count).to eq 0 diff --git a/spec/services/moderator/merge_user_spec.rb b/spec/services/moderator/merge_user_spec.rb index 1d6f08498..c27c5e4a9 100644 --- a/spec/services/moderator/merge_user_spec.rb +++ b/spec/services/moderator/merge_user_spec.rb @@ -23,20 +23,6 @@ RSpec.describe Moderator::MergeUser, type: :service do expect(User.find_by(id: keep_user.id)).not_to be_nil end - it "updates documents in Elasticsearch" do - related_records - drain_all_sidekiq_jobs - expect(article.elasticsearch_doc.dig("_source", "user", "id")).to eq(delete_user_id) - expect(comment.elasticsearch_doc.dig("_source", "user", "id")).to eq(delete_user_id) - - sidekiq_perform_enqueued_jobs do - described_class.call(admin: admin, keep_user: keep_user, delete_user_id: delete_user.id) - end - drain_all_sidekiq_jobs - expect(article.reload.elasticsearch_doc.dig("_source", "user", "id")).to eq(keep_user.id) - expect(comment.reload.elasticsearch_doc.dig("_source", "user", "id")).to eq(keep_user.id) - end - it "updates badge_achievements_count" do create_list(:badge_achievement, 2, user: delete_user) diff --git a/spec/services/podcasts/create_episode_spec.rb b/spec/services/podcasts/create_episode_spec.rb index 58939b85d..ef2319169 100644 --- a/spec/services/podcasts/create_episode_spec.rb +++ b/spec/services/podcasts/create_episode_spec.rb @@ -19,11 +19,6 @@ RSpec.describe Podcasts::CreateEpisode, type: :service do end.to change(PodcastEpisode, :count).by(1) end - it "indexes the episode" do - sidekiq_perform_enqueued_jobs { described_class.call(podcast.id, item) } - expect { podcast.podcast_episodes.each(&:elasticsearch_doc) }.not_to raise_error - end - it "creates an episode with correct data" do episode = described_class.call(podcast.id, item) expect(episode.title).to eq("Individual Contributor Career Growth w/ Matt Klein (part 1)") diff --git a/spec/services/search/base_spec.rb b/spec/services/search/base_spec.rb deleted file mode 100644 index 47a971f8c..000000000 --- a/spec/services/search/base_spec.rb +++ /dev/null @@ -1,170 +0,0 @@ -require "rails_helper" - -RSpec.describe Search::Base, type: :service do - let(:document_id) { 123 } - - def recreate_tag_index - Search::Tag.delete_index if described_class.index_exists? - Search::Tag.create_index - Search::Tag.add_alias - Search::Tag.update_mappings - end - - before do - # Need to use an existing index name to ensure proper data cleanup - stub_const("#{described_class}::INDEX_NAME", "tags_#{Rails.env}") - stub_const("#{described_class}::INDEX_ALIAS", "tags_#{Rails.env}_alias") - stub_const("#{described_class}::MAPPINGS", Search::Tag::MAPPINGS) - Search::Tag.refresh_index - clear_elasticsearch_data(Search::Tag) - allow(described_class).to receive(:index_settings).and_return({}) - end - - describe "::index" do - it "indexes a document to elasticsearch" do - expect { described_class.find_document(document_id) }.to raise_error(Search::Errors::Transport::NotFound) - described_class.index(document_id, id: document_id) - expect(described_class.find_document(document_id)).not_to be_nil - end - - it "sets last_indexed_at field" do - Timecop.freeze(Time.current) do - described_class.index(document_id, id: document_id) - last_indexed_at = described_class.find_document(document_id).dig("_source", "last_indexed_at") - expect(Time.zone.parse(last_indexed_at).to_i).to eq(Time.current.to_i) - end - end - end - - describe "::bulk_index" do - it "indexes a set of data hashes to Elasticsearch" do - id_list = [123, 456, 789] - id_list.each do |document_id| - expect { described_class.find_document(document_id) }.to raise_error(Search::Errors::Transport::NotFound) - end - data_hashes = id_list.map { |id| { id: id, name: "i_am_a_tag" } } - described_class.bulk_index(data_hashes) - - id_list.each do |document_id| - doc = described_class.find_document(document_id) - expect(doc.dig("_source", "id")).to eql(document_id) - end - end - - it "sets last_indexed_at field" do - Timecop.freeze(Time.current) do - described_class.bulk_index([{ id: document_id, name: "i_am_a_tag" }]) - last_indexed_at = described_class.find_document(document_id).dig("_source", "last_indexed_at") - expect(Time.zone.parse(last_indexed_at).to_i).to eq(Time.current.to_i) - end - end - end - - describe "::find_document" do - it "fetches a document for a given ID from elasticsearch" do - described_class.index(document_id, id: document_id) - expect { described_class.find_document(document_id) }.not_to raise_error - end - end - - describe "::delete_document" do - it "deletes a document for a given ID from elasticsearch" do - described_class.index(document_id, id: document_id) - expect { described_class.find_document(document_id) }.not_to raise_error - described_class.delete_document(document_id) - expect { described_class.find_document(document_id) }.to raise_error(Search::Errors::Transport::NotFound) - end - end - - describe "::create_index" do - before { recreate_tag_index } - - after { recreate_tag_index } - - it "creates an elasticsearch index with INDEX_NAME" do - described_class.delete_index - expect(Search::Client.indices.exists(index: described_class::INDEX_NAME)).to eq(false) - described_class.create_index - expect(Search::Client.indices.exists(index: described_class::INDEX_NAME)).to eq(true) - end - - it "creates an elasticsearch index with name argument" do - other_name = "random" - expect(Search::Client.indices.exists(index: other_name)).to eq(false) - described_class.create_index(index_name: other_name) - expect(Search::Client.indices.exists(index: other_name)).to eq(true) - - # Have to cleanup index since it wont automatically be handled by our cluster class bc of the unexpected name - described_class.delete_index(index_name: other_name) - end - end - - describe "::update_index" do - before do - recreate_tag_index - allow(described_class).to receive(:dynamic_index_settings).and_return( - refresh_interval: "10s", - ) - end - - after { recreate_tag_index } - - it "updates elasticsearch index settings with INDEX_NAME" do - index_info = Search::Client.indices.get(index: described_class::INDEX_NAME) - expect(index_info.values.first.dig("settings", "index", "refresh_interval")).to be_nil - described_class.update_index - - new_index_info = Search::Client.indices.get(index: described_class::INDEX_NAME) - expect(new_index_info.values.first.dig("settings", "index", "refresh_interval")).to eq("10s") - end - end - - describe "::delete_index" do - before { recreate_tag_index } - - after { recreate_tag_index } - - it "deletes an elasticsearch index with INDEX_NAME" do - expect(Search::Client.indices.exists(index: described_class::INDEX_NAME)).to eq(true) - described_class.delete_index - expect(Search::Client.indices.exists(index: described_class::INDEX_NAME)).to eq(false) - end - - it "deletes an elasticsearch index with name argument" do - other_name = "random" - described_class.create_index(index_name: other_name) - expect(Search::Client.indices.exists(index: other_name)).to eq(true) - - described_class.delete_index(index_name: other_name) - expect(Search::Client.indices.exists(index: other_name)).to eq(false) - end - end - - describe "::add_alias" do - it "adds alias INDEX_ALIAS to elasticsearch index with INDEX_NAME" do - Search::Client.indices.delete_alias(index: described_class::INDEX_NAME, name: described_class::INDEX_ALIAS) - expect(Search::Client.indices.exists(index: described_class::INDEX_ALIAS)).to eq(false) - described_class.add_alias - expect(Search::Client.indices.exists(index: described_class::INDEX_ALIAS)).to eq(true) - end - - it "adds custom alias to elasticsearch index with INDEX_NAME" do - other_alias = "random" - expect(Search::Client.indices.exists(index: other_alias)).to eq(false) - described_class.add_alias(index_name: described_class::INDEX_NAME, index_alias: other_alias) - expect(Search::Client.indices.exists(index: other_alias)).to eq(true) - end - end - - describe "::update_mappings" do - it "updates index mappings" do - other_name = "index_name" - allow(Search::Client.indices).to receive(:put_mapping) - - described_class.update_mappings(index_alias: other_name) - expect(Search::Client.indices).to have_received(:put_mapping).with( - index: other_name, body: described_class::MAPPINGS, - ) - end - end -end diff --git a/spec/services/search/chat_channel_membership_spec.rb b/spec/services/search/chat_channel_membership_spec.rb deleted file mode 100644 index 3914ff364..000000000 --- a/spec/services/search/chat_channel_membership_spec.rb +++ /dev/null @@ -1,148 +0,0 @@ -require "rails_helper" - -RSpec.describe Search::ChatChannelMembership, type: :service do - describe "::search_documents", elasticsearch: "ChatChannelMembership" do - let(:user) { create(:user) } - let(:chat_channel_membership1) { create(:chat_channel_membership, user_id: user.id) } - let(:chat_channel_membership2) { create(:chat_channel_membership, user_id: user.id) } - - it "parses chat_channel_membership document hits from search response" do - mock_search_response = { "hits" => { "hits" => {} } } - allow(described_class).to receive(:search) { mock_search_response } - described_class.search_documents(params: { user_id: 1 }) - expect(described_class).to have_received(:search).with(body: a_kind_of(Hash)) - end - - context "with a query" do - it "searches by channel_text" do - allow(chat_channel_membership1).to receive(:channel_text).and_return("a name") - allow(chat_channel_membership2).to receive(:channel_text).and_return("another name and slug") - index_documents([chat_channel_membership1, chat_channel_membership2]) - name_params = { size: 5, channel_text: "name", user_id: [user.id] } - - chat_channel_membership_docs = described_class.search_documents(params: name_params) - expect(chat_channel_membership_docs.count).to eq(2) - doc_ids = chat_channel_membership_docs.map { |t| t["id"] } - expect(doc_ids).to include(chat_channel_membership1.id, chat_channel_membership2.id) - end - end - - context "with a filter" do - it "searches by viewable_by" do - new_user = create(:user) - chat_channel_membership3 = create(:chat_channel_membership, user_id: new_user.id) - index_documents([chat_channel_membership1, chat_channel_membership2, chat_channel_membership3]) - params = { size: 5, user_id: [new_user.id] } - - chat_channel_membership_docs = described_class.search_documents(params: params) - expect(chat_channel_membership_docs.count).to eq(1) - expect(chat_channel_membership_docs.first["id"]).to eq(chat_channel_membership3.id) - end - - it "searches by channel_status" do - allow(chat_channel_membership1).to receive(:channel_status).and_return("popping") - index_documents([chat_channel_membership1, chat_channel_membership2]) - params = { size: 5, channel_status: "popping", user_id: [user.id] } - - chat_channel_membership_docs = described_class.search_documents(params: params) - expect(chat_channel_membership_docs.count).to eq(1) - expect(chat_channel_membership_docs.first["id"]).to eq(chat_channel_membership1.id) - end - - it "searches by channel_type" do - allow(chat_channel_membership2).to receive(:channel_type).and_return("invite_only") - index_documents([chat_channel_membership1, chat_channel_membership2]) - params = { size: 5, channel_type: "invite_only", user_id: [user.id] } - - chat_channel_membership_docs = described_class.search_documents(params: params) - expect(chat_channel_membership_docs.count).to eq(1) - expect(chat_channel_membership_docs.first["id"]).to eq(chat_channel_membership2.id) - end - - it "only returns active status memberships" do - chat_channel_membership1.update(status: "inactive") - chat_channel_membership2.update(status: "active") - index_documents([chat_channel_membership1, chat_channel_membership2]) - params = { size: 5, user_id: [user.id] } - - chat_channel_membership_docs = described_class.search_documents(params: params) - expect(chat_channel_membership_docs.count).to eq(1) - expect(chat_channel_membership_docs.first["id"]).to eq(chat_channel_membership2.id) - end - end - - context "with a query and filter" do - it "searches by channel_text and status" do - allow(chat_channel_membership1).to receive(:channel_text).and_return("a name") - allow(chat_channel_membership2).to receive(:channel_text).and_return("another name") - chat_channel_membership1.update(status: "active") - chat_channel_membership2.update(status: "inactive") - index_documents([chat_channel_membership1, chat_channel_membership2]) - name_params = { size: 5, channel_text: "name", status: "active", user_id: [user.id] } - - chat_channel_membership_docs = described_class.search_documents(params: name_params) - expect(chat_channel_membership_docs.count).to eq(1) - doc_ids = chat_channel_membership_docs.map { |t| t["id"] } - expect(doc_ids).to include(chat_channel_membership1.id) - end - end - - it "sorts documents for given field" do - allow(chat_channel_membership1).to receive(:channel_type).and_return("not_direct") - allow(chat_channel_membership2).to receive(:channel_type).and_return("direct") - index_documents([chat_channel_membership1, chat_channel_membership2]) - params = { size: 5, sort_by: "channel_type", sort_direction: "asc", user_id: [user.id] } - - chat_channel_membership_docs = described_class.search_documents(params: params) - expect(chat_channel_membership_docs.count).to eq(2) - expect(chat_channel_membership_docs.first["id"]).to eq(chat_channel_membership2.id) - expect(chat_channel_membership_docs.last["id"]).to eq(chat_channel_membership1.id) - end - - it "sorts documents by channel_last_message_at by default" do - allow(chat_channel_membership1).to receive(:channel_last_message_at).and_return(Time.current) - allow(chat_channel_membership2).to receive(:channel_last_message_at).and_return(1.year.ago) - index_documents([chat_channel_membership1, chat_channel_membership2]) - params = { size: 5, user_id: [user.id] } - - chat_channel_membership_docs = described_class.search_documents(params: params) - expect(chat_channel_membership_docs.count).to eq(2) - expect(chat_channel_membership_docs.first["id"]).to eq(chat_channel_membership1.id) - expect(chat_channel_membership_docs.last["id"]).to eq(chat_channel_membership2.id) - end - - it "will return a set number of docs based on pagination params" do - index_documents([chat_channel_membership1, chat_channel_membership2]) - params = { page: 0, per_page: 1, user_id: [user.id] } - - chat_channel_membership_docs = described_class.search_documents(params: params) - expect(chat_channel_membership_docs.count).to eq(1) - end - - it "paginates the results" do - allow(chat_channel_membership1).to receive(:channel_last_message_at).and_return(Time.current) - allow(chat_channel_membership2).to receive(:channel_last_message_at).and_return(1.year.ago) - index_documents([chat_channel_membership1, chat_channel_membership2]) - first_page_params = { page: 0, per_page: 1, sort_by: "channel_last_message_at", order: "dsc", user_id: [user.id] } - - chat_channel_membership_docs = described_class.search_documents(params: first_page_params) - expect(chat_channel_membership_docs.first["id"]).to eq(chat_channel_membership1.id) - - second_page_params = { page: 1, per_page: 1, sort_by: "channel_last_message_at", order: "dsc", - user_id: [user.id] } - - chat_channel_membership_docs = described_class.search_documents(params: second_page_params) - expect(chat_channel_membership_docs.first["id"]).to eq(chat_channel_membership2.id) - end - - it "returns an empty Array if no results are found" do - allow(chat_channel_membership1).to receive(:channel_last_message_at).and_return(Time.current) - allow(chat_channel_membership2).to receive(:channel_last_message_at).and_return(1.year.ago) - index_documents([chat_channel_membership1, chat_channel_membership2]) - params = { page: 3, per_page: 1, user_id: [user.id] } - - chat_channel_membership_docs = described_class.search_documents(params: params) - expect(chat_channel_membership_docs).to eq([]) - end - end -end diff --git a/spec/services/search/cluster_spec.rb b/spec/services/search/cluster_spec.rb deleted file mode 100644 index 6ae6433b4..000000000 --- a/spec/services/search/cluster_spec.rb +++ /dev/null @@ -1,88 +0,0 @@ -require "rails_helper" - -RSpec.describe Search::Cluster, type: :service do - describe "::recreate_indexes" do - it "destroys and sets up indexes" do - allow(described_class).to receive(:delete_indexes) - allow(described_class).to receive(:setup_indexes) - - described_class.recreate_indexes - expect(described_class).to have_received(:delete_indexes) - expect(described_class).to have_received(:setup_indexes) - end - end - - describe "::setup_indexes" do - it "creates, adds aliases and updates mappings for indexes" do - allow(described_class).to receive(:create_indexes) - allow(described_class).to receive(:add_aliases) - allow(described_class).to receive(:update_mappings) - - described_class.setup_indexes - expect(described_class).to have_received(:create_indexes) - expect(described_class).to have_received(:add_aliases) - expect(described_class).to have_received(:update_mappings) - end - end - - describe "::create_indexes" do - it "calls create_index for each search class" do - allow(Search::Client.indices).to receive(:exists).and_return(false) - described_class::SEARCH_CLASSES.each do |search_class| - allow(search_class).to receive(:create_index) - end - described_class.create_indexes - expect(described_class::SEARCH_CLASSES).to all(have_received(:create_index)) - end - end - - describe "::update_indexes" do - it "calls update_index for each search class" do - described_class::SEARCH_CLASSES.each do |search_class| - allow(search_class).to receive(:update_index) - end - described_class.update_indexes - expect(described_class::SEARCH_CLASSES).to all(have_received(:update_index)) - end - end - - describe "::delete_indexes" do - it "calls delete_index for each search class" do - allow(Search::Client.indices).to receive(:exists).and_return(true) - described_class::SEARCH_CLASSES.each do |search_class| - allow(search_class).to receive(:delete_index) - end - described_class.delete_indexes - expect(described_class::SEARCH_CLASSES).to all(have_received(:delete_index)) - end - end - - describe "::add_aliases" do - it "calls add_alias for each search class" do - described_class::SEARCH_CLASSES.each do |search_class| - allow(search_class).to receive(:add_alias) - end - described_class.add_aliases - expect(described_class::SEARCH_CLASSES).to all(have_received(:add_alias)) - end - end - - describe "::update_mappings" do - it "calls update_mappings for each search class" do - described_class::SEARCH_CLASSES.each do |search_class| - allow(search_class).to receive(:update_mappings) - end - described_class.update_mappings - expect(described_class::SEARCH_CLASSES).to all(have_received(:update_mappings)) - end - end - - describe "::update_settings" do - it "updates cluster settings" do - described_class.update_settings - cluster_settings = Search::Client.cluster.get_settings - auto_create_setting = cluster_settings.dig("persistent", "action", "auto_create_index") - expect(auto_create_setting).to eq("false") - end - end -end diff --git a/spec/services/search/feed_content_spec.rb b/spec/services/search/feed_content_spec.rb deleted file mode 100644 index 1670603f1..000000000 --- a/spec/services/search/feed_content_spec.rb +++ /dev/null @@ -1,224 +0,0 @@ -require "rails_helper" - -RSpec.describe Search::FeedContent, type: :service do - it "defines INDEX_NAME, INDEX_ALIAS, and MAPPINGS", :aggregate_failures do - expect(described_class::INDEX_NAME).not_to be_nil - expect(described_class::INDEX_ALIAS).not_to be_nil - expect(described_class::MAPPINGS).not_to be_nil - end - - describe "::search_documents", elasticsearch: "FeedContent" do - let(:article1) { create(:article, published_at: 1.day.ago) } - let(:article2) { create(:article, published_at: 2.days.ago) } - let(:article3) { create(:article, published_at: Time.current) } - - it "parses feed content document hits from search response" do - mock_search_response = { "hits" => { "hits" => {} } } - allow(described_class).to receive(:search) { mock_search_response } - described_class.search_documents(params: {}) - expect(described_class).to have_received(:search).with(body: a_kind_of(Hash)) - end - - it "returns highlighted fields" do - allow(article1).to receive(:body_text).and_return("I love ruby") - allow(article2).to receive(:body_text).and_return("Ruby Tuesday is love") - index_documents([article1, article2]) - query_params = { size: 5, search_fields: "love ruby" } - - feed_docs = described_class.search_documents(params: query_params) - expect(feed_docs.count).to eq(2) - doc_highlights = feed_docs.map { |t| t.dig("highlight", "body_text") }.flatten - expect(doc_highlights).to include("I love ruby", - "Ruby Tuesday is love") - end - - it "returns fields necessary for the view" do - allow(article1).to receive(:flare_tag).and_return(name: "help", bg_color_hex: nil, text_color_hex: nil) - view_keys = %w[ - id title path class_name cloudinary_video_url comments_count flare_tag tag_list user_id user - published_at_int published_timestamp readable_publish_date - ] - flare_tag_keys = %w[name bg_color_hex text_color_hex] - user_keys = %w[username name profile_image_90] - podcast_keys = %w[slug image_url title] - index_documents([article1]) - - feed_doc = described_class.search_documents(params: { size: 1 }).first - expect(feed_doc.keys).to include(*view_keys) - expect(feed_doc["user"].keys).to include(*user_keys) - expect(feed_doc["flare_tag"].keys).to include(*flare_tag_keys) - expect(feed_doc["podcast"].keys).to include(*podcast_keys) - end - - context "with chronological sorting specified" do - before do - allow(article1).to receive(:title).and_return("Ruby Slippers") - allow(article2).to receive(:title).and_return("Ruby Tuesday") - allow(article3).to receive(:title).and_return("Just Ruby") - end - - it "sorts articles from newest to oldest" do - index_documents([article1, article2, article3]) - query_params = { size: 5, search_fields: "ruby", sort_by: "published_at", sort_direction: "desc" } - - titles_in_order = described_class.search_documents(params: query_params).map { |doc| doc["title"] } - - expect(titles_in_order).to eq ["Just Ruby", "Ruby Slippers", "Ruby Tuesday"] - end - - it "sorts articles from oldest to newest" do - index_documents([article1, article2, article3]) - query_params = { size: 5, search_fields: "ruby", sort_by: "published_at", sort_direction: "asc" } - - titles_in_order = described_class.search_documents(params: query_params).map { |doc| doc["title"] } - - expect(titles_in_order).to eq ["Ruby Tuesday", "Ruby Slippers", "Just Ruby"] - end - end - - context "with a query" do - it "searches by search_fields" do - allow(article1).to receive(:title).and_return("ruby") - allow(article2).to receive(:body_text).and_return("Ruby Tuesday") - index_documents([article1, article2]) - query_params = { size: 5, search_fields: "ruby" } - - feed_docs = described_class.search_documents(params: query_params) - expect(feed_docs.count).to eq(2) - doc_ids = feed_docs.map { |t| t["id"] } - expect(doc_ids).to include(article1.id, article2.id) - end - - # Skipped because this seems inconsistent - xit "bumps scores for words closer together" do - allow(article1).to receive(:body_text).and_return("Ruby I dont know maybe is cool") - allow(article2).to receive(:body_text).and_return("Ruby is cool I dont know maybe") - index_documents([article1, article2]) - query_params = { size: 5, search_fields: "ruby is cool" } - - feed_docs = described_class.search_documents(params: query_params) - expect(feed_docs.count).to eq(2) - doc_ids = feed_docs.map { |t| t["id"] } - expect(doc_ids).to eq([article2.id, article1.id]) - end - end - - context "with a filter term" do - it "filters by tag names" do - article1.tags << create(:tag, name: "ruby") - article2.tags << create(:tag, name: "python") - index_documents([article1, article2]) - query_params = { size: 5, tag_names: "ruby" } - - feed_docs = described_class.search_documents(params: query_params) - expect(feed_docs.count).to eq(1) - doc_ids = feed_docs.map { |t| t["id"] } - expect(doc_ids).to include(article1.id) - end - - it "filters by multiple tag names when tag_boolean_mode is set to all" do - tag_one = create(:tag) - tag_two = create(:tag) - article1.tags << tag_one - article2.tags << tag_two - article2.tags << tag_one - index_documents([article1, article2]) - query_params = { - tag_names: [tag_one.name, tag_two.name], - tag_boolean_mode: "all" - } - - feed_docs = described_class.search_documents(params: query_params) - expect(feed_docs.count).to eq(1) - doc_ids = feed_docs.map { |t| t["id"] } - expect(doc_ids).to include(article2.id) - end - - it "filters by user_id" do - index_documents([article1, article2]) - query_params = { size: 5, user_id: article1.user_id } - - feed_docs = described_class.search_documents(params: query_params) - expect(feed_docs.count).to eq(1) - doc_ids = feed_docs.map { |t| t["id"] } - expect(doc_ids).to include(article1.id) - end - - it "filters by approved" do - article1.update(approved: false) - article2.update(approved: true) - index_documents([article1, article2]) - query_params = { size: 5, approved: true } - - feed_docs = described_class.search_documents(params: query_params) - expect(feed_docs.count).to eq(1) - doc_ids = feed_docs.map { |t| t["id"] } - expect(doc_ids).to include(article2.id) - end - - it "filters by class_name" do - pde = create(:podcast_episode) - index_documents([pde, article1, article2]) - query_params = { size: 5, class_name: "PodcastEpisode" } - - feed_docs = described_class.search_documents(params: query_params) - expect(feed_docs.count).to eq(1) - doc_ids = feed_docs.map { |t| t["id"] } - expect(doc_ids).to include(pde.id) - end - - it "filters by id" do - index_documents([article1, article2]) - query_params = { size: 5, id: ["article_#{article1.id}"] } - - feed_docs = described_class.search_documents(params: query_params) - expect(feed_docs.count).to eq(1) - doc_ids = feed_docs.map { |t| t["id"] } - expect(doc_ids).to include(article1.id) - expect(doc_ids).not_to include(article2.id) - end - end - - context "with range keys" do - it "searches by published_at" do - article1.update(published_at: 1.year.ago) - article2.update(published_at: 1.month.ago) - index_documents([article1, article2]) - query_params = { size: 5, published_at: { gte: 2.months.ago.iso8601 } } - - feed_docs = described_class.search_documents(params: query_params) - expect(feed_docs.count).to eq(1) - doc_ids = feed_docs.map { |t| t["id"] } - expect(doc_ids).to include(article2.id) - end - end - - context "with default sorting" do - it "sorts by Elasticsearch _score which is weighted based on article score" do - ruby_tag = create(:tag, name: "ruby") - allow(article1).to receive(:score).and_return(200) - article1.tags << ruby_tag - allow(article2).to receive(:score).and_return(1500) - article2.tags << ruby_tag - index_documents([article1, article2]) - query_params = { size: 5, search_fields: "ruby" } - - feed_docs = described_class.search_documents(params: query_params) - doc_ids = feed_docs.map { |t| t["id"] } - expect(doc_ids).to eq([article2.id, article1.id]) - end - end - end - - describe "document counts", elasticsearch: "FeedContent" do - it "returns counts for each document class" do - article = create(:article) - comment = create(:comment) - pde = create(:podcast_episode) - index_documents([article, comment, pde]) - described_class::INCLUDED_CLASS_NAMES.each do |class_name| - expect(described_class.public_send("#{class_name.underscore.pluralize}_document_count")).to eq(1) - end - end - end -end diff --git a/spec/services/search/listing_spec.rb b/spec/services/search/listing_spec.rb deleted file mode 100644 index c19865167..000000000 --- a/spec/services/search/listing_spec.rb +++ /dev/null @@ -1,178 +0,0 @@ -require "rails_helper" - -RSpec.describe Search::Listing, type: :service do - describe "::search_documents", elasticsearch: "Listing" do - let(:listing) { create(:listing) } - - it "parses listing document hits from search response" do - mock_search_response = { "hits" => { "hits" => {} } } - allow(described_class).to receive(:search) { mock_search_response } - described_class.search_documents(params: {}) - expect(described_class).to have_received(:search).with(body: a_kind_of(Hash)) - end - - context "with a query" do - # listing_search is a copy_to field including: - # body_markdown, location, slug, tags, and title - it "searches by listing_search" do - listing1 = create(:listing, body_markdown: "# body_markdown with test") - listing2 = create(:listing, location: "a test location") - listing3 = create(:listing, title: "this test title is testing slug") - listing4 = create(:listing, tag_list: ["test"]) - listing5 = create(:listing, title: "a test title") - listings = [listing1, listing2, listing3, listing4, listing5] - index_documents(listings) - - listing_docs = described_class.search_documents(params: { size: 5, listing_search: "test" }) - expect(listing_docs.count).to eq(5) - expect(listing_docs.map { |t| t["id"] }).to match_array(listings.map(&:id)) - end - end - - context "with a term filter" do - it "searches by category" do - new_category = create(:listing_category, :cfp) - listing.update(listing_category_id: new_category.id) - index_documents(listing) - params = { size: 5, category: new_category.slug } - - listing_docs = described_class.search_documents(params: params) - expect(listing_docs.count).to eq(1) - expect(listing_docs.first["id"]).to eq(listing.id) - end - - it "searches by contact_via_connect" do - listing.update(contact_via_connect: true) - index_documents(listing) - params = { size: 5, contact_via_connect: true } - - listing_docs = described_class.search_documents(params: params) - expect(listing_docs.count).to eq(1) - expect(listing_docs.first["id"]).to eq(listing.id) - end - - it "searches by location" do - listing.update(location: "a location") - index_documents(listing) - params = { size: 5, location: "location" } - - listing_docs = described_class.search_documents(params: params) - expect(listing_docs.count).to eq(1) - expect(listing_docs.first["id"]).to eq(listing.id) - end - - it "searches by slug" do - slug_listing = create(:listing, title: "A slug is created from this title in a callback") - index_documents(slug_listing) - params = { size: 5, slug: "slug" } - - listing_docs = described_class.search_documents(params: params) - expect(listing_docs.count).to eq(1) - expect(listing_docs.first["id"]).to eq(slug_listing.id) - end - - it "searches by tags" do - listing.update(tag_list: %w[beginners career]) - index_documents(listing) - params = { size: 5, tags: "career" } - - listing_docs = described_class.search_documents(params: params) - expect(listing_docs.count).to eq(1) - expect(listing_docs.first["id"]).to eq(listing.id) - end - - it "searches by title" do - listing.update(title: "An Amazing Title") - index_documents(listing) - params = { size: 5, title: "amazing" } - - listing_docs = described_class.search_documents(params: params) - expect(listing_docs.count).to eq(1) - expect(listing_docs.first["id"]).to eq(listing.id) - end - - it "searches by user_id" do - index_documents(listing) - params = { size: 5, user_id: listing.user_id } - - listing_docs = described_class.search_documents(params: params) - expect(listing_docs.count).to eq(1) - expect(listing_docs.first["id"]).to eq(listing.id) - end - - it "searches by bumped_at" do - listing.update(bumped_at: 1.day.from_now) - index_documents(listing) - params = { size: 5, bumped_at: { gt: Time.current } } - - listing_docs = described_class.search_documents(params: params) - expect(listing_docs.count).to eq(1) - expect(listing_docs.first["id"]).to eq(listing.id) - end - - it "searches by expires_at" do - listing.update(expires_at: 1.day.ago) - index_documents(listing) - params = { size: 5, expires_at: { lt: Time.current } } - - listing_docs = described_class.search_documents(params: params) - expect(listing_docs.count).to eq(1) - expect(listing_docs.first["id"]).to eq(listing.id) - end - end - - it "sorts documents for a given field" do - listing = create(:listing) - cfp = create(:listing_category, :cfp) - listing2 = create(:listing, listing_category_id: cfp.id) - index_documents([listing, listing2]) - params = { size: 5, sort_by: "category", sort_direction: "asc" } - - listing_docs = described_class.search_documents(params: params) - expect(listing_docs.count).to eq(2) - expect(listing_docs.first["id"]).to eq(listing2.id) - expect(listing_docs.last["id"]).to eq(listing.id) - end - - it "sorts documents by bumped_at by default" do - listing.update(bumped_at: 1.year.ago) - listing2 = create(:listing, bumped_at: Time.current) - index_documents([listing, listing2]) - params = { size: 5 } - - listing_docs = described_class.search_documents(params: params) - expect(listing_docs.count).to eq(2) - expect(listing_docs.first["id"]).to eq(listing2.id) - expect(listing_docs.last["id"]).to eq(listing.id) - end - - it "paginates the results" do - listing.update(bumped_at: 1.year.ago) - listing2 = create(:listing, bumped_at: Time.current) - index_documents([listing, listing2]) - first_page_params = { page: 0, per_page: 1, sort_by: "bumped_at", order: "dsc" } - - listing_docs = described_class.search_documents(params: first_page_params) - expect(listing_docs.first["id"]).to eq(listing2.id) - - second_page_params = { page: 1, per_page: 1, sort_by: "bumped_at", order: "dsc" } - - listing_docs = described_class.search_documents(params: second_page_params) - expect(listing_docs.first["id"]).to eq(listing.id) - end - - it "returns an empty Array if no results are found" do - jobs_category = create(:listing_category, :jobs) - listing.update(listing_category: jobs_category) - - cfp_category = create(:listing_category, :cfp) - listing2 = create(:listing, - listing_category: cfp_category) - index_documents([listing, listing2]) - params = { page: 3, per_page: 1 } - - listing_docs = described_class.search_documents(params: params) - expect(listing_docs).to eq([]) - end - end -end diff --git a/spec/services/search/query_builders/chat_channel_membership_spec.rb b/spec/services/search/query_builders/chat_channel_membership_spec.rb deleted file mode 100644 index 26afaaeda..000000000 --- a/spec/services/search/query_builders/chat_channel_membership_spec.rb +++ /dev/null @@ -1,103 +0,0 @@ -require "rails_helper" - -RSpec.describe Search::QueryBuilders::ChatChannelMembership, type: :service do - describe "::initialize" do - it "sets params" do - filter_params = { foo: "bar" } - filter = described_class.new(params: filter_params) - expect(filter.params).to include(filter_params) - end - - it "builds query body" do - filter = described_class.new(params: {}) - expect(filter.body).not_to be_nil - end - end - - describe "#as_hash" do - it "applies FILTER_KEYS from params" do - params = { channel_status: "active", channel_type: "direct", user_id: 1 } - filter = described_class.new(params: params) - expected_filters = [ - { "term" => { "channel_status" => "active" } }, - { "term" => { "channel_type" => "direct" } }, - { "terms" => { "status" => %w[active joining_request] } }, - { "terms" => { "viewable_by" => 1 } }, - ] - expect(filter.as_hash.dig("query", "bool", "filter")).to match_array(expected_filters) - end - - it "applies QUERY_KEYS from params" do - params = { channel_text: "a_name" } - query = described_class.new(params: params) - expected_query = [{ - "simple_query_string" => { - "query" => "a_name*", "fields" => [:channel_text], "lenient" => true, "analyze_wildcard" => true - } - }] - expect(query.as_hash.dig("query", "bool", "must")).to match_array(expected_query) - end - - it "applies QUERY_KEYS and FILTER_KEYS from params" do - params = { channel_text: "a_name", channel_status: "active", user_id: 1 } - query = described_class.new(params: params) - expected_query = [{ - "simple_query_string" => { - "query" => "a_name*", "fields" => [:channel_text], "lenient" => true, "analyze_wildcard" => true - } - }] - expected_filters = [ - { "term" => { "channel_status" => "active" } }, - { "terms" => { "status" => %w[active joining_request] } }, - { "terms" => { "viewable_by" => 1 } }, - ] - expect(query.as_hash.dig("query", "bool", "must")).to match_array(expected_query) - expect(query.as_hash.dig("query", "bool", "filter")).to match_array(expected_filters) - end - - it "always applies viewable_by and status params" do - params = { user_id: 1 } - filter = described_class.new(params: params) - expected_filters = [ - { "terms" => { "status" => %w[active joining_request] } }, - { "terms" => { "viewable_by" => 1 } }, - ] - expect(filter.as_hash.dig("query", "bool", "filter")).to match_array(expected_filters) - end - - it "always applies channel discoverable and status params" do - allow(Settings::Mascot).to receive(:mascot_user_id).and_return(2) - - params = { user_id: Settings::Mascot.mascot_user_id } - filter = described_class.new(params: params) - expected_filters = [ - { "terms" => { "status" => %w[active joining_request] } }, - { "terms" => { "viewable_by" => Settings::Mascot.mascot_user_id } }, - ] - expect(filter.as_hash.dig("query", "bool", "filter")).to match_array(expected_filters) - end - - it "ignores params we dont support" do - params = { not_supported: "direct", user_id: 1 } - filter = described_class.new(params: params) - expected_filters = [ - { "terms" => { "status" => %w[active joining_request] } }, - { "terms" => { "viewable_by" => 1 } }, - ] - expect(filter.as_hash.dig("query", "bool", "filter")).to match_array(expected_filters) - end - - it "sets default params when not present" do - filter = described_class.new(params: {}) - expect(filter.as_hash["sort"]).to eq("channel_last_message_at" => "desc") - expect(filter.as_hash["size"]).to eq(0) - end - - it "allows default params to be overriden" do - params = { sort_by: "status", sort_direction: "asc", size: 20 } - filter = described_class.new(params: params) - expect(filter.as_hash["sort"]).to eq("status" => "asc") - expect(filter.as_hash["size"]).to eq(20) - end - end -end diff --git a/spec/services/search/query_builders/feed_content_spec.rb b/spec/services/search/query_builders/feed_content_spec.rb deleted file mode 100644 index 57bdf848e..000000000 --- a/spec/services/search/query_builders/feed_content_spec.rb +++ /dev/null @@ -1,130 +0,0 @@ -require "rails_helper" - -RSpec.describe Search::QueryBuilders::FeedContent, type: :service do - describe "::initialize" do - it "sets params" do - filter_params = { foo: "bar" } - filter = described_class.new(params: filter_params) - expect(filter.params).to include(filter_params) - end - - it "builds query body" do - filter = described_class.new(params: {}) - expect(filter.body).not_to be_nil - end - - it "builds query body with html encoder" do - filter = described_class.new(params: {}) - expect(filter.body).to include("highlight" => hash_including("encoder" => "html")) - end - - it "sets published to true" do - filter = described_class.new(params: {}) - expect(filter.params).to include(published: true) - end - end - - describe "#as_hash" do - let(:query_fields) { described_class::QUERY_KEYS[:search_fields] } - let(:expected_query) do - [{ - "simple_query_string" => { - "query" => "test", - "fields" => query_fields, - "lenient" => true, - "analyze_wildcard" => true, - "minimum_should_match" => 2 - } - }] - end - let(:expected_match_phrase) do - [{ - "match_phrase" => { - "search_fields" => { - "query" => "test", - "slop" => 0 - } - } - }] - end - - it "applies QUERY_KEYS from params" do - params = { search_fields: "test" } - filter = described_class.new(params: params) - expect(search_bool_clause(filter)["must"]).to match_array(expected_query) - expect(search_bool_clause(filter)["should"]).to match_array(expected_match_phrase) - expect(search_bool_clause(filter)["minimum_should_match"]).to eq(0) - end - - it "applies TERM_KEYS from params" do - params = { approved: true, tag_names: "beginner", user_id: 777, class_name: "Article" } - filter = described_class.new(params: params) - expected_filters = [ - { "terms" => { "approved" => [true] } }, - { "terms" => { "tags.name" => ["beginner"] } }, - { "terms" => { "user.id" => [777] } }, - { "terms" => { "class_name" => ["Article"] } }, - { "terms" => { "published" => [true] } }, - ] - expect(search_bool_clause(filter)["filter"]).to match_array(expected_filters) - end - - it "applies RANGE_KEYS from params" do - Timecop.freeze(Time.current) do - params = { published_at: { lte: Time.current } } - filter = described_class.new(params: params) - expected_filters = [ - { "range" => { "published_at" => { lte: Time.current } } }, - { "terms" => { "published" => [true] } }, - ] - expect(search_bool_clause(filter)["filter"]).to match_array(expected_filters) - end - end - - it "applies QUERY_KEYS, TERM_KEYS, and RANGE_KEYS from params" do - Timecop.freeze(Time.current) do - params = { search_fields: "ruby", published_at: { lte: Time.current }, tag_names: "cfp" } - filter = described_class.new(params: params) - expected_query = [{ - "simple_query_string" => { "query" => "ruby", "fields" => query_fields, "lenient" => true, - "analyze_wildcard" => true, "minimum_should_match" => 2 } - }] - expected_filters = [ - { "range" => { "published_at" => { lte: Time.current } } }, - { "terms" => { "tags.name" => ["cfp"] } }, - { "terms" => { "published" => [true] } }, - ] - expect(search_bool_clause(filter)["must"]).to match_array(expected_query) - expect(search_bool_clause(filter)["filter"]).to match_array(expected_filters) - end - end - - it "ignores params we don't support" do - params = { not_supported: "trash", search_fields: "cfp" } - filter = described_class.new(params: params) - expected_query = [{ - "simple_query_string" => { - "query" => "cfp", "fields" => query_fields, "lenient" => true, - "analyze_wildcard" => true, "minimum_should_match" => 2 - } - }] - expect(search_bool_clause(filter)["must"]).to match_array(expected_query) - end - - it "allows default params to be overriden" do - params = { sort_by: "published_at", sort_direction: "asc", size: 20 } - filter = described_class.new(params: params).as_hash - expect(filter["sort"]).to eq("published_at" => "asc") - expect(filter["size"]).to eq(20) - end - - it "correctly sets default sort" do - filter = described_class.new(params: {}).as_hash - expect(filter["sort"]).to eq(described_class::DEFAULT_PARAMS[:sort]) - end - end - - def search_bool_clause(query_builder) - query_builder.as_hash.dig("query", "function_score", "query", "bool") - end -end diff --git a/spec/services/search/query_builders/listing_spec.rb b/spec/services/search/query_builders/listing_spec.rb deleted file mode 100644 index 911d50ec0..000000000 --- a/spec/services/search/query_builders/listing_spec.rb +++ /dev/null @@ -1,114 +0,0 @@ -require "rails_helper" - -RSpec.describe Search::QueryBuilders::Listing, type: :service do - describe "::intialize" do - it "sets params" do - filter_params = { foo: "bar" } - filter = described_class.new(params: filter_params) - expect(filter.params).to include(filter_params) - end - - it "builds query body" do - filter = described_class.new(params: {}) - expect(filter.body).not_to be_nil - end - end - - describe "#as_hash" do - it "applies TERM_KEYS from params" do - params = { category: "cfp", tags: %w[beginner intermediate professional], contact_via_connect: false } - filter = described_class.new(params: params) - exepcted_filters = [ - { "terms" => { "category" => ["cfp"] } }, - { "terms" => { "tags" => %w[beginner intermediate professional] } }, - { "terms" => { "contact_via_connect" => [false] } }, - { "terms" => { "published" => [true] } }, - ] - expect(filter.as_hash.dig("query", "bool", "filter")).to match_array(exepcted_filters) - end - - it "applies TERM_KEYS from params with boolean mode" do - params = { category: "cfp", tags: %w[beginner intermediate professional], contact_via_connect: false, - tag_boolean_mode: "all" } - filter = described_class.new(params: params) - expected_filters = [ - { "terms" => { "category" => ["cfp"] } }, - { "terms" => { "tags" => ["beginner"] } }, - { "terms" => { "tags" => ["intermediate"] } }, - { "terms" => { "tags" => ["professional"] } }, - { "terms" => { "contact_via_connect" => [false] } }, - { "terms" => { "published" => [true] } }, - ] - expect(filter.as_hash.dig("query", "bool", "filter")).to match_array(expected_filters) - end - - it "applies RANGE_KEYS from params" do - Timecop.freeze(Time.current) do - params = { bumped_at: Time.current, expires_at: 1.day.from_now } - filter = described_class.new(params: params) - expected_filters = [ - { "range" => { "bumped_at" => Time.current } }, - { "range" => { "expires_at" => 1.day.from_now } }, - { "terms" => { "published" => [true] } }, - ] - expect(filter.as_hash.dig("query", "bool", "filter")).to match_array(expected_filters) - end - end - - it "applies QUERY_KEYS from params" do - params = { listing_search: "test" } - filter = described_class.new(params: params) - expected_query = [{ - "simple_query_string" => { - "query" => "test*", - "fields" => [:listing_search], - "lenient" => true, - "analyze_wildcard" => true - } - }] - expect(filter.as_hash.dig("query", "bool", "must")).to match_array(expected_query) - end - - it "applies QUERY_KEYS, TERM_KEYS, and RANGE_KEYS from params" do - Timecop.freeze(Time.current) do - params = { listing_search: "test", bumped_at: Time.current, category: "cfp" } - filter = described_class.new(params: params) - expected_query = [{ - "simple_query_string" => { "query" => "test*", "fields" => [:listing_search], "lenient" => true, - "analyze_wildcard" => true } - }] - expected_filters = [ - { "range" => { "bumped_at" => Time.current } }, - { "terms" => { "category" => ["cfp"] } }, - { "terms" => { "published" => [true] } }, - ] - expect(filter.as_hash.dig("query", "bool", "must")).to match_array(expected_query) - expect(filter.as_hash.dig("query", "bool", "filter")).to match_array(expected_filters) - end - end - - it "ignores params we don't support" do - params = { not_supported: "trash", category: "cfp" } - filter = described_class.new(params: params) - expected_filters = [ - { "terms" => { "category" => ["cfp"] } }, - { "terms" => { "published" => [true] } }, - ] - expect(filter.as_hash.dig("query", "bool", "filter")).to match_array(expected_filters) - end - - it "sets default params when not present" do - filter = described_class.new(params: {}).as_hash - expect(filter["sort"]).to eq("bumped_at" => "desc") - expect(filter["size"]).to eq(0) - expect(filter.dig("query", "bool", "filter")).to match_array([{ "terms" => { "published" => [true] } }]) - end - - it "allows default params to be overriden" do - params = { sort_by: "category", sort_direction: "asc", size: 20 } - filter = described_class.new(params: params).as_hash - expect(filter["sort"]).to eq("category" => "asc") - expect(filter["size"]).to eq(20) - end - end -end diff --git a/spec/services/search/query_builders/user_spec.rb b/spec/services/search/query_builders/user_spec.rb deleted file mode 100644 index 363aa907e..000000000 --- a/spec/services/search/query_builders/user_spec.rb +++ /dev/null @@ -1,70 +0,0 @@ -require "rails_helper" - -RSpec.describe Search::QueryBuilders::User, type: :service do - describe "::intialize" do - it "sets params" do - filter_params = { foo: "bar" } - filter = described_class.new(params: filter_params) - expect(filter.params).to include(filter_params) - end - - it "builds query body" do - filter = described_class.new(params: {}) - expect(filter.body).not_to be_nil - end - end - - describe "#as_hash" do - it "applies QUERY_KEYS from params" do - params = { search_fields: "test" } - filter = described_class.new(params: params) - expected_query = [{ - "simple_query_string" => { - "query" => "test*", "fields" => [:search_fields], "lenient" => true, "analyze_wildcard" => true - } - }] - expect(filter.as_hash.dig("query", "bool", "must")).to match_array(expected_query) - end - - it "applies EXCLUDED_TERM_KEYS by default" do - filter = described_class.new(params: {}) - expected_filters = [ - { "terms" => { "roles" => %w[suspended banned] } }, - ] - expect(filter.as_hash.dig("query", "bool", "must_not")).to match_array(expected_filters) - end - - it "applies EXCLUDED_TERM_KEYS and QUERY_KEYS" do - params = { search_fields: "test" } - filter = described_class.new(params: params) - expected_query = [{ - "simple_query_string" => { - "query" => "test*", "fields" => [:search_fields], "lenient" => true, "analyze_wildcard" => true - } - }] - expected_filters = [ - { "terms" => { "roles" => %w[suspended banned] } }, - ] - expect(filter.as_hash.dig("query", "bool", "must_not")).to match_array(expected_filters) - expect(filter.as_hash.dig("query", "bool", "must")).to match_array(expected_query) - end - - it "ignores params we don't support" do - params = { not_supported: "trash", search_fields: "cfp" } - filter = described_class.new(params: params) - expected_query = [{ - "simple_query_string" => { - "query" => "cfp*", "fields" => [:search_fields], "lenient" => true, "analyze_wildcard" => true - } - }] - expect(filter.as_hash.dig("query", "bool", "must")).to match_array(expected_query) - end - - it "allows default params to be overriden" do - params = { sort_by: "name", sort_direction: "asc", size: 20 } - filter = described_class.new(params: params).as_hash - expect(filter["sort"]).to eq("name" => "asc") - expect(filter["size"]).to eq(20) - end - end -end diff --git a/spec/services/search/reading_list_spec.rb b/spec/services/search/reading_list_spec.rb deleted file mode 100644 index 2295199a5..000000000 --- a/spec/services/search/reading_list_spec.rb +++ /dev/null @@ -1,115 +0,0 @@ -require "rails_helper" - -RSpec.describe Search::ReadingList, type: :service do - describe "::search_documents", elasticsearch: "FeedContent" do - let(:user) { create(:user) } - let(:article0) { create(:article) } - let(:article1) { create(:article) } - let(:article2) { create(:article) } - let(:reaction1) { create(:reaction, user: user, category: "readinglist", reactable: article1) } - let(:reaction2) { create(:reaction, user: user, category: "readinglist", reactable: article2) } - let(:reaction3) { create(:reaction, user: user, category: "readinglist", reactable: article0) } - let(:query_params) { {} } - - before do - reaction1 - reaction2 - end - - def index_documents(docs) - index_documents_for_search_class(Array.wrap(docs), Search::FeedContent) - end - - it "returns fields necessary for the view" do - view_keys = %w[id reactable user_id] - reactable_keys = %w[title path readable_publish_date_string tags user] - user_keys = %w[username name profile_image_90] - index_documents(article1) - - result = described_class.search_documents(params: {}, user: user) - reaction_doc = result["reactions"].first - expect(result["total"]).to be_present - expect(reaction_doc.keys).to include(*view_keys) - expect(reaction_doc["reactable"].keys).to include(*reactable_keys) - expect(reaction_doc.dig("reactable", "user").keys).to include(*user_keys) - end - - context "with a query" do - it "searches by search_fields" do - allow(article1).to receive(:title).and_return("ruby") - allow(article2).to receive(:body_text).and_return("Ruby Tuesday") - index_documents([article1, article2]) - query_params[:search_fields] = "ruby" - - reaction_docs = described_class.search_documents(params: query_params, user: user)["reactions"] - expect(reaction_docs.count).to eq(2) - doc_ids = reaction_docs.map { |t| t["id"] } - expect(doc_ids).to include(reaction1.id, reaction2.id) - end - end - - context "with a filter term" do - let(:tag_one) { create(:tag) } - let(:tag_two) { create(:tag) } - - it "filters by tag names" do - article1.tags << tag_one - article2.tags << tag_two - index_documents([article1, article2]) - query_params[:tag_names] = [tag_one.name] - - reaction_docs = described_class.search_documents(params: query_params, user: user)["reactions"] - expect(reaction_docs.count).to eq(1) - doc_ids = reaction_docs.map { |t| t["id"] } - expect(doc_ids).to include(reaction1.id) - expect(doc_ids).not_to include(reaction2.id) - end - - it "filters by multiple tag names when tag_boolean_mode is set to all" do - article1.tags << tag_one - article2.tags << tag_two - article2.tags << tag_one - index_documents([article1, article2]) - query_params[:tag_names] = [tag_one.name, tag_two.name] - query_params[:tag_boolean_mode] = "all" - - reaction_docs = described_class.search_documents(params: query_params, user: user)["reactions"] - expect(reaction_docs.count).to eq(1) - doc_ids = reaction_docs.map { |t| t["id"] } - expect(doc_ids).to include(reaction2.id) - expect(doc_ids).not_to include(reaction1.id) - end - - it "filters by status" do - reaction1.update(status: "invalid") - index_documents([article1, article2]) - query_params[:status] = %w[valid confirmed] - - reaction_docs = described_class.search_documents(params: query_params, user: user)["reactions"] - expect(reaction_docs.count).to eq(1) - doc_ids = reaction_docs.map { |t| t["id"] } - expect(doc_ids).to include(reaction2.id) - end - - it "only returns readinglist reactions" do - reaction1.update(category: "like") - index_documents([article1, article2]) - - reaction_docs = described_class.search_documents(params: query_params, user: user)["reactions"] - expect(reaction_docs.count).to eq(1) - doc_ids = reaction_docs.map { |t| t["id"] } - expect(doc_ids).to include(reaction2.id) - expect(doc_ids).not_to include(reaction1.id) - end - end - - it "sorts by reaction ID DESC" do - reaction3 - index_documents([article0, article1, article2]) - - reaction_docs = described_class.search_documents(params: query_params, user: user)["reactions"] - doc_ids = reaction_docs.map { |t| t["id"] } - expect(doc_ids).to eq([reaction3.id, reaction2.id, reaction1.id]) - end - end -end diff --git a/spec/services/search/tag_spec.rb b/spec/services/search/tag_spec.rb deleted file mode 100644 index 753b98424..000000000 --- a/spec/services/search/tag_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -require "rails_helper" - -RSpec.describe Search::Tag, type: :service do - describe "::search_documents", elasticsearch: "Tag" do - let(:tag_doc_1) { { "name" => "tag1" } } - let(:tag_doc_2) { { "name" => "tag2" } } - let(:mock_search_response) do - { - "hits" => { - "hits" => [ - { "_source" => tag_doc_1 }, - { "_source" => tag_doc_2 }, - ] - } - } - end - - it "searches with name:tag" do - tag = create(:tag, :search_indexed, name: "tag1") - - described_class.refresh_index - tag_docs = described_class.search_documents("name:#{tag.name}") - expect(tag_docs.count).to eq(1) - expect(tag_docs).to match([ - a_hash_including("name" => tag.name), - ]) - end - - it "analyzes wildcards" do - tag1 = create(:tag, :search_indexed, name: "tag1") - tag2 = create(:tag, :search_indexed, name: "tag2") - tag3 = create(:tag, :search_indexed, name: "3tag") - - described_class.refresh_index - - tag_docs = described_class.search_documents("name:tag*") - expect(tag_docs).to match([ - a_hash_including("name" => tag1.name), - a_hash_including("name" => tag2.name), - ]) - expect(tag_docs).not_to match(a_hash_including("name" => tag3.name)) - end - - it "parses tag document hits from search response" do - allow(Search::Client).to receive(:search) { mock_search_response } - tag_docs = described_class.search_documents("query") - expect(tag_docs.count).to eq(2) - expect(tag_docs).to include(tag_doc_1, tag_doc_2) - end - - it "does not allow leading wildcards" do - expect { described_class.search_documents("name:*tag") }.to raise_error(Search::Errors::Transport::BadRequest) - end - end -end diff --git a/spec/services/search/user_spec.rb b/spec/services/search/user_spec.rb deleted file mode 100644 index 731142747..000000000 --- a/spec/services/search/user_spec.rb +++ /dev/null @@ -1,99 +0,0 @@ -require "rails_helper" - -RSpec.describe Search::User, type: :service do - it "defines INDEX_NAME, INDEX_ALIAS, and MAPPINGS", :aggregate_failures do - expect(described_class::INDEX_NAME).not_to be_nil - expect(described_class::INDEX_ALIAS).not_to be_nil - expect(described_class::MAPPINGS).not_to be_nil - end - - describe "::search_documents", elasticsearch: "User" do - let(:user1) { create(:user) } - let(:user2) { create(:user) } - - it "parses user document hits from search response" do - mock_search_response = { "hits" => { "hits" => {} } } - allow(described_class).to receive(:search) { mock_search_response } - described_class.search_documents(params: {}) - expect(described_class).to have_received(:search).with(body: a_kind_of(Hash)) - end - - context "with a query" do - it "searches by search_fields" do - allow(user1).to receive(:available_for).and_return("ruby") - allow(user2).to receive(:employer_name).and_return("Ruby Tuesday") - index_documents([user1, user2]) - query_params = { size: 5, search_fields: "ruby" } - - user_docs = described_class.search_documents(params: query_params) - expect(user_docs.count).to eq(2) - doc_ids = user_docs.map { |t| t["id"] } - expect(doc_ids).to include(user1.id, user2.id) - end - - it "searches by a username" do - allow(user1).to receive(:username).and_return("kyloren") - index_documents([user1]) - query_params = { size: 5, search_fields: "kyloren" } - - user_docs = described_class.search_documents(params: query_params) - expect(user_docs.count).to eq(1) - doc_ids = user_docs.map { |t| t["id"] } - expect(doc_ids).to include(user1.id) - end - end - - context "with a filter" do - it "searches by excluding roles" do - user1.add_role(:admin) - user2.add_role(:suspended) - index_documents([user1, user2]) - query_params = { size: 5, exclude_roles: %w[suspended banned] } - - user_docs = described_class.search_documents(params: query_params) - expect(user_docs.count).to eq(1) - doc_ids = user_docs.map { |t| t["id"] } - expect(doc_ids).to match_array([user1.id]) - end - end - end - - describe "::search_usernames", elasticsearch: "User" do - let(:user1) { create(:user, username: "star_wars_is_the_best") } - let(:user2) { create(:user, username: "star_trek_is_the_best") } - - before do - index_documents([user1, user2]) - end - - it "searches with username" do - usernames = described_class.search_usernames(user1.username) - expect(usernames.count).to eq(1) - expect(usernames.map { |u| u["username"] }).to match([user1.username]) - end - - it "analyzes wildcards" do - user3 = create(:user, username: "does_not_start_with_a_star") - index_documents([user3]) - - usernames = described_class.search_usernames("star*") - expect(usernames.map { |u| u["username"] }).to match( - [user1.username, user2.username], - ) - expect(usernames.map { |u| u["username"] }).not_to include(user3.username) - end - - it "does not allow leading wildcards" do - expect { described_class.search_usernames("*star") }.to raise_error(Search::Errors::Transport::BadRequest) - end - - it "limits the number of results to the value of MAX_RESULTS" do - max_results = 1 - stub_const("Search::Postgres::Username::MAX_RESULTS", max_results) - - usernames = described_class.search_usernames("star*") - expect(usernames.size).to eq(max_results) - expect([user1.username, user2.username]).to include(usernames.first["username"]) - end - end -end diff --git a/spec/services/users/delete_articles_spec.rb b/spec/services/users/delete_articles_spec.rb index dc2ff7156..60d4584f9 100644 --- a/spec/services/users/delete_articles_spec.rb +++ b/spec/services/users/delete_articles_spec.rb @@ -14,14 +14,6 @@ RSpec.describe Users::DeleteArticles, type: :service do expect(Article.find(article3.id)).to be_present end - it "removes article from Elasticsearch" do - sidekiq_perform_enqueued_jobs - expect(article.elasticsearch_doc).not_to be_nil - - sidekiq_perform_enqueued_jobs { described_class.call(user) } - expect { article.elasticsearch_doc }.to raise_error(Search::Errors::Transport::NotFound) - end - context "with comments" do before do allow(EdgeCache::BustComment).to receive(:call) @@ -42,18 +34,5 @@ RSpec.describe Users::DeleteArticles, type: :service do expect(EdgeCache::BustUser).to have_received(:call).with(user2).at_least(:once) expect(EdgeCache::BustArticle).to have_received(:call).with(article) end - - it "removes comments from Elasticsearch", :aggregate_failures do - sidekiq_perform_enqueued_jobs - comments = article.comments - comments.each { |comment| expect(comment.elasticsearch_doc).not_to be_nil } - sidekiq_perform_enqueued_jobs(only: Search::RemoveFromIndexWorker) do - described_class.call(user) - end - - comments.each do |comment| - expect { comment.elasticsearch_doc }.to raise_error(Search::Errors::Transport::NotFound) - end - end end end diff --git a/spec/services/users/delete_comments_spec.rb b/spec/services/users/delete_comments_spec.rb index 9d1090f8b..438b39618 100644 --- a/spec/services/users/delete_comments_spec.rb +++ b/spec/services/users/delete_comments_spec.rb @@ -19,14 +19,6 @@ RSpec.describe Users::DeleteComments, type: :service do expect(Comment.where(user_id: user.id).any?).to be false end - it "removes comments from Elasticsearch" do - comment - sidekiq_perform_enqueued_jobs - expect(comment.elasticsearch_doc).not_to be_nil - sidekiq_perform_enqueued_jobs { described_class.call(user) } - expect { comment.elasticsearch_doc }.to raise_error(Search::Errors::Transport::NotFound) - end - it "busts cache" do described_class.call(user) expect(EdgeCache::BustComment).to have_received(:call).with(article).at_least(:once) diff --git a/spec/services/users/delete_spec.rb b/spec/services/users/delete_spec.rb index 10e093cec..2f2f2a9e5 100644 --- a/spec/services/users/delete_spec.rb +++ b/spec/services/users/delete_spec.rb @@ -60,25 +60,6 @@ RSpec.describe Users::Delete, type: :service do expect(audit_log.reload.user_id).to be(nil) end - it "removes user from Elasticsearch" do - sidekiq_perform_enqueued_jobs { user } - expect(user.elasticsearch_doc).not_to be_nil - sidekiq_perform_enqueued_jobs do - described_class.call(user) - end - expect { user.elasticsearch_doc }.to raise_error(Search::Errors::Transport::NotFound) - end - - it "removes articles from Elasticsearch" do - article = create(:article, user: user) - sidekiq_perform_enqueued_jobs - expect(article.elasticsearch_doc).not_to be_nil - sidekiq_perform_enqueued_jobs do - described_class.call(user) - end - expect { article.elasticsearch_doc }.to raise_error(Search::Errors::Transport::NotFound) - end - it "deletes field tests memberships" do create(:field_test_membership, participant_id: user.id) diff --git a/spec/support/elasticsearch_helpers.rb b/spec/support/elasticsearch_helpers.rb deleted file mode 100644 index 128eb05b2..000000000 --- a/spec/support/elasticsearch_helpers.rb +++ /dev/null @@ -1,19 +0,0 @@ -# Only included in specs with elasticsearch: true -module ElasticsearchHelpers - def index_documents(resources) - index_documents_for_search_class(Array.wrap(resources), described_class) - end - - def index_documents_for_search_class(records, search_class) - records.each(&:index_to_elasticsearch_inline) - search_class.refresh_index - end - - def clear_elasticsearch_data(search_class) - search_class.refresh_index - Search::Client.delete_by_query( - index: search_class::INDEX_ALIAS, body: { query: { match_all: {} } }, - ) - search_class.refresh_index - end -end diff --git a/spec/support/initializers/vcr.rb b/spec/support/initializers/vcr.rb index 164bbae94..1802e6fe7 100644 --- a/spec/support/initializers/vcr.rb +++ b/spec/support/initializers/vcr.rb @@ -13,8 +13,7 @@ VCR.configure do |config| "github.com/mozilla/geckodriver/releases", "selenium-release.storage.googleapis.com", "developer.microsoft.com/en-us/microsoft-edge/tools/webdriver", - "api.knapsackpro.com", "localhost", "127.0.0.1", "0.0.0.0", - "elasticsearch" + "api.knapsackpro.com", "localhost", "127.0.0.1", "0.0.0.0" ) # Removes all private data (Basic Auth, Set-Cookie headers...) diff --git a/spec/system/articles/user_visits_articles_by_tag_spec.rb b/spec/system/articles/user_visits_articles_by_tag_spec.rb index 9ffd0d4ab..1547f2bc4 100644 --- a/spec/system/articles/user_visits_articles_by_tag_spec.rb +++ b/spec/system/articles/user_visits_articles_by_tag_spec.rb @@ -18,16 +18,16 @@ RSpec.describe "User visits articles by tag", type: :system do visit "/t/javascript" end - it "shows the header", js: true, stub_elasticsearch: true do + it "shows the header", js: true do within("h1") { expect(page).to have_text("javascript") } end - it "shows the follow button", js: true, stub_elasticsearch: true do + it "shows the follow button", js: true do within("header.tag-header") { expect(page).to have_button("Follow") } end # Regression test for https://github.com/forem/forem/pull/12724 - it "does not display a comment count of 0", js: true, stub_elasticsearch: true do + it "does not display a comment count of 0", js: true do expect(page).to have_text("Add Comment") expect(page).not_to have_text("0 comments") end @@ -62,7 +62,7 @@ RSpec.describe "User visits articles by tag", type: :system do visit "/t/functional" end - it "shows the following button", js: true, stub_elasticsearch: true do + it "shows the following button", js: true do wait_for_javascript within("header.tag-header") { expect(page).to have_button("Following") } diff --git a/spec/system/articles/user_visits_articles_by_timeframe_spec.rb b/spec/system/articles/user_visits_articles_by_timeframe_spec.rb index e0f84c18b..944ec8926 100644 --- a/spec/system/articles/user_visits_articles_by_timeframe_spec.rb +++ b/spec/system/articles/user_visits_articles_by_timeframe_spec.rb @@ -105,7 +105,7 @@ RSpec.describe "User visits articles by timeframe", type: :system do end end - context "when user has logged in", js: true, stub_elasticsearch: true do + context "when user has logged in", js: true do let(:user) { create(:user) } before do diff --git a/spec/system/comments/user_delete_a_comment_spec.rb b/spec/system/comments/user_delete_a_comment_spec.rb index 1758e88bd..0b3feb23a 100644 --- a/spec/system/comments/user_delete_a_comment_spec.rb +++ b/spec/system/comments/user_delete_a_comment_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -RSpec.describe "Deleting Comment", type: :system, js: true, elasticsearch: "FeedContent" do +RSpec.describe "Deleting Comment", type: :system, js: true do let(:user) { create(:user) } let(:raw_comment) { Faker::Lorem.paragraph } let(:article) do diff --git a/spec/system/homepage/user_visits_homepage_articles_spec.rb b/spec/system/homepage/user_visits_homepage_articles_spec.rb index b701d2526..a88967a5c 100644 --- a/spec/system/homepage/user_visits_homepage_articles_spec.rb +++ b/spec/system/homepage/user_visits_homepage_articles_spec.rb @@ -20,7 +20,7 @@ RSpec.describe "User visits a homepage", type: :system do end # Regression test for https://github.com/forem/forem/pull/12724 - it "does not display a comment count of 0", js: true, stub_elasticsearch: true do + it "does not display a comment count of 0", js: true do expect(page).to have_text("Add Comment") expect(page).not_to have_text("0 comments") article.update_column(:comments_count, 50) @@ -28,7 +28,7 @@ RSpec.describe "User visits a homepage", type: :system do expect(page).to have_text(/50\s*comments/) end - it "shows the main article readable date and time", js: true, stub_elasticsearch: true do + it "shows the main article readable date and time", js: true do expect(page).to have_selector(".crayons-story--featured time", text: published_date) selector = ".crayons-story--featured time[datetime='#{timestamp}']" expect(page).to have_selector(selector) @@ -48,7 +48,7 @@ RSpec.describe "User visits a homepage", type: :system do expect(page).to have_text(article2.title) end - it "shows all articles' dates and times", js: true, stub_elasticsearch: true do + it "shows all articles' dates and times", js: true do expect(page).to have_selector(".crayons-story time", text: published_date, count: 2) selector = ".crayons-story time[datetime='#{timestamp}']" expect(page).to have_selector(selector, count: 2) diff --git a/spec/system/homepage/user_visits_homepage_spec.rb b/spec/system/homepage/user_visits_homepage_spec.rb index d7fa07416..6832f5cf7 100644 --- a/spec/system/homepage/user_visits_homepage_spec.rb +++ b/spec/system/homepage/user_visits_homepage_spec.rb @@ -105,7 +105,7 @@ RSpec.describe "User visits a homepage", type: :system do end end - it "shows followed tags ordered by weight and name", js: true, elasticsearch: "FeedContent" do + it "shows followed tags ordered by weight and name", js: true do # Need to ensure the user data is loaded before doing any checks find("body")["data-user"] diff --git a/spec/system/homepage/user_visits_homepage_with_announcement_spec.rb b/spec/system/homepage/user_visits_homepage_with_announcement_spec.rb index a9cf6df17..d7ac189a9 100644 --- a/spec/system/homepage/user_visits_homepage_with_announcement_spec.rb +++ b/spec/system/homepage/user_visits_homepage_with_announcement_spec.rb @@ -13,7 +13,7 @@ def expect_no_broadcast_data(page) expect(page).not_to have_text("Hello, World!") end -RSpec.describe "User visits a homepage", stub_elasticsearch: true, type: :system do +RSpec.describe "User visits a homepage", type: :system do context "when user hasn't logged in" do context "with an active announcement" do before do diff --git a/spec/system/organization/user_views_an_organization_spec.rb b/spec/system/organization/user_views_an_organization_spec.rb index cffec23a0..0f6f3dc48 100644 --- a/spec/system/organization/user_views_an_organization_spec.rb +++ b/spec/system/organization/user_views_an_organization_spec.rb @@ -12,7 +12,7 @@ RSpec.describe "Organization index", type: :system do context "when 2 articles" do before { visit "/#{organization.slug}" } - it "shows the header", js: true, stub_elasticsearch: true do + it "shows the header", js: true do within("h1") { expect(page).to have_content(organization.name) } within("div.profile-header__actions") do expect(page).to have_button("Follow") @@ -36,7 +36,7 @@ RSpec.describe "Organization index", type: :system do end context "when more articles" do - it "visits ok", js: true, stub_elasticsearch: true do + it "visits ok", js: true do create_list(:article, 3, organization: organization) visit "/#{organization.slug}" end @@ -51,7 +51,7 @@ RSpec.describe "Organization index", type: :system do user.follows.create(followable: organization) end - it "shows the correct button", js: true, stub_elasticsearch: true do + it "shows the correct button", js: true do visit "/#{organization.slug}" within(".profile-header__actions") do diff --git a/spec/system/search/display_articles_search_spec.rb b/spec/system/search/display_articles_search_spec.rb index 9570e6afb..f8bfdc19c 100644 --- a/spec/system/search/display_articles_search_spec.rb +++ b/spec/system/search/display_articles_search_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -RSpec.describe "Display articles search spec", type: :system, js: true, elasticsearch: "FeedContent" do +RSpec.describe "Display articles search spec", type: :system, js: true do it "returns correct results for a search" do found_article_one = create(:article) found_article_one.update_columns(cached_tag_list: "ruby") diff --git a/spec/system/search/display_comments_search_spec.rb b/spec/system/search/display_comments_search_spec.rb index 72ff64037..37e850aea 100644 --- a/spec/system/search/display_comments_search_spec.rb +++ b/spec/system/search/display_comments_search_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -RSpec.describe "Display articles search spec", type: :system, js: true, elasticsearch: "FeedContent" do +RSpec.describe "Display articles search spec", type: :system, js: true do it "returns correct results for a search" do query = "XSS" create(:comment, body_markdown: query) diff --git a/spec/system/search/display_users_search_spec.rb b/spec/system/search/display_users_search_spec.rb index e531a1b0f..e113e1f54 100644 --- a/spec/system/search/display_users_search_spec.rb +++ b/spec/system/search/display_users_search_spec.rb @@ -1,14 +1,16 @@ require "rails_helper" -RSpec.describe "Display users search spec", type: :system, js: true, elasticsearch: "User" do +RSpec.describe "Display users search spec", type: :system, js: true do let(:current_user) { create(:user, username: "ironman", name: "Iron Man") } let(:found_user) { create(:user, username: "janedoe", name: "Jane Doe") } let(:found_two_user) { create(:user, username: "doejane", name: "Doe Jane") } let(:not_found_user) { create(:user, username: "batman", name: "Batman") } it "returns correct results for name search" do - users = [current_user, found_user, found_two_user, not_found_user] - index_documents_for_search_class(users, Search::User) + current_user + found_user + found_two_user + not_found_user visit "/search?q=jane&filters=class_name:User" expect(page).to have_content(found_user.name) @@ -18,8 +20,9 @@ RSpec.describe "Display users search spec", type: :system, js: true, elasticsear end it "returns all expected user fields" do + current_user + found_user sign_in current_user - index_documents_for_search_class([found_user], Search::User) visit "/search?q=jane&filters=class_name:User" expect(page).to have_content(found_user.name) diff --git a/spec/system/search/user_searches_users_spec.rb b/spec/system/search/user_searches_users_spec.rb index 06f4f459b..e6f6ef058 100644 --- a/spec/system/search/user_searches_users_spec.rb +++ b/spec/system/search/user_searches_users_spec.rb @@ -10,11 +10,10 @@ RSpec.describe "User searches users", type: :system do sign_in current_user current_user.follow(followed_user) follow_back_user.follow(current_user) - [current_user, followed_user, not_followed_user, follow_back_user].each(&:index_to_elasticsearch_inline) - Search::User.refresh_index + not_followed_user end - it "shows the correct follow buttons", js: true, elasticsearch: "User" do + it "shows the correct follow buttons", js: true do visit "/search?q=&filters=class_name:User" expect(JSON.parse(find_button("Edit profile")["data-info"])["id"]).to eq(current_user.id) diff --git a/spec/system/user/view_user_comments_spec.rb b/spec/system/user/view_user_comments_spec.rb index b64205868..c06710b68 100644 --- a/spec/system/user/view_user_comments_spec.rb +++ b/spec/system/user/view_user_comments_spec.rb @@ -15,7 +15,7 @@ RSpec.describe "User comments", type: :system do end end - it "shows user's comments", js: true, stub_elasticsearch: true do + it "shows user's comments", js: true do within("#substories div.profile-comment-card") do expect(page).to have_content("All 2 comments") expect(page).to have_link(nil, href: comment.path) @@ -25,7 +25,7 @@ RSpec.describe "User comments", type: :system do end context "when user has too many comments" do - it "show user's last comments ", js: true, stub_elasticsearch: true do + it "show user's last comments ", js: true do stub_const("CommentsHelper::MAX_COMMENTS_TO_RENDER", 1) visit "/user3000/comments" within("#substories div.profile-comment-card") do diff --git a/spec/system/user/view_user_index_spec.rb b/spec/system/user/view_user_index_spec.rb index cc7b94aff..fe3341bca 100644 --- a/spec/system/user/view_user_index_spec.rb +++ b/spec/system/user/view_user_index_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -RSpec.describe "User index", type: :system, stub_elasticsearch: true do +RSpec.describe "User index", type: :system do let!(:user) { create(:user) } let!(:article) { create(:article, user: user) } let!(:other_article) { create(:article) } diff --git a/spec/system/user_uses_the_editor_spec.rb b/spec/system/user_uses_the_editor_spec.rb index d0858f940..258ba89fe 100644 --- a/spec/system/user_uses_the_editor_spec.rb +++ b/spec/system/user_uses_the_editor_spec.rb @@ -93,7 +93,7 @@ RSpec.describe "Using the editor", type: :system do end end - describe "using v2 editor", js: true, stub_elasticsearch: true do + describe "using v2 editor", js: true do before { user.update(editor_version: "v2") } it "fill out form with rich content and click publish" do diff --git a/spec/workers/articles/score_calc_worker_spec.rb b/spec/workers/articles/score_calc_worker_spec.rb index e26edae93..7e72e1256 100644 --- a/spec/workers/articles/score_calc_worker_spec.rb +++ b/spec/workers/articles/score_calc_worker_spec.rb @@ -31,13 +31,6 @@ RSpec.describe Articles::ScoreCalcWorker, type: :worker do expect(article.hotness_score).to be(373) expect(article.spaminess_rating).to be(2) end - - it "indexes the article to Elasticsearch" do - allow(Article).to receive(:find_by).and_return(article) - allow(article).to receive(:index_to_elasticsearch_inline) - worker.perform(article.id) - expect(article).to have_received(:index_to_elasticsearch_inline).once - end end context "without article" do diff --git a/spec/workers/chat_channels/indexes_memberships_worker_spec.rb b/spec/workers/chat_channels/indexes_memberships_worker_spec.rb deleted file mode 100644 index e7d146877..000000000 --- a/spec/workers/chat_channels/indexes_memberships_worker_spec.rb +++ /dev/null @@ -1,18 +0,0 @@ -require "rails_helper" - -RSpec.describe ChatChannels::IndexesMembershipsWorker, type: :worker do - describe "#perform" do - let(:chat_channel) { create(:chat_channel) } - let(:chat_channel_membership) { create(:chat_channel_membership) } - - it "indexes chat channel memberships" do - allow(ChatChannel).to receive(:find).and_return(chat_channel) - allow(chat_channel).to receive(:chat_channel_memberships).and_return([chat_channel_membership]) - allow(chat_channel_membership).to receive(:index_to_elasticsearch) - - described_class.new.perform(chat_channel.id) - - expect(chat_channel_membership).to have_received(:index_to_elasticsearch) - end - end -end diff --git a/spec/workers/metrics/record_data_counts_worker_spec.rb b/spec/workers/metrics/record_data_counts_worker_spec.rb index 833cfa106..9d60d0523 100644 --- a/spec/workers/metrics/record_data_counts_worker_spec.rb +++ b/spec/workers/metrics/record_data_counts_worker_spec.rb @@ -28,14 +28,5 @@ RSpec.describe Metrics::RecordDataCountsWorker, type: :worker do ForemStatsClient, ).to have_received(:gauge).with("postgres.db_table_size", 0, tags: Array).at_least(1) end - - it "logs index counts in Datadog" do - allow(ForemStatsClient).to receive(:gauge) - described_class.new.perform - - expect( - ForemStatsClient, - ).to have_received(:gauge).with("elasticsearch.document_count", Integer, tags: Array).at_least(1) - end end end diff --git a/spec/workers/rating_votes/assign_rating_worker_spec.rb b/spec/workers/rating_votes/assign_rating_worker_spec.rb index fbb5d37e7..ff84e4694 100644 --- a/spec/workers/rating_votes/assign_rating_worker_spec.rb +++ b/spec/workers/rating_votes/assign_rating_worker_spec.rb @@ -37,12 +37,5 @@ RSpec.describe RatingVotes::AssignRatingWorker, type: :worker do expect(article.reload.experience_level_rating).to eq(2.5) expect(article.reload.experience_level_rating_distribution).to eq(3.0) end - - it "updates article in Elasticsearch" do - create(:rating_vote, article_id: article.id, user_id: user.id, rating: 4.0) - create(:rating_vote, article_id: article.id, user_id: second_user.id, rating: 1.0, context: "comment") - worker.perform(article.id) - expect(article.elasticsearch_doc.dig("_source", "experience_level_rating")).to eq(2.5) - end end end diff --git a/spec/workers/search/bulk_index_worker_spec.rb b/spec/workers/search/bulk_index_worker_spec.rb deleted file mode 100644 index 3db204bb5..000000000 --- a/spec/workers/search/bulk_index_worker_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -require "rails_helper" - -RSpec.describe Search::BulkIndexWorker, type: :worker do - let(:worker) { subject } - let(:article) { create(:article) } - - include_examples "#enqueues_on_correct_queue", "high_priority", ["Tag", 1] - - it "indexes documents for a set of given ids and object class", elasticsearch: "Tag" do - tags = [create(:tag), create(:tag), create(:tag)] - Sidekiq::Worker.clear_all - - tags.each do |tag| - expect { tag.elasticsearch_doc }.to raise_error(Search::Errors::Transport::NotFound) - end - worker.perform("Tag", tags.map(&:id)) - - tags.each do |tag| - expect(tag.elasticsearch_doc.dig("_source", "id")).to eql(tag.id) - end - end -end diff --git a/spec/workers/search/index_worker_spec.rb b/spec/workers/search/index_worker_spec.rb deleted file mode 100644 index ff8688d7a..000000000 --- a/spec/workers/search/index_worker_spec.rb +++ /dev/null @@ -1,19 +0,0 @@ -require "rails_helper" - -RSpec.describe Search::IndexWorker, type: :worker, elasticsearch: "Tag" do - let(:worker) { subject } - - include_examples "#enqueues_on_correct_queue", "high_priority", ["Tag", 1] - - it "indexes document" do - tag = FactoryBot.create(:tag) - expect { tag.elasticsearch_doc }.to raise_error(Search::Errors::Transport::NotFound) - worker.perform(tag.class.name, tag.id) - - expect(tag.elasticsearch_doc.dig("_source", "id")).to eql(tag.id) - end - - it "does not raise an error if record is not found" do - expect { worker.perform("Tag", 1234) }.not_to raise_error - end -end diff --git a/spec/workers/search/remove_from_index_worker_spec.rb b/spec/workers/search/remove_from_index_worker_spec.rb deleted file mode 100644 index 6b60e0545..000000000 --- a/spec/workers/search/remove_from_index_worker_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -require "rails_helper" - -RSpec.describe Search::RemoveFromIndexWorker, type: :worker do - let(:worker) { subject } - let(:search_class) { Search::Tag } - - include_examples "#enqueues_on_correct_queue", "medium_priority", ["SearchClass", 1] - - it "deletes document for given search class" do - allow(search_class).to receive(:delete_document) - described_class.new.perform(search_class.to_s, 1) - expect(search_class).to have_received(:delete_document).with(1) - end - - context "when document is not found" do - it "does not raise error" do - expect { described_class.new.perform(search_class.to_s, 1) }.not_to raise_error - end - end -end diff --git a/vendor/cache/elasticsearch-7.12.0.gem b/vendor/cache/elasticsearch-7.12.0.gem deleted file mode 100644 index 0e23fe4f1f729c50c6163ace06bfb27de50d01b3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13824 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 diff --git a/vendor/cache/elasticsearch-api-7.12.0.gem b/vendor/cache/elasticsearch-api-7.12.0.gem deleted file mode 100644 index e26a933c584f83f744681d6fb7059ce80d7a66af..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 90624 zcmeFVQ;;q|(GTj-sEa0Z?&P1@4ZlK^uJ3jdgW%CxMj)mO|H(crpM*J7G}bMCL%>bkzoPp)PK&; z?SkHuC|6IM8K}D@2PRAXdHfc<2jcAb0jEo!K33@3xn8q+f}OjbvDVw!?y>ctXd^LJ ztMF24j@l#BVKx>s<6P40G>O&NcC_99orwhdUD?qho3wboxEy`Cv|5lfYnN8%hz!Q4 zX)RqmHEWdpon=j*Y%em?y55E_{iv^?&L3c&ei7)i*?%*sdIGI)>d%pSg=Co zJDTl3rb!JWA}E++zf)n0du6VOf9rp6oVn{%mH96d4vlBG&62Mz%%rTq+~YY{(nxOqJ+IKS zem3>77g>?bE$>L=*Xh*i`FwsS{L2qk`M7@Lyz!iBFUR;%7BKx74|7}G@*{4n4gEQX zzxjMy=dXL{gEPd-oZnr6^g_yUv+6R&^DQ@BfFQNv-?moo`NSnmq}({+`q3 z2A+^j#Hq0qm|#^YL@wwtl1p9$i5>uLr+`gjum3IEuEq} zcAX^!%(k*WQ+oLPilD`igvK}@dD z`5ci>fkl!VJr|#x+7A(an3q`qyj>w7=wP)Mk@_p7 zU=5WR*-&s&$q*>DQ9)Tt?n^_W8%-Dwo|FMxReo7pY`DeBl8ZHM0eHK+;7cEhtb_Ns zhQ5S$wJ#no>-3qsA5j{*tk%xv8H`%0;^N|Ar62_aN8t>q$FyssyuU3c={-VltN>MU zRr(M#xWn!>0Jw`Nx?MKb%5}^Us0(&exYUjM5WI<3frAlfBe5WB^2eVO{6bs;ds>JIh(?Nfcr#lB+kzm*ZgAeKZRq4EQf+PZ%fnePPPrO$zLc*%n&X37PDmXpj-% z{7sw%uHroc3m z+iiEasC;x5D-2VSl`b`3kzK45*kCMLNkwFy27;jW5~=4_$DMOo$mkiUR2;ASJr;oT z84i}`Alz&^79WB!)`C+Ok1NonkrfCwDi*&Iy<>zb7eniY$^bWIsqoe)0cN9L{f7c& zH)Yw0X4=(toCOM@BI_dkbgeWf;(E;p8mTSMbrraDH!w?PlxX__j-sXHx9mu6V^gQM z8XAl}!sWEqu%pp90JcH5=`Lzv$$I8ecZ#^FAcm`p^ja>`nq`NZyo=kiy|!*CplyL` zq68kbHH0(CExSVlqw^xbe(#5N-`zoYw{!3Qp&Hb6tK=weIWK}#9U1}5Yt=s_#0x(U&=2ARjDhEKZJsPfEf<42*muD$Z)5aaE)YIfxvK zw^|sHLfD0p$xpFR0n{IFMMmN_Plh}Y$s8&Clg1yJH|9c|E1!HcMC~gFw98Migc>LV zb!|%TG9Rrw#5l!+(j8)Vno?ckI)jC1w9MF|5vMfD%RHKVi6!hMfr3VZwSQ9i4Ho~R zUH*IquM{Gm_csUWUWq5jd^;Hd`-MA3F+*4hw)o48eYZ~=rugh8(1r}?W`xwmfrJ=3 zP!&Hc6R9TJ!^DmT4Z2LE(@;`a!Jz6J>OVsX^Q}mMbBFqKsF5BdvH*e;BH)@m=@RGS zRZ_TIYW|@l4>VcA!5Ish;}$Fzs}$9c!a)QW+{3&i>3PdfvPVgFWT00ZC~m3zz`R9~ zka>A@QVIc8rNW3dFkcmt!;*hs2aBfa@dPR>ZpWBwGz<&y@a3&r!3`E1lf)Mas z#TkpgBZ$I*Y9$st5r`8&d^&P$upXCzPJvO2mm6G6AZ)2ZNrI;qjlrvi_+(%nX6yHq zKu3B4;M}c8@n@COPR+ia+~O^W9{C$(K2YvGa|C%1_&yD1|HHs(ue`^`z7K$%|LsR6 z6m;8q0NxLl^Yp=4s`aE1dz>@O&Kn4EVH?jr`kT-IwH)z=YpPs;;=DrPN`0#tN(J5D z?QL_qbl<~6F;-&7;;`QP24O~-1wpa-`)Z{>`2g{4Zg#t*PblAZ&aa@*dS3UfNW;hC z*x%Qrci~pdyz(5Mu}$qU(;6?9eCNQ|gXkaZSE5wpp8Iu!S+h8976yJM*B3_jkJ!rZpgt>5Tq⪙ngr#lgX-;$ftz`dJp+~hU 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

2lFe|O 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