docbrown/spec/services/search/postgres/reading_list_spec.rb
rhymes 83852038e4
[Search 2.0] Reading list (#13052)
* Step one in populating the reading list with PG

This first attempt tries to recycle the `Search::ArticleSerializer` which is only
used in input in ES, but we're using it in output in PG.
For this reason it's currently 15.55x times slower

* Serialize only what is requested by the frontend

`Search::ArticleSerializer` which is only used in ES in the indexing step aims
to add as much info as possible for broader purposes, in this case
(with serialization in output) we should aim to save only what's requested from
the frontend.

* Optimize selection of articles columns

* Select only needed columns for users

* Compute total of reading list items

* Attach the basic filtering based on PG on the search controller

* Restructure in methods

* Add tags support

* Use LIKE on articles.cached_tag_list

* Fix tags as nil

* Fix default pagination

* Add optional FTS for reading list

* Reworded the tags comment explaining why

* Add index to reactions.status

* Fix total counter in Preact readingList component

* Fix total count in reading list backend search

* Add GIN index to articles.cached_tag_list

* Add service tests

* Add search request specs

* Added missing early return

* Update spec/requests/search_spec.rb

Co-authored-by: Julianna Tetreault <32834804+juliannatetreault@users.noreply.github.com>

* Extract MAX_PER_PAGE constant and add comments

Co-authored-by: Julianna Tetreault <32834804+juliannatetreault@users.noreply.github.com>
2021-03-24 15:40:00 +01:00

327 lines
13 KiB
Ruby

require "rails_helper"
RSpec.describe Search::Postgres::ReadingList, type: :service do
let(:user) { create(:user) }
let(:article) { create(:article) }
let(:article_not_in_reading_list) { create(:article) }
let(:article_1) { create(:article, with_tags: false) }
let(:article_2) { create(:article, with_tags: false) }
def extract_from_results(result, attribute)
result[:items].pluck(:reactable).pluck(attribute)
end
describe "::search_documents" do
before do
create(:reaction, user: user, reactable: article, category: :readinglist, status: :valid)
end
it "returns an empty result without a user" do
expect(described_class.search_documents(nil)).to be_empty
end
it "does not include an article not in the reading list" do
result = described_class.search_documents(user)
expect(extract_from_results(result, :path)).not_to include(article_not_in_reading_list.path)
end
it "does not return an article belonging to another user's reading list" do
create(
:reaction,
reactable: article_not_in_reading_list,
category: :readinglist,
user: article_not_in_reading_list.user,
)
result = described_class.search_documents(user)
expect(extract_from_results(result, :path)).not_to include(article_not_in_reading_list.path)
end
it "returns results of articles in the reading list" do
result = described_class.search_documents(user)
expect(extract_from_results(result, :path)).to include(article.path)
end
it "returns the total count of the articles in the reading list" do
articles = create_list(:article, 2)
articles.each { |article| create(:reaction, category: :readinglist, reactable: article, user: user) }
result = described_class.search_documents(user)
expect(result[:total]).to eq(user.reactions.readinglist.count)
end
context "when describing the result format" do
let(:result) { described_class.search_documents(user) }
it "returns the correct attributes for the result", :aggregate_failures do
expect(result.keys).to match_array(%i[items total])
expect(result[:total]).to be_present
expect(result[:items]).to be_present
end
it "returns the correct attributes for a reading list item", :aggregate_failures do
item = result[:items].first
expect(item.keys).to match_array(%i[id user_id reactable])
expect(item[:id]).to eq(user.reactions.readinglist.first.id)
expect(item[:user_id]).to eq(user.id)
end
it "returns the correct attributes for a reading list item's reactable", :aggregate_failures do
item = result[:items].first
reactable = item[:reactable]
expect(reactable.keys).to match_array(
%i[
path readable_publish_date_string reading_time
tag_list tags title user
],
)
expect(reactable[:path]).to eq(article.path)
expect(reactable[:readable_publish_date_string]).to eq(article.readable_publish_date)
expect(reactable[:reading_time]).to eq(article.reading_time)
tags = article.cached_tag_list.to_s.split(", ")
expect(reactable[:tag_list]).to eq(tags)
expect(reactable[:tags]).to eq(tags.map { |tag| { name: tag } })
expect(reactable[:title]).to eq(article.title)
end
it "returns the correct attributes for a reading list item's reactable's user", :aggregate_failures do
item = result[:items].first
reactable = item[:reactable]
reactable_user = reactable[:user]
expect(reactable_user.keys).to eq(%i[name profile_image_90 username])
expect(reactable_user[:name]).to eq(article.user.name)
expect(reactable_user[:profile_image_90]).to eq(article.user.profile_image_90)
expect(reactable_user[:username]).to eq(article.user.username)
end
end
context "when filtering by statuses" do
it "returns confirmed items by default" do
item = user.reactions.readinglist.last
item.update_columns(status: :confirmed)
expect(described_class.search_documents(user)[:items].first[:id]).to eq(item.id)
end
it "returns valid items by default" do
item = user.reactions.readinglist.last
item.update_columns(status: :valid)
expect(described_class.search_documents(user)[:items].first[:id]).to eq(item.id)
end
it "selects archived items upon request", :aggregate_failures do
valid_item = user.reactions.readinglist.last
archived_item = create(:reaction, user: user, category: :readinglist, status: :archived)
# rubocop:disable Rails/PluckId
ids = described_class.search_documents(user, statuses: :archived)[:items].pluck(:id)
# rubocop:enable Rails/PluckId
expect(ids).to include(archived_item.id)
expect(ids).not_to include(valid_item.id)
end
end
context "when filtering by tags" do
after do
user.reactions.readinglist.delete_all
end
it "filters out items belonging to an article without the requested tag" do
article_1.tag_list.add(:beginners)
article_1.save!
create(:reaction, reactable: article_1, user: user, category: :readinglist)
result = described_class.search_documents(user, tags: [:foobar])
expect(extract_from_results(result, :path)).to be_empty
end
it "selects items belonging to an article with the requested tag" do
article_1.tag_list.add(:beginners)
article_1.save!
create(:reaction, reactable: article_1, user: user, category: :readinglist)
result = described_class.search_documents(user, tags: [:beginners])
expect(extract_from_results(result, :path)).to match_array([article_1.path])
end
it "selects items belonging to an article with all the requested tags", :aggregate_failures do
article_1.tag_list.add(:beginners)
article_1.tag_list.add(:ruby)
article_1.save!
article_2.tag_list.add(:beginners)
article_2.save!
create(:reaction, reactable: article_1, user: user, category: :readinglist)
create(:reaction, reactable: article_2, user: user, category: :readinglist)
result = described_class.search_documents(user, tags: %i[beginners ruby])
expect(extract_from_results(result, :path)).to include(article_1.path)
expect(extract_from_results(result, :path)).not_to include(article_2.path)
end
end
context "when filtering by statuses and tags" do
it "selects items with the requested statuses and articles tags", :aggregate_failures do
article_1.tag_list.add(:beginners)
article_2.tag_list.add(:beginners)
article_1.save!
article_2.save!
valid_item = create(:reaction, category: :readinglist, user: user, reactable: article_1, status: :valid)
archived_item = create(:reaction, category: :readinglist, user: user, reactable: article_2, status: :archived)
result = described_class.search_documents(user, statuses: %i[valid], tags: %i[beginners])
# rubocop:disable Rails/PluckId
item_ids = result[:items].pluck(:id)
# rubocop:enable Rails/PluckId
expect(item_ids).to include(valid_item.id)
expect(item_ids).not_to include(archived_item.id)
end
end
context "when searching for a term" do
it "matches against the article's body_markdown", :aggregate_failures do
article.update_columns(body_markdown: "Life of the party")
result = described_class.search_documents(user, term: "part")
expect(extract_from_results(result, :path)).to include(article.path)
result = described_class.search_documents(user, term: "fiesta")
expect(extract_from_results(result, :path)).to be_empty
end
it "matches against the article's title", :aggregate_failures do
article.update_columns(title: "Life of the party")
result = described_class.search_documents(user, term: "part")
expect(extract_from_results(result, :path)).to include(article.path)
result = described_class.search_documents(user, term: "fiesta")
expect(extract_from_results(result, :path)).to be_empty
end
it "matches against the article's tags", :aggregate_failures do
article.update_columns(cached_tag_list: "javascript, beginners, ruby")
result = described_class.search_documents(user, term: "beginner")
expect(extract_from_results(result, :path)).to include(article.path)
result = described_class.search_documents(user, term: "newbie")
expect(extract_from_results(result, :path)).to be_empty
end
it "matches against the article's organization's name", :aggregate_failures do
article.organization = create(:organization, name: "ACME corp")
article.save!
result = described_class.search_documents(user, term: "ACME")
expect(extract_from_results(result, :path)).to include(article.path)
result = described_class.search_documents(user, term: "ECMA")
expect(extract_from_results(result, :path)).to be_empty
end
it "matches against the article's user's name", :aggregate_failures do
article_user = article.user
article_user.update_columns(name: "Friday Sunday")
result = described_class.search_documents(user, term: "Frida")
expect(extract_from_results(result, :path)).to include(article.path)
result = described_class.search_documents(user, term: "Sat")
expect(extract_from_results(result, :path)).to be_empty
end
it "matches against the article's user's username", :aggregate_failures do
article_user = article.user
article_user.update_columns(username: "fridaysunday")
result = described_class.search_documents(user, term: "Frida")
expect(extract_from_results(result, :path)).to include(article.path)
result = described_class.search_documents(user, term: "Sat")
expect(extract_from_results(result, :path)).to be_empty
end
end
context "when searching for a term and filtering by statuses" do
it "selects items with the requested status belonging to articles matching the term", :aggregate_failures do
article.update_columns(body_markdown: "Life of the party")
result = described_class.search_documents(user, term: "part", statuses: %i[valid])
expect(extract_from_results(result, :path)).to include(article.path)
result = described_class.search_documents(user, term: "part", statuses: %i[archived])
expect(extract_from_results(result, :path)).to be_empty
result = described_class.search_documents(user, term: "fiesta", statuses: %i[valid])
expect(extract_from_results(result, :path)).to be_empty
end
end
context "when searching for a term and filtering by tags" do
it "selects items with the requested status belonging to articles matching the term", :aggregate_failures do
article.update_columns(body_markdown: "Life of the party", cached_tag_list: "javascript, beginners, ruby")
result = described_class.search_documents(user, term: "part", tags: %i[javascript])
expect(extract_from_results(result, :path)).to include(article.path)
result = described_class.search_documents(user, term: "part", tags: %i[python])
expect(extract_from_results(result, :path)).to be_empty
result = described_class.search_documents(user, term: "fiesta", tags: %i[javascript])
expect(extract_from_results(result, :path)).to be_empty
end
end
context "when searching for a term and filtering by statuses and tags" do
it "selects items with the requested status belonging to articles matching the term", :aggregate_failures do
article.update_columns(body_markdown: "Life of the party", cached_tag_list: "javascript, beginners, ruby")
result = described_class.search_documents(user, term: "part", statuses: %i[valid], tags: %i[javascript])
expect(extract_from_results(result, :path)).to include(article.path)
result = described_class.search_documents(user, term: "part", statuses: %i[archived], tags: %i[javascript])
expect(extract_from_results(result, :path)).to be_empty
result = described_class.search_documents(user, term: "part", statuses: %i[valid], tags: %i[python])
expect(extract_from_results(result, :path)).to be_empty
end
end
context "when paginating" do
let(:articles) { create_list(:article, 2) }
before do
articles.each { |article| create(:reaction, category: :readinglist, reactable: article, user: user) }
end
it "returns the total count of the articles pre-pagination" do
result = described_class.search_documents(user, page: 1, per_page: 1)
expect(result[:total]).to eq(user.reactions.readinglist.count)
end
it "returns no items when out of pagination bounds" do
result = described_class.search_documents(user, page: 99)
expect(result[:items]).to be_empty
end
it "returns paginated items", :aggregate_failures do
result = described_class.search_documents(user, page: 1, per_page: 1)
expect(result[:items].length).to eq(1)
result = described_class.search_documents(user, page: 2, per_page: 1)
expect(result[:items].length).to eq(1)
end
end
end
end