FEATURE: topic title is validated for blocked words (#8127)

FEATURE: topic title is validated for blocked words (#8127)

Currently, the topic is only validated for censored words and should be validated for blocked words as well.

Blocked word validation is now used by both Post and Topic. To avoid code duplication, I extracted blocked words validation code into separate Validator, and use it in both places.

The only downside is that even if the topic contains blocked words validation message is saying “Your post contains a word that’s not allowed: tomato” but I think this is descriptive enough.

diff --git a/app/models/topic.rb b/app/models/topic.rb
index b99b9c7..208b639 100644
--- a/app/models/topic.rb
+++ b/app/models/topic.rb
@@ -74,6 +74,7 @@ class Topic < ActiveRecord::Base
                     presence: true,
                     topic_title_length: true,
                     censored_words: true,
+                    watched_words: true,
                     quality_title: { unless: :private_message? },
                     max_emojis: true,
                     unique_among: { unless: Proc.new { |t| (SiteSetting.allow_duplicate_topic_titles? || t.private_message?) },
diff --git a/lib/validators/post_validator.rb b/lib/validators/post_validator.rb
index 09c564b..dc391b6 100644
--- a/lib/validators/post_validator.rb
+++ b/lib/validators/post_validator.rb
@@ -36,7 +36,7 @@ class Validators::PostValidator < ActiveModel::Validator
     return if options[:skip_post_body] || post.topic&.pm_with_non_human_user?
     stripped_length(post)
     raw_quality(post)
-    watched_words(post)
+    WatchedWordsValidator.new(attributes: [:raw]).validate(post) if !post.acting_user&.staged
   end
 
   def stripped_length(post)
@@ -59,19 +59,6 @@ class Validators::PostValidator < ActiveModel::Validator
     post.errors.add(:raw, I18n.t(:is_invalid)) unless sentinel.valid?
   end
 
-  def watched_words(post)
-    if !post.acting_user&.staged && matches = WordWatcher.new(post.raw).should_block?.presence
-      if matches.size == 1
-        key = 'contains_blocked_word'
-        translation_args = { word: matches[0] }
-      else
-        key = 'contains_blocked_words'
-        translation_args = { words: matches.join(', ') }
-      end
-      post.errors.add(:base, I18n.t(key, translation_args))
-    end
-  end
-
   # Ensure maximum amount of mentions in a post
   def max_mention_validator(post)
     return if post.acting_user.try(:staff?)
diff --git a/lib/validators/watched_words_validator.rb b/lib/validators/watched_words_validator.rb
new file mode 100644
index 0000000..cfa7e5a
--- /dev/null
+++ b/lib/validators/watched_words_validator.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class WatchedWordsValidator < ActiveModel::EachValidator
+  def validate_each(record, attribute, value)
+    if matches = WordWatcher.new(value).should_block?.presence
+      if matches.size == 1
+        key = 'contains_blocked_word'
+        translation_args = { word: matches[0] }
+      else
+        key = 'contains_blocked_words'
+        translation_args = { words: matches.join(', ') }
+      end
+      record.errors.add(:base, I18n.t(key, translation_args))
+    end
+  end
+end
diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb
index e9da4a7..0c3f638 100644
--- a/spec/models/topic_spec.rb
+++ b/spec/models/topic_spec.rb
@@ -86,6 +86,22 @@ describe Topic do
           end
         end
       end
+
+      describe 'blocked words' do
+        describe 'when title contains watched words' do
+          it 'should not be valid' do
+            Fabricate(:watched_word, word: 'pineapple', action: WatchedWord.actions[:block])
+
+            topic.title = 'pen PinEapple apple pen is a complete sentence'
+
+            expect(topic).to_not be_valid
+
+            expect(topic.errors.full_messages.first).to include(I18n.t(
+              'contains_blocked_word', word: 'PinEapple'
+            ))
+          end
+        end
+      end
     end
   end

GitHub sha: f331b5ea