* Ensuring reading list excludes unpublished articles Prior to this commit, if a given user adds an article to their reading list then the author unpublishes the article, the article remains in the reading list. When the given user would then "click" on the now unpublished article, they would get a 404 Not Found notice. With this commit, we now exclude unpublished articles from the search results for the reading list. This should also address the issue of attempting to archive the reading list item (because it won't be visible in the listing). One of the behaviors that is expected is if, in the above scenario, the author again publishes the article, that article will again "appear" on the reading list for the given user. Closes #14796 * Updating documentation and adding spec * Addressing pull request feedback Yes, I should've used a scope! And also, no need to test present. Just let it's "truthy"-ness speak for itself! * Update spec/services/search/reading_list_spec.rb Co-authored-by: Jamie Gaskins <jamie@forem.com> Co-authored-by: Jamie Gaskins <jamie@forem.com>
362 lines
15 KiB
Ruby
362 lines
15 KiB
Ruby
require "rails_helper"
|
|
|
|
RSpec.describe Search::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) }
|
|
let(:unpublished_article) { create(:article) }
|
|
|
|
def extract_from_results(result, attribute)
|
|
result[:items].pluck(:reactable).pluck(attribute)
|
|
end
|
|
|
|
def count_of_published_readling_items_for(user:)
|
|
Article.published.where(id: user.reactions.readinglist.map(&:reactable_id)).count
|
|
end
|
|
|
|
def published_reading_list_item_for(user:)
|
|
user.reactions.readinglist.detect { |item| item.reactable.published? }
|
|
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(count_of_published_readling_items_for(user: user))
|
|
end
|
|
|
|
context "with an article added to a reading list then unpublished" do
|
|
before do
|
|
create(:reaction, user: user, reactable: unpublished_article, category: :readinglist, status: :valid)
|
|
unpublished_article.update_columns(published: false)
|
|
end
|
|
|
|
it "does not include the unpublished article" do
|
|
result = described_class.search_documents(user)
|
|
expect(extract_from_results(result, :path)).not_to include(unpublished_article.path)
|
|
end
|
|
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])
|
|
|
|
# The user's reading includes published and unpublished
|
|
# articles. We want to make sure that what we found in the
|
|
# search is on the user's reading list.
|
|
expect(user.reactions.readinglist.map(&:id)).to include(item[: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 = published_reading_list_item_for(user: user)
|
|
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 = published_reading_list_item_for(user: user)
|
|
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
|
|
|
|
it "does not match on partial tags", :aggregate_failures do
|
|
article_1.tag_list.add(:javascript)
|
|
article_1.save!
|
|
|
|
create(:reaction, reactable: article_1, user: user, category: :readinglist)
|
|
|
|
result = described_class.search_documents(user, tags: [:java])
|
|
expect(extract_from_results(result, :path)).to be_empty
|
|
|
|
result = described_class.search_documents(user, tags: [:asc])
|
|
expect(extract_from_results(result, :path)).to be_empty
|
|
|
|
result = described_class.search_documents(user, tags: [:script])
|
|
expect(extract_from_results(result, :path)).to be_empty
|
|
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
|
|
result = described_class.search_documents(user, term: article.user_name.first(3))
|
|
expect(extract_from_results(result, :path)).to include(article.path)
|
|
|
|
result = described_class.search_documents(user, term: "notaname")
|
|
expect(extract_from_results(result, :path)).to be_empty
|
|
end
|
|
|
|
it "matches against the article's user's username", :aggregate_failures do
|
|
result = described_class.search_documents(user, term: article.user_username.first(3))
|
|
expect(extract_from_results(result, :path)).to include(article.path)
|
|
|
|
result = described_class.search_documents(user, term: "notausername")
|
|
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(count_of_published_readling_items_for(user: user))
|
|
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
|