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) } 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 describe "#after_update_commit" do it "if article is unpublished removes reading list reactions from index" do reaction = create(:reaction, reactable: article, category: "readinglist") sidekiq_perform_enqueued_jobs expect(reaction.elasticsearch_doc).not_to be_nil unpublished_body = "---\ntitle: Hellohnnnn#{rand(1000)}\npublished: false\ntags: hiring\n---\n\nHello" article.update(body_markdown: unpublished_body) sidekiq_perform_enqueued_jobs expect { reaction.elasticsearch_doc }.to raise_error(Search::Errors::Transport::NotFound) end it "if article is published indexes reading list reactions" do reaction = create(:reaction, reactable: article, category: "readinglist") sidekiq_perform_enqueued_jobs unpublished_body = "---\ntitle: Hellohnnnn#{rand(1000)}\npublished: false\ntags: hiring\n---\n\nHello" article.update(body_markdown: unpublished_body) sidekiq_perform_enqueued_jobs expect { reaction.elasticsearch_doc }.to raise_error(Search::Errors::Transport::NotFound) published_body = "---\ntitle: Hellohnnnn#{rand(1000)}\npublished: true\ntags: hiring\n---\n\nHello" article.update(body_markdown: published_body) sidekiq_perform_enqueued_jobs expect(reaction.elasticsearch_doc).not_to be_nil end it "indexes reaction if a REACTION_INDEXED_FIELDS is changed" do reaction = create(:reaction, reactable: article, category: "readinglist") allow(article).to receive(:index_to_elasticsearch) allow(article.user).to receive(:index_to_elasticsearch) sidekiq_assert_enqueued_with(job: Search::IndexWorker, args: ["Reaction", reaction.id]) do article.update(body_markdown: "---\ntitle: NEW TITLE#{rand(1000)}\n") end end end context "when published" do before do # rubocop:disable RSpec/NamedSubject allow(subject).to receive(:published?).and_return(true) # rubocop:enable RSpec/NamedSubject end it { is_expected.to validate_presence_of(:slug) } end describe "#search_id" do it "returns article_ID" do expect(article.search_id).to eq("article_#{article.id}") end 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 describe "tag validation" do let(:article) { build(:article, user: user) } # See https://github.com/thepracticaldev/dev.to/pull/6302 # rubocop:disable RSpec/VerifiedDoubles it "does not modify the tag list if there are no adjustments" do allow(TagAdjustment).to receive(:where).and_return(TagAdjustment.none) allow(article).to receive(:tag_list).and_return(spy("tag_list")) article.save # We expect this to happen once in #evaluate_front_matter expect(article.tag_list).to have_received(:add).once expect(article.tag_list).not_to have_received(:remove) end # rubocop:enable RSpec/VerifiedDoubles 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?(" 10 end it "only includes root comments" do expect(article.top_comments).not_to include(child_comment) end it "doesn't include hidden comments" do expect(article.top_comments).not_to include(hidden_comment) end it "doesn't include deleted comments" do expect(article.top_comments).not_to include(deleted_comment) end end context "when article does not have any comments" do it "retrns empty set if there aren't any top comments" do expect(article.top_comments).to be_empty end 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 end