Instruction:
Write RSpec test file for following ruby class
```ruby
# frozen_string_literal: true

class WordWatcher
  REPLACEMENT_LETTER ||= CGI.unescape_html("■")
  CACHE_VERSION ||= 3

  def initialize(raw)
    @raw = raw
  end

  @cache_enabled = true

  def self.disable_cache
    @cache_enabled = false
  end

  def self.cache_enabled?
    @cache_enabled
  end

  def self.cache_key(action)
    "watched-words-list:v#{CACHE_VERSION}:#{action}"
  end

  def self.clear_cache!
    WatchedWord.actions.each { |action, _| Discourse.cache.delete(cache_key(action)) }
  end

  def self.words_for_action(action)
    WatchedWord
      .where(action: WatchedWord.actions[action.to_sym])
      .limit(WatchedWord::MAX_WORDS_PER_ACTION)
      .order(:id)
      .pluck(:word, :replacement, :case_sensitive)
      .to_h do |w, r, c|
        [
          word_to_regexp(w, match_word: false),
          { word: w, replacement: r, case_sensitive: c }.compact,
        ]
      end
  end

  def self.words_for_action_exist?(action)
    WatchedWord.where(action: WatchedWord.actions[action.to_sym]).exists?
  end

  def self.cached_words_for_action(action)
    if cache_enabled?
      Discourse
        .cache
        .fetch(cache_key(action), expires_in: 1.day) { words_for_action(action).presence }
    else
      words_for_action(action).presence
    end
  end

  def self.regexps_for_action(action, engine: :ruby)
    cached_words_for_action(action)&.to_h do |_, attrs|
      [word_to_regexp(attrs[:word], engine: engine), attrs]
    end
  end

  # This regexp is run in miniracer, and the client JS app
  # Make sure it is compatible with major browsers when changing
  # hint: non-chrome browsers do not support 'lookbehind'
  def self.compiled_regexps_for_action(action, engine: :ruby, raise_errors: false)
    words = cached_words_for_action(action)
    return [] if words.blank?

    words
      .values
      .group_by { |attrs| attrs[:case_sensitive] ? :case_sensitive : :case_insensitive }
      .map do |group_key, attrs_list|
        words = attrs_list.map { |attrs| attrs[:word] }

        # Compile all watched words into a single regular expression
        regexp =
          words
            .map do |word|
              r = word_to_regexp(word, match_word: SiteSetting.watched_words_regular_expressions?)
              begin
                r if Regexp.new(r)
              rescue RegexpError
                raise if raise_errors
              end
            end
            .select { |r| r.present? }
            .join("|")

        # Add word boundaries to the regexp for regular watched words
        regexp =
          match_word_regexp(
            regexp,
            engine: engine,
          ) if !SiteSetting.watched_words_regular_expressions?

        # Add case insensitive flag if needed
        Regexp.new(regexp, group_key == :case_sensitive ? nil : Regexp::IGNORECASE)
      end
  end

  def self.serialized_regexps_for_action(action, engine: :ruby)
    compiled_regexps_for_action(action, engine: engine).map do |r|
      { r.source => { case_sensitive: !r.casefold? } }
    end
  end

  def self.word_to_regexp(word, engine: :ruby, match_word: true)
    if SiteSetting.watched_words_regular_expressions?
      regexp = word
      regexp = "(#{regexp})" if match_word
      regexp
    else
      # Convert word to regex by escaping special characters in a regexp.
      # Avoid using Regexp.escape because it escapes more characters than
      # it should (for example, whitespaces, dashes, etc)
      regexp = word.gsub(/([.*+?^${}()|\[\]\\])/, '\\\\\1')

      # Convert wildcards to regexp
      regexp = regexp.gsub("\\*", '\S*')

      regexp = match_word_regexp(regexp, engine: engine) if match_word
      regexp
    end
  end

  def self.censor(html)
    regexps = compiled_regexps_for_action(:censor)
    return html if regexps.blank?

    doc = Nokogiri::HTML5.fragment(html)
    doc.traverse do |node|
      regexps.each do |regexp|
        node.content = censor_text_with_regexp(node.content, regexp) if node.text?
      end
    end

    doc.to_s
  end

  def self.censor_text(text)
    return text if text.blank?

    regexps = compiled_regexps_for_action(:censor)
    return text if regexps.blank?

    regexps.inject(text) { |txt, regexp| censor_text_with_regexp(txt, regexp) }
  end

  def self.replace_text(text)
    return text if text.blank?
    replace(text, :replace)
  end

  def self.replace_link(text)
    return text if text.blank?
    replace(text, :link)
  end

  def self.apply_to_text(text)
    text = censor_text(text)
    text = replace_text(text)
    text = replace_link(text)
    text
  end

  def requires_approval?
    word_matches_for_action?(:require_approval)
  end

  def should_flag?
    word_matches_for_action?(:flag)
  end

  def should_block?
    word_matches_for_action?(:block, all_matches: true)
  end

  def should_silence?
    word_matches_for_action?(:silence)
  end

  def word_matches_for_action?(action, all_matches: false)
    regexps = self.class.compiled_regexps_for_action(action)
    return if regexps.blank?

    match_list = []
    regexps.each do |regexp|
      match = regexp.match(@raw)

      if !all_matches
        return match if match
        next
      end

      next if !match

      if SiteSetting.watched_words_regular_expressions?
        set = Set.new
        @raw
          .scan(regexp)
          .each do |m|
            if Array === m
              set.add(m.find(&:present?))
            elsif String === m
              set.add(m)
            end
          end

        matches = set.to_a
      else
        matches = @raw.scan(regexp)
        matches.flatten!
      end

      match_list.concat(matches)
    end

    return if match_list.blank?

    match_list.compact!
    match_list.uniq!
    match_list.sort!
    match_list
  end

  def word_matches?(word, case_sensitive: false)
    options = case_sensitive ? nil : Regexp::IGNORECASE
    Regexp.new(WordWatcher.word_to_regexp(word), options).match?(@raw)
  end

  def self.replace_text_with_regexp(text, regexp, replacement)
    text.gsub(regexp) do |match|
      prefix = ""
      # match may be prefixed with a non-word character from the non-capturing group
      # Ensure this isn't replaced if watched words regular expression is disabled.
      if !SiteSetting.watched_words_regular_expressions? && (match[0] =~ /\W/) != nil
        prefix = "#{match[0]}"
      end

      "#{prefix}#{replacement}"
    end
  end

  private_class_method :replace_text_with_regexp

  def self.censor_text_with_regexp(text, regexp)
    text.gsub(regexp) do |match|
      # the regex captures leading whitespaces
      padding = match.size - match.lstrip.size
      if padding > 0
        match[0..padding - 1] + REPLACEMENT_LETTER * (match.size - padding)
      else
        REPLACEMENT_LETTER * match.size
      end
    end
  end

  private_class_method :censor_text_with_regexp

  # Returns a regexp that transforms a regular expression into a regular
  # expression that matches a whole word.
  def self.match_word_regexp(regexp, engine: :ruby)
    if engine == :js
      "(?:\\P{L}|^)(#{regexp})(?=\\P{L}|$)"
    elsif engine == :ruby
      "(?:[^[:word:]]|^)(#{regexp})(?=[^[:word:]]|$)"
    else
      raise "unknown regexp engine: #{engine}"
    end
  end

  private_class_method :match_word_regexp

  def self.replace(text, watch_word_type)
    regexps_for_action(watch_word_type)
      .to_a
      .reduce(text) do |t, (word_regexp, attrs)|
        case_flag = attrs[:case_sensitive] ? nil : Regexp::IGNORECASE
        replace_text_with_regexp(t, Regexp.new(word_regexp, case_flag), attrs[:replacement])
      end
  end

  private_class_method :replace
end

```

Response:
# frozen_string_literal: true

