require "rails_helper" RSpec.describe Comment, type: :model do let(:user) { create(:user) } let(:article) { create(:article, user: user) } let(:comment) { create(:comment, user: user, commentable: article) } include_examples "#sync_reactions_count", :article_comment describe "validations" do subject { comment } describe "builtin validations" do it { is_expected.to belong_to(:user) } it { is_expected.to belong_to(:commentable).optional } it { is_expected.to have_many(:reactions).dependent(:destroy) } it { is_expected.to have_many(:mentions).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(:body_markdown) } it { is_expected.to validate_presence_of(:positive_reactions_count) } it { is_expected.to validate_presence_of(:public_reactions_count) } it { is_expected.to validate_presence_of(:reactions_count) } it { is_expected.to validate_presence_of(:user_id) } end it do # rubocop:disable RSpec/NamedSubject subject.commentable = article subject.user = user expect(subject).to( validate_uniqueness_of(:body_markdown).scoped_to(:user_id, :ancestry, :commentable_id, :commentable_type), ) # rubocop:enable RSpec/NamedSubject end it { is_expected.to validate_length_of(:body_markdown).is_at_least(1).is_at_most(25_000) } # rubocop:disable RSpec/NamedSubject describe "commentable" do it "is invalid if commentable is an unpublished article" do subject.commentable = build(:article, published: false) expect(subject).not_to be_valid end it "is invalid if commentable is an article and the discussion is locked" do subject.commentable = build(:article, :with_discussion_lock) expect(subject).not_to be_valid end it "is valid without a commentable" do subject.commentable = nil expect(subject).to be_valid end it "checks for commentable_id presence only if commentable_type is present" do subject.commentable = nil subject.commentable_type = "Article" expect(subject).not_to be_valid end it "checks for commentable_type inclusion only if commentable_id is present" do subject.commentable = nil subject.commentable_id = article.id expect(subject).not_to be_valid expect(subject.errors.messages[:commentable_type].first).to match(/not included in the list/) end it "is valid with Article commentable type" do subject.commentable_type = "Article" expect(subject).to be_valid end it "is valid with PodcastEpisode commentable type" do subject.commentable_type = "PodcastEpisode" expect(subject).to be_valid end it "is not valid with Podcast commentable type" do subject.commentable_type = "Podcast" expect(subject).not_to be_valid end end describe "#user_mentions_in_markdown" do before do stub_const("Comment::MAX_USER_MENTION_LIVE_AT", 1.day.ago) # Set live_at date to a time in the past end it "is valid with any number of mentions if created before MAX_USER_MENTION_LIVE_AT date" do # Explicitly set created_at date to a time before MAX_USER_MENTION_LIVE_AT subject.created_at = 3.days.ago subject.commentable_type = "Article" subject.body_markdown = "hi @#{user.username}! " * (Settings::RateLimit.mention_creation + 1) expect(subject).to be_valid end it "is valid with seven or fewer mentions if created after MAX_USER_MENTION_LIVE_AT date" do subject.commentable_type = "Article" subject.body_markdown = "hi @#{user.username}! " * Settings::RateLimit.mention_creation expect(subject).to be_valid end it "is invalid with more than seven mentions if created after MAX_USER_MENTION_LIVE_AT date" do subject.commentable_type = "Article" subject.body_markdown = "hi @#{user.username}! " * (Settings::RateLimit.mention_creation + 1) expect(subject).not_to be_valid expect(subject.errors[:base]) .to include("You cannot mention more than #{Settings::RateLimit.mention_creation} users in a comment!") end end # rubocop:enable RSpec/NamedSubject describe "#search_id" do it "returns comment_ID" do expect(comment.search_id).to eq("comment_#{comment.id}") end end describe "#processed_html" do let(:comment) { build(:comment, user: user, commentable: article) } it "converts body_markdown to proper processed_html" do comment.body_markdown = "# hello\n\nhy hey hey" comment.validate! expect(comment.processed_html.include?("

")).to be(true) end it "adds rel=nofollow to links" do comment.body_markdown = "this is a comment with a link: http://dev.to" comment.validate! expect(comment.processed_html.include?('rel="nofollow"')).to be(true) end it "adds a mention url if user is mentioned like @mention" do comment.body_markdown = "Hello @#{user.username}, you are cool." comment.validate! expect(comment.processed_html.include?("/#{user.username}")).to be(true) expect(comment.processed_html.include?("href")).to be(true) expect(comment.processed_html.include?("Hello ")).to be(true) long_text = "Does not strip out text without urls #{'x' * 200}#{'y' * 200}" comment.body_markdown = "[**#{long_text}**](#{long_url})" comment.validate! expect(comment.processed_html.include?("...")).to be(false) image_url = "https://i.picsum.photos/id/126/500/500.jpg?hmac=jNnQC44a_UR01TNuazfKROio0T_HaZVg0ikfR0d_xWY" comment.body_markdown = "Hello ![Alt-text](#{image_url})" comment.validate! expect(comment.processed_html.include?("4:30")).to be(true) comment.body_markdown = "I like the part at 4:30 and 5:50" comment.validate! expect(comment.processed_html.include?(">5:50")).to eq(true) comment.body_markdown = "I like the part at 5:30 and :55" comment.validate! expect(comment.processed_html.include?(">:55")).to eq(true) comment.body_markdown = "I like the part at 52:30" comment.validate! expect(comment.processed_html.include?(">52:30")).to eq(true) comment.body_markdown = "I like the part at 1:52:30 and 1:20" comment.validate! expect(comment.processed_html.include?(">1:52:30")).to eq(true) expect(comment.processed_html.include?(">1:20")).to eq(true) end # rubocop:enable RSpec/ExampleLength it "does not add timestamp if commentable does not have video" do article.video = nil comment.body_markdown = "I like the part at 1:52:30 and 1:20" comment.validate! expect(comment.processed_html.include?(">1:52:30")).to eq(false) end it "does not add DOCTYPE and html body to processed html" do comment.body_markdown = "Hello https://longurl.com/#{'x' * 100}?#{'y' * 100}" comment.validate! expect(comment.processed_html).not_to include("") end end end describe "#id_code_generated" do it "gets proper generated ID code" do expect(described_class.new(id: 1000).id_code_generated).to eq("1cc") end end describe "#readable_publish_date" do it "does not show year in readable time if not current year" do expect(comment.readable_publish_date).to eq(comment.created_at.strftime("%b %-e")) end it "shows year in readable time if not current year" do comment.created_at = 1.year.ago last_year = 1.year.ago.year % 100 expect(comment.readable_publish_date.include?("'#{last_year}")).to eq(true) end end describe "#path" do it "returns the properly formed path" do expect(comment.path).to eq("/#{comment.user.username}/comment/#{comment.id_code_generated}") end end describe "#parent_or_root_article" do it "returns root article if no parent comment" do expect(comment.parent_or_root_article).to eq(comment.commentable) end it "returns root parent comment if exists" do child_comment = build(:comment, parent: comment) expect(child_comment.parent_or_root_article).to eq(comment) end end describe "#parent_user" do it "returns the root article's user if no parent comment" do expect(comment.parent_user).to eq(user) end it "returns the root parent comment's user if root parent comment exists" do child_comment_user = build(:user) child_comment = build(:comment, parent: comment, user: child_comment_user) expect(child_comment.parent_user).not_to eq(child_comment_user) expect(child_comment.parent_user).to eq(comment.user) end end describe "#title" do it "is no more than 80 characters" do expect(comment.title.length).to be <= 80 end it "is allows title of greater length if passed" do expect(comment.title(5).length).to eq(5) end it "gets content from body_markdown" do comment.body_markdown = "Migas fingerstache pbr&b tofu." comment.validate! expect(comment.title).to eq("Migas fingerstache pbr&b tofu.") end it "is converted to deleted if the comment is deleted" do comment.deleted = true expect(comment.title).to eq("[deleted]") end it "does not contain the wrong encoding" do comment.body_markdown = "It's the best post ever. It's so great." comment.validate! expect(comment.title).not_to include("'") end end describe "#custom_css" do it "returns nothing when no liquid tag was used" do expect(comment.custom_css).to be_blank end it "returns proper liquid tag classes if used" do text = "{% devcomment #{comment.id_code_generated} %}" comment.body_markdown = text expect(comment.custom_css).to be_present end end describe ".tree_for" do let!(:other_comment) { create(:comment, commentable: article, user: user) } let!(:child_comment) { create(:comment, commentable: article, parent: comment, user: user) } before { comment.update_column(:score, 1) } it "returns a full tree" do comments = described_class.tree_for(article) expect(comments).to eq(comment => { child_comment => {} }, other_comment => {}) end it "returns part of the tree" do comments = described_class.tree_for(article, 1) expect(comments).to eq(comment => { child_comment => {} }) end end context "when callbacks are triggered after create" do let(:comment) { build(:comment, user: user, commentable: article) } it "creates an id code" do comment.save expect(comment.reload.id_code).to eq(comment.id.to_s(26)) end it "enqueue a worker to create the first reaction" do expect do comment.save end.to change(Comments::CreateFirstReactionWorker.jobs, :size).by(1) end it "enqueues a worker to calculate comment score" do expect do comment.save end.to change(Comments::CalculateScoreWorker.jobs, :size).by(1) end it "enqueues a worker to send email" do comment.save! child_comment_user = create(:user) child_comment = build(:comment, parent: comment, user: child_comment_user, commentable: article) expect do child_comment.save! end.to change(Comments::SendEmailNotificationWorker.jobs, :size).by(1) end it "enqueues a worker to bust comment cache" do expect do comment.save end.to change(Comments::BustCacheWorker.jobs, :size).by(1) end it "touches user updated_at" do user.updated_at = 1.month.ago user.save expect { comment.save }.to change(user, :updated_at) end it "touches user last_comment_at" do user.last_comment_at = 1.month.ago user.save expect { comment.save }.to change(user, :last_comment_at) end describe "slack messages" do let!(:user) { create(:user) } before do article comment # making sure there are no other enqueued jobs from other tests sidekiq_perform_enqueued_jobs(only: Slack::Messengers::Worker) end it "queues a slack message when a warned user leaves a comment" do user.add_role(:warned) sidekiq_assert_enqueued_jobs(1, only: Slack::Messengers::Worker) do create(:comment, user: user, commentable: article) end end it "does not send notification if a regular user leaves a comment" do sidekiq_assert_no_enqueued_jobs(only: Slack::Messengers::Worker) do create(:comment, commentable: article, user: user) end end end end describe "spam" do before do allow(Settings::General).to receive(:mascot_user_id).and_return(user.id) allow(Settings::RateLimit).to receive(:spam_trigger_terms).and_return(["yahoomagoo gogo", "anothertestterm"]) end it "creates vomit reaction if possible spam" do comment.body_markdown = "This post is about Yahoomagoo gogo" comment.save expect(Reaction.last.category).to eq("vomit") expect(Reaction.last.user_id).to eq(user.id) end it "does no suspend user if only single vomit" do comment.body_markdown = "This post is about Yahoomagoo gogo" comment.save expect(comment.user.suspended?).to be false end it "suspends user with 3 comment vomits" do comment.body_markdown = "This post is about Yahoomagoo gogo" second_comment = create(:comment, user: comment.user, body_markdown: "This post is about Yahoomagoo gogo") third_comment = create(:comment, user: comment.user, body_markdown: "This post is about Yahoomagoo gogo") comment.save second_comment.save third_comment.save expect(comment.user.suspended?).to be true expect(Note.last.reason).to eq "automatic_suspend" end it "does not create vomit reaction if user is established in this context" do user.update_column(:registered_at, 10.days.ago) comment.body_markdown = "This post is about Yahoomagoo gogo" comment.save expect(Reaction.last).to be nil end it "does not create vomit reaction if does not have matching title" do comment.save expect(Reaction.last).to be nil end end context "when callbacks are triggered before save" do context "when the post is present" do it "generates character count before saving" do comment.save expect(comment.markdown_character_count).to eq(comment.body_markdown.size) end end context "when the commentable is not present" do it "raises a validation error with message 'item has been deleted'", :aggregate_failures do comment = build(:comment, user: user, commentable: nil, commentable_type: nil) comment.validate expect(comment).not_to be_valid expect(comment.errors_as_sentence).to match("item has been deleted") end it "raises a validation error with commentable_type, if commentable_type is present", :aggregate_failures do comment = build(:comment, user: user, commentable: nil, commentable_type: "Article") comment.validate expect(comment).not_to be_valid expect(comment.errors_as_sentence).to match("Article has been deleted") end end end context "when callbacks are triggered after save" do it "updates user last comment date" do comment = build(:comment, commentable: article, user: user) expect { comment.save }.to change(user, :last_comment_at) end end context "when callbacks are triggered after update" do it "deletes the comment's notifications when deleted is set to true" do create(:notification, notifiable: comment, user: user) sidekiq_perform_enqueued_jobs do comment.update(deleted: true) end expect(comment.notifications).to be_empty end it "deletes the comment's notifications when hidden_by_commentable_user is set to true" do create(:notification, notifiable: comment, user: user) sidekiq_perform_enqueued_jobs do comment.update(hidden_by_commentable_user: true) end expect(comment.notifications).to be_empty end it "updates the notifications of the descendants with [deleted]" do comment = create(:comment, commentable: article) child_comment = create(:comment, parent: comment, commentable: article, user: user) create(:notification, notifiable: child_comment, user: user) sidekiq_perform_enqueued_jobs do comment.update(deleted: true) end notification = child_comment.notifications.first expect(notification.json_data["comment"]["ancestors"][0]["title"]).to eq("[deleted]") end end context "when callbacks are triggered after destroy" do let!(:comment) { create(:comment, user: user, commentable: article) } it "updates user's last_comment_at" do comment = create(:comment, user: user) expect { comment.destroy }.to change(user, :last_comment_at) end it "busts the comment cache" do sidekiq_assert_enqueued_with(job: Comments::BustCacheWorker, args: [comment.id]) do comment.destroy end end end describe "#root_exists?" do let(:root_comment) { create(:comment) } let(:comment) { create(:comment, ancestry: root_comment.id) } it "returns true if root is present" do expect(comment.root_exists?).to eq(true) end it "returns false if root has been deleted" do root_comment.destroy expect(comment.reload.root_exists?).to eq(false) end end end