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

class InlineUploads
  PLACEHOLDER = "__replace__"
  PATH_PLACEHOLDER = "__replace_path__"

  UPLOAD_REGEXP_PATTERN = "/original/(\\dX/(?:\\h/)*\\h{40}[a-zA-Z0-9.]*)(\\?v=\\d+)?"
  private_constant :UPLOAD_REGEXP_PATTERN

  def self.process(markdown, on_missing: nil)
    markdown = markdown.dup

    match_md_reference(markdown) do |match, src, replacement, index|
      if upload = Upload.get_from_url(src)
        markdown = markdown.sub(match, replacement.sub!(PATH_PLACEHOLDER, "__#{upload.sha1}__"))
      end
    end

    cooked_fragment = Nokogiri::HTML5.fragment(PrettyText.cook(markdown, disable_emojis: true))
    link_occurrences = []

    cooked_fragment.traverse do |node|
      if node.name == "img"
        # Do nothing
      elsif !(
            node.children.count == 1 &&
              (node.children[0].name != "img" && node.children[0].children.blank?)
          ) &&
            !(
              node.name == "a" && node.children.count > 1 &&
                !node_children_names(node).include?("img")
            )
        next
      end

      if seen_link = matched_uploads(node).first
        if (actual_link = (node.attributes["href"]&.value || node.attributes["src"]&.value))
          link_occurrences << { link: actual_link, is_valid: true }
        elsif node.name != "p"
          link_occurrences << { link: seen_link, is_valid: false }
        end
      end
    end

    raw_matches = []

    match_bbcode_img(markdown) do |match, src, replacement, index|
      raw_matches << [match, src, replacement, index]
    end

    match_md_inline_img(markdown) do |match, src, replacement, index|
      raw_matches << [match, src, replacement, index]
    end

    match_img(markdown) do |match, src, replacement, index|
      raw_matches << [match, src, replacement, index]
    end

    match_anchor(markdown) do |match, href, replacement, index|
      raw_matches << [match, href, replacement, index]
    end

    regexps = [
      %r{(https?://[a-zA-Z0-9\./-]+/#{Discourse.store.upload_path}#{UPLOAD_REGEXP_PATTERN})},
    ]

    if Discourse.store.external?
      regexps << /((?:https?:)?#{SiteSetting.Upload.s3_base_url}#{UPLOAD_REGEXP_PATTERN})/
      regexps << /(#{SiteSetting.Upload.s3_cdn_url}#{UPLOAD_REGEXP_PATTERN})/
    end

    regexps.each do |regexp|
      indexes = Set.new

      markdown.scan(/(\n{2,}|\A)#{regexp}$/) do |match|
        if match[1].present? && match[2].present?
          extension = match[2].split(".")[-1].downcase
          index = $~.offset(2)[0]
          indexes << index
          if FileHelper.supported_images.include?(extension)
            raw_matches << [match[1], match[1], +"![](#{PLACEHOLDER})", index]
          else
            raw_matches << [match[1], match[1], +"#{Discourse.base_url}#{PATH_PLACEHOLDER}", index]
          end
        end
      end

      markdown.scan(/^#{regexp}(\s)/) do |match|
        if match[0].present?
          index = $~.offset(0)[0]
          next if !indexes.add?(index)
          raw_matches << [match[0], match[0], +"#{Discourse.base_url}#{PATH_PLACEHOLDER}", index]
        end
      end

      markdown.scan(/\[[^\[\]]*\]: #{regexp}/) do |match|
        indexes.add($~.offset(1)[0]) if match[0].present?
      end

      markdown.scan(/(([\n\s\)\]\<])+)#{regexp}/) do |match|
        if matched_uploads(match[2]).present?
          next if !indexes.add?($~.offset(3)[0])
          index = $~.offset(0)[0]
          raw_matches << [match[2], match[2], +"#{Discourse.base_url}#{PATH_PLACEHOLDER}", index]
        end
      end
    end

    raw_matches
      .sort { |a, b| a[3] <=> b[3] }
      .each do |match, link, replace_with, _index|
        node_info = link_occurrences.shift
        next unless node_info&.dig(:is_valid)

        if link.include?(node_info[:link])
          begin
            uri = URI(link)
          rescue URI::Error
          end

          if !Discourse.store.external?
            host = uri&.host

            hosts = [Discourse.current_hostname]

            if cdn_url = GlobalSetting.cdn_url
              hosts << URI(GlobalSetting.cdn_url).hostname
            end

            next if host && !hosts.include?(host)
          end

          upload = Upload.get_from_url(link)

          if upload
            replace_with.sub!(PLACEHOLDER, upload.short_url)
            replace_with.sub!(PATH_PLACEHOLDER, upload.short_path)
            markdown.sub!(match, replace_with)
          else
            on_missing.call(link) if on_missing
          end
        end
      end

    markdown.scan(/(__(\h{40})__)/) do |match|
      upload = Upload.find_by(sha1: match[1])
      markdown = markdown.sub(match[0], upload.short_path)
    end

    markdown
  end

  def self.match_md_inline_img(markdown, external_src: false)
    markdown.scan(/(!?\[([^\[\]]*)\]\(([^\s\)]+)([ ]*['"]{1}[^\)]*['"]{1}[ ]*)?\))/) do |match|
      if (external_src || matched_uploads(match[2]).present?) && block_given?
        yield(
          match[0],
          match[2],
          +"#{match[0].start_with?("!") ? "!" : ""}[#{match[1]}](#{PLACEHOLDER}#{match[3]})",
          $~.offset(0)[0]
        )
      end
    end
  end

  def self.match_bbcode_img(markdown, external_src: false)
    markdown.scan(%r{(\[img\]\s*([^\[\]\s]+)\s*\[/img\])}i) do |match|
      if (external_src || (matched_uploads(match[1]).present?)) && block_given?
        yield(match[0], match[1], +"![](#{PLACEHOLDER})", $~.offset(0)[0])
      end
    end
  end

  def self.match_md_reference(markdown)
    markdown.scan(/(\[([^\]]+)\]:([ ]+)(\S+))/) do |match|
      if match[3] && matched_uploads(match[3]).present? && block_given?
        yield(
          match[0],
          match[3],
          +"[#{match[1]}]:#{match[2]}#{Discourse.base_url}#{PATH_PLACEHOLDER}",
          $~.offset(0)[0]
        )
      end
    end
  end

  def self.match_anchor(markdown, external_href: false)
    markdown.scan(%r{((<a[^<]+>)([^<\a>]*?)</a>)}i) do |match|
      node = Nokogiri::HTML5.fragment(match[0]).children[0]
      href = node.attributes["href"]&.value

      if href && (external_href || matched_uploads(href).present?)
        has_attachment = node.attributes["class"]&.value
        index = $~.offset(0)[0]
        text = match[2].strip.gsub("\n", "").gsub(/ +/, " ")
        text = "#{text}|attachment" if has_attachment

        yield(match[0], href, +"[#{text}](#{PLACEHOLDER})", index) if block_given?
      end
    end
  end

  def self.match_img(markdown, external_src: false, uploads: nil)
    markdown.scan(%r{(<(?!img)[^<>]+/?>)?(\s*)(<img [^>\n]+>)}i) do |match|
      node = Nokogiri::HTML5.fragment(match[2].strip).children[0]
      src = node&.attributes&.[]("src")&.value

      if src && (external_src || matched_uploads(src).present?)
        upload = uploads&.[](src)
        node["src"] = upload&.short_url || PLACEHOLDER

        spaces_before = match[1].present? ? match[1][/ +$/].size : 0
        replacement = +"#{" " * spaces_before}#{node.to_s}"

        yield(match[2], src, replacement, $~.offset(0)[0]) if block_given?
      end
    end
  end

  def self.replace_hotlinked_image_urls(raw:, &blk)
    replace =
      Proc.new do |match, match_src, replacement, _index|
        upload = blk.call(match_src)
        next if !upload

        replacement =
          if replacement.include?(InlineUploads::PLACEHOLDER)
            replacement.sub(InlineUploads::PLACEHOLDER, upload.short_url)
          elsif replacement.include?(InlineUploads::PATH_PLACEHOLDER)
            replacement.sub(InlineUploads::PATH_PLACEHOLDER, upload.short_path)
          end

        raw = raw.gsub(match, replacement)
      end

    # there are 6 ways to insert an image in a post
    # HTML tag - <img src="http://...">
    InlineUploads.match_img(raw, external_src: true, &replace)

    # BBCode tag - [img]http://...[/img]
    InlineUploads.match_bbcode_img(raw, external_src: true, &replace)

    # Markdown linked image - [![alt](http://...)](http://...)
    # Markdown inline - ![alt](http://...)
    # Markdown inline - ![](http://... "image title")
    # Markdown inline - ![alt](http://... "image title")
    InlineUploads.match_md_inline_img(raw, external_src: true, &replace)

    raw =
      raw.gsub(%r{^(https?://\S+)(\s?)$}) do |match|
        if upload = blk.call(match)
          "![](#{upload.short_url})"
        else
          match
        end
      end

    raw
  end

  def self.matched_uploads(node)
    upload_path = Discourse.store.upload_path
    base_url = Discourse.base_url.sub(%r{https?://}, "(https?://)")

    regexps = [
      %r{(upload://([a-zA-Z0-9]+)[a-zA-Z0-9\.]*)},
      %r{(/uploads/short-url/([a-zA-Z0-9]+)[a-zA-Z0-9\.]*)},
      %r{(#{base_url}/uploads/short-url/([a-zA-Z0-9]+)[a-zA-Z0-9\.]*)},
      %r{(#{GlobalSetting.relative_url_root}/#{upload_path}#{UPLOAD_REGEXP_PATTERN})},
      %r{(#{base_url}/#{upload_path}#{UPLOAD_REGEXP_PATTERN})},
    ]

    if GlobalSetting.cdn_url && (cdn_url = GlobalSetting.cdn_url.sub(%r{https?://}, "(https?://)"))
      regexps << %r{(#{cdn_url}/#{upload_path}#{UPLOAD_REGEXP_PATTERN})}
      if GlobalSetting.relative_url_root.present?
        regexps << %r{(#{cdn_url}#{GlobalSetting.relative_url_root}/#{upload_path}#{UPLOAD_REGEXP_PATTERN})}
      end
    end

    if Discourse.store.external?
      if Rails.configuration.multisite
        regexps << %r{((https?:)?#{SiteSetting.Upload.s3_base_url}/#{upload_path}#{UPLOAD_REGEXP_PATTERN})}
        regexps << %r{(#{SiteSetting.Upload.s3_cdn_url}/#{upload_path}#{UPLOAD_REGEXP_PATTERN})}
      else
        regexps << /((https?:)?#{SiteSetting.Upload.s3_base_url}#{UPLOAD_REGEXP_PATTERN})/
        regexps << /(#{SiteSetting.Upload.s3_cdn_url}#{UPLOAD_REGEXP_PATTERN})/
      end
    end

    matches = []
    node = node.to_s

    regexps.each do |regexp|
      node.scan(/(^|[\n\s"'\(>])#{regexp}($|[\n\s"'\)<])/) { |matched| matches << matched[1] }
    end

    matches
  end
  private_class_method :matched_uploads

  def self.node_children_names(node, names = Set.new)
    if node.children.blank?
      names << node.name
      return names
    end

    node.children.each { |child| names = node_children_names(child, names) }

    names
  end
  private_class_method :node_children_names
end

```

Response:
# frozen_string_literal: true
RSpec.describe InlineUploads do
  before { set_cdn_url "https://awesome.com" }

  describe ".process" do
    context "with local uploads" do
      fab!(:upload)
      fab!(:upload2) { Fabricate(:upload) }
      fab!(:upload3) { Fabricate(:upload) }

      it "should not correct existing inline uploads" do
        md = <<~MD
        ![test](#{upload.short_url})haha
        [test]#{upload.short_url}
        MD

        expect(InlineUploads.process(md)).to eq(md)

        md = <<~MD
        ![test](#{upload.short_url})
        [test|attachment](#{upload.short_url})
        MD

        expect(InlineUploads.process(md)).to eq(md)
      end

      it "should not escape existing content" do
        md = "1 > 2"

        expect(InlineUploads.process(md)).to eq(md)
      end

      it "should not escape invalid HTML tags" do
        md = "<x>.<y>"

        expect(InlineUploads.process(md)).to eq(md)
      end

      it "should work with invalid img tags" do
        md = <<~MD
        <img src="#{upload.url}">

        This is an invalid `<img ...>` tag
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        <img src="#{upload.short_url}">

        This is an invalid `<img ...>` tag
        MD

        md = '<img data-id="<>">'
        expect(InlineUploads.process(md)).to eq(md)
      end

      it "should not correct code blocks" do
        md = "`<a class=\"attachment\" href=\"#{upload2.url}\">In Code Block</a>`"

        expect(InlineUploads.process(md)).to eq(md)

        md = "    <a class=\"attachment\" href=\"#{upload2.url}\">In Code Block</a>"

        expect(InlineUploads.process(md)).to eq(md)
      end

      it "should not correct invalid links in quotes" do
        post = Fabricate(:post)
        user = Fabricate(:user)

        md = <<~MD
        [quote="#{user.username}, post:#{post.post_number}, topic:#{post.topic.id}"]
        <img src="#{upload.url}"
        someothertext#{upload2.url}someothertext

        <img src="#{upload.url}"

        sometext#{upload2.url}sometext

        #{upload3.url}

        #{Discourse.base_url}#{upload3.url}
        [/quote]

        <img src="#{upload2.url}">
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        [quote="#{user.username}, post:#{post.post_number}, topic:#{post.topic.id}"]
        <img src="#{upload.url}"
        someothertext#{upload2.url}someothertext

        <img src="#{upload.url}"

        sometext#{upload2.url}sometext

        #{upload3.url}

        ![](#{upload3.short_url})
        [/quote]

        <img src="#{upload2.short_url}">
        MD
      end

      it "should correct links in quotes" do
        post = Fabricate(:post)
        user = Fabricate(:user)

        md = <<~MD
        [quote="#{user.username}, post:#{post.post_number}, topic:#{post.topic.id}"]
        some quote

        #{Discourse.base_url}#{upload3.url}

        ![](#{upload.url})
        [/quote]
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        [quote="#{user.username}, post:#{post.post_number}, topic:#{post.topic.id}"]
        some quote

        ![](#{upload3.short_url})

        ![](#{upload.short_url})
        [/quote]
        MD
      end

      it "should correct markdown linked images" do
        md = <<~MD
        [![](#{upload.url})](https://somelink.com)

        [![some test](#{upload2.url})](https://somelink.com)
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        [![](#{upload.short_url})](https://somelink.com)

        [![some test](#{upload2.short_url})](https://somelink.com)
        MD
      end

      it "should correct markdown images with title" do
        md = <<~MD
        ![](#{upload.url} "some alt")
        ![testing](#{upload2.url}  'some alt'  )
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        ![](#{upload.short_url} "some alt")
        ![testing](#{upload2.short_url}  'some alt'  )
        MD
      end

      it "should correct bbcode img URLs to the short version" do
        md = <<~MD
        [img]http://some.external.img[/img]
        [img]#{upload.url}[/img]
        <img src="#{upload3.url}">

        [img]
        #{upload2.url}
        [/img]

        [img]#{upload.url}[/img][img]#{upload2.url}[/img]
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        [img]http://some.external.img[/img]
        ![](#{upload.short_url})
        <img src="#{upload3.short_url}">

        ![](#{upload2.short_url})

        ![](#{upload.short_url})![](#{upload2.short_url})
        MD
      end

      it "should correct markdown references" do
        md = <<~MD
        [link3][3]

        [3]: #{Discourse.base_url}#{upload2.url}

        This is a [link1][1] test [link2][2] something

        <img src="#{upload.url}">

        [1]: #{Discourse.base_url}#{upload.url}
        [2]: #{Discourse.base_url.sub("http://", "https://")}#{upload2.url}
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        [link3][3]

        [3]: #{Discourse.base_url}#{upload2.short_path}

        This is a [link1][1] test [link2][2] something

        <img src="#{upload.short_url}">

        [1]: #{Discourse.base_url}#{upload.short_path}
        [2]: #{Discourse.base_url}#{upload2.short_path}
        MD
      end

      it "should correct html and markdown uppercase references" do
        md = <<~MD
        [IMG]#{upload.url}[/IMG]
        <IMG src="#{upload2.url}" />
        <A class="attachment" href="#{upload3.url}">Text</A>
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        ![](#{upload.short_url})
        <img src="#{upload2.short_url}">
        [Text|attachment](#{upload3.short_url})
        MD
      end

      it "should correct image URLs with v parameters" do
        md = <<~MD
        <img src="#{upload.url}?v=1">

        <img src="#{Discourse.base_url}#{upload.url}?v=2">

        <img src="#{GlobalSetting.cdn_url}#{upload.url}?v=3">

        #{Discourse.base_url}#{upload.url}?v=45

        #{GlobalSetting.cdn_url}#{upload.url}?v=999
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        <img src="#{upload.short_url}">

        <img src="#{upload.short_url}">

        <img src="#{upload.short_url}">

        ![](#{upload.short_url})

        ![](#{upload.short_url})
        MD
      end

      context "with subfolder" do
        before { set_subfolder "/community" }

        it "should correct subfolder images" do
          md = <<~MD
            <img src="/community#{upload.url}">

            #{Discourse.base_url}#{upload.url}
          MD

          expect(InlineUploads.process(md)).to eq(<<~MD)
            <img src="#{upload.short_url}">

            ![](#{upload.short_url})
          MD
        end
      end

      it "should correct raw image URLs to the short url and paths" do
        md = <<~MD
        #{Discourse.base_url}#{upload.url}

        #{Discourse.base_url}#{upload.url} #{Discourse.base_url}#{upload2.url}

        #{Discourse.base_url}#{upload3.url}

        #{GlobalSetting.cdn_url}#{upload3.url}
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        ![](#{upload.short_url})

        #{Discourse.base_url}#{upload.short_path} #{Discourse.base_url}#{upload2.short_path}

        ![](#{upload3.short_url})

        ![](#{upload3.short_url})
        MD
      end

      it "should correct non image URLs to the short url" do
        SiteSetting.authorized_extensions = "mp4"
        upload = Fabricate(:video_upload)
        upload2 = Fabricate(:video_upload)

        md = <<~MD
        #{Discourse.base_url}#{upload.url}

        #{Discourse.base_url}#{upload.url} #{Discourse.base_url}#{upload2.url}

        #{GlobalSetting.cdn_url}#{upload2.url}
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        #{Discourse.base_url}#{upload.short_path}

        #{Discourse.base_url}#{upload.short_path} #{Discourse.base_url}#{upload2.short_path}

        #{Discourse.base_url}#{upload2.short_path}
        MD
      end

      it "should correct img tags with uppercase upload extension" do
        md = <<~MD
        test<img src="#{upload.url.sub(".png", ".PNG")}">
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        test<img src="#{upload.short_url}">
        MD
      end

      it "should correct image URLs that follows an image md" do
        md = <<~MD
        ![image|690x290](#{upload.short_url})#{Discourse.base_url}#{upload2.url}

        <#{Discourse.base_url}#{upload2.url}>
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        ![image|690x290](#{upload.short_url})#{Discourse.base_url}#{upload2.short_path}

        <#{Discourse.base_url}#{upload2.short_path}>
        MD
      end

      it "should correct image URLs to the short version" do
        md = <<~MD
        ![image|690x290](#{upload.short_url})

        ![IMAge|690x190,60%](#{upload.short_url})

        ![image](#{upload2.url})
        ![image|100x100](#{upload3.url})

        <img src="#{Discourse.base_url}#{upload.url}" alt="some image" title="some title" />
        <img src="#{Discourse.base_url}#{upload2.url}" alt="some image"><img src="#{Discourse.base_url}#{upload3.url}" alt="some image">

        #{Discourse.base_url}#{upload3.url} #{Discourse.base_url}#{upload3.url}

        <img src="#{upload.url}" width="5" height="4">
        <img src="#{upload.url}" width="5px" height="auto">

        `<img src="#{upload.url}" alt="image inside code quotes">`

        ```
        <img src="#{upload.url}" alt="image inside code fences">
        ```

            <img src="#{upload.url}" alt="image inside code block">
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        ![image|690x290](#{upload.short_url})

        ![IMAge|690x190,60%](#{upload.short_url})

        ![image](#{upload2.short_url})
        ![image|100x100](#{upload3.short_url})

        <img src="#{upload.short_url}" alt="some image" title="some title">
        <img src="#{upload2.short_url}" alt="some image"><img src="#{upload3.short_url}" alt="some image">

        #{Discourse.base_url}#{upload3.short_path} #{Discourse.base_url}#{upload3.short_path}

        <img src="#{upload.short_url}" width="5" height="4">
        <img src="#{upload.short_url}" width="5px" height="auto">

        `<img src="#{upload.url}" alt="image inside code quotes">`

        ```
        <img src="#{upload.url}" alt="image inside code fences">
        ```

            <img src="#{upload.url}" alt="image inside code block">
        MD
      end

      it "should not replace identical markdown in code blocks", skip: "Known issue" do
        md = <<~MD
        `![image|690x290](#{upload.url})`
        ![image|690x290](#{upload.url})
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        `![image|690x290](#{upload.url})`
        ![image|690x290](#{upload.short_url})
        MD
      end

      it "should not be affected by an emoji" do
        CustomEmoji.create!(name: "test", upload: upload3)
        Emoji.clear_cache

        md = <<~MD
        :test:

        ![image|690x290](#{upload.url})
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        :test:

        ![image|690x290](#{upload.short_url})
        MD
      end

      it "should correctly update images sources within anchor tags with indentation" do
        md = <<~MD
        <h1></h1>
                        <a href="http://somelink.com">
                          <img src="#{upload2.url}" alt="test" width="500" height="500">
                        </a>

                        <a href="http://somelink.com">
                          <img src="#{upload2.url}" alt="test" width="500" height="500">
                        </a>
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        <h1></h1>
                        <a href="http://somelink.com">
                          <img src="#{upload2.short_url}" alt="test" width="500" height="500">
                        </a>

                        <a href="http://somelink.com">
                          <img src="#{upload2.url}" alt="test" width="500" height="500">
                        </a>
        MD

        md =
          "<h1></h1>\r\n<a href=\"http://somelink.com\">\r\n        <img src=\"#{upload.url}\" alt=\"test\" width=\"500\" height=\"500\">\r\n</a>"

        expect(InlineUploads.process(md)).to eq(
          "<h1></h1>\r\n<a href=\"http://somelink.com\">\r\n        <img src=\"#{upload.short_url}\" alt=\"test\" width=\"500\" height=\"500\">\r\n</a>",
        )
      end

      it "should correctly update image sources within anchor or paragraph tags" do
        md = <<~MD
        <a href="http://somelink.com">
          <img src="#{upload.url}" alt="test" width="500" height="500">
        </a>

        <p>
          <img src="#{upload2.url}" alt="test">
        </p>

        <a href="http://somelink.com"><img src="#{upload3.url}" alt="test" width="500" height="500"></a>

        <a href="http://somelink.com">  <img src="#{upload.url}" alt="test" width="500" height="500">  </a>

        <a href="http://somelink.com">

        <img src="#{upload.url}" alt="test" width="500" height="500">

        </a>

        <p>Test <img src="#{upload2.url}" alt="test" width="500" height="500"></p>

        <hr/>
        <img src="#{upload2.url}" alt="test" width="500" height="500">
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        <a href="http://somelink.com">
          <img src="#{upload.short_url}" alt="test" width="500" height="500">
        </a>

        <p>
          <img src="#{upload2.short_url}" alt="test">
        </p>

        <a href="http://somelink.com"><img src="#{upload3.short_url}" alt="test" width="500" height="500"></a>

        <a href="http://somelink.com">  <img src="#{upload.short_url}" alt="test" width="500" height="500">  </a>

        <a href="http://somelink.com">

        <img src="#{upload.short_url}" alt="test" width="500" height="500">

        </a>

        <p>Test <img src="#{upload2.short_url}" alt="test" width="500" height="500"></p>

        <hr/>
        <img src="#{upload2.short_url}" alt="test" width="500" height="500">
        MD
      end

      it "should not be affected by fake HTML tags" do
        md = <<~MD
        ```
        This is some <img src=" and <a href="
        ```

        <img src="#{upload.url}" alt="test">

        <img src="#{upload2.url}" alt="test" height="150<img">

        > some quote

        <a class="attachment" href="#{upload2.url}">test2</a>
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        ```
        This is some <img src=" and <a href="
        ```

        <img src="#{upload.short_url}" alt="test">

        <img src="#{upload2.short_url}" alt="test" height="150<img">

        > some quote

        [test2|attachment](#{upload2.short_url})
        MD
      end

      it "should not be affected by an external or invalid links" do
        md = <<~MD
        <a id="test">invalid</a>

        [test]("https://this.is.some.external.link")

        <a href="https://some.external.com/link">test</a>

        <a class="attachment" href="#{upload2.url}">test2</a>
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        <a id="test">invalid</a>

        [test]("https://this.is.some.external.link")

        <a href="https://some.external.com/link">test</a>

        [test2|attachment](#{upload2.short_url})
        MD
      end

      it "should correct attachment URLS to the short version when raw contains inline image" do
        md = <<~MD
        ![image](#{upload.short_url}) ![image](#{upload.short_url})

        [some complicated.doc %50](#{upload3.url})

        <a class="attachment" href="#{upload2.url}">test2</a>
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        ![image](#{upload.short_url}) ![image](#{upload.short_url})

        [some complicated.doc %50](#{upload3.short_url})

        [test2|attachment](#{upload2.short_url})
        MD
      end

      it "should correct attachment URLs to the short version" do
        md = <<~MD
        <a class="attachment" href="#{upload.url}">
          this
          is
          some
          attachment

        </a>

        - <a class="attachment" href="#{upload.url}">test2</a>
          - <a class="attachment" href="#{upload2.url}">test2</a>
            - <a class="attachment" href="#{upload3.url}">test2</a>

        <a class="test attachment" href="#{upload.url}">test3</a>
        <a class="test attachment" href="#{upload2.url}">test3</a><a class="test attachment" href="#{upload3.url}">test3</a>

        <a class="test attachment" href="#{upload3.url}">This is some _test_ here</a>
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        [this is some attachment|attachment](#{upload.short_url})

        - [test2|attachment](#{upload.short_url})
          - [test2|attachment](#{upload2.short_url})
            - [test2|attachment](#{upload3.short_url})

        [test3|attachment](#{upload.short_url})
        [test3|attachment](#{upload2.short_url})[test3|attachment](#{upload3.short_url})

        [This is some _test_ here|attachment](#{upload3.short_url})
        MD
      end

      it "should correct full upload url to the shorter version" do
        md = <<~MD
        Some random text

        ![test](#{upload.short_url})
        [test|attachment](#{upload.short_url})

        <a class="test attachment" href="#{upload.url}">
          test
        </a>

        `<a class="attachment" href="#{upload2.url}">In Code Block</a>`

            <a class="attachment" href="#{upload3.url}">In Code Block</a>

        <a href="#{upload.url}">newtest</a>
        <a href="#{Discourse.base_url_no_prefix}#{upload.url}">newtest</a>

        <a href="https://somerandomesite.com#{upload.url}">test</a>
        <a class="attachment" href="https://somerandom.com/url">test</a>
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        Some random text

        ![test](#{upload.short_url})
        [test|attachment](#{upload.short_url})

        [test|attachment](#{upload.short_url})

        `<a class="attachment" href="#{upload2.url}">In Code Block</a>`

            <a class="attachment" href="#{upload3.url}">In Code Block</a>

        [newtest](#{upload.short_url})
        [newtest](#{upload.short_url})

        <a href="https://somerandomesite.com#{upload.url}">test</a>
        <a class="attachment" href="https://somerandom.com/url">test</a>
        MD
      end

      it "accepts a block that yields when link does not match an upload in the db" do
        url = "#{Discourse.base_url}#{upload.url}"

        md = <<~MD
        <img src="#{url}" alt="some image">
        <img src="#{upload2.url}" alt="some image">
        MD

        upload.destroy!

        InlineUploads.process(md, on_missing: lambda { |link| expect(link).to eq(url) })
      end
    end

    context "with s3 uploads" do
      let(:upload) { Fabricate(:upload_s3) }
      let(:upload2) { Fabricate(:upload_s3) }
      let(:upload3) { Fabricate(:upload) }

      before do
        upload3
        setup_s3
        SiteSetting.s3_cdn_url = "https://s3.cdn.com"
      end

      it "should correct image URLs to the short version" do
        md = <<~MD
        #{upload.url}
        <img src="#{upload.url}" alt="some image">
        test<img src="#{upload2.url}" alt="some image">test
        <img src="#{URI.join(SiteSetting.s3_cdn_url, URI.parse(upload2.url).path).to_s}" alt="some image">
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        ![](#{upload.short_url})
        <img src="#{upload.short_url}" alt="some image">
        test<img src="#{upload2.short_url}" alt="some image">test
        <img src="#{upload2.short_url}" alt="some image">
        MD
      end

      it "should correct markdown references" do
        md = <<~MD
        This is a [some reference] something

        [some reference]: https:#{upload.url}
        MD

        expect(InlineUploads.process(md)).to eq(<<~MD)
        This is a [some reference] something

        [some reference]: #{Discourse.base_url}#{upload.short_path}
        MD
      end
    end
  end

  describe ".match_md_inline_img" do
    it "matches URLs with various characters" do
      md = <<~MD
      ![test](https://some-site.com/a_test?q=1&b=hello%20there)
      MD

      url = nil
      InlineUploads.match_md_inline_img(md, external_src: true) { |_match, src| url = src }

      expect(url).to eq("https://some-site.com/a_test?q=1&b=hello%20there")
    end
  end
end


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

class UserSilencer
  attr_reader :user_history

  def initialize(user, by_user = nil, opts = {})
    @user, @by_user, @opts = user, by_user, opts
  end

  def self.silence(user, by_user = nil, opts = {})
    UserSilencer.new(user, by_user, opts).silence
  end

  def self.unsilence(user, by_user = nil, opts = {})
    UserSilencer.new(user, by_user, opts).unsilence
  end

  def self.was_silenced_for?(post)
    return false if post.blank?

    UserHistory.where(action: UserHistory.actions[:silence_user], post: post).exists?
  end

  def silence
    hide_posts unless @opts[:keep_posts]
    return false if @user.silenced_till.present?
    @user.silenced_till = @opts[:silenced_till] || 1000.years.from_now
    if @user.save
      message_type = @opts[:message] || :silenced_by_staff

      details = StaffMessageFormat.new(:silence, @opts[:reason], @opts[:message_body]).format

      context = "#{message_type}: #{@opts[:reason]}"

      if @by_user
        log_params = { context: context, details: details }
        log_params[:post_id] = @opts[:post_id].to_i if @opts[:post_id]

        @user_history = StaffActionLogger.new(@by_user).log_silence_user(@user, log_params)
      end

      silence_message_params = {}
      DiscourseEvent.trigger(
        :user_silenced,
        user: @user,
        silenced_by: @by_user,
        reason: @opts[:reason],
        message: @opts[:message_body],
        user_history: @user_history,
        post_id: @opts[:post_id],
        silenced_till: @user.silenced_till,
        silenced_at: DateTime.now,
        silence_message_params: silence_message_params,
      )

      silence_message_params.merge!(post_alert_options: { skip_send_email: true })
      SystemMessage.create(@user, message_type, silence_message_params)
      true
    end
  end

  def hide_posts
    return unless @user.trust_level == TrustLevel[0]

    Post
      .where(user_id: @user.id)
      .where("created_at > ?", 24.hours.ago)
      .update_all(
        [
          "hidden = true, hidden_reason_id = COALESCE(hidden_reason_id, ?)",
          Post.hidden_reasons[:new_user_spam_threshold_reached],
        ],
      )
    topic_ids =
      Post
        .where(user_id: @user.id, post_number: 1)
        .where("created_at > ?", 24.hours.ago)
        .pluck(:topic_id)
    Topic.where(id: topic_ids).update_all(visible: false) unless topic_ids.empty?
  end

  def unsilence
    @user.silenced_till = nil
    if @user.save
      DiscourseEvent.trigger(:user_unsilenced, user: @user, by_user: @by_user)
      SystemMessage.create(@user, :unsilenced)
      StaffActionLogger.new(@by_user).log_unsilence_user(@user) if @by_user
    end
  end
end

```

Response:
# frozen_string_literal: true

RSpec.describe UserSilencer do
  fab!(:user) { Fabricate(:user, trust_level: 0) }
  fab!(:post) { Fabricate(:post, user: user) }
  fab!(:admin)

  describe "silence" do
    subject(:silence_user) { silencer.silence }

    let(:silencer) { UserSilencer.new(user) }

    it "silences the user correctly" do
      expect { UserSilencer.silence(user, admin) }.to change { user.reload.silenced? }

      # no need to silence as we are already silenced
      expect { UserSilencer.silence(user) }.not_to change { Post.count }

      # post should be hidden
      post.reload
      expect(post.topic.visible).to eq(false)
      expect(post.hidden).to eq(true)

      # history should be right
      count =
        UserHistory.where(
          action: UserHistory.actions[:silence_user],
          acting_user_id: admin.id,
          target_user_id: user.id,
        ).count

      expect(count).to eq(1)
    end

    it "skips sending the email for the silence PM via post alert" do
      NotificationEmailer.enable
      Jobs.run_immediately!
      UserSilencer.silence(user, admin)
      expect(ActionMailer::Base.deliveries.size).to eq(0)
    end

    it "does not hide posts for tl1" do
      user.update!(trust_level: 1)

      UserSilencer.silence(user, admin)

      post.reload
      expect(post.topic.visible).to eq(true)
      expect(post.hidden).to eq(false)
    end

    it "allows us to silence the user for a particular post" do
      expect(UserSilencer.was_silenced_for?(post)).to eq(false)
      UserSilencer.new(user, Discourse.system_user, post_id: post.id).silence
      expect(user).to be_silenced
      expect(UserSilencer.was_silenced_for?(post)).to eq(true)
    end

    it "only hides posts from the past 24 hours" do
      old_post = Fabricate(:post, user: user, created_at: 2.days.ago)

      UserSilencer.new(user, Discourse.system_user, post_id: post.id).silence

      expect(post.reload).to be_hidden
      expect(post.topic.reload).to_not be_visible
      old_post.reload
      expect(old_post).to_not be_hidden
      expect(old_post.topic).to be_visible
    end

    context "with a plugin hook" do
      before do
        @override_silence_message = ->(opts) do
          opts[:silence_message_params][:message_title] = "override title"
          opts[:silence_message_params][:message_raw] = "override raw"
        end

        DiscourseEvent.on(:user_silenced, &@override_silence_message)
      end

      after { DiscourseEvent.off(:user_silenced, &@override_silence_message) }

      it "allows the message to be overridden" do
        UserSilencer.silence(user, admin)
        # force a reload in case instance has no posts
        system_user = User.find(Discourse::SYSTEM_USER_ID)

        post = system_user.posts.order("posts.id desc").first

        expect(post.topic.title).to eq("override title")
        expect(post.raw).to eq("override raw")
      end
    end
  end

  describe "unsilence" do
    it "unsilences the user correctly" do
      user.update!(silenced_till: 1.year.from_now)

      expect { UserSilencer.unsilence(user, admin) }.to change { user.reload.silenced? }

      # sends a message
      pm = user.topics_allowed.order("topics.id desc").first
      title = I18n.t("system_messages.unsilenced.subject_template")
      expect(pm.title).to eq(title)

      # logs it
      count =
        UserHistory.where(
          action: UserHistory.actions[:unsilence_user],
          acting_user_id: admin.id,
          target_user_id: user.id,
        ).count

      expect(count).to eq(1)
    end
  end
end


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

class TopicBookmarkable < BaseBookmarkable
  include TopicPostBookmarkableHelper

  def self.model
    Topic
  end

  def self.serializer
    UserTopicBookmarkSerializer
  end

  def self.preload_associations
    [:category, :tags, { first_post: :user }]
  end

  def self.perform_custom_preload!(topic_bookmarks, guardian)
    topics = topic_bookmarks.map(&:bookmarkable)
    topic_user_lookup = TopicUser.lookup_for(guardian.user, topics)

    topics.each { |topic| topic.user_data = topic_user_lookup[topic.id] }
  end

  def self.list_query(user, guardian)
    topics = Topic.listable_topics.secured(guardian)
    pms = Topic.private_messages_for_user(user)
    topic_bookmarks =
      user
        .bookmarks_of_type("Topic")
        .joins(
          "INNER JOIN topics ON topics.id = bookmarks.bookmarkable_id AND bookmarks.bookmarkable_type = 'Topic'",
        )
        .joins("LEFT JOIN topic_users ON topic_users.topic_id = topics.id")
        .where("topic_users.user_id = ?", user.id)
    guardian.filter_allowed_categories(topic_bookmarks.merge(topics.or(pms)))
  end

  def self.search_query(bookmarks, query, ts_query, &bookmarkable_search)
    bookmarkable_search.call(
      bookmarks.joins(
        "LEFT JOIN posts ON posts.topic_id = topics.id AND posts.post_number = 1",
      ).joins("LEFT JOIN post_search_data ON post_search_data.post_id = posts.id"),
      "#{ts_query} @@ post_search_data.search_data",
    )
  end

  def self.reminder_handler(bookmark)
    send_reminder_notification(
      bookmark,
      topic_id: bookmark.bookmarkable_id,
      post_number: 1,
      data: {
        title: bookmark.bookmarkable.title,
        bookmarkable_url: bookmark.bookmarkable.first_post.url,
      },
    )
  end

  def self.reminder_conditions(bookmark)
    bookmark.bookmarkable.present? && self.can_see?(bookmark.user.guardian, bookmark)
  end

  def self.can_see?(guardian, bookmark)
    guardian.can_see_topic?(bookmark.bookmarkable)
  end

  def self.bookmark_metadata(bookmark, user)
    { topic_bookmarked: Bookmark.for_user_in_topic(user.id, bookmark.bookmarkable.id).exists? }
  end

  def self.validate_before_create(guardian, bookmarkable)
    raise Discourse::InvalidAccess if bookmarkable.blank? || !guardian.can_see_topic?(bookmarkable)
  end

  def self.after_create(guardian, bookmark, opts)
    sync_topic_user_bookmarked(guardian.user, bookmark.bookmarkable, opts)
  end

  def self.after_destroy(guardian, bookmark, opts)
    sync_topic_user_bookmarked(guardian.user, bookmark.bookmarkable, opts)
  end

  def self.cleanup_deleted
    related_topics = DB.query(<<~SQL, grace_time: 3.days.ago)
      DELETE FROM bookmarks b
      USING topics t
      WHERE b.bookmarkable_id = t.id AND b.bookmarkable_type = 'Topic'
      AND (t.deleted_at < :grace_time)
      RETURNING t.id AS topic_id
    SQL

    related_topics_ids = related_topics.map(&:topic_id).uniq
    related_topics_ids.each do |topic_id|
      Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: topic_id)
    end
  end
end

```

Response:
# frozen_string_literal: true

require "rails_helper"

RSpec.describe TopicBookmarkable do
  subject(:registered_bookmarkable) { RegisteredBookmarkable.new(TopicBookmarkable) }

  fab!(:user)
  fab!(:private_category) { Fabricate(:private_category, group: Fabricate(:group)) }

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

  let!(:topic1) { Fabricate(:topic) }
  let!(:topic2) { Fabricate(:topic) }
  let!(:post) { Fabricate(:post, topic: topic1) }
  let!(:bookmark1) do
    Fabricate(:bookmark, user: user, bookmarkable: topic1, name: "something i gotta do")
  end
  let!(:bookmark2) { Fabricate(:bookmark, user: user, bookmarkable: topic2) }
  let!(:bookmark3) { Fabricate(:bookmark) }
  let!(:topic_user1) { Fabricate(:topic_user, user: user, topic: topic1) }
  let!(:topic_user2) { Fabricate(:topic_user, user: user, topic: topic2) }

  describe "#perform_list_query" do
    it "returns all the user's bookmarks" do
      expect(registered_bookmarkable.perform_list_query(user, guardian).map(&:id)).to match_array(
        [bookmark1.id, bookmark2.id],
      )
    end

    it "does not return bookmarks for posts where the user does not have access to the topic category" do
      bookmark1.bookmarkable.update!(category: private_category)
      expect(registered_bookmarkable.perform_list_query(user, guardian).map(&:id)).to match_array(
        [bookmark2.id],
      )
    end

    it "does not return bookmarks for posts where the user does not have access to the private message" do
      bookmark1.update!(bookmarkable: Fabricate(:private_message_topic))
      expect(registered_bookmarkable.perform_list_query(user, guardian).map(&:id)).to match_array(
        [bookmark2.id],
      )
    end
  end

  describe "#perform_search_query" do
    before { SearchIndexer.enable }

    it "returns bookmarks that match by name" do
      ts_query = Search.ts_query(term: "gotta", ts_config: "simple")
      expect(
        registered_bookmarkable.perform_search_query(
          registered_bookmarkable.perform_list_query(user, guardian),
          "%gotta%",
          ts_query,
        ).map(&:id),
      ).to match_array([bookmark1.id])
    end

    it "returns bookmarks that match by post search data (topic title or post content)" do
      post.update(raw: "some post content")
      topic1.update(title: "a great topic title")

      ts_query = Search.ts_query(term: "post content", ts_config: "simple")
      expect(
        registered_bookmarkable.perform_search_query(
          registered_bookmarkable.perform_list_query(user, guardian),
          "%post content%",
          ts_query,
        ).map(&:id),
      ).to match_array([bookmark1.id])

      ts_query = Search.ts_query(term: "great topic", ts_config: "simple")
      expect(
        registered_bookmarkable.perform_search_query(
          registered_bookmarkable.perform_list_query(user, guardian),
          "%great topic%",
          ts_query,
        ).map(&:id),
      ).to match_array([bookmark1.id])

      ts_query = Search.ts_query(term: "blah", ts_config: "simple")
      expect(
        registered_bookmarkable.perform_search_query(
          registered_bookmarkable.perform_list_query(user, guardian),
          "%blah%",
          ts_query,
        ).map(&:id),
      ).to eq([])
    end
  end

  describe "#can_send_reminder?" do
    it "cannot send reminder if the topic is deleted" do
      expect(registered_bookmarkable.can_send_reminder?(bookmark1)).to eq(true)
      bookmark1.bookmarkable.trash!
      bookmark1.reload
      expect(registered_bookmarkable.can_send_reminder?(bookmark1)).to eq(false)
    end

    it "cannot send reminder if the user cannot access the topic" do
      expect(registered_bookmarkable.can_send_reminder?(bookmark1)).to eq(true)
      bookmark1.bookmarkable.update!(category: private_category)
      bookmark1.reload
      expect(registered_bookmarkable.can_send_reminder?(bookmark1)).to eq(false)
    end
  end

  describe "#reminder_handler" do
    it "creates a notification for the user with the correct details" do
      expect { registered_bookmarkable.send_reminder_notification(bookmark1) }.to change {
        Notification.count
      }.by(1)
      notif = user.notifications.last
      expect(notif.notification_type).to eq(Notification.types[:bookmark_reminder])
      expect(notif.topic_id).to eq(bookmark1.bookmarkable_id)
      expect(notif.post_number).to eq(1)
      expect(notif.data).to eq(
        {
          title: bookmark1.bookmarkable.title,
          bookmarkable_url: bookmark1.bookmarkable.first_post.url,
          display_username: bookmark1.user.username,
          bookmark_name: bookmark1.name,
          bookmark_id: bookmark1.id,
        }.to_json,
      )
    end
  end

  describe "#can_see?" do
    it "returns false if the post is in a private category or private message the user cannot see" do
      expect(registered_bookmarkable.can_see?(guardian, bookmark1)).to eq(true)
      bookmark1.bookmarkable.update!(category: private_category)
      expect(registered_bookmarkable.can_see?(guardian, bookmark1)).to eq(false)
      bookmark1.update!(bookmarkable: Fabricate(:private_message_topic))
      expect(registered_bookmarkable.can_see?(guardian, bookmark1)).to eq(false)
    end
  end
end


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

class PostBookmarkable < BaseBookmarkable
  include TopicPostBookmarkableHelper

  def self.model
    Post
  end

  def self.serializer
    UserPostBookmarkSerializer
  end

  def self.preload_associations
    [{ topic: %i[tags category] }, :user]
  end

  def self.list_query(user, guardian)
    topics = Topic.listable_topics.secured(guardian)
    pms = Topic.private_messages_for_user(user)
    post_bookmarks =
      user
        .bookmarks_of_type("Post")
        .joins(
          "INNER JOIN posts ON posts.id = bookmarks.bookmarkable_id AND bookmarks.bookmarkable_type = 'Post'",
        )
        .joins("LEFT JOIN topics ON topics.id = posts.topic_id")
        .joins("LEFT JOIN topic_users ON topic_users.topic_id = topics.id")
        .where("topic_users.user_id = ?", user.id)
    guardian.filter_allowed_categories(
      post_bookmarks.merge(topics.or(pms)).merge(Post.secured(guardian)),
    )
  end

  def self.search_query(bookmarks, query, ts_query, &bookmarkable_search)
    bookmarkable_search.call(
      bookmarks.joins(
        "LEFT JOIN post_search_data ON post_search_data.post_id = bookmarks.bookmarkable_id AND bookmarks.bookmarkable_type = 'Post'",
      ),
      "#{ts_query} @@ post_search_data.search_data",
    )
  end

  def self.reminder_handler(bookmark)
    send_reminder_notification(
      bookmark,
      topic_id: bookmark.bookmarkable.topic_id,
      post_number: bookmark.bookmarkable.post_number,
      data: {
        title: bookmark.bookmarkable.topic.title,
        bookmarkable_url: bookmark.bookmarkable.url,
      },
    )
  end

  def self.reminder_conditions(bookmark)
    bookmark.bookmarkable.present? && bookmark.bookmarkable.topic.present? &&
      self.can_see?(bookmark.user.guardian, bookmark)
  end

  def self.can_see?(guardian, bookmark)
    guardian.can_see_post?(bookmark.bookmarkable)
  end

  def self.bookmark_metadata(bookmark, user)
    {
      topic_bookmarked: Bookmark.for_user_in_topic(user.id, bookmark.bookmarkable.topic_id).exists?,
    }
  end

  def self.validate_before_create(guardian, bookmarkable)
    if bookmarkable.blank? || bookmarkable.topic.blank? ||
         !guardian.can_see_topic?(bookmarkable.topic) || !guardian.can_see_post?(bookmarkable)
      raise Discourse::InvalidAccess
    end
  end

  def self.after_create(guardian, bookmark, opts)
    sync_topic_user_bookmarked(guardian.user, bookmark.bookmarkable.topic, opts)
  end

  def self.after_destroy(guardian, bookmark, opts)
    sync_topic_user_bookmarked(guardian.user, bookmark.bookmarkable.topic, opts)
  end

  def self.cleanup_deleted
    related_topics = DB.query(<<~SQL, grace_time: 3.days.ago)
      DELETE FROM bookmarks b
      USING topics t, posts p
      WHERE t.id = p.topic_id AND b.bookmarkable_id = p.id AND b.bookmarkable_type = 'Post'
      AND (t.deleted_at < :grace_time OR p.deleted_at < :grace_time)
      RETURNING t.id AS topic_id
    SQL

    related_topics_ids = related_topics.map(&:topic_id).uniq
    related_topics_ids.each do |topic_id|
      Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: topic_id)
    end
  end
end

```

Response:
# frozen_string_literal: true

require "rails_helper"

RSpec.describe PostBookmarkable do
  subject(:registered_bookmarkable) { RegisteredBookmarkable.new(PostBookmarkable) }

  fab!(:user)
  fab!(:private_category) { Fabricate(:private_category, group: Fabricate(:group)) }

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

  let!(:post1) { Fabricate(:post) }
  let!(:post2) { Fabricate(:post) }
  let!(:bookmark1) do
    Fabricate(:bookmark, user: user, bookmarkable: post1, name: "something i gotta do")
  end
  let!(:bookmark2) { Fabricate(:bookmark, user: user, bookmarkable: post2) }
  let!(:bookmark3) { Fabricate(:bookmark) }
  let!(:topic_user1) { Fabricate(:topic_user, user: user, topic: post1.topic) }
  let!(:topic_user2) { Fabricate(:topic_user, user: user, topic: post2.topic) }

  describe "#perform_list_query" do
    it "returns all the user's bookmarks" do
      expect(registered_bookmarkable.perform_list_query(user, guardian).map(&:id)).to match_array(
        [bookmark1.id, bookmark2.id],
      )
    end

    it "does not return bookmarks for posts where the user does not have access to the topic category" do
      bookmark1.bookmarkable.topic.update(category: private_category)
      expect(registered_bookmarkable.perform_list_query(user, guardian).map(&:id)).to match_array(
        [bookmark2.id],
      )
    end

    it "does not return bookmarks for posts where the user does not have access to the private message" do
      bookmark1.bookmarkable.update(topic: Fabricate(:private_message_topic))
      expect(registered_bookmarkable.perform_list_query(user, guardian).map(&:id)).to match_array(
        [bookmark2.id],
      )
    end
  end

  describe "#perform_search_query" do
    before { SearchIndexer.enable }

    it "returns bookmarks that match by name" do
      ts_query = Search.ts_query(term: "gotta", ts_config: "simple")
      expect(
        registered_bookmarkable.perform_search_query(
          registered_bookmarkable.perform_list_query(user, guardian),
          "%gotta%",
          ts_query,
        ).map(&:id),
      ).to match_array([bookmark1.id])
    end

    it "returns bookmarks that match by post search data (topic title or post content)" do
      post2.update(raw: "some post content")
      post2.topic.update(title: "a great topic title")

      ts_query = Search.ts_query(term: "post content", ts_config: "simple")
      expect(
        registered_bookmarkable.perform_search_query(
          registered_bookmarkable.perform_list_query(user, guardian),
          "%post content%",
          ts_query,
        ).map(&:id),
      ).to match_array([bookmark2.id])

      ts_query = Search.ts_query(term: "great topic", ts_config: "simple")
      expect(
        registered_bookmarkable.perform_search_query(
          registered_bookmarkable.perform_list_query(user, guardian),
          "%great topic%",
          ts_query,
        ).map(&:id),
      ).to match_array([bookmark2.id])

      ts_query = Search.ts_query(term: "blah", ts_config: "simple")
      expect(
        registered_bookmarkable.perform_search_query(
          registered_bookmarkable.perform_list_query(user, guardian),
          "%blah%",
          ts_query,
        ).map(&:id),
      ).to eq([])
    end
  end

  describe "#can_send_reminder?" do
    it "cannot send reminder if the post or topic is deleted" do
      expect(registered_bookmarkable.can_send_reminder?(bookmark1)).to eq(true)
      bookmark1.bookmarkable.trash!
      bookmark1.reload
      expect(registered_bookmarkable.can_send_reminder?(bookmark1)).to eq(false)
      Post.with_deleted.find_by(id: bookmark1.bookmarkable_id).recover!
      bookmark1.reload
      bookmark1.bookmarkable.topic.trash!
      bookmark1.reload
      expect(registered_bookmarkable.can_send_reminder?(bookmark1)).to eq(false)
    end

    it "cannot send reminder if the user cannot access the topic" do
      expect(registered_bookmarkable.can_send_reminder?(bookmark1)).to eq(true)
      bookmark1.bookmarkable.topic.update!(category: private_category)
      bookmark1.reload
      expect(registered_bookmarkable.can_send_reminder?(bookmark1)).to eq(false)
    end
  end

  describe "#reminder_handler" do
    it "creates a notification for the user with the correct details" do
      expect { registered_bookmarkable.send_reminder_notification(bookmark1) }.to change {
        Notification.count
      }.by(1)
      notif = user.notifications.last
      expect(notif.notification_type).to eq(Notification.types[:bookmark_reminder])
      expect(notif.topic_id).to eq(bookmark1.bookmarkable.topic_id)
      expect(notif.post_number).to eq(bookmark1.bookmarkable.post_number)
      expect(notif.data).to eq(
        {
          title: bookmark1.bookmarkable.topic.title,
          bookmarkable_url: bookmark1.bookmarkable.url,
          display_username: bookmark1.user.username,
          bookmark_name: bookmark1.name,
          bookmark_id: bookmark1.id,
        }.to_json,
      )
    end
  end

  describe "#can_see?" do
    it "returns false if the post is in a private category or private message the user cannot see" do
      expect(registered_bookmarkable.can_see?(guardian, bookmark1)).to eq(true)
      bookmark1.bookmarkable.topic.update(category: private_category)
      expect(registered_bookmarkable.can_see?(guardian, bookmark1)).to eq(false)
      bookmark1.bookmarkable.update(topic: Fabricate(:private_message_topic))
      expect(registered_bookmarkable.can_see?(guardian, bookmark1)).to eq(false)
    end
  end
end


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

module WildcardUrlChecker
  def self.check_url(url, url_to_check)
    return false if !valid_url?(url_to_check)

    escaped_url = Regexp.escape(url).sub("\\*", '\S*')
    url_regex = Regexp.new("\\A#{escaped_url}\\z", "i")

    url_to_check.match?(url_regex)
  end

  private

  def self.valid_url?(url)
    uri = URI.parse(url)
    uri&.scheme.present? && uri&.host.present?
  rescue URI::InvalidURIError
    false
  end
end

```

Response:
# frozen_string_literal: true

RSpec.describe WildcardUrlChecker do
  describe ".check_url" do
    context "when url is valid" do
      it "returns true" do
        result1 =
          described_class.check_url(
            "https://*.discourse.org",
            "https://anything.is.possible.discourse.org",
          )
        expect(result1).to eq(true)

        result2 =
          described_class.check_url("https://www.discourse.org", "https://www.discourse.org")
        expect(result2).to eq(true)

        result3 = described_class.check_url("*", "https://hello.discourse.org")
        expect(result3).to eq(true)

        result4 =
          described_class.check_url("discourse://auth_redirect", "discourse://auth_redirect")
        expect(result4).to eq(true)

        result5 =
          described_class.check_url(
            "customprotocol://www.discourse.org",
            "customprotocol://www.discourse.org",
          )
        expect(result5).to eq(true)
      end
    end

    context "when url is invalid" do
      it "returns false" do
        result1 =
          described_class.check_url(
            "https://*.discourse.org",
            "https://bad-domain.discourse.org.evil.com",
          )
        expect(result1).to eq(false)

        result2 =
          described_class.check_url(
            "https://www.discourse.org",
            "https://www.discourse.org.evil.com",
          )
        expect(result2).to eq(false)

        result3 =
          described_class.check_url("https://www.discourse.org", "https://www.www.discourse.org")
        expect(result3).to eq(false)

        result4 =
          described_class.check_url(
            "https://www.discourse.org",
            "https://www.discourse.org\nwww.discourse.org.evil.com",
          )
        expect(result4).to eq(false)

        result5 = described_class.check_url("https://", "https://")
        expect(result5).to eq(false)

        result6 =
          described_class.check_url(
            "invalid$protocol://www.discourse.org",
            "invalid$protocol://www.discourse.org",
          )
        expect(result6).to eq(false)

        result7 = described_class.check_url("noscheme", "noscheme")
        expect(result7).to eq(false)
      end
    end
  end
end


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

class EmailStyleUpdater
  attr_reader :errors

  def initialize(user)
    @user = user
    @errors = []
  end

  def update(attrs)
    if attrs.has_key?(:html) && !attrs[:html].include?("%{email_content}")
      @errors << I18n.t("email_style.html_missing_placeholder", placeholder: "%{email_content}")
    end

    if attrs.has_key?(:css)
      begin
        compiled_css = SassC::Engine.new(attrs[:css], style: :compressed).render
      rescue SassC::SyntaxError => e
        # @errors << I18n.t('email_style.css_syntax_error')
        @errors << e.message[0...(e.message.index("\n"))]
      end
    end

    return false unless @errors.empty?

    if attrs.has_key?(:html)
      if attrs[:html] == EmailStyle.default_template
        SiteSetting.remove_override!(:email_custom_template)
      else
        SiteSetting.email_custom_template = attrs[:html]
      end
    end

    if attrs.has_key?(:css)
      if attrs[:css] == EmailStyle.default_css
        SiteSetting.remove_override!(:email_custom_css)
        SiteSetting.remove_override!(:email_custom_css_compiled)
      else
        SiteSetting.email_custom_css = attrs[:css]
        SiteSetting.email_custom_css_compiled = compiled_css
      end
    end

    @errors.empty?
  end
end

```

Response:
# frozen_string_literal: true

RSpec.describe EmailStyleUpdater do
  fab!(:admin)
  let(:default_html) { File.read("#{Rails.root}/app/views/email/default_template.html") }
  let(:updater) { EmailStyleUpdater.new(admin) }

  def expect_settings_to_be_unset
    expect(SiteSetting.email_custom_template).to_not be_present
    expect(SiteSetting.email_custom_css).to_not be_present
    expect(SiteSetting.email_custom_css_compiled).to_not be_present
  end

  describe "update" do
    it "can change the settings" do
      expect(updater.update(html: "For you: %{email_content}", css: "h1 { color: blue; }")).to eq(
        true,
      )
      expect(SiteSetting.email_custom_template).to eq("For you: %{email_content}")
      expect(SiteSetting.email_custom_css).to eq("h1 { color: blue; }")
      expect(SiteSetting.email_custom_css_compiled.strip).to eq("h1{color:blue}")
    end

    it "will not store defaults" do
      updater.update(html: default_html, css: "")
      expect_settings_to_be_unset
    end

    it "can clear settings if defaults given" do
      SiteSetting.email_custom_template = "For you: %{email_content}"
      SiteSetting.email_custom_css = "h1 { color: blue; }"
      SiteSetting.email_custom_css_compiled = "h1{color:blue}"
      updater.update(html: default_html, css: "")
      expect_settings_to_be_unset
    end

    it "fails if html is missing email_content" do
      expect(updater.update(html: "No email content", css: "")).to eq(false)
      expect(updater.errors).to include(
        I18n.t("email_style.html_missing_placeholder", placeholder: "%{email_content}"),
      )
      expect_settings_to_be_unset
    end

    it "fails if css is not valid scss" do
      expect(updater.update(html: "For you: %{email_content}", css: "h1 { color: blue;")).to eq(
        false,
      )
      expect(updater.errors).to_not be_empty
      expect(updater.errors.first).to include('Error: expected "}".')
      expect_settings_to_be_unset
    end
  end
end


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

class SearchIndexer
  MIN_POST_BLURB_INDEX_VERSION = 4

  POST_INDEX_VERSION = 5
  TOPIC_INDEX_VERSION = 4
  CATEGORY_INDEX_VERSION = 3
  USER_INDEX_VERSION = 3
  TAG_INDEX_VERSION = 3

  # version to apply when issuing a background reindex
  REINDEX_VERSION = 0
  TS_VECTOR_PARSE_REGEX = /('([^']*|'')*'\:)(([0-9]+[A-D]?,?)+)/

  def self.disable
    @disabled = true
  end

  def self.enable
    @disabled = false
  end

  def self.update_index(table:, id:, a_weight: nil, b_weight: nil, c_weight: nil, d_weight: nil)
    raw_data = { a: a_weight, b: b_weight, c: c_weight, d: d_weight }

    # The version used in excerpts
    search_data = raw_data.transform_values { |data| Search.prepare_data(data || "", :index) }

    # The version used to build the index
    indexed_data =
      search_data.transform_values do |data|
        data.gsub(/\S+/) { |word| word[0...SiteSetting.search_max_indexed_word_length] }
      end

    table_name = "#{table}_search_data"
    foreign_key = "#{table}_id"

    # for user login and name use "simple" lowercase stemmer
    stemmer = table == "user" ? "simple" : Search.ts_config

    ranked_index = <<~SQL
      setweight(to_tsvector('#{stemmer}', #{Search.wrap_unaccent("coalesce(:a,''))")}, 'A') ||
      setweight(to_tsvector('#{stemmer}', #{Search.wrap_unaccent("coalesce(:b,''))")}, 'B') ||
      setweight(to_tsvector('#{stemmer}', #{Search.wrap_unaccent("coalesce(:c,''))")}, 'C') ||
      setweight(to_tsvector('#{stemmer}', #{Search.wrap_unaccent("coalesce(:d,''))")}, 'D')
    SQL

    tsvector = DB.query_single("SELECT #{ranked_index}", indexed_data)[0]
    additional_lexemes = []

    # we also want to index parts of a domain name
    # that way stemmed single word searches will match
    additional_words = []

    tsvector
      .scan(/'(([a-zA-Z0-9]+\.)+[a-zA-Z0-9]+)'\:([\w+,]+)/)
      .reduce(additional_lexemes) do |array, (lexeme, _, positions)|
        count = 0

        if lexeme !~ /\A(\d+\.)?(\d+\.)*(\*|\d+)\z/
          loop do
            count += 1
            break if count >= 10 # Safeguard here to prevent infinite loop when a term has many dots
            term, _, remaining = lexeme.partition(".")
            break if remaining.blank?

            additional_words << [term, positions]

            array << "'#{remaining}':#{positions}"
            lexeme = remaining
          end
        end

        array
      end

    extra_domain_word_terms =
      if additional_words.length > 0
        DB
          .query_single(
            "SELECT to_tsvector(?, ?)",
            stemmer,
            additional_words.map { |term, _| term }.join(" "),
          )
          .first
          .scan(TS_VECTOR_PARSE_REGEX)
          .map do |term, _, indexes|
            new_indexes =
              indexes
                .split(",")
                .map do |index|
                  existing_positions = additional_words[index.to_i - 1]
                  if existing_positions
                    existing_positions[1]
                  else
                    index
                  end
                end
                .join(",")
            "#{term}#{new_indexes}"
          end
          .join(" ")
      end

    tsvector = "#{tsvector} #{additional_lexemes.join(" ")} #{extra_domain_word_terms}"

    if (max_dupes = SiteSetting.max_duplicate_search_index_terms) > 0
      reduced = []
      tsvector
        .scan(TS_VECTOR_PARSE_REGEX)
        .each do |term, _, indexes|
          family_counts = Hash.new(0)
          new_index_array = []

          indexes
            .split(",")
            .each do |index|
              family = nil
              family = index[-1] if index[-1].match?(/[A-D]/)
              # title dupes can completely dominate the index
              # so we limit them to 1
              if (family_counts[family] += 1) <= (family == "A" ? 1 : max_dupes)
                new_index_array << index
              end
            end
          reduced << "#{term.strip}#{new_index_array.join(",")}"
        end
      tsvector = reduced.join(" ")
    end

    indexed_data =
      if table.to_s == "post"
        clean_post_raw_data!(search_data[:d])
      else
        search_data.values.select { |d| d.length > 0 }.join(" ")
      end

    params = {
      "raw_data" => indexed_data,
      "#{foreign_key}" => id,
      "locale" => SiteSetting.default_locale,
      "version" => const_get("#{table.upcase}_INDEX_VERSION"),
      "search_data" => tsvector,
    }

    yield params if block_given?
    table_name.camelize.constantize.upsert(params)
  rescue => e
    if Rails.env.test?
      raise
    else
      # TODO is there any way we can safely avoid this?
      # best way is probably pushing search indexer into a dedicated process so it no longer happens on save
      # instead in the post processor
      Discourse.warn_exception(
        e,
        message: "Unexpected error while indexing #{table} for search",
        env: {
          id: id,
        },
      )
    end
  end

  def self.update_topics_index(topic_id, title, cooked)
    # a bit inconsistent that we use title as A and body as B when in
    # the post index body is D
    update_index(
      table: "topic",
      id: topic_id,
      a_weight: title,
      b_weight: HtmlScrubber.scrub(cooked)[0...Topic::MAX_SIMILAR_BODY_LENGTH],
    )
  end

  def self.update_posts_index(
    post_id:,
    topic_title:,
    category_name:,
    topic_tags:,
    cooked:,
    private_message:
  )
    update_index(
      table: "post",
      id: post_id,
      a_weight: topic_title,
      b_weight: category_name,
      c_weight: topic_tags,
      # The tsvector resulted from parsing a string can be double the size of
      # the original string. Since there is no way to estimate the length of
      # the expected tsvector, we limit the input to ~50% of the maximum
      # length of a tsvector (1_048_576 bytes).
      d_weight: HtmlScrubber.scrub(cooked)[0..600_000],
    ) { |params| params["private_message"] = private_message }
  end

  def self.update_users_index(user_id, username, name, custom_fields)
    update_index(
      table: "user",
      id: user_id,
      a_weight: username,
      b_weight: name,
      c_weight: custom_fields,
    )
  end

  def self.update_categories_index(category_id, name)
    update_index(table: "category", id: category_id, a_weight: name)
  end

  def self.update_tags_index(tag_id, name)
    update_index(table: "tag", id: tag_id, a_weight: name.downcase)
  end

  def self.queue_category_posts_reindex(category_id)
    return if @disabled

    DB.exec(<<~SQL, category_id: category_id, version: REINDEX_VERSION)
      UPDATE post_search_data
      SET version = :version
      FROM posts
      INNER JOIN topics ON posts.topic_id = topics.id
      INNER JOIN categories ON topics.category_id = categories.id
      WHERE post_search_data.post_id = posts.id
      AND categories.id = :category_id
    SQL
  end

  def self.queue_users_reindex(user_ids)
    return if @disabled

    DB.exec(<<~SQL, user_ids: user_ids, version: REINDEX_VERSION)
      UPDATE user_search_data
      SET version = :version
      WHERE user_search_data.user_id IN (:user_ids)
    SQL
  end

  def self.queue_post_reindex(topic_id)
    return if @disabled

    DB.exec(<<~SQL, topic_id: topic_id, version: REINDEX_VERSION)
      UPDATE post_search_data
      SET version = :version
      FROM posts
      WHERE post_search_data.post_id = posts.id
      AND posts.topic_id = :topic_id
    SQL
  end

  def self.index(obj, force: false)
    return if @disabled

    category_name = nil
    tag_names = nil
    topic = nil

    if Topic === obj
      topic = obj
    elsif Post === obj
      topic = obj.topic
    end

    category_name = topic.category&.name if topic

    if topic
      tags = topic.tags.select(:id, :name).to_a

      if tags.present?
        tag_names =
          (tags.map(&:name) + Tag.where(target_tag_id: tags.map(&:id)).pluck(:name)).join(" ")
      end
    end

    if Post === obj && obj.raw.present? &&
         (force || obj.saved_change_to_cooked? || obj.saved_change_to_topic_id?)
      if topic
        SearchIndexer.update_posts_index(
          post_id: obj.id,
          topic_title: topic.title,
          category_name: category_name,
          topic_tags: tag_names,
          cooked: obj.cooked,
          private_message: topic.private_message?,
        )

        SearchIndexer.update_topics_index(topic.id, topic.title, obj.cooked) if obj.is_first_post?
      end
    end

    if User === obj && (obj.saved_change_to_username? || obj.saved_change_to_name? || force)
      SearchIndexer.update_users_index(
        obj.id,
        obj.username_lower || "",
        obj.name ? obj.name.downcase : "",
        obj.user_custom_fields.searchable.map(&:value).join(" "),
      )
    end

    if Topic === obj && (obj.saved_change_to_title? || force)
      if obj.posts
        if post = obj.posts.find_by(post_number: 1)
          SearchIndexer.update_posts_index(
            post_id: post.id,
            topic_title: obj.title,
            category_name: category_name,
            topic_tags: tag_names,
            cooked: post.cooked,
            private_message: obj.private_message?,
          )

          SearchIndexer.update_topics_index(obj.id, obj.title, post.cooked)
        end
      end
    end

    if Category === obj && (obj.saved_change_to_name? || force)
      SearchIndexer.queue_category_posts_reindex(obj.id)
      SearchIndexer.update_categories_index(obj.id, obj.name)
    end

    if Tag === obj && (obj.saved_change_to_name? || force)
      SearchIndexer.update_tags_index(obj.id, obj.name)
    end
  end

  def self.clean_post_raw_data!(raw_data)
    urls = Set.new
    raw_data.scan(Discourse::Utils::URI_REGEXP) { urls << $& }

    urls.each do |url|
      begin
        case File.extname(URI(url).path || "")
        when Oneboxer::VIDEO_REGEX
          raw_data.gsub!(url, I18n.t("search.video"))
        when Oneboxer::AUDIO_REGEX
          raw_data.gsub!(url, I18n.t("search.audio"))
        end
      rescue URI::InvalidURIError
      end
    end

    raw_data
  end
  private_class_method :clean_post_raw_data!

  class HtmlScrubber < Nokogiri::XML::SAX::Document
    attr_reader :scrubbed

    def initialize
      @scrubbed = +""
    end

    def self.scrub(html)
      return +"" if html.blank?

      begin
        document = Nokogiri.HTML5("<div>#{html}</div>", nil, Encoding::UTF_8.to_s)
      rescue ArgumentError
        return +""
      end

      nodes = document.css("div.#{CookedPostProcessor::LIGHTBOX_WRAPPER_CSS_CLASS}")

      if nodes.present?
        nodes.each do |node|
          node.traverse do |child_node|
            next if child_node == node

            if %w[a img].exclude?(child_node.name)
              child_node.remove
            elsif child_node.name == "a"
              ATTRIBUTES.each { |attribute| child_node.remove_attribute(attribute) }
            end
          end
        end
      end

      document.css("img.emoji").each { |node| node.remove_attribute("alt") }

      document
        .css("a[href]")
        .each do |node|
          if node["href"] == node.text || MENTION_CLASSES.include?(node["class"])
            node.remove_attribute("href")
          end

          if node["class"] == "anchor" && node["href"].starts_with?("#")
            node.remove_attribute("href")
          end
        end

      html_scrubber = new
      Nokogiri::HTML::SAX::Parser.new(html_scrubber).parse(document.to_html)
      html_scrubber.scrubbed.squish
    end

    MENTION_CLASSES ||= %w[mention mention-group]
    ATTRIBUTES ||= %w[alt title href data-video-title]

    def start_element(_name, attributes = [])
      attributes = Hash[*attributes.flatten]

      ATTRIBUTES.each do |attribute_name|
        if attributes[attribute_name].present? &&
             !(attribute_name == "href" && UrlHelper.is_local(attributes[attribute_name]))
          characters(attributes[attribute_name])
        end
      end
    end

    def characters(str)
      scrubbed << " #{str} "
    end
  end
end

```

Response:
# frozen_string_literal: true

RSpec.describe SearchIndexer do
  let(:post_id) { 99 }

  before { SearchIndexer.enable }

  after { SearchIndexer.disable }

  it "correctly indexes chinese" do
    SiteSetting.default_locale = "zh_CN"
    data = "你好世界"

    SearchIndexer.update_posts_index(
      post_id: post_id,
      topic_title: "",
      category_name: "",
      topic_tags: "",
      cooked: data,
      private_message: false,
    )

    post_search_data = PostSearchData.find_by(post_id: post_id)

    expect(post_search_data.raw_data).to eq("你好 世界")
    expect(post_search_data.search_data).to eq("'世界':2 '你好':1")
  end

  it "extract youtube title" do
    html =
      "<div class=\"lazy-video-container\" data-video-id=\"lmFgeFh2nlw\" data-video-title=\"Metallica Mixer Explains Missing Bass on 'And Justice for All' [Exclusive]\" data-provider-name=\"youtube\"></div>"
    scrubbed = SearchIndexer::HtmlScrubber.scrub(html)
    expect(scrubbed).to eq(
      "Metallica Mixer Explains Missing Bass on 'And Justice for All' [Exclusive]",
    )
  end

  it "extract a link" do
    html = "<a href='http://meta.discourse.org/'>link</a>"
    scrubbed = SearchIndexer::HtmlScrubber.scrub(html)
    expect(scrubbed).to eq("http://meta.discourse.org/ link")
  end

  it "ignores autogenerated link anchors" do
    html = "<a class='anchor' href='#something-special'>something special</a>"
    scrubbed = SearchIndexer::HtmlScrubber.scrub(html)
    expect(scrubbed).to eq("something special")
  end

  it "extracts @username from mentions" do
    html =
      '<p><a class="mention" href="/u/%E7%8B%AE%E5%AD%90">@狮子</a> <a class="mention" href="/u/foo">@foo</a></p>'
    scrubbed = SearchIndexer::HtmlScrubber.scrub(html)
    expect(scrubbed).to eq("@狮子 @foo")
  end

  it "extracts @groupname from group mentions" do
    html =
      '<p><a class="mention-group" href="/groups/%D0%B0%D0%B2%D1%82%D0%BE%D0%BC%D0%BE%D0%B1%D0%B8%D0%BB%D0%B8%D1%81%D1%82">@автомобилист</a></p>'
    scrubbed = SearchIndexer::HtmlScrubber.scrub(html)
    expect(scrubbed).to eq("@автомобилист")
  end

  it "extracts emoji name from emoji image" do
    emoji = Emoji["wink"]
    html =
      %Q|<img src=\"#{URI.join(Discourse.base_url_no_prefix, emoji.url)}\" title=\":wink:\" class=\"emoji only-emoji\" alt=\":wink:\" loading=\"lazy\" width=\"20\" height=\"20\">|
    scrubbed = SearchIndexer::HtmlScrubber.scrub(html)

    expect(scrubbed).to eq(":wink:")
  end

  it "doesn't index local files" do
    html = <<~HTML
      <p><img src="https://www.discourse.org/logo.png" alt="Discourse"></p>
      <p><img src="#{Discourse.base_url_no_prefix}/uploads/episodeinteractive/original/3X/0/f/0f40b818356bdc1d80acfa905034e95cfd112a3a.png" alt="51%20PM" width="289" height="398"></p>
      <div class="lightbox-wrapper">
        <a class="lightbox" href="#{Discourse.base_url_no_prefix}/uploads/episodeinteractive/original/3X/1/6/16790095df3baf318fb2eb1d7e5d7860dc45d48b.jpg" data-download-href="#{Discourse.base_url_no_prefix}/uploads/episodeinteractive/16790095df3baf318fb2eb1d7e5d7860dc45d48b" title="Untitled design (21).jpg" rel="nofollow noopener">
          <img src="#{Discourse.base_url_no_prefix}/uploads/episodeinteractive/optimized/3X/1/6/16790095df3baf318fb2eb1d7e5d7860dc45d48b_1_563x500.jpg" alt="Untitled%20design%20(21)" width="563" height="500">
          <div class="meta">
            <svg class="fa d-icon d-icon-far-image svg-icon" aria-hidden="true"><use href="#far-image"></use></svg>
            <span class="filename">Untitled design (21).jpg</span>
            <span class="informations">1280x1136 472 KB</span>
            <svg class="fa d-icon d-icon-discourse-expand svg-icon" aria-hidden="true"><use href="#discourse-expand"></use></svg>
          </div>
        </a>
      </div>
    HTML

    scrubbed = SearchIndexer::HtmlScrubber.scrub(html)

    expect(scrubbed).to eq("Discourse 51%20PM Untitled%20design%20(21)")
  end

  it "correctly indexes a post according to version" do
    # Preparing so that they can be indexed to right version
    SearchIndexer.update_posts_index(
      post_id: post_id,
      topic_title: "dummy",
      category_name: "",
      topic_tags: nil,
      cooked: nil,
      private_message: false,
    )

    PostSearchData.find_by(post_id: post_id).update!(version: -1)

    data = "<a>This</a> is a test"

    SearchIndexer.update_posts_index(
      post_id: post_id,
      topic_title: "",
      category_name: "",
      topic_tags: nil,
      cooked: data,
      private_message: false,
    )

    raw_data, locale, version =
      PostSearchData.where(post_id: post_id).pluck(:raw_data, :locale, :version)[0]
    expect(raw_data).to eq("This is a test")
    expect(locale).to eq(SiteSetting.default_locale)
    expect(version).to eq(SearchIndexer::POST_INDEX_VERSION)
  end

  describe ".index" do
    let(:topic) { Fabricate(:topic, title: "this is a title that I am testing") }
    let(:post) { Fabricate(:post, topic: topic) }

    it "should index posts correctly" do
      expect { post }.to change { PostSearchData.count }.by(1)

      expect { post.update!(raw: "this is new content") }.to change {
        post.reload.post_search_data.search_data
      }

      expect { post.update!(topic_id: Fabricate(:topic).id) }.to change {
        post.reload.post_search_data.search_data
      }
    end

    it "should work with edge case domain names" do
      # 00E5A4 stems to 00e5 and a4, which is odd, but by-design
      # this may cause internal indexing to fail due to indexes not aligning
      # when stuffing terms for domains
      post.update!(cooked: <<~HTML)
        Test.00E5A4.1
      HTML

      SearchIndexer.update_posts_index(
        post_id: post.id,
        topic_title: post.topic.title,
        category_name: post.topic.category&.name,
        topic_tags: post.topic.tags.map(&:name).join(" "),
        cooked: post.cooked,
        private_message: post.topic.private_message?,
      )
    end

    it "should work with invalid HTML" do
      post.update!(cooked: "<FD>" * Nokogiri::Gumbo::DEFAULT_MAX_TREE_DEPTH)

      SearchIndexer.update_posts_index(
        post_id: post.id,
        topic_title: post.topic.title,
        category_name: post.topic.category&.name,
        topic_tags: post.topic.tags.map(&:name).join(" "),
        cooked: post.cooked,
        private_message: post.topic.private_message?,
      )
    end

    it "should not index posts with empty raw" do
      expect do
        post = Fabricate.build(:post, raw: "", post_type: Post.types[:small_action])
        post.save!(validate: false)
      end.to_not change { PostSearchData.count }
    end

    it "should not tokenize urls and duplicate title and href in <a>" do
      post.update!(raw: <<~RAW)
      https://meta.discourse.org/some.png
      RAW

      post.rebake!
      post.reload

      expect(post.post_search_data.raw_data).to eq("https://meta.discourse.org/some.png")

      expect(post.post_search_data.search_data).to eq_ts_vector(
        "'/some.png':12 'discourse.org':11 'meta.discourse.org':11 'meta.discourse.org/some.png':10 'org':11 'test':8A 'titl':4A 'uncategor':9B 'meta':11 'discours':11",
      )
    end

    it "should not tokenize versions" do
      post.update!(raw: "123.223")

      expect(post.post_search_data.search_data).to eq(
        "'123.223':10 'test':8A 'titl':4A 'uncategor':9B",
      )

      post.update!(raw: "15.2.231.423")
      post.reload

      expect(post.post_search_data.search_data).to eq(
        "'15.2.231.423':10 'test':8A 'titl':4A 'uncategor':9B",
      )
    end

    it "should tokenize host of a URL and removes query string" do
      category = Fabricate(:category, name: "awesome category")
      topic = Fabricate(:topic, category: category, title: "this is a test topic")

      post = Fabricate(:post, topic: topic, raw: <<~RAW)
      a https://car.com?bob=1, http://efg.com.au?bill=1 b hij.net/xyz=1
      www.klm.net/?IGNORE=1 <a href="http://abc.de.nop.co.uk?IGNORE=1&ignore2=2">test</a> https://cars.com
      RAW

      post.rebake!
      post.reload
      topic = post.topic

      # Note, a random non URL string should be tokenized properly,
      # hence www.klm.net?IGNORE=1 it was inserted in autolinking.
      # We could consider amending the auto linker to add
      # more context to say "hey, this part of <a href>...</a> was a guess by autolinker.
      # A blanket treating of non-urls without this logic is risky.
      expect(post.post_search_data.raw_data).to eq(
        "a https://car.com , http://efg.com.au b http://hij.net/xyz=1 hij.net/xyz=1 http://www.klm.net/ www.klm.net/?IGNORE=1 http://abc.de.nop.co.uk test https://cars.com",
      )

      expect(post.post_search_data.search_data).to eq_ts_vector(
        "'/?ignore=1':21 '/xyz=1':14,17 'car.com':9 'cars.com':24 'abc.de.nop.co.uk':22 'au':10 'awesom':6B 'b':11 'categori':7B 'co.uk':22 'com':9,10,24 'com.au':10 'de.nop.co.uk':22 'efg.com.au':10 'hij.net':13,16 'hij.net/xyz=1':12,15 'klm.net':18,20 'net':13,16,18,20 'nop.co.uk':22 'test':4A,23 'topic':5A 'uk':22 'www.klm.net':18,20 'www.klm.net/?ignore=1':19 'car':9,24 'co':22 'de':22 'efg':10 'hij':13,16 'klm':18,20 'nop':22 'www':18,20 'abc':22",
      )
    end

    it "should not include lightbox in search" do
      Jobs.run_immediately!
      SiteSetting.max_image_width = 1

      stub_request(:get, "https://1.2.3.4/some.png").to_return(
        status: 200,
        body: file_from_fixtures("logo.png").read,
      )

      src = "https://meta.discourse.org/some.png"

      post = Fabricate(:post, raw: <<~RAW)
      Let me see how I can fix this image
      <img src="#{src}" title="GOT" alt="white walkers" width="2" height="2">
      RAW

      post.rebake!
      post.reload

      expect(post.cooked).not_to include(CookedPostProcessor::LIGHTBOX_WRAPPER_CSS_CLASS)

      expect(post.post_search_data.raw_data).to eq(
        "Let me see how I can fix this image white walkers GOT",
      )
    end

    it "should strips audio and videos URLs from raw data" do
      SiteSetting.authorized_extensions = "mp4"
      Fabricate(:video_upload)

      post.update!(raw: <<~RAW)
      link to an external page: https://google.com/?u=bar

      link to an audio file: https://somesite.com/audio.m4a

      link to a video file: https://somesite.com/content/somethingelse.MOV

      link to an invalid URL: http:error]
      RAW

      expect(post.post_search_data.raw_data).to eq(
        "link to an external page: https://google.com/ link to an audio file: #{I18n.t("search.audio")} link to a video file: #{I18n.t("search.video")} link to an invalid URL: http:error]",
      )

      expect(post.post_search_data.search_data).to eq_ts_vector(
        "'/audio.m4a':23 '/content/somethingelse.mov':31 'audio':19 'com':15,22,30 'error':38 'extern':13 'file':20,28 'google.com':15 'http':37 'invalid':35 'link':10,16,24,32 'page':14 'somesite.com':22,30 'somesite.com/audio.m4a':21 'somesite.com/content/somethingelse.mov':29 'test':8A 'titl':4A 'uncategor':9B 'url':36 'video':27 'googl':15 'somesit':22,30",
      )
    end

    it "should unaccent indexed content" do
      SiteSetting.search_ignore_accents = true
      post.update!(raw: "Cette oeuvre d'art n'est pas une œuvre")
      post.post_search_data.reload
      expect(post.post_search_data.search_data).not_to include("œuvr")
      expect(post.post_search_data.search_data).to include("oeuvr")

      SiteSetting.search_ignore_accents = false
      SearchIndexer.index(post, force: true)
      post.post_search_data.reload
      expect(post.post_search_data.search_data).to include("œuvr")
      expect(post.post_search_data.search_data).to include("oeuvr")
    end

    it "truncates long words in the index" do
      SiteSetting.search_max_indexed_word_length = 4
      title = "A title that is long enough"
      contents = "I am the best beige object http://example.com/long/url"

      topic.update!(title: title)
      post.update!(raw: contents)
      post_search_data = post.post_search_data
      post_search_data.reload

      expect(post_search_data.raw_data).to eq(contents)

      words = post_search_data.search_data.scan(/'([^']*)'/).map { |match| match[0] }
      expect(words).to contain_exactly(
        "best",
        "beig",
        "obj",
        "http",
        "titl",
        "long",
        "enou",
        "unca",
      )
    end

    it "limits number of repeated terms when max_duplicate_search_index_terms site setting has been configured" do
      SiteSetting.max_duplicate_search_index_terms = 5

      contents = <<~TEXT
        #{"sam " * 10}
        <a href="https://something.com/path:path'path?term='hello'">url</a>
        <a href="https://somethings.com/path:path'path?term='hello'">url</a>
      TEXT

      post.update!(raw: contents)

      post_search_data = post.post_search_data
      post_search_data.reload

      terms =
        "'/path:path''path':22,26 'com':21,25 'sam':10,11,12,13,14 'something.com':21 'something.com/path:path''path':20 'test':8A 'titl':4A 'uncategor':9B 'url':23,27 'someth':21,25 'somethings.com':25 'somethings.com/path:path''path':24"

      expect(post_search_data.search_data).to eq_ts_vector(terms)
    end
  end

  describe ".queue_post_reindex" do
    let(:post) { Fabricate(:post) }
    let(:topic) { post.topic }

    it "should reset the version of search data for all posts in the topic" do
      post2 = Fabricate(:post)

      SearchIndexer.queue_post_reindex(topic.id)

      expect(post.reload.post_search_data.version).to eq(SearchIndexer::REINDEX_VERSION)

      expect(post2.reload.post_search_data.version).to eq(SearchIndexer::POST_INDEX_VERSION)
    end
  end

  describe ".queue_users_reindex" do
    let!(:user) { Fabricate(:user) }
    let!(:user2) { Fabricate(:user) }

    it "should reset the version of search data for all users" do
      SearchIndexer.index(user, force: true)
      SearchIndexer.index(user2, force: true)
      SearchIndexer.queue_users_reindex([user.id])

      expect(user.reload.user_search_data.version).to eq(SearchIndexer::REINDEX_VERSION)

      expect(user2.reload.user_search_data.version).to eq(SearchIndexer::USER_INDEX_VERSION)
    end
  end
end


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

class UserNotificationScheduleProcessor
  attr_accessor :schedule, :user, :timezone_name

  def initialize(schedule)
    @schedule = schedule
    @user = schedule.user
    @timezone_name = user.user_option.timezone
  end

  def create_do_not_disturb_timings
    local_time = Time.now.in_time_zone(timezone_name)

    create_timings_for(local_time, days: 2)
  end

  def self.create_do_not_disturb_timings_for(schedule)
    processor = UserNotificationScheduleProcessor.new(schedule)
    processor.create_do_not_disturb_timings
  end

  private

  def create_timings_for(local_time, days: 0, previous_timing: nil)
    weekday = transform_wday(local_time.wday)
    start_minute = schedule["day_#{weekday}_start_time"]
    end_minute = schedule["day_#{weekday}_end_time"]

    previous_timing = find_previous_timing(local_time) if previous_timing.nil? && start_minute != 0

    if start_minute > 0
      previous_timing.ends_at = utc_time_at_minute(local_time, start_minute - 1)
      if previous_timing.id
        previous_timing.save
      else
        user.do_not_disturb_timings.find_or_create_by(previous_timing.attributes.except("id"))
      end

      next_timing =
        user.do_not_disturb_timings.new(
          starts_at: utc_time_at_minute(local_time, end_minute),
          scheduled: true,
        )
      save_timing_and_continue(local_time, next_timing, days)
    else
      save_timing_and_continue(local_time, previous_timing, days)
    end
  end

  private

  def find_previous_timing(local_time)
    # Try and find a previously scheduled dnd timing that we can extend if the
    # ends_at is at the previous midnight. fallback to a new timing if not.
    previous =
      user.do_not_disturb_timings.find_by(
        ends_at: (local_time - 1.day).end_of_day.utc,
        scheduled: true,
      )
    previous ||
      user.do_not_disturb_timings.new(starts_at: local_time.beginning_of_day.utc, scheduled: true)
  end

  def save_timing_and_continue(local_time, timing, days)
    if days == 0
      if timing
        timing.ends_at = local_time.end_of_day.utc
        user.do_not_disturb_timings.find_or_create_by(timing.attributes.except("id"))
      end
      user.publish_do_not_disturb(ends_at: user.do_not_disturb_until)
    else
      create_timings_for(local_time + 1.day, days: days - 1, previous_timing: timing)
    end
  end

  def utc_time_at_minute(base_time, total_minutes)
    hour = total_minutes / 60
    minute = total_minutes % 60
    Time.new(
      base_time.year,
      base_time.month,
      base_time.day,
      hour,
      minute,
      0,
      base_time.formatted_offset,
    ).utc
  end

  def transform_wday(wday)
    wday == 0 ? 6 : wday - 1
  end
end

```

Response:
# frozen_string_literal: true

RSpec.describe UserNotificationScheduleProcessor do
  include ActiveSupport::Testing::TimeHelpers

  fab!(:user)
  let(:standard_schedule) do
    schedule =
      UserNotificationSchedule.create({ user: user }.merge(UserNotificationSchedule::DEFAULT))
    schedule.enabled = true
    schedule.save
    schedule
  end

  describe "#create_do_not_disturb_timings" do
    [
      { timezone: "UTC", offset: "+00:00" },
      { timezone: "America/Chicago", offset: "-06:00" },
      { timezone: "Australia/Sydney", offset: "+11:00" },
    ].each do |timezone_info|
      it "creates dnd timings correctly for each timezone" do
        user.user_option.update(timezone: timezone_info[:timezone])

        travel_to Time.new(2020, 1, 4, 12, 0, 0, "+00:00") do
          UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)

          # The default schedule is 8am - 5pm.
          # Expect DND timings to fill gaps before/after those times for 3 days.
          dnd_timings = user.do_not_disturb_timings
          offset = timezone_info[:offset]
          expect(dnd_timings[0].starts_at).to eq_time(Time.new(2020, 1, 4, 0, 0, 0, offset))
          expect(dnd_timings[0].ends_at).to eq_time(Time.new(2020, 1, 4, 7, 59, 0, offset))

          expect(dnd_timings[1].starts_at).to eq_time(Time.new(2020, 1, 4, 17, 0, 0, offset))
          expect(dnd_timings[1].ends_at).to eq_time(Time.new(2020, 1, 5, 7, 59, 0, offset))

          expect(dnd_timings[2].starts_at).to eq_time(Time.new(2020, 1, 5, 17, 0, 0, offset))
          expect(dnd_timings[2].ends_at).to eq_time(Time.new(2020, 1, 6, 7, 59, 0, offset))

          expect(dnd_timings[3].starts_at).to eq_time(Time.new(2020, 1, 6, 17, 0, 0, offset))
          expect(dnd_timings[3].ends_at).to be_within(1.second).of Time.new(
                 2020,
                 1,
                 6,
                 23,
                 59,
                 59,
                 offset,
               )
        end
      end
    end

    it "does not create duplicate record, but ensures the correct records exist" do
      user.user_option.update(timezone: "UTC")

      travel_to Time.new(2020, 1, 4, 12, 0, 0, "+00:00") do
        UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)
        expect(user.do_not_disturb_timings.count).to eq(4)
        # All duplicates, so no new timings should be created
        UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)
        expect(user.do_not_disturb_timings.count).to eq(4)
      end

      travel_to Time.new(2020, 1, 5, 12, 0, 0, "+00:00") do
        UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)
        # There is 1 overlap, so expect only 3 more to be created
        expect(user.do_not_disturb_timings.count).to eq(7)
      end

      travel_to Time.new(2020, 1, 10, 12, 0, 0, "+00:00") do
        UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)
        # There is no overlap, so expect only 4 more to be created
        expect(user.do_not_disturb_timings.count).to eq(11)
      end
    end

    it "extends previously scheduled dnd timings to remove gaps" do
      user.user_option.update(timezone: "UTC")

      travel_to Time.new(2020, 1, 4, 12, 0, 0, "+00:00") do
        existing_timing =
          user.do_not_disturb_timings.create(
            scheduled: true,
            starts_at: 1.day.ago,
            ends_at: Time.new(2020, 1, 03, 11, 0, 0, "+00:00").end_of_day,
          )
        UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(standard_schedule)
        expect(existing_timing.reload.ends_at).to eq_time(Time.new(2020, 1, 4, 7, 59, 0, "+00:00"))
      end
    end

    it "creates the correct timings when the whole schedule is DND (-1)" do
      user.user_option.update(timezone: "UTC")
      schedule = standard_schedule
      schedule.update(
        day_0_start_time: -1,
        day_1_start_time: -1,
        day_2_start_time: -1,
        day_3_start_time: -1,
        day_4_start_time: -1,
        day_5_start_time: -1,
        day_6_start_time: -1,
      )

      travel_to Time.new(2020, 1, 4, 12, 0, 0, "+00:00") do
        UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(schedule)
        expect(user.do_not_disturb_timings.count).to eq(1)
        expect(user.do_not_disturb_timings.first.starts_at).to eq_time(
          Time.new(2020, 1, 4, 0, 0, 0, "+00:00"),
        )
        expect(user.do_not_disturb_timings.first.ends_at).to be_within(1.second).of Time.new(
               2020,
               1,
               6,
               23,
               59,
               59,
               "+00:00",
             )
      end
    end

    it "creates the correct timings at the end of a month and year" do
      user.user_option.update(timezone: "UTC")
      schedule = standard_schedule
      schedule.update(
        day_3_start_time: -1, # December 31, 2020 was a thursday. testing more cases.
      )

      travel_to Time.new(2020, 12, 31, 12, 0, 0, "+00:00") do
        UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(schedule)
        expect(user.do_not_disturb_timings[0].starts_at).to eq_time(
          Time.new(2020, 12, 31, 0, 0, 0, "+00:00"),
        )
        expect(user.do_not_disturb_timings[0].ends_at).to eq_time(
          Time.new(2021, 1, 1, 7, 59, 0, "+00:00"),
        )
        expect(user.do_not_disturb_timings[1].starts_at).to eq_time(
          Time.new(2021, 1, 1, 17, 0, 0, "+00:00"),
        )
        expect(user.do_not_disturb_timings[1].ends_at).to eq_time(
          Time.new(2021, 1, 2, 7, 59, 0, "+00:00"),
        )
        expect(user.do_not_disturb_timings[2].starts_at).to eq_time(
          Time.new(2021, 1, 2, 17, 0, 0, "+00:00"),
        )
        expect(user.do_not_disturb_timings[2].ends_at).to be_within(1.second).of Time.new(
               2021,
               1,
               2,
               23,
               59,
               59,
               "+00:00",
             )
      end
    end

    it "handles midnight to midnight for multiple days (no timings created)" do
      user.user_option.update(timezone: "UTC")
      schedule = standard_schedule
      schedule.update(
        day_0_start_time: 0,
        day_0_end_time: 1440,
        day_1_start_time: 0,
        day_1_end_time: 1440,
        day_2_start_time: 0,
        day_2_end_time: 1440,
      )
      travel_to Time.new(2021, 1, 4, 12, 0, 0, "+00:00") do
        UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(schedule)
        expect(user.do_not_disturb_timings.count).to eq(0)
      end
    end

    it "publishes to message bus when the user should enter DND" do
      user.user_option.update(timezone: "UTC")
      schedule = standard_schedule
      travel_to Time.new(2020, 12, 31, 1, 0, 0, "+00:00") do
        messages =
          MessageBus.track_publish("/do-not-disturb/#{user.id}") do
            UserNotificationScheduleProcessor.create_do_not_disturb_timings_for(schedule)
          end

        expect(messages.size).to eq(1)
        expect(messages[0].data[:ends_at]).to eq(
          Time.new(2020, 12, 31, 7, 59, 0, "+00:00").httpdate,
        )
        expect(messages[0].user_ids).to contain_exactly(user.id)
      end
    end
  end
end