docbrown/spec/models/article_spec.rb
Ben Halpern 071ec03a3c
Fix article description issues (#5679) [deploy]
* Fix article description issues

* Move tag info to decorator

* Fix description edge cases

* Fix description creation logic

* Fix test and adjust naming

* Modify line in processed_description
2020-01-26 14:43:11 -05:00

639 lines
24 KiB
Ruby

require "rails_helper"
RSpec.describe Article, type: :model do
def build_and_validate_article(*args)
article = build(:article, *args)
article.validate!
article
end
let_it_be(:user) { create(:user) }
let!(:article) { create(:article, user: user) }
include_examples "#sync_reactions_count", :article
describe "validations" do
it { is_expected.to validate_uniqueness_of(:canonical_url).allow_blank }
it { is_expected.to validate_uniqueness_of(:slug).scoped_to(:user_id) }
it { is_expected.to validate_uniqueness_of(:feed_source_url).allow_blank }
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_length_of(:title).is_at_most(128) }
it { is_expected.to validate_length_of(:cached_tag_list).is_at_most(126) }
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:organization).optional }
it { is_expected.to belong_to(:collection).optional.touch(true) }
it { is_expected.to have_many(:comments) }
it { is_expected.to have_many(:reactions).dependent(:destroy) }
it { is_expected.to have_many(:notifications).dependent(:delete_all) }
it { is_expected.to have_many(:notification_subscriptions).dependent(:destroy) }
it { is_expected.to validate_presence_of(:user_id) }
it { is_expected.not_to allow_value("foo").for(:main_image_background_hex_color) }
context "when published" do
before do
allow(subject).to receive(:published?).and_return(true) # rubocop:disable RSpec/NamedSubject
end
it { is_expected.to validate_presence_of(:slug) }
end
describe "#main_image_background_hex_color" do
it "must have true hex for image background" do
article.main_image_background_hex_color = "hello"
expect(article.valid?).to eq(false)
article.main_image_background_hex_color = "#fff000"
expect(article.valid?).to eq(true)
end
end
describe "#main_image" do
it "must have url for main image if present" do
article.main_image = "hello"
expect(article.valid?).to eq(false)
article.main_image = "https://image.com/image.png"
expect(article.valid?).to eq(true)
end
end
describe "dates" do
it "reject future dates" do
expect(build(:article, with_date: true, date: Date.tomorrow).valid?).to be(false)
end
it "reject future dates even when it's published at" do
article.published_at = Date.tomorrow
expect(article.valid?).to be(false)
end
end
describe "polls" do
let!(:poll) { create(:poll, article: article) }
it "does not allow the use of admin-only liquid tags for non-admins" do
article.body_markdown = "hello hey hey hey {% poll #{poll.id} %}"
expect(article.valid?).to eq(false)
end
it "allows admins" do
article.user.add_role(:admin)
article.body_markdown = "hello hey hey hey {% poll #{poll.id} %}"
expect(article.valid?).to eq(true)
end
end
describe "liquid tags" do
it "is not valid if it contains invalid liquid tags" do
body = "{% github /thepracticaldev/dev.to %}"
article = build(:article, body_markdown: body)
expect(article).not_to be_valid
expect(article.errors[:base]).to eq(["Invalid Github Repo link"])
end
it "is valid with valid liquid tags", :vcr do
VCR.use_cassette("twitter_gem") do
article = build_and_validate_article(with_tweet_tag: true)
expect(article).to be_valid
end
end
end
end
context "when data is extracted from evaluation of the front matter during validation" do
let!(:title) { "Talk About It, Justify It" }
let!(:slug) { "talk-about-it-justify-it" }
let!(:test_article) { build(:article, title: title) }
before { test_article.validate }
describe "#title" do
it "produces a proper title" do
expect(test_article.title).to eq(title)
end
end
describe "#slug" do
it "produces a proper slug similar to the title" do
expect(test_article.slug).to start_with(slug)
end
end
describe "#tag" do
it "parses tags" do
expect(test_article.tag_list.length.positive?).to be(true)
end
it "accepts an empty tag list and returns empty array" do
expect(build_and_validate_article(with_tags: false).tag_list).to be_empty
end
it "rejects more than 4 tags" do
five_tags = "one, two, three, four, five"
expect(build(:article, tags: five_tags).valid?).to be(false)
end
it "rejects tags with length > 30" do
tags = "'testing tag length with more than 30 chars', tag"
expect(build(:article, tags: tags).valid?).to be(false)
end
it "parses tags when description is empty" do
body_markdown = "---\ntitle: Title\npublished: false\ndescription:\ntags: one\n---\n\n"
expect(build_and_validate_article(body_markdown: body_markdown).tag_list).to eq(["one"])
end
end
describe "#description" do
it "creates proper description when description is present" do
body_markdown = "---\ntitle: Title\npublished: false\ndescription: hey hey hoho\ntags: one\n---\n\n"
expect(build_and_validate_article(body_markdown: body_markdown).description).to eq("hey hey hoho")
end
it "creates proper description when description is not present and body is present and short, with no tags" do
body_markdown = "---\ntitle: Title\npublished: false\ndescription:\ntags:\n---\n\nThis is the body yo"
expect(build_and_validate_article(body_markdown: body_markdown).description).to eq("This is the body yo")
end
it "creates proper description when description is not present and body is present and short" do
body_markdown = "---\ntitle: Title\npublished: false\ndescription:\ntags: heytag\n---\n\nThis is the body yo"
expect(build_and_validate_article(body_markdown: body_markdown).description).to eq("This is the body yo")
end
it "creates proper description when description is not present and body is present and long" do
paragraphs = Faker::Hipster.paragraph(sentence_count: 40)
body_markdown = "---\ntitle: Title\npublished: false\ndescription:\ntags:\n---\n\n#{paragraphs}"
expect(build_and_validate_article(body_markdown: body_markdown).description).to end_with("...")
end
end
describe "#canonical_url" do
let!(:article_with_canon_url) { build(:article, with_canonical_url: true) }
before do
article_with_canon_url.validate
end
it "parses does not assign canonical_url" do
expect(article.canonical_url).to eq(nil)
end
it "parses canonical_url if canonical_url is present" do
expect(article_with_canon_url.canonical_url).not_to be_nil
end
it "parses does not remove canonical_url" do
initial_link = article_with_canon_url.canonical_url
article_with_canon_url.body_markdown = build(:article).body_markdown
article_with_canon_url.validate
expect(article_with_canon_url.canonical_url).to eq(initial_link)
end
end
describe "#reading_time" do
it "produces a correct reading time" do
expect(test_article.reading_time).to eq(1)
end
end
describe "#processed_html" do
it "fixes the issue with --- hr tags" do
article = build_and_validate_article(with_hr_issue: true)
expect(article.processed_html.include?("<hr")).to be(true)
end
end
describe "#body_text" do
it "return a sanitized version of processed_html" do
sanitized_html = ActionView::Base.full_sanitizer.sanitize(test_article.processed_html)
expect(test_article.body_text).to eq(sanitized_html)
end
end
end
describe "#class_name" do
it "returns class name" do
expect(article.class_name).to eq("Article")
end
end
describe "#published_at" do
it "does not have a published_at if not published" do
unpublished_article = build(:article, published: false)
unpublished_article.validate # to make sure the front matter extraction happens
expect(unpublished_article.published_at).to be_nil
end
it "does have a published_at if published" do
# this works because validation triggers the extraction of the date from the front matter
article.validate
expect(article.published_at).not_to be_nil
end
end
describe "#nth_published_by_author" do
it "does not have a nth_published_by_author if not published" do
unpublished_article = build(:article, published: false)
unpublished_article.validate # to make sure the front matter extraction happens
expect(unpublished_article.nth_published_by_author).to eq(0)
end
it "does have a nth_published_by_author if published" do
# this works because validation triggers the extraction of the date from the front matter
published_article = create(:article, published: true, user: user)
expect(published_article.reload.nth_published_by_author).to eq(user.articles.size)
second_article = create(:article, user_id: published_article.user_id)
expect(second_article.reload.nth_published_by_author).to eq(user.articles.size)
end
it "adds have a nth_published_by_author if published" do
# this works because validation triggers the extraction of the date from the front matter
published_article = create(:article, published: true, user: user)
expect(published_article.reload.nth_published_by_author).to eq(user.articles.size)
second_article = create(:article, user_id: published_article.user_id)
second_article.update_column(:nth_published_by_author, 0)
second_article.save
expect(second_article.reload.nth_published_by_author).to eq(user.articles.size)
end
it "adds have a nth_published_by_author to earlier posts if added for first time" do
# this works because validation triggers the extraction of the date from the front matter
published_article = create(:article, published: true, user: user)
expect(published_article.reload.nth_published_by_author).to eq(user.articles.size)
create(:article, user_id: published_article.user_id)
published_article.update_column(:nth_published_by_author, 0)
published_article.save
expect(published_article.reload.nth_published_by_author).to eq(user.articles.size - 1)
end
end
describe "#crossposted_at" do
it "does not have crossposted_at if not published_from_feed" do
expect(article.crossposted_at).to be_nil
end
it "does have crossposted_at if not published_from_feed" do
article.update(published_from_feed: true)
expect(article.crossposted_at).not_to be_nil
end
end
describe "#featured_number" do
it "is updated if approved when already true" do
body = "---\ntitle: Hellohnnnn#{rand(1000)}\npublished: true\ntags: hiring\n---\n\nHello"
article.update(body_markdown: body, approved: true)
Timecop.travel(1.second.from_now) do
article.update(body_markdown: body + "s")
end
expect(article.featured_number).not_to eq(article.updated_at.to_i)
end
end
describe "#slug" do
let(:title) { "hey This' is$ a SLUG" }
let(:article0) { build(:article, title: title, published: false) }
let(:article1) { build(:article, title: title, published: false) }
before do
article0.validate!
end
context "when unpublished" do
it "creates proper slug with this-is-the-slug format" do
expect(article0.slug).to match(/(.*-){4,}/)
end
it "modifies slug on create if proposed slug already exists on the user" do
article1.validate
expect(article1.slug).not_to start_with(article0.slug)
end
it "properly converts underscores and still has a valid slug" do
underscored_article = build(:article, title: "hey_hey_hey node_modules", published: false)
expect(underscored_article.valid?).to eq true
end
end
context "when published" do
before { article0.update(published: true) }
it "creates proper slug with this-is-the-slug format" do
expect(article0.slug).to start_with("hey-this-is-a-slug")
end
it "does not change slug if the article was edited" do
article0.update(title: "New title.")
expect(article0.slug).to start_with("hey-this-is-a-slug")
end
it "properly converts underscores and still has a valid slug" do
underscored_article = build(:article, title: "hey_hey_hey node_modules", published: true)
expect(underscored_article.valid?).to eq true
end
end
end
describe "#username" do
it "returns the user's username" do
expect(article.username).to eq(user.username)
end
it "returns the organization slug if the article belongs to an organization" do
article.organization = build(:organization)
expect(article.username).to eq(article.organization.slug)
end
end
describe "#has_frontmatter?" do
it "returns true if the article has a frontmatter" do
body = "---\ntitle: Hellohnnnn#{rand(1000)}\npublished: true\ntags: hiring\n---\n\nHello"
article.body_markdown = body
expect(article.has_frontmatter?).to eq(true)
end
it "returns false if the article does not have a frontmatter" do
article.body_markdown = "Hey hey Ho Ho"
expect(article.has_frontmatter?).to eq(false)
end
end
describe "#readable_edit_date" do
it "returns nil if article is not edited" do
expect(article.readable_edit_date).to be_nil
end
it "does not show year in readable time if not current year" do
time_now = Time.current
article.edited_at = time_now
expect(article.readable_edit_date).to eq(time_now.strftime("%b %e"))
end
it "shows year in readable time if not current year" do
article.edited_at = 1.year.ago
last_year = 1.year.ago.year % 100
expect(article.readable_edit_date.include?("'#{last_year}")).to eq(true)
end
end
describe "#readable_publish_date" do
it "does not show year in readable time if not current year" do
time_now = Time.current
article.published_at = time_now
expect(article.readable_publish_date).to eq(time_now.strftime("%b %e"))
end
it "shows year in readable time if not current year" do
article.published_at = 1.year.ago
last_year = 1.year.ago.year % 100
expect(article.readable_publish_date.include?("'#{last_year}")).to eq(true)
end
end
describe "#published_timestamp" do
it "returns empty string if the article is not published" do
article.published = false
expect(article.published_timestamp).to be_empty
end
it "returns the timestamp of the crossposting date over the publishing date" do
crossposted_at = 1.week.ago
published_at = 1.day.ago
article.published = true
article.crossposted_at = crossposted_at
article.published_at = published_at
expect(article.published_timestamp).to eq(crossposted_at.utc.iso8601)
end
it "returns the timestamp of the publishing date if there is no crossposting date" do
published_at = 1.day.ago
article.published = true
article.crossposted_at = nil
article.published_at = published_at
expect(article.published_timestamp).to eq(published_at.utc.iso8601)
end
end
describe "#video" do
before do
user.created_at = 3.weeks.ago
article.video = "https://youtube.com"
end
it "is not valid with a non url" do
article.video = "hey"
expect(article).not_to be_valid
end
it "is not valid if the user is too recent" do
user.created_at = Time.current
expect(article).not_to be_valid
end
it "has padded video_duration_in_minutes" do
article.video_duration_in_seconds = 1141
expect(article.video_duration_in_minutes).to eq("19:01")
end
it "has correctly non-padded seconds in video_duration_in_minutes" do
article.video_duration_in_seconds = 1161
expect(article.video_duration_in_minutes).to eq("19:21")
end
it "has video_duration_in_minutes display hour when video is an hour or longer" do
article.video_duration_in_seconds = 3600
expect(article.video_duration_in_minutes).to eq("1:00:00")
end
it "has correctly non-padded minutes with hour in video_duration_in_minutes" do
article.video_duration_in_seconds = 5000
expect(article.video_duration_in_minutes).to eq("1:23:20")
end
end
describe "#index_id" do
it "is equal to articles-ID" do
# NOTE: we shouldn't test private things but cheating a bit for Algolia here
expect(article.send(:index_id)).to eq("articles-#{article.id}")
end
end
describe ".seo_boostable" do
let!(:top_article) do
create(:article, organic_page_views_past_month_count: 20, score: 30, tags: "good, greatalicious", user: user)
end
it "returns articles ordered by organic_page_views_count" do
articles = described_class.seo_boostable
expect(articles.first[0]).to eq(top_article.path)
end
it "returns articles if within time frame" do
articles = described_class.seo_boostable(nil, 1.month.ago)
expect(articles.first[0]).to eq(top_article.path)
end
it "does not return articles outside of timeframe" do
articles = described_class.seo_boostable(nil, 1.month.from_now)
expect(articles).to be_empty
end
it "returns articles ordered by organic_page_views_count by tag" do
articles = described_class.seo_boostable("greatalicious")
expect(articles.first[0]).to eq(top_article.path)
end
it "returns nothing if no tagged articles" do
articles = described_class.seo_boostable("godsdsdsdsgoo")
expect(articles).to be_empty
end
end
context "when indexing and deindexing" do
it "deindexes unpublished article from Article index" do
sidekiq_assert_enqueued_with(job: Search::RemoveFromIndexWorker, args: [described_class.algolia_index_name, article.id]) do
article.update(body_markdown: "---\ntitle: Title\npublished: false\ndescription:\ntags: one\n---\n\n")
end
end
it "deindexes unpublished article from searchables index" do
sidekiq_assert_enqueued_with(job: Search::RemoveFromIndexWorker, args: ["searchables_#{Rails.env}", article.index_id]) do
article.update(body_markdown: "---\ntitle: Title\npublished: false\ndescription:\ntags: one\n---\n\n")
end
end
it "deindexes unpublished article from ordered_articles index" do
sidekiq_assert_enqueued_with(job: Search::RemoveFromIndexWorker, args: ["ordered_articles_#{Rails.env}", article.index_id]) do
article.update(body_markdown: "---\ntitle: Title\npublished: false\ndescription:\ntags: one\n---\n\n")
end
end
it "deindexes hiring article from Article index" do
sidekiq_assert_enqueued_with(job: Search::RemoveFromIndexWorker, args: [described_class.algolia_index_name, article.id]) do
article.update(body_markdown: "---\ntitle: Title\npublished: true\ndescription:\ntags: hiring\n---\n\n")
end
end
it "deindexes hiring article from searchables index" do
sidekiq_assert_enqueued_with(job: Search::RemoveFromIndexWorker, args: ["searchables_#{Rails.env}", article.index_id]) do
article.update(body_markdown: "---\ntitle: Title\npublished: true\ndescription:\ntags: hiring\n---\n\n")
end
end
it "deindexes hiring article from ordered_articles index" do
sidekiq_assert_enqueued_with(job: Search::RemoveFromIndexWorker, args: ["ordered_articles_#{Rails.env}", article.index_id]) do
article.update(body_markdown: "---\ntitle: Title\npublished: true\ndescription:\ntags: hiring\n---\n\n")
end
end
it "indexes published non-hiring article" do
sidekiq_assert_enqueued_with(job: Search::IndexWorker, args: ["Article", article.id]) do
article.update(published: false)
end
end
it "triggers auto removal from index on destroy" do
allow(article).to receive(:remove_from_index!)
allow(article).to receive(:delete_related_objects)
article.destroy
expect(article).to have_received(:remove_from_index!)
expect(article).to have_received(:delete_related_objects)
end
end
context "when callbacks are triggered before save" do
it "assigns path on save" do
expect(article.path).to eq("/#{article.username}/#{article.slug}")
end
it "assigns cached_user_name on save" do
expect(article.cached_user_name).to eq(article.user_name)
end
it "assigns cached_user_username on save" do
expect(article.cached_user_username).to eq(article.user_username)
end
it "assigns cached_user on save" do
expect(article.cached_user.name).to eq(article.user.name)
expect(article.cached_user.username).to eq(article.user.username)
expect(article.cached_user.slug).to eq(article.user.username)
expect(article.cached_user.profile_image_90).to eq(article.user.profile_image_90)
expect(article.cached_user.profile_image_url).to eq(article.user.profile_image_url)
expect(article.cached_user.pro).to eq(article.user.pro?)
end
it "assigns cached_organization on save" do
article = create(:article, user: user, organization: create(:organization))
expect(article.cached_organization.name).to eq(article.organization.name)
expect(article.cached_organization.username).to eq(article.organization.username)
expect(article.cached_organization.slug).to eq(article.organization.slug)
expect(article.cached_organization.profile_image_90).to eq(article.organization.profile_image_90)
expect(article.cached_organization.profile_image_url).to eq(article.organization.profile_image_url)
expect(article.cached_organization.pro).to be(false)
end
end
context "when callbacks are triggered after save" do
describe "main image background color" do
let(:article) { build(:article, user: user) }
it "enqueues a job to update the main image background if #dddddd" do
article.main_image_background_hex_color = "#dddddd"
allow(article).to receive(:update_main_image_background_hex).and_call_original
assert_enqueued_with(job: Articles::UpdateMainImageBackgroundHexJob) do
article.save
end
expect(article).to have_received(:update_main_image_background_hex)
end
it "does not enqueue a job to update the main image background if not #dddddd" do
article.main_image_background_hex_color = "#fff000"
allow(article).to receive(:update_main_image_background_hex).and_call_original
assert_no_enqueued_jobs(only: Articles::UpdateMainImageBackgroundHexJob) do
article.save
end
expect(article).to have_received(:update_main_image_background_hex)
end
end
describe "async score calc" do
it "enqueues Articles::ScoreCalcJob if published" do
assert_enqueued_with(job: Articles::ScoreCalcJob) do
article.save
end
end
it "does not enqueue Articles::ScoreCalcJob if not published" do
article = build(:article, published: false)
assert_no_enqueued_jobs(only: Articles::ScoreCalcJob) do
article.save
end
end
end
describe "detect human language" do
it "calls the human language detector" do
article.language = ""
assert_enqueued_with(job: Articles::DetectHumanLanguageJob) do
article.save
end
end
it "does not call the human language detector if there is already a language" do
article.language = "en"
assert_no_enqueued_jobs(only: Articles::DetectHumanLanguageJob) do
article.save
end
end
end
end
describe ".feed" do
it "returns records with a subset of attributes" do
feed_article = described_class.feed.first
fields = %w[id tag_list published_at processed_html user_id organization_id title path]
expect(feed_article.attributes.keys).to match_array(fields)
end
end
end