RSpec.describe WordWatcher do
  let(:raw) { <<~RAW.strip }
      Do you like liquorice?

      I really like them. One could even say that I am *addicted* to liquorice. And if
      you can mix it up with some anise, then I'm in heaven ;)
    RAW

  after { Discourse.redis.flushdb }

  describe ".words_for_action" do
    it "returns words with metadata including case sensitivity flag" do
      Fabricate(:watched_word, action: WatchedWord.actions[:censor])
      word1 = Fabricate(:watched_word, action: WatchedWord.actions[:block]).word
      word2 =
        Fabricate(:watched_word, action: WatchedWord.actions[:block], case_sensitive: true).word

      expect(described_class.words_for_action(:block)).to include(
        word1 => {
          case_sensitive: false,
          word: word1,
        },
        word2 => {
          case_sensitive: true,
          word: word2,
        },
      )
    end

    it "returns word with metadata including replacement if word has replacement" do
      word =
        Fabricate(
          :watched_word,
          action: WatchedWord.actions[:link],
          replacement: "http://test.localhost/",
        ).word

      expect(described_class.words_for_action(:link)).to include(
        word => {
          case_sensitive: false,
          replacement: "http://test.localhost/",
          word: word,
        },
      )
    end

    it "returns an empty hash when no words are present" do
      expect(described_class.words_for_action(:tag)).to eq({})
    end
  end

  describe ".compiled_regexps_for_action" do
    let!(:word1) { Fabricate(:watched_word, action: WatchedWord.actions[:block]).word }
    let!(:word2) { Fabricate(:watched_word, action: WatchedWord.actions[:block]).word }
    let!(:word3) do
      Fabricate(:watched_word, action: WatchedWord.actions[:block], case_sensitive: true).word
    end
    let!(:word4) do
      Fabricate(:watched_word, action: WatchedWord.actions[:block], case_sensitive: true).word
    end

    context "when watched_words_regular_expressions = true" do
      it "returns the proper regexp" do
        SiteSetting.watched_words_regular_expressions = true
        regexps = described_class.compiled_regexps_for_action(:block)

        expect(regexps).to be_an(Array)
        expect(regexps.map(&:inspect)).to contain_exactly(
          "/(#{word1})|(#{word2})/i",
          "/(#{word3})|(#{word4})/",
        )
      end
    end

    context "when watched_words_regular_expressions = false" do
      it "returns the proper regexp" do
        SiteSetting.watched_words_regular_expressions = false
        regexps = described_class.compiled_regexps_for_action(:block)

        expect(regexps).to be_an(Array)
        expect(regexps.map(&:inspect)).to contain_exactly(
          "/(?:[^[:word:]]|^)(#{word1}|#{word2})(?=[^[:word:]]|$)/i",
          "/(?:[^[:word:]]|^)(#{word3}|#{word4})(?=[^[:word:]]|$)/",
        )
      end

      it "is empty for an action without watched words" do
        regexps = described_class.compiled_regexps_for_action(:censor)

        expect(regexps).to be_an(Array)
        expect(regexps).to be_empty
      end
    end

    context "when regular expression is invalid" do
      before do
        SiteSetting.watched_words_regular_expressions = true
        Fabricate(:watched_word, word: "Test[\S*", action: WatchedWord.actions[:block])
      end

      it "does not raise an exception by default" do
        expect { described_class.compiled_regexps_for_action(:block) }.not_to raise_error
        expect(described_class.compiled_regexps_for_action(:block)).to contain_exactly(
          /(#{word1})|(#{word2})/i,
          /(#{word3})|(#{word4})/,
        )
      end

      it "raises an exception with raise_errors set to true" do
        expect {
          described_class.compiled_regexps_for_action(:block, raise_errors: true)
        }.to raise_error(RegexpError)
      end
    end
  end

  describe "#word_matches_for_action?" do
    it "is falsey when there are no watched words" do
      expect(described_class.new(raw).word_matches_for_action?(:require_approval)).to be_falsey
    end

    context "with watched words" do
      fab!(:anise) do
        Fabricate(:watched_word, word: "anise", action: WatchedWord.actions[:require_approval])
      end

      it "is falsey without a match" do
        expect(
          described_class.new("No liquorice for me, thanks...").word_matches_for_action?(
            :require_approval,
          ),
        ).to be_falsey
      end

      it "is returns matched words if there's a match" do
        matches = described_class.new(raw).word_matches_for_action?(:require_approval)
        expect(matches).to be_truthy
        expect(matches[1]).to eq(anise.word)
      end

      it "finds at start of string" do
        matches =
          described_class.new("#{anise.word} is garbage").word_matches_for_action?(
            :require_approval,
          )
        expect(matches[1]).to eq(anise.word)
      end

      it "finds at end of string" do
        matches =
          described_class.new("who likes #{anise.word}").word_matches_for_action?(:require_approval)
        expect(matches[1]).to eq(anise.word)
      end

      it "finds non-letters in place of letters" do
        Fabricate(:watched_word, word: "co(onut", action: WatchedWord.actions[:require_approval])

        matches =
          described_class.new("This co(onut is delicious.").word_matches_for_action?(
            :require_approval,
          )
        expect(matches[1]).to eq("co(onut")
      end

      it "handles * for wildcards" do
        Fabricate(:watched_word, word: "a**le*", action: WatchedWord.actions[:require_approval])

        matches =
          described_class.new("I acknowledge you.").word_matches_for_action?(:require_approval)
        expect(matches[1]).to eq("acknowledge")
      end

      it "handles word boundary" do
        Fabricate(:watched_word, word: "love", action: WatchedWord.actions[:require_approval])
        expect(
          described_class.new("I Love, bananas.").word_matches_for_action?(:require_approval)[1],
        ).to eq("Love")
        expect(
          described_class.new("I LOVE; apples.").word_matches_for_action?(:require_approval)[1],
        ).to eq("LOVE")
        expect(
          described_class.new("love: is a thing.").word_matches_for_action?(:require_approval)[1],
        ).to eq("love")
        expect(
          described_class.new("I love. oranges").word_matches_for_action?(:require_approval)[1],
        ).to eq("love")
        expect(
          described_class.new("I :love. pineapples").word_matches_for_action?(:require_approval)[1],
        ).to eq("love")
        expect(
          described_class.new("peace ,love and understanding.").word_matches_for_action?(
            :require_approval,
          )[
            1
          ],
        ).to eq("love")
      end

      context "when there are multiple matches" do
        context "with non regexp words" do
          it "lists all matching words" do
            %w[bananas hate hates].each do |word|
              Fabricate(:watched_word, word: word, action: WatchedWord.actions[:block])
            end

            matches =
              described_class.new("I hate bananas").word_matches_for_action?(
                :block,
                all_matches: true,
              )
            expect(matches).to contain_exactly("hate", "bananas")

            matches =
              described_class.new("She hates bananas too").word_matches_for_action?(
                :block,
                all_matches: true,
              )
            expect(matches).to contain_exactly("hates", "bananas")
          end
        end

        context "with regexp words" do
          before { SiteSetting.watched_words_regular_expressions = true }

          it "lists all matching patterns" do
            Fabricate(:watched_word, word: "(pine)?apples", action: WatchedWord.actions[:block])
            Fabricate(
              :watched_word,
              word: "((move|store)(d)?)|((watch|listen)(ed|ing)?)",
              action: WatchedWord.actions[:block],
            )

            matches =
              described_class.new("pine pineapples apples").word_matches_for_action?(
                :block,
                all_matches: true,
              )
            expect(matches).to contain_exactly("pineapples", "apples")

            matches =
              described_class.new(
                "go watched watch ed ing move d moveed moved moving",
              ).word_matches_for_action?(:block, all_matches: true)
            expect(matches).to contain_exactly(*%w[watched watch move moved])
          end
        end
      end

      context "when word is an emoji" do
        it "handles emoji" do
          Fabricate(:watched_word, word: ":joy:", action: WatchedWord.actions[:require_approval])

          matches =
            described_class.new("Lots of emojis here :joy:").word_matches_for_action?(
              :require_approval,
            )
          expect(matches[1]).to eq(":joy:")
        end

        it "handles unicode emoji" do
          Fabricate(:watched_word, word: "🎃", action: WatchedWord.actions[:require_approval])

          matches =
            described_class.new("Halloween party! 🎃").word_matches_for_action?(:require_approval)
          expect(matches[1]).to eq("🎃")
        end

        it "handles emoji skin tone" do
          Fabricate(
            :watched_word,
            word: ":woman:t5:",
            action: WatchedWord.actions[:require_approval],
          )

          matches =
            described_class.new("To Infinity and beyond! 🚀 :woman:t5:").word_matches_for_action?(
              :require_approval,
            )
          expect(matches[1]).to eq(":woman:t5:")
        end
      end

      context "when word is a regular expression" do
        before { SiteSetting.watched_words_regular_expressions = true }

        it "supports regular expressions on word boundaries" do
          Fabricate(:watched_word, word: /\btest\b/, action: WatchedWord.actions[:block])

          matches = described_class.new("this is not a test.").word_matches_for_action?(:block)
          expect(matches[0]).to eq("test")
        end

        it "supports regular expressions as a site setting" do
          Fabricate(
            :watched_word,
            word: /tro[uo]+t/,
            action: WatchedWord.actions[:require_approval],
          )

          matches =
            described_class.new("Evil Trout is cool").word_matches_for_action?(:require_approval)
          expect(matches[0]).to eq("Trout")

          matches =
            described_class.new("Evil Troot is cool").word_matches_for_action?(:require_approval)
          expect(matches[0]).to eq("Troot")

          matches = described_class.new("trooooooooot").word_matches_for_action?(:require_approval)
          expect(matches[0]).to eq("trooooooooot")
        end

        it "support uppercase" do
          Fabricate(:watched_word, word: /a\S+ce/, action: WatchedWord.actions[:require_approval])

          matches = described_class.new("Amazing place").word_matches_for_action?(:require_approval)
          expect(matches).to be_nil

          matches =
            described_class.new("Amazing applesauce").word_matches_for_action?(:require_approval)
          expect(matches[0]).to eq("applesauce")

          matches =
            described_class.new("Amazing AppleSauce").word_matches_for_action?(:require_approval)
          expect(matches[0]).to eq("AppleSauce")
        end
      end

      context "when case sensitive words are present" do
        before do
          Fabricate(
            :watched_word,
            word: "Discourse",
            action: WatchedWord.actions[:block],
            case_sensitive: true,
          )
        end

        context "when watched_words_regular_expressions = true" do
          it "respects case sensitivity flag in matching words" do
            SiteSetting.watched_words_regular_expressions = true
            Fabricate(:watched_word, word: "p(rivate|ublic)", action: WatchedWord.actions[:block])

            matches =
              described_class.new(
                "PUBLIC: Discourse is great for public discourse",
              ).word_matches_for_action?(:block, all_matches: true)
            expect(matches).to contain_exactly("PUBLIC", "Discourse", "public")
          end
        end

        context "when watched_words_regular_expressions = false" do
          it "repects case sensitivity flag in matching" do
            SiteSetting.watched_words_regular_expressions = false
            Fabricate(:watched_word, word: "private", action: WatchedWord.actions[:block])

            matches =
              described_class.new(
                "PRIVATE: Discourse is also great private discourse",
              ).word_matches_for_action?(:block, all_matches: true)

            expect(matches).to contain_exactly("PRIVATE", "Discourse", "private")
          end
        end
      end
    end
  end

  describe "word replacement" do
    fab!(:censored_word) do
      Fabricate(:watched_word, word: "censored", action: WatchedWord.actions[:censor])
    end
    fab!(:replaced_word) do
      Fabricate(
        :watched_word,
        word: "to replace",
        replacement: "replaced",
        action: WatchedWord.actions[:replace],
      )
    end
    fab!(:link_word) do
      Fabricate(
        :watched_word,
        word: "https://notdiscourse.org",
        replacement: "https://discourse.org",
        action: WatchedWord.actions[:link],
      )
    end

    it "censors text" do
      expect(described_class.censor_text("a censored word")).to eq(
        "a #{described_class::REPLACEMENT_LETTER * 8} word",
      )
    end

    it "replaces text" do
      expect(described_class.replace_text("a word to replace meow")).to eq("a word replaced meow")
    end

    it "replaces links" do
      expect(described_class.replace_link("please visit https://notdiscourse.org meow")).to eq(
        "please visit https://discourse.org meow",
      )
    end

    describe ".apply_to_text" do
      it "replaces all types of words" do
        text = "hello censored world to replace https://notdiscourse.org"
        expected =
          "hello #{described_class::REPLACEMENT_LETTER * 8} world replaced https://discourse.org"
        expect(described_class.apply_to_text(text)).to eq(expected)
      end

      context "when watched_words_regular_expressions = true" do
        it "replaces captured non-word prefix" do
          SiteSetting.watched_words_regular_expressions = true
          Fabricate(
            :watched_word,
            word: "\\Wplaceholder",
            replacement: "replacement",
            action: WatchedWord.actions[:replace],
          )

          text = "is \tplaceholder in https://notdiscourse.org"
          expected = "is replacement in https://discourse.org"
          expect(described_class.apply_to_text(text)).to eq(expected)
        end
      end

      context "when watched_words_regular_expressions = false" do
        it "maintains non-word character prefix" do
          SiteSetting.watched_words_regular_expressions = false

          text = "to replace and\thttps://notdiscourse.org"
          expected = "replaced and\thttps://discourse.org"
          expect(described_class.apply_to_text(text)).to eq(expected)
        end
      end
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# frozen_string_literal: true

class UsernameCheckerService
  def initialize(allow_reserved_username: false)
    @allow_reserved_username = allow_reserved_username
  end

  def check_username(username, email)
    if username && username.length > 0
      validator = UsernameValidator.new(username)
      if !validator.valid_format?
        { errors: validator.errors }
      else
        check_username_availability(username, email)
      end
    end
  end

  def check_username_availability(username, email)
    available =
      User.username_available?(username, email, allow_reserved_username: @allow_reserved_username)

    if available
      { available: true, is_developer: is_developer?(email) }
    else
      { available: false, suggestion: UserNameSuggester.suggest(username) }
    end
  end

  def is_developer?(value)
    Rails.configuration.respond_to?(:developer_emails) &&
      Rails.configuration.developer_emails.include?(value)
  end

  def self.is_developer?(email)
    UsernameCheckerService.new.is_developer?(email)
  end
end

```

Response:
# frozen_string_literal: true

RSpec.describe UsernameCheckerService do
  describe "#check_username" do
    before do
      @service = UsernameCheckerService.new
      @nil_email = nil
      @email = "vincentvega@example.com"
    end

    context "when username is invalid" do
      it "rejects too short usernames" do
        result = @service.check_username("a", @nil_email)
        expect(result).to have_key(:errors)
      end

      it "rejects too long usernames" do
        result = @service.check_username("a123456789b123456789c123456789", @nil_email)
        expect(result).to have_key(:errors)
      end

      it "rejects usernames with invalid characters" do
        result = @service.check_username("vincent-", @nil_email)
        expect(result).to have_key(:errors)
      end

      it "rejects usernames that do not start with an alphanumeric character" do
        result = @service.check_username(".vincent", @nil_email)
        expect(result).to have_key(:errors)
      end

      context "with reserved usernames" do
        before { SiteSetting.reserved_usernames = "test|donkey" }

        it "rejects usernames that are reserved" do
          result = @service.check_username("test", @nil_email)
          expect(result[:available]).to eq(false)
        end

        it "allows reserved username checker to be skipped" do
          @service = UsernameCheckerService.new(allow_reserved_username: true)
          result = @service.check_username("test", @nil_email)
          expect(result[:available]).to eq(true)
        end
      end
    end

    it "username not available locally" do
      User.stubs(:username_available?).returns(false)
      UserNameSuggester.stubs(:suggest).returns("einar-j")
      result = @service.check_username("vincent", @nil_email)
      expect(result[:available]).to eq(false)
      expect(result[:suggestion]).to eq("einar-j")
    end

    it "username available locally" do
      User.stubs(:username_available?).returns(true)
      result = @service.check_username("vincent", @nil_email)
      expect(result[:available]).to eq(true)
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# frozen_string_literal: true

class HashtagAutocompleteService
  HASHTAGS_PER_REQUEST = 20
  SEARCH_MAX_LIMIT = 50
  DEFAULT_DATA_SOURCES = [CategoryHashtagDataSource, TagHashtagDataSource]
  DEFAULT_CONTEXTUAL_TYPE_PRIORITIES = [
    { type: "category", context: "topic-composer", priority: 100 },
    { type: "tag", context: "topic-composer", priority: 50 },
  ]

  def self.search_conditions
    @search_conditions ||= Enum.new(contains: 0, starts_with: 1)
  end

  attr_reader :guardian

  # NOTE: This is not meant to be called directly; use `enabled_data_sources`
  # or the individual data_source_X methods instead.
  def self.data_sources
    # Category and Tag data sources are in core and always should be
    # included for searches and lookups.
    Set.new(DEFAULT_DATA_SOURCES | DiscoursePluginRegistry.hashtag_autocomplete_data_sources)
  end

  def self.contextual_type_priorities
    # Category and Tag type priorities for the composer are default and
    # always are included.
    Set.new(
      DEFAULT_CONTEXTUAL_TYPE_PRIORITIES |
        DiscoursePluginRegistry.hashtag_autocomplete_contextual_type_priorities,
    )
  end

  def self.enabled_data_sources
    self.data_sources.filter(&:enabled?)
  end

  def self.data_source_types
    self.enabled_data_sources.map(&:type)
  end

  def self.data_source_icon_map
    self.enabled_data_sources.map { |ds| [ds.type, ds.icon] }.to_h
  end

  def self.data_source_from_type(type)
    self.enabled_data_sources.find { |ds| ds.type == type }
  end

  def self.find_priorities_for_context(context)
    contextual_type_priorities.select { |ctp| ctp[:context] == context }
  end

  def self.unique_contexts
    contextual_type_priorities.map { |ctp| ctp[:context] }.uniq
  end

  def self.ordered_types_for_context(context)
    find_priorities_for_context(context)
      .sort_by { |ctp| -ctp[:priority] }
      .map { |ctp| ctp[:type] }
      .reject { |type| data_source_types.exclude?(type) }
  end

  def self.contexts_with_ordered_types
    Hash[unique_contexts.map { |context| [context, ordered_types_for_context(context)] }]
  end

  class HashtagItem
    # The text to display in the UI autocomplete menu for the item.
    attr_accessor :text

    # Some items may want to display extra text in the UI styled differently, e.g. tag topic counts.
    attr_accessor :secondary_text

    # The description text to display in the UI autocomplete menu on hover.
    # This will be things like e.g. category description.
    attr_accessor :description

    # Canonical slug for the item. Different from the ref, which can
    # have the type as a suffix to distinguish between conflicts.
    attr_accessor :slug

    # The icon to display in the UI autocomplete menu for the item.
    attr_accessor :icon

    # Distinguishes between different entities e.g. tag, category.
    attr_accessor :type

    # Inserted into the textbox when an autocomplete item is selected,
    # and must be unique so it can be used for lookups via the #lookup
    # method above.
    attr_accessor :ref

    # The relative URL for the resource that is represented by the autocomplete
    # item, used for the cooked hashtags, e.g. /c/2/staff
    attr_accessor :relative_url

    # The ID of the resource that is represented by the autocomplete item,
    # e.g. category.id, tag.id
    attr_accessor :id

    def initialize(params = {})
      @relative_url = params[:relative_url]
      @text = params[:text]
      @description = params[:description]
      @icon = params[:icon]
      @type = params[:type]
      @ref = params[:ref]
      @slug = params[:slug]
      @id = params[:id]
    end

    def to_h
      {
        relative_url: self.relative_url,
        text: self.text,
        description: self.description,
        icon: self.icon,
        type: self.type,
        ref: self.ref,
        slug: self.slug,
        id: self.id,
      }
    end
  end

  def initialize(guardian)
    @guardian = guardian
  end

  ##
  # Finds resources of the provided types by their exact slugs, unlike
  # search which can search partial names, slugs, etc. Used for cooking
  # fully formed #hashtags in the markdown pipeline. The @guardian handles
  # permissions around which results should be returned here.
  #
  # @param {Array} slugs The fully formed slugs to look up, which can have
  #                      ::type suffixes attached as well (e.g. ::category),
  #                      and in the case of categories can have parent:child
  #                      relationships.
  # @param {Array} types_in_priority_order The resource types we are looking up
  #                                        and the priority order in which we should
  #                                        match them if they do not have type suffixes.
  # @returns {Hash} A hash with the types as keys and an array of HashtagItem that
  #                 matches the provided slugs.
  def lookup(slugs, types_in_priority_order)
    raise Discourse::InvalidParameters.new(:slugs) if !slugs.is_a?(Array)
    raise Discourse::InvalidParameters.new(:order) if !types_in_priority_order.is_a?(Array)

    types_in_priority_order =
      types_in_priority_order.select do |type|
        HashtagAutocompleteService.data_source_types.include?(type)
      end
    lookup_results = Hash[types_in_priority_order.collect { |type| [type.to_sym, []] }]
    limited_slugs = slugs[0..HashtagAutocompleteService::HASHTAGS_PER_REQUEST]

    slugs_without_suffixes =
      limited_slugs.reject do |slug|
        HashtagAutocompleteService.data_source_types.any? { |type| slug.ends_with?("::#{type}") }
      end
    slugs_with_suffixes = (limited_slugs - slugs_without_suffixes)

    # For all the slugs without a type suffix, we need to lookup in order, falling
    # back to the next type if no results are returned for a slug for the current
    # type. This way slugs without suffix make sense in context, e.g. in the topic
    # composer we want a slug without a suffix to be a category first, tag second.
    if slugs_without_suffixes.any?
      types_in_priority_order.each do |type|
        # We do not want to continue fallback if there are conflicting slugs where
        # one has a type and one does not, this may result in duplication. An
        # example:
        #
        # A category with slug `management` is not found because of permissions
        # and we also have a slug with suffix in the form of `management::tag`.
        # There is a tag that exists with the `management` slug. The tag should
        # not be found here but rather in the next lookup since it's got a more
        # specific lookup with the type.
        slugs_to_lookup =
          slugs_without_suffixes.reject { |slug| slugs_with_suffixes.include?("#{slug}::#{type}") }
        found_from_slugs = execute_lookup!(lookup_results, type, guardian, slugs_to_lookup)

        slugs_without_suffixes = slugs_without_suffixes - found_from_slugs.map(&:ref)
        break if slugs_without_suffixes.empty?
      end
    end

    # We then look up the remaining slugs based on their type suffix, stripping out
    # the type suffix first since it will not match the actual slug.
    if slugs_with_suffixes.any?
      types_in_priority_order.each do |type|
        slugs_for_type =
          slugs_with_suffixes
            .select { |slug| slug.ends_with?("::#{type}") }
            .map { |slug| slug.gsub("::#{type}", "") }
        next if slugs_for_type.empty?
        execute_lookup!(lookup_results, type, guardian, slugs_for_type)

        # Make sure the refs are the same going out as they were going in.
        lookup_results[type.to_sym].each do |item|
          item.ref = "#{item.ref}::#{type}" if slugs_with_suffixes.include?("#{item.ref}::#{type}")
        end
      end
    end

    lookup_results
  end

  ##
  # Searches registered hashtag data sources using the provided term (data
  # sources determine what is actually searched) and prioritises the results
  # based on types_in_priority_order and the limit. For example, if 5 categories
  # were returned for the term and the limit was 5, we would not even bother
  # searching tags. The @guardian handles permissions around which results should
  # be returned here.
  #
  # Items which have a slug that exactly matches the search term via lookup will be found
  # first and floated to the top of the results, and still be ordered by type.
  #
  # @param {String} term Search term, from the UI generally where the user is typing #has...
  # @param {Array} types_in_priority_order The resource types we are searching for
  #                                        and the priority order in which we should
  #                                        return them.
  # @param {Integer} limit The maximum number of search results to return, we don't
  #                        bother searching subsequent types if the first types in
  #                        the array already reach the limit.
  # @returns {Array} The results as HashtagItems
  def search(
    term,
    types_in_priority_order,
    limit: SiteSetting.experimental_hashtag_search_result_limit
  )
    raise Discourse::InvalidParameters.new(:order) if !types_in_priority_order.is_a?(Array)
    limit = [limit, SEARCH_MAX_LIMIT].min
    types_in_priority_order =
      types_in_priority_order.select do |type|
        HashtagAutocompleteService.data_source_types.include?(type)
      end

    return search_without_term(types_in_priority_order, limit) if term.blank?

    limited_results = []
    top_ranked_type = nil
    term = term.downcase

    # Float exact matches by slug to the top of the list, any of these will be excluded
    # from further results.
    types_in_priority_order.each do |type|
      search_results = execute_lookup!(nil, type, guardian, [term])
      limited_results.concat(search_results) if search_results
      break if limited_results.length >= limit
    end

    # Next priority are slugs which start with the search term.
    if limited_results.length < limit
      types_in_priority_order.each do |type|
        limited_results =
          search_using_condition(
            limited_results,
            term,
            type,
            limit,
            HashtagAutocompleteService.search_conditions[:starts_with],
          )
        top_ranked_type = type if top_ranked_type.nil?
        break if limited_results.length >= limit
      end
    end

    # Search the data source for each type, validate and sort results,
    # and break off from searching more data sources if we reach our limit
    if limited_results.length < limit
      types_in_priority_order.each do |type|
        limited_results =
          search_using_condition(
            limited_results,
            term,
            type,
            limit,
            HashtagAutocompleteService.search_conditions[:contains],
          )
        top_ranked_type = type if top_ranked_type.nil?
        break if limited_results.length >= limit
      end
    end

    # Any items that are _not_ the top-ranked type (which could possibly not be
    # the same as the first item in the types_in_priority_order if there was
    # no data for that type) that have conflicting slugs with other items for
    # other higher-ranked types need to have a ::type suffix added to their ref.
    #
    # This will be used for the lookup method above if one of these items is
    # chosen in the UI, otherwise there is no way to determine whether a hashtag is
    # for a category or a tag etc.
    #
    # For example, if there is a category with the slug #general and a tag
    # with the slug #general, then the tag will have its ref changed to #general::tag
    append_types_to_conflicts(limited_results, top_ranked_type, types_in_priority_order, limit)
  end

  private

  def search_using_condition(limited_results, term, type, limit, condition)
    search_results =
      search_for_type(type, guardian, term, limit - limited_results.length, condition)
    return limited_results if search_results.empty?

    search_results =
      HashtagAutocompleteService.data_source_from_type(type).search_sort(
        search_results.reject do |item|
          limited_results.any? { |exact| exact.type == type && exact.slug === item.slug }
        end,
        term,
      )

    limited_results.concat(search_results)
  end

  def search_without_term(types_in_priority_order, limit)
    split_limit = (limit.to_f / types_in_priority_order.length.to_f).ceil
    limited_results = []

    types_in_priority_order.each do |type|
      search_results =
        filter_valid_data_items(
          HashtagAutocompleteService.data_source_from_type(type).search_without_term(
            guardian,
            split_limit,
          ),
        )
      next if search_results.empty?

      # This is purposefully unsorted as search_without_term should sort
      # in its own way.
      limited_results.concat(set_types(set_refs(search_results), type))
    end

    limited_results.take(limit)
  end

  # Sometimes a specific ref is required, e.g. for categories that have
  # a parent their ref will be parent_slug:child_slug, though most of the
  # time it will be the same as the slug. The ref can then be used for
  # lookup in the UI.
  def set_refs(hashtag_items)
    hashtag_items.each { |item| item.ref ||= item.slug }
  end

  def set_types(hashtag_items, type)
    hashtag_items.each { |item| item.type = type }
  end

  def filter_valid_data_items(items)
    items.select { |item| item.kind_of?(HashtagItem) && item.slug.present? && item.text.present? }
  end

  def search_for_type(
    type,
    guardian,
    term,
    limit,
    condition = HashtagAutocompleteService.search_conditions[:contains]
  )
    filter_valid_data_items(
      set_types(
        set_refs(
          HashtagAutocompleteService.data_source_from_type(type).search(
            guardian,
            term,
            limit,
            condition,
          ),
        ),
        type,
      ),
    )
  end

  def execute_lookup!(lookup_results, type, guardian, slugs)
    found_from_slugs = filter_valid_data_items(lookup_for_type(type, guardian, slugs))
    found_from_slugs.sort_by! { |item| item.text.downcase }

    if lookup_results.present?
      lookup_results[type.to_sym] = lookup_results[type.to_sym].concat(found_from_slugs)
    end

    found_from_slugs
  end

  def lookup_for_type(type, guardian, slugs)
    set_types(
      set_refs(HashtagAutocompleteService.data_source_from_type(type).lookup(guardian, slugs)),
      type,
    )
  end

  def append_types_to_conflicts(limited_results, top_ranked_type, types_in_priority_order, limit)
    limited_results.each do |hashtag_item|
      next if hashtag_item.type == top_ranked_type

      # We only need to change the ref to include the type if there is a
      # higher-ranked hashtag slug that conflicts with this one.
      higher_ranked_types =
        types_in_priority_order.slice(0, types_in_priority_order.index(hashtag_item.type))
      higher_ranked_slugs =
        limited_results
          .reject { |r| r.type === hashtag_item.type }
          .select { |r| higher_ranked_types.include?(r.type) }
          .map(&:slug)

      if higher_ranked_slugs.include?(hashtag_item.slug)
        hashtag_item.ref = "#{hashtag_item.ref}::#{hashtag_item.type}"
      end
    end

    limited_results.take(limit)
  end
end

```

Response:
# frozen_string_literal: true

RSpec.describe HashtagAutocompleteService do
  subject(:service) { described_class.new(guardian) }

  fab!(:user)
  fab!(:category1) { Fabricate(:category, name: "The Book Club", slug: "the-book-club") }
  fab!(:tag1) do
    Fabricate(:tag, name: "great-books", staff_topic_count: 22, public_topic_count: 22)
  end
  fab!(:topic1) { Fabricate(:topic) }

  let(:guardian) { Guardian.new(user) }

  after { DiscoursePluginRegistry.reset! }

  describe ".enabled_data_sources" do
    it "only returns data sources that are enabled" do
      expect(HashtagAutocompleteService.enabled_data_sources).to eq(
        HashtagAutocompleteService::DEFAULT_DATA_SOURCES,
      )
    end
  end

  describe ".contexts_with_ordered_types" do
    it "returns a hash of all the registered search contexts and their types in the defined priority order" do
      expect(HashtagAutocompleteService.contexts_with_ordered_types).to eq(
        { "topic-composer" => %w[category tag] },
      )
      DiscoursePluginRegistry.register_hashtag_autocomplete_contextual_type_priority(
        { type: "category", context: "awesome-composer", priority: 50 },
        stub(enabled?: true),
      )
      DiscoursePluginRegistry.register_hashtag_autocomplete_contextual_type_priority(
        { type: "tag", context: "awesome-composer", priority: 100 },
        stub(enabled?: true),
      )
      expect(HashtagAutocompleteService.contexts_with_ordered_types).to eq(
        { "topic-composer" => %w[category tag], "awesome-composer" => %w[tag category] },
      )
    end

    it "does not return types which have been disabled" do
      SiteSetting.tagging_enabled = false
      expect(HashtagAutocompleteService.contexts_with_ordered_types).to eq(
        { "topic-composer" => %w[category] },
      )
    end
  end

  describe ".data_source_icon_map" do
    it "gets an array for all icons defined by data sources so they can be used for markdown allowlisting" do
      expect(HashtagAutocompleteService.data_source_icon_map).to eq(
        { "category" => "folder", "tag" => "tag" },
      )
    end
  end

  describe "#search" do
    it "returns search results for tags and categories by default" do
      expect(service.search("book", %w[category tag]).map(&:text)).to eq(
        ["The Book Club", "great-books"],
      )
    end

    it "respects the types_in_priority_order param" do
      expect(service.search("book", %w[tag category]).map(&:text)).to eq(
        ["great-books", "The Book Club"],
      )
    end

    it "respects the limit param" do
      expect(service.search("book", %w[tag category], limit: 1).map(&:text)).to eq(["great-books"])
    end

    it "does not allow more than SEARCH_MAX_LIMIT results to be specified by the limit param" do
      stub_const(HashtagAutocompleteService, "SEARCH_MAX_LIMIT", 1) do
        expect(service.search("book", %w[category tag], limit: 1000).map(&:text)).to eq(
          ["The Book Club"],
        )
      end
    end

    it "does not search other data sources if the limit is reached by earlier type data sources" do
      # only expected once to try get the exact matches first
      DiscourseTagging.expects(:filter_allowed_tags).never
      service.search("the-book", %w[category tag], limit: 1)
    end

    it "includes the tag count" do
      tag1.update!(staff_topic_count: 78, public_topic_count: 78)
      expect(service.search("book", %w[tag category]).map(&:text)).to eq(
        ["great-books", "The Book Club"],
      )
    end

    it "does case-insensitive search" do
      expect(service.search("bOOk", %w[category tag]).map(&:text)).to eq(
        ["The Book Club", "great-books"],
      )
    end

    it "can search categories by name or slug" do
      expect(service.search("the-book-club", %w[category]).map(&:text)).to eq(["The Book Club"])
      expect(service.search("Book C", %w[category]).map(&:text)).to eq(["The Book Club"])
    end

    it "does not include categories the user cannot access" do
      category1.update!(read_restricted: true)
      expect(service.search("book", %w[tag category]).map(&:text)).to eq(["great-books"])
    end

    it "does not include tags the user cannot access" do
      Fabricate(:tag_group, permissions: { "staff" => 1 }, tag_names: ["great-books"])
      expect(service.search("book", %w[tag]).map(&:text)).to be_empty
    end

    it "includes other data sources" do
      Fabricate(:bookmark, user: user, name: "read review of this fantasy book")
      Fabricate(:bookmark, user: user, name: "cool rock song")
      guardian.user.reload

      DiscoursePluginRegistry.register_hashtag_autocomplete_data_source(
        FakeBookmarkHashtagDataSource,
        stub(enabled?: true),
      )

      expect(service.search("book", %w[category tag bookmark]).map(&:text)).to eq(
        ["The Book Club", "great-books", "read review of this fantasy book"],
      )
    end

    it "handles refs for categories that have a parent" do
      parent = Fabricate(:category, name: "Hobbies", slug: "hobbies")
      category1.update!(parent_category: parent)
      expect(service.search("book", %w[category tag]).map(&:ref)).to eq(
        %w[hobbies:the-book-club great-books],
      )
      category1.update!(parent_category: nil)
    end

    it "appends type suffixes for the ref on conflicting slugs on items that are not the top priority type" do
      Fabricate(:tag, name: "the-book-club")
      expect(service.search("book", %w[category tag]).map(&:ref)).to eq(
        %w[the-book-club great-books the-book-club::tag],
      )

      Fabricate(:bookmark, user: user, name: "book club")
      guardian.user.reload

      DiscoursePluginRegistry.register_hashtag_autocomplete_data_source(
        FakeBookmarkHashtagDataSource,
        stub(enabled?: true),
      )

      expect(service.search("book", %w[category tag bookmark]).map(&:ref)).to eq(
        %w[book-club the-book-club great-books the-book-club::tag],
      )
    end

    it "does not add a type suffix where
        1. a subcategory name conflicts with an existing tag name and
        2. the category is not the top ranked type" do
      parent = Fabricate(:category, name: "Hobbies", slug: "hobbies")
      category1.update!(parent_category: parent)
      Fabricate(:tag, name: "the-book-club")

      Fabricate(:bookmark, user: user, name: "book club")
      guardian.user.reload

      DiscoursePluginRegistry.register_hashtag_autocomplete_data_source(
        FakeBookmarkHashtagDataSource,
        stub(enabled?: true),
      )

      expect(service.search("book", %w[bookmark category tag]).map(&:ref)).to eq(
        %w[book-club hobbies:the-book-club great-books the-book-club::tag],
      )
    end

    it "handles the type suffix where the top ranked type conflicts with a subcategory" do
      parent = Fabricate(:category, name: "Hobbies", slug: "hobbies")
      category1.update!(parent_category: parent)
      Fabricate(:tag, name: "the-book-club")

      Fabricate(:bookmark, user: user, name: "the book club")
      guardian.user.reload

      DiscoursePluginRegistry.register_hashtag_autocomplete_data_source(
        FakeBookmarkHashtagDataSource,
        stub(enabled?: true),
      )

      expect(service.search("book", %w[bookmark category tag]).map(&:ref)).to eq(
        %w[the-book-club hobbies:the-book-club::category great-books the-book-club::tag],
      )
    end

    it "orders results by (with type ordering within each section):
        1. exact match on slug (ignoring parent/child distinction for categories)
        2. slugs that start with the term
        3. then name for everything else" do
      category2 = Fabricate(:category, name: "Book Library", slug: "book-library")
      Fabricate(:category, name: "Horror", slug: "book", parent_category: category2)
      Fabricate(:category, name: "Romance", slug: "romance-books")
      Fabricate(:category, name: "Abstract Philosophy", slug: "abstract-philosophy-books")
      category6 = Fabricate(:category, name: "Book Reviews", slug: "book-reviews")
      Fabricate(:category, name: "Good Books", slug: "book", parent_category: category6)

      Fabricate(:tag, name: "bookmania", staff_topic_count: 15, public_topic_count: 15)
      Fabricate(:tag, name: "awful-books", staff_topic_count: 56, public_topic_count: 56)

      expect(service.search("book", %w[category tag]).map(&:ref)).to eq(
        [
          "book-reviews:book", # category exact match on slug, name sorted
          "book-library:book",
          "book-library", # category starts with match on slug, name sorted
          "book-reviews",
          "bookmania", # tag starts with match on slug, name sorted
          "abstract-philosophy-books", # category partial match on slug, name sorted
          "romance-books",
          "the-book-club",
          "awful-books", # tag partial match on slug, name sorted
          "great-books",
        ],
      )
      expect(service.search("book", %w[category tag]).map(&:text)).to eq(
        [
          "Good Books",
          "Horror",
          "Book Library",
          "Book Reviews",
          "bookmania",
          "Abstract Philosophy",
          "Romance",
          "The Book Club",
          "awful-books",
          "great-books",
        ],
      )
    end

    context "when multiple tags and categories are returned" do
      fab!(:category2) { Fabricate(:category, name: "Book Zone", slug: "book-zone") }
      fab!(:category3) { Fabricate(:category, name: "Book Dome", slug: "book-dome") }
      fab!(:category4) { Fabricate(:category, name: "Bookworld", slug: "book") }
      fab!(:tag2) { Fabricate(:tag, name: "mid-books") }
      fab!(:tag3) { Fabricate(:tag, name: "terrible-books") }
      fab!(:tag4) { Fabricate(:tag, name: "book") }

      it "orders them by name within their type order and prioritizes exact matches to the top of the list" do
        expect(service.search("book", %w[category tag], limit: 10).map(&:ref)).to eq(
          %w[book book::tag book-dome book-zone the-book-club great-books mid-books terrible-books],
        )
      end
    end

    context "when not tagging_enabled" do
      before { SiteSetting.tagging_enabled = false }

      it "does not return any tags" do
        expect(service.search("book", %w[category tag]).map(&:text)).to eq(["The Book Club"])
      end
    end

    context "when no term is provided (default results) triggered by a # with no characters in the UI" do
      fab!(:category2) do
        Fabricate(:category, name: "Book Zone", slug: "book-zone", topic_count: 546)
      end
      fab!(:category3) do
        Fabricate(:category, name: "Book Dome", slug: "book-dome", topic_count: 987)
      end
      fab!(:category4) { Fabricate(:category, name: "Bookworld", slug: "book", topic_count: 56) }
      fab!(:category5) { Fabricate(:category, name: "Media", slug: "media", topic_count: 446) }
      fab!(:tag2) do
        Fabricate(:tag, name: "mid-books", staff_topic_count: 33, public_topic_count: 33)
      end
      fab!(:tag3) do
        Fabricate(:tag, name: "terrible-books", staff_topic_count: 2, public_topic_count: 2)
      end
      fab!(:tag4) { Fabricate(:tag, name: "book", staff_topic_count: 1, public_topic_count: 1) }

      it "returns the 'most popular' categories and tags (based on topic_count) that the user can access" do
        category1.update!(read_restricted: true)
        Fabricate(:tag_group, permissions: { "staff" => 1 }, tag_names: ["terrible-books"])

        expect(service.search(nil, %w[category tag]).map(&:text)).to eq(
          [
            "Book Dome",
            "Book Zone",
            "Media",
            "Bookworld",
            Category.find(SiteSetting.uncategorized_category_id).name,
            "mid-books",
            "great-books",
            "book",
          ],
        )
      end

      it "does not error if a type provided for priority order has been disabled" do
        SiteSetting.tagging_enabled = false
        expect(service.search(nil, %w[category tag]).map(&:ref)).to eq(
          %w[book-dome book-zone media book uncategorized the-book-club],
        )
      end
    end
  end

  describe "#lookup" do
    fab!(:tag2) { Fabricate(:tag, name: "fiction-books") }

    it "returns category and tag in a hash format with the slug and url" do
      result = service.lookup(%w[the-book-club great-books fiction-books], %w[category tag])
      expect(result[:category].map(&:slug)).to eq(["the-book-club"])
      expect(result[:category].map(&:relative_url)).to eq(["/c/the-book-club/#{category1.id}"])
      expect(result[:tag].map(&:slug)).to eq(%w[fiction-books great-books])
      expect(result[:tag].map(&:relative_url)).to eq(%w[/tag/fiction-books /tag/great-books])
    end

    it "does not include category the user cannot access" do
      category1.update!(read_restricted: true)
      result = service.lookup(%w[the-book-club great-books fiction-books], %w[category tag])
      expect(result[:category]).to eq([])
    end

    it "does not include tag the user cannot access" do
      Fabricate(:tag_group, permissions: { "staff" => 1 }, tag_names: ["great-books"])
      result = service.lookup(%w[the-book-club great-books fiction-books], %w[category tag])
      expect(result[:tag].map(&:slug)).to eq(%w[fiction-books])
      expect(result[:tag].map(&:relative_url)).to eq(["/tag/fiction-books"])
    end

    it "handles type suffixes for slugs" do
      result =
        service.lookup(%w[the-book-club::category great-books::tag fiction-books], %w[category tag])
      expect(result[:category].map(&:slug)).to eq(["the-book-club"])
      expect(result[:category].map(&:relative_url)).to eq(["/c/the-book-club/#{category1.id}"])
      expect(result[:tag].map(&:slug)).to eq(%w[fiction-books great-books])
      expect(result[:tag].map(&:relative_url)).to eq(%w[/tag/fiction-books /tag/great-books])
    end

    it "handles parent:child category lookups" do
      parent_category = Fabricate(:category, name: "Media", slug: "media")
      category1.update!(parent_category: parent_category)
      result = service.lookup(%w[media:the-book-club], %w[category tag])
      expect(result[:category].map(&:slug)).to eq(["the-book-club"])
      expect(result[:category].map(&:ref)).to eq(["media:the-book-club"])
      expect(result[:category].map(&:relative_url)).to eq(
        ["/c/media/the-book-club/#{category1.id}"],
      )
      category1.update!(parent_category: nil)
    end

    it "handles parent:child category lookups with type suffix" do
      parent_category = Fabricate(:category, name: "Media", slug: "media")
      category1.update!(parent_category: parent_category)
      result = service.lookup(%w[media:the-book-club::category], %w[category tag])
      expect(result[:category].map(&:slug)).to eq(["the-book-club"])
      expect(result[:category].map(&:ref)).to eq(["media:the-book-club::category"])
      expect(result[:category].map(&:relative_url)).to eq(
        ["/c/media/the-book-club/#{category1.id}"],
      )
      category1.update!(parent_category: nil)
    end

    it "does not return the category if the parent does not match the child" do
      parent_category = Fabricate(:category, name: "Media", slug: "media")
      category1.update!(parent_category: parent_category)
      result = service.lookup(%w[bad-parent:the-book-club], %w[category tag])
      expect(result[:category]).to be_empty
    end

    it "for slugs without a type suffix it falls back in type order until a result is found or types are exhausted" do
      result = service.lookup(%w[the-book-club great-books fiction-books], %w[category tag])
      expect(result[:category].map(&:slug)).to eq(["the-book-club"])
      expect(result[:category].map(&:relative_url)).to eq(["/c/the-book-club/#{category1.id}"])
      expect(result[:tag].map(&:slug)).to eq(%w[fiction-books great-books])
      expect(result[:tag].map(&:relative_url)).to eq(%w[/tag/fiction-books /tag/great-books])

      category2 = Fabricate(:category, name: "Great Books", slug: "great-books")
      result = service.lookup(%w[the-book-club great-books fiction-books], %w[category tag])
      expect(result[:category].map(&:slug)).to eq(%w[great-books the-book-club])
      expect(result[:category].map(&:relative_url)).to eq(
        ["/c/great-books/#{category2.id}", "/c/the-book-club/#{category1.id}"],
      )
      expect(result[:tag].map(&:slug)).to eq(%w[fiction-books])
      expect(result[:tag].map(&:relative_url)).to eq(%w[/tag/fiction-books])

      category1.destroy!
      Fabricate(:tag, name: "the-book-club")
      result = service.lookup(%w[the-book-club great-books fiction-books], %w[category tag])
      expect(result[:category].map(&:slug)).to eq(["great-books"])
      expect(result[:category].map(&:relative_url)).to eq(["/c/great-books/#{category2.id}"])
      expect(result[:tag].map(&:slug)).to eq(%w[fiction-books the-book-club])
      expect(result[:tag].map(&:relative_url)).to eq(%w[/tag/fiction-books /tag/the-book-club])

      result = service.lookup(%w[the-book-club great-books fiction-books], %w[tag category])
      expect(result[:category]).to eq([])
      expect(result[:tag].map(&:slug)).to eq(%w[fiction-books great-books the-book-club])
      expect(result[:tag].map(&:relative_url)).to eq(
        %w[/tag/fiction-books /tag/great-books /tag/the-book-club],
      )
    end

    it "includes other data sources" do
      Fabricate(:bookmark, user: user, name: "read review of this fantasy book")
      Fabricate(:bookmark, user: user, name: "coolrock")
      guardian.user.reload

      DiscoursePluginRegistry.register_hashtag_autocomplete_data_source(
        FakeBookmarkHashtagDataSource,
        stub(enabled?: true),
      )

      result = service.lookup(["coolrock"], %w[category tag bookmark])
      expect(result[:bookmark].map(&:slug)).to eq(["coolrock"])
    end

    it "handles type suffix lookups where there is another type with a conflicting slug that the user cannot access" do
      category1.update!(read_restricted: true)
      Fabricate(:tag, name: "the-book-club")
      result = service.lookup(%w[the-book-club::tag the-book-club], %w[category tag])
      expect(result[:category].map(&:ref)).to eq([])
      expect(result[:tag].map(&:ref)).to eq(["the-book-club::tag"])
    end

    context "when not tagging_enabled" do
      before { SiteSetting.tagging_enabled = false }

      it "does not return tag" do
        result = service.lookup(%w[the-book-club great-books fiction-books], %w[category tag])
        expect(result[:category].map(&:slug)).to eq(["the-book-club"])
        expect(result[:category].map(&:relative_url)).to eq(["/c/the-book-club/#{category1.id}"])
        expect(result[:tag]).to eq(nil)
      end
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# frozen_string_literal: true

class HeatSettingsUpdater
  def self.update
    return unless SiteSetting.automatic_topic_heat_values

    views_by_percentile = views_thresholds
    update_setting(:topic_views_heat_high, views_by_percentile[10])
    update_setting(:topic_views_heat_medium, views_by_percentile[25])
    update_setting(:topic_views_heat_low, views_by_percentile[45])

    like_ratios_by_percentile = like_ratio_thresholds
    update_setting(:topic_post_like_heat_high, like_ratios_by_percentile[10])
    update_setting(:topic_post_like_heat_medium, like_ratios_by_percentile[25])
    update_setting(:topic_post_like_heat_low, like_ratios_by_percentile[45])
  end

  def self.views_thresholds
    results = DB.query(<<~SQL)
      SELECT ranked.bucket * 5 as percentile, MIN(ranked.views) as views
      FROM (
        SELECT NTILE(20) OVER (ORDER BY t.views DESC) AS bucket, t.views
        FROM (
          SELECT views
            FROM topics
           WHERE deleted_at IS NULL
             AND archetype <> 'private_message'
             AND visible = TRUE
        ) t
      ) ranked
      WHERE bucket <= 9
      GROUP BY bucket
    SQL

    results.inject({}) do |h, row|
      h[row.percentile] = row.views
      h
    end
  end

  def self.like_ratio_thresholds
    results = DB.query(<<~SQL)
      SELECT ranked.bucket * 5 as percentile, MIN(ranked.ratio) as like_ratio
      FROM (
        SELECT NTILE(20) OVER (ORDER BY t.ratio DESC) AS bucket, t.ratio
        FROM (
          SELECT like_count::decimal / posts_count AS ratio
            FROM topics
           WHERE deleted_at IS NULL
             AND archetype <> 'private_message'
             AND visible = TRUE
             AND posts_count >= 10
             AND like_count > 0
        ORDER BY created_at DESC
           LIMIT 1000
        ) t
      ) ranked
      WHERE bucket <= 9
      GROUP BY bucket
    SQL

    results.inject({}) do |h, row|
      h[row.percentile] = row.like_ratio
      h
    end
  end

  def self.update_setting(name, new_value)
    if new_value.nil? || new_value <= SiteSetting.defaults[name]
      if SiteSetting.get(name) != SiteSetting.defaults[name]
        SiteSetting.set_and_log(name, SiteSetting.defaults[name])
      end
    elsif SiteSetting.get(name) == 0 || (new_value.to_f / SiteSetting.get(name) - 1.0).abs >= 0.05
      rounded_new_value =
        if new_value.is_a?(Integer)
          if new_value > 9
            digits = new_value.digits.reverse
            (digits[0] * 10 + digits[1]) * 10.pow(digits[2..-1].size)
          else
            new_value
          end
        else
          new_value.round(2)
        end

      SiteSetting.set_and_log(name, rounded_new_value) if SiteSetting.get(name) != rounded_new_value
    end
  end
end

```

Response:
# frozen_string_literal: true

RSpec.describe HeatSettingsUpdater do
  describe ".update" do
    subject(:update_settings) { described_class.update }

    def expect_default_values
      %i[topic_views_heat topic_post_like_heat].each do |prefix|
        %i[low medium high].each do |level|
          setting_name = "#{prefix}_#{level}"
          expect(SiteSetting.get(setting_name)).to eq(SiteSetting.defaults[setting_name])
        end
      end
    end

    it "changes nothing on fresh install" do
      expect { update_settings }.to_not change { UserHistory.count }
      expect_default_values
    end

    context "with low activity" do
      let!(:hottest_topic1) { Fabricate(:topic, views: 3000, posts_count: 10, like_count: 2) }
      let!(:hottest_topic2) { Fabricate(:topic, views: 3000, posts_count: 10, like_count: 2) }
      let!(:warm_topic1) { Fabricate(:topic, views: 1500, posts_count: 10, like_count: 1) }
      let!(:warm_topic2) { Fabricate(:topic, views: 1500, posts_count: 10, like_count: 1) }
      let!(:warm_topic3) { Fabricate(:topic, views: 1500, posts_count: 10, like_count: 1) }
      let!(:lukewarm_topic1) { Fabricate(:topic, views: 800, posts_count: 10, like_count: 0) }
      let!(:lukewarm_topic2) { Fabricate(:topic, views: 800, posts_count: 10, like_count: 0) }
      let!(:lukewarm_topic3) { Fabricate(:topic, views: 800, posts_count: 10, like_count: 0) }
      let!(:lukewarm_topic4) { Fabricate(:topic, views: 800, posts_count: 10, like_count: 0) }
      let!(:cold_topic) { Fabricate(:topic, views: 100, posts_count: 10, like_count: 0) }

      it "doesn't make settings lower than defaults" do
        expect { update_settings }.to_not change { UserHistory.count }
        expect_default_values
      end

      it "can set back down to minimum defaults" do
        %i[low medium high].each do |level|
          SiteSetting.set("topic_views_heat_#{level}", 20_000)
          SiteSetting.set("topic_post_like_heat_#{level}", 5.0)
        end
        expect { update_settings }.to change { UserHistory.count }.by(6)
        expect_default_values
      end
    end

    context "with similar activity" do
      let!(:hottest_topic1) { Fabricate(:topic, views: 3530, posts_count: 100, like_count: 201) }
      let!(:hottest_topic2) { Fabricate(:topic, views: 3530, posts_count: 100, like_count: 201) }
      let!(:warm_topic1) { Fabricate(:topic, views: 2020, posts_count: 100, like_count: 99) }
      let!(:warm_topic2) { Fabricate(:topic, views: 2020, posts_count: 100, like_count: 99) }
      let!(:warm_topic3) { Fabricate(:topic, views: 2020, posts_count: 100, like_count: 99) }
      let!(:lukewarm_topic1) { Fabricate(:topic, views: 1010, posts_count: 100, like_count: 51) }
      let!(:lukewarm_topic2) { Fabricate(:topic, views: 1010, posts_count: 100, like_count: 51) }
      let!(:lukewarm_topic3) { Fabricate(:topic, views: 1010, posts_count: 100, like_count: 51) }
      let!(:lukewarm_topic4) { Fabricate(:topic, views: 1010, posts_count: 100, like_count: 51) }
      let!(:cold_topic) { Fabricate(:topic, views: 100, posts_count: 100, like_count: 1) }

      it "doesn't make small changes" do
        expect { update_settings }.to_not change { UserHistory.count }
        expect_default_values
      end
    end

    context "with increased activity" do
      let!(:hottest_topic1) { Fabricate(:topic, views: 10_100, posts_count: 100, like_count: 230) }
      let!(:hottest_topic2) { Fabricate(:topic, views: 10_012, posts_count: 100, like_count: 220) }
      let!(:warm_topic1) { Fabricate(:topic, views: 4020, posts_count: 99, like_count: 126) }
      let!(:warm_topic2) { Fabricate(:topic, views: 4010, posts_count: 99, like_count: 116) }
      let!(:warm_topic3) { Fabricate(:topic, views: 4005, posts_count: 99, like_count: 106) }
      let!(:lukewarm_topic1) { Fabricate(:topic, views: 2040, posts_count: 99, like_count: 84) }
      let!(:lukewarm_topic2) { Fabricate(:topic, views: 2030, posts_count: 99, like_count: 74) }
      let!(:lukewarm_topic3) { Fabricate(:topic, views: 2020, posts_count: 99, like_count: 64) }
      let!(:lukewarm_topic4) { Fabricate(:topic, views: 2002, posts_count: 99, like_count: 54) }
      let!(:cold_topic) { Fabricate(:topic, views: 100, posts_count: 100, like_count: 1) }

      it "changes settings when difference is significant" do
        expect { update_settings }.to change { UserHistory.count }.by(6)
        expect(SiteSetting.topic_views_heat_high).to eq(10_000)
        expect(SiteSetting.topic_views_heat_medium).to eq(4000)
        expect(SiteSetting.topic_views_heat_low).to eq(2000)
        expect(SiteSetting.topic_post_like_heat_high).to eq(2.2)
        expect(SiteSetting.topic_post_like_heat_medium).to eq(1.07)
        expect(SiteSetting.topic_post_like_heat_low).to eq(0.55)
      end

      it "doesn't change settings when automatic_topic_heat_values is false" do
        SiteSetting.automatic_topic_heat_values = false
        expect { update_settings }.to_not change { UserHistory.count }
        expect_default_values
      end
    end
  end
end