docbrown/spec/models/article_spec.rb
Gonzalo Ricco 5d22cdef79
Prevent deadlocks with article-collection updates. (#6371) [deploy]
When an article is updated the associated collection is touched along with all its articles. This creates a deadlock where two transaction(nested) lock the article that was updated.
With this commit the collection wll be touched after the article updates transaction has been commited. Doing so we have two sequential transactions.
2020-03-02 09:38:15 -05:00

677 lines
25 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 }
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 "#canonical_url_must_not_have_spaces" do
let!(:article) { build :article, user: user }
it "is valid without spaces" do
valid_url = "https://www.positronx.io/angular-radio-buttons-example/"
article.canonical_url = valid_url
expect(article).to be_valid
end
it "is not valid with spaces" do
invalid_url = "https://www.positronx.io/angular radio-buttons-example/"
article.canonical_url = invalid_url
messages = ["must not have spaces"]
expect(article).not_to be_valid
expect(article.errors.messages[:canonical_url]).to eq messages
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_fetch_status") 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
it "returns true if parser raises a Psych::DisallowedClass error" do
allow(FrontMatterParser::Parser).to receive(:new).and_raise(Psych::DisallowedClass.new("msg"))
expect(article.has_frontmatter?).to eq(true)
end
it "returns true if parser raises a Psych::SyntaxError error" do
syntax_error = Psych::SyntaxError.new("file", 1, 1, 0, "problem", "context")
allow(FrontMatterParser::Parser).to receive(:new).and_raise(syntax_error)
expect(article.has_frontmatter?).to eq(true)
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
sidekiq_assert_enqueued_with(job: Articles::UpdateMainImageBackgroundHexWorker) 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
sidekiq_assert_no_enqueued_jobs(only: Articles::UpdateMainImageBackgroundHexWorker) do
article.save
end
expect(article).to have_received(:update_main_image_background_hex)
end
end
describe "async score calc" do
it "enqueues Articles::ScoreCalcWorker if published" do
sidekiq_assert_enqueued_with(job: Articles::ScoreCalcWorker, args: [article.id]) do
article.save
end
end
it "does not enqueue Articles::ScoreCalcWorker if not published" do
article = build(:article, published: false)
sidekiq_assert_no_enqueued_jobs(only: Articles::ScoreCalcWorker) do
article.save
end
end
end
describe "detect human language" do
let(:language_detector) { instance_double(LanguageDetector) }
before do
allow(LanguageDetector).to receive(:new).and_return(language_detector)
allow(language_detector).to receive(:detect)
end
it "calls the human language detector" do
article.language = ""
article.save
expect(language_detector).to have_received(:detect)
end
it "does not call the human language detector if there is already a language" do
article.language = "en"
article.save
expect(language_detector).not_to have_received(:detect)
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