Browse Source

misago.markup app cleanup (#1265)

* Split bbcode.blocks into separate modules

* Move parsing api tests to pytest

* Move more markup tests to pytest

* Move all markup tests to pytest
Rafał Pitoń 5 years ago
parent
commit
bbb0e4b9f3
37 changed files with 881 additions and 911 deletions
  1. 6 0
      misago/conftest.py
  2. 1 1
      misago/markup/__init__.py
  3. 4 3
      misago/markup/api.py
  4. 22 0
      misago/markup/bbcode/code.py
  5. 10 0
      misago/markup/bbcode/hr.py
  6. 6 6
      misago/markup/bbcode/inline.py
  7. 1 27
      misago/markup/bbcode/quote.py
  8. 1 1
      misago/markup/finalize.py
  9. 2 2
      misago/markup/md/strikethrough.py
  10. 16 13
      misago/markup/parser.py
  11. 8 0
      misago/markup/tests/conftest.py
  12. 0 0
      misago/markup/tests/snapshots/__init__.py
  13. 28 0
      misago/markup/tests/snapshots/snap_test_code_bbcode.py
  14. 12 0
      misago/markup/tests/snapshots/snap_test_finalization.py
  15. 14 0
      misago/markup/tests/snapshots/snap_test_hr_bbcode.py
  16. 46 0
      misago/markup/tests/snapshots/snap_test_inline_bbcode.py
  17. 41 0
      misago/markup/tests/snapshots/snap_test_link_handling.py
  18. 14 0
      misago/markup/tests/snapshots/snap_test_parser.py
  19. 80 0
      misago/markup/tests/snapshots/snap_test_quote_bbcode.py
  20. 12 0
      misago/markup/tests/snapshots/snap_test_short_image_markdown.py
  21. 10 0
      misago/markup/tests/snapshots/snap_test_strikethrough_markdown.py
  22. 0 94
      misago/markup/tests/test_api.py
  23. 18 9
      misago/markup/tests/test_checksums.py
  24. 37 0
      misago/markup/tests/test_code_bbcode.py
  25. 0 55
      misago/markup/tests/test_finalise.py
  26. 7 0
      misago/markup/tests/test_finalization.py
  27. 17 0
      misago/markup/tests/test_hr_bbcode.py
  28. 73 0
      misago/markup/tests/test_inline_bbcode.py
  29. 149 0
      misago/markup/tests/test_link_handling.py
  30. 75 85
      misago/markup/tests/test_mentions.py
  31. 11 613
      misago/markup/tests/test_parser.py
  32. 69 0
      misago/markup/tests/test_parsing_api.py
  33. 61 0
      misago/markup/tests/test_quote_bbcode.py
  34. 7 0
      misago/markup/tests/test_short_image_markdown.py
  35. 7 0
      misago/markup/tests/test_strikethrough_markdown.py
  36. 14 0
      misago/test.py
  37. 2 2
      misago/threads/models/post.py

+ 6 - 0
misago/conftest.py

@@ -7,6 +7,7 @@ from .conf import SETTINGS_CACHE
 from .conf.dynamicsettings import DynamicSettings
 from .conf.staticsettings import StaticSettings
 from .socialauth import SOCIALAUTH_CACHE
+from .test import MisagoClient
 from .themes import THEME_CACHE
 from .threads.test import post_thread
 from .users import BANS_CACHE
@@ -116,6 +117,11 @@ def other_superuser(db, user_password):
 
 
 @pytest.fixture
+def client():
+    return MisagoClient()
+
+
+@pytest.fixture
 def user_client(mocker, client, user):
     client.force_login(user)
     session = client.session

+ 1 - 1
misago/markup/__init__.py

@@ -1,4 +1,4 @@
-from .finalise import finalise_markup
+from .finalize import finalize_markup
 from .flavours import common as common_flavour, signature as signature_flavour
 from .parser import parse
 

+ 4 - 3
misago/markup/api.py

@@ -2,12 +2,13 @@ from rest_framework import status
 from rest_framework.decorators import api_view
 from rest_framework.response import Response
 
-from . import common_flavour, finalise_markup
+from . import common_flavour, finalize_markup
 from .serializers import MarkupSerializer
 
 
 @api_view(["POST"])
 def parse_markup(request):
+    print(request.data)
     serializer = MarkupSerializer(
         data=request.data, context={"settings": request.settings}
     )
@@ -18,6 +19,6 @@ def parse_markup(request):
     parsing_result = common_flavour(
         request, request.user, serializer.data["post"], force_shva=True
     )
-    finalised = finalise_markup(parsing_result["parsed_text"])
+    finalized = finalize_markup(parsing_result["parsed_text"])
 
-    return Response({"parsed": finalised})
+    return Response({"parsed": finalized})

+ 22 - 0
misago/markup/bbcode/code.py

@@ -0,0 +1,22 @@
+import re
+
+import markdown
+from markdown.extensions.fenced_code import FencedBlockPreprocessor
+
+
+class CodeBlockExtension(markdown.Extension):
+    def extendMarkdown(self, md):
+        md.registerExtension(self)
+
+        md.preprocessors.add(
+            "misago_code_bbcode", CodeBlockPreprocessor(md), ">normalize_whitespace"
+        )
+
+
+class CodeBlockPreprocessor(FencedBlockPreprocessor):
+    FENCED_BLOCK_RE = re.compile(
+        r"""
+\[code(=("?)(?P<lang>.*?)("?))?](([ ]*\n)+)?(?P<code>.*?)((\s|\n)+)?\[/code\]
+""",
+        re.IGNORECASE | re.MULTILINE | re.DOTALL | re.VERBOSE,
+    )

+ 10 - 0
misago/markup/bbcode/hr.py

@@ -0,0 +1,10 @@
+import re
+
+from markdown.blockprocessors import HRProcessor
+
+
+class BBCodeHRProcessor(HRProcessor):
+    RE = r"^\[hr\]*"
+
+    # Detect hr on any line of a block.
+    SEARCH_RE = re.compile(RE, re.MULTILINE | re.IGNORECASE)

+ 6 - 6
misago/markup/bbcode/inline.py

@@ -82,12 +82,12 @@ class BBCodeUrlPattern(BBcodePattern, LinkPattern):
     def handleMatch(self, m):
         el = util.etree.Element("a")
 
-        if m.group(6):
-            el.text = m.group(8)
-            href = m.group(5)
+        if m.group("arg"):
+            el.text = m.group("content")
+            href = m.group("arg")
         else:
-            el.text = m.group(8).strip()
-            href = m.group(8)
+            el.text = m.group("content").strip()
+            href = m.group("content")
 
         if href:
             el.set("href", self.sanitize_url(self.unescape(href.strip())))
@@ -96,7 +96,7 @@ class BBCodeUrlPattern(BBcodePattern, LinkPattern):
         return el
 
 
-URL_PATTERN = r'((\[url=("?)(.*?)("?)\])|(\[url\]))(.*?)\[/url\]'
+URL_PATTERN = r'((\[url=("?)(?P<arg>.*?)("?)\])|(\[url\]))(?P<content>.*?)\[/url\]'
 
 
 def url(md):

+ 1 - 27
misago/markup/bbcode/blocks.py → misago/markup/bbcode/quote.py

@@ -2,8 +2,7 @@ import re
 
 import markdown
 from django.utils.crypto import get_random_string
-from markdown.blockprocessors import BlockProcessor, HRProcessor
-from markdown.extensions.fenced_code import FencedBlockPreprocessor
+from markdown.blockprocessors import BlockProcessor
 from markdown.preprocessors import Preprocessor
 from markdown.util import etree
 
@@ -11,13 +10,6 @@ QUOTE_START = get_random_string(32)
 QUOTE_END = get_random_string(32)
 
 
-class BBCodeHRProcessor(HRProcessor):
-    RE = r"^\[hr\]*"
-
-    # Detect hr on any line of a block.
-    SEARCH_RE = re.compile(RE, re.MULTILINE | re.IGNORECASE)
-
-
 class QuoteExtension(markdown.Extension):
     def extendMarkdown(self, md):
         md.registerExtension(self)
@@ -102,21 +94,3 @@ class QuoteBlockProcessor(BlockProcessor):
                 heading.text = title
 
             self.parser.parseBlocks(blockquote, children)
-
-
-class CodeBlockExtension(markdown.Extension):
-    def extendMarkdown(self, md):
-        md.registerExtension(self)
-
-        md.preprocessors.add(
-            "misago_code_bbcode", CodeBlockPreprocessor(md), ">normalize_whitespace"
-        )
-
-
-class CodeBlockPreprocessor(FencedBlockPreprocessor):
-    FENCED_BLOCK_RE = re.compile(
-        r"""
-\[code(=("?)(?P<lang>.*?)("?))?](([ ]*\n)+)?(?P<code>.*?)((\s|\n)+)?\[/code\]
-""",
-        re.IGNORECASE | re.MULTILINE | re.DOTALL | re.VERBOSE,
-    )

+ 1 - 1
misago/markup/finalise.py → misago/markup/finalize.py

@@ -10,7 +10,7 @@ HEADER_RE = re.compile(
 )
 
 
-def finalise_markup(post):
+def finalize_markup(post):
     return HEADER_RE.sub(replace_headers, post)
 
 

+ 2 - 2
misago/markup/md/striketrough.py → misago/markup/md/strikethrough.py

@@ -4,9 +4,9 @@ from markdown.inlinepatterns import SimpleTagPattern
 STRIKETROUGH_RE = r"(~{2})(.+?)\2"
 
 
-class StriketroughExtension(markdown.Extension):
+class StrikethroughExtension(markdown.Extension):
     def extendMarkdown(self, md):
         md.registerExtension(self)
         md.inlinePatterns.add(
-            "misago_striketrough", SimpleTagPattern(STRIKETROUGH_RE, "del"), "_end"
+            "misago_strikethrough", SimpleTagPattern(STRIKETROUGH_RE, "del"), "_end"
         )

+ 16 - 13
misago/markup/parser.py

@@ -7,9 +7,12 @@ from htmlmin.minify import html_minify
 from markdown.extensions.fenced_code import FencedCodeExtension
 
 from ..conf import settings
-from .bbcode import blocks, inline
+from .bbcode.code import CodeBlockExtension
+from .bbcode.hr import BBCodeHRProcessor
+from .bbcode.inline import bold, image, italics, underline, url
+from .bbcode.quote import QuoteExtension
 from .md.shortimgs import ShortImagesExtension
-from .md.striketrough import StriketroughExtension
+from .md.strikethrough import StrikethroughExtension
 from .mentions import add_mentions
 from .pipeline import pipeline
 
@@ -88,17 +91,17 @@ def md_factory(allow_links=True, allow_images=True, allow_blocks=True):
     del md.inlinePatterns["short_reference"]
 
     # Add [b], [i], [u]
-    md.inlinePatterns.add("bb_b", inline.bold, "<strong")
-    md.inlinePatterns.add("bb_i", inline.italics, "<emphasis")
-    md.inlinePatterns.add("bb_u", inline.underline, "<emphasis2")
+    md.inlinePatterns.add("bb_b", bold, "<strong")
+    md.inlinePatterns.add("bb_i", italics, "<emphasis")
+    md.inlinePatterns.add("bb_u", underline, "<emphasis2")
 
     # Add ~~deleted~~
-    striketrough_md = StriketroughExtension()
+    striketrough_md = StrikethroughExtension()
     striketrough_md.extendMarkdown(md)
 
     if allow_links:
         # Add [url]
-        md.inlinePatterns.add("bb_url", inline.url(md), "<link")
+        md.inlinePatterns.add("bb_url", url(md), "<link")
     else:
         # Remove links
         del md.inlinePatterns["link"]
@@ -107,7 +110,7 @@ def md_factory(allow_links=True, allow_images=True, allow_blocks=True):
 
     if allow_images:
         # Add [img]
-        md.inlinePatterns.add("bb_img", inline.image(md), "<image_link")
+        md.inlinePatterns.add("bb_img", image(md), "<image_link")
         short_images_md = ShortImagesExtension()
         short_images_md.extendMarkdown(md)
     else:
@@ -116,17 +119,15 @@ def md_factory(allow_links=True, allow_images=True, allow_blocks=True):
 
     if allow_blocks:
         # Add [hr] and [quote] blocks
-        md.parser.blockprocessors.add(
-            "bb_hr", blocks.BBCodeHRProcessor(md.parser), ">hr"
-        )
+        md.parser.blockprocessors.add("bb_hr", BBCodeHRProcessor(md.parser), ">hr")
 
         fenced_code = FencedCodeExtension()
         fenced_code.extendMarkdown(md, None)
 
-        code_bbcode = blocks.CodeBlockExtension()
+        code_bbcode = CodeBlockExtension()
         code_bbcode.extendMarkdown(md)
 
-        quote_bbcode = blocks.QuoteExtension()
+        quote_bbcode = QuoteExtension()
         quote_bbcode.extendMarkdown(md)
     else:
         # Remove blocks
@@ -227,6 +228,8 @@ def clean_internal_link(link, host):
 def clean_attachment_link(link, force_shva=False):
     try:
         resolution = resolve(link)
+        if not resolution.namespaces:
+            return link
         url_name = ":".join(resolution.namespaces + [resolution.url_name])
     except (Http404, ValueError):
         return link

+ 8 - 0
misago/markup/tests/conftest.py

@@ -0,0 +1,8 @@
+from unittest.mock import Mock
+
+import pytest
+
+
+@pytest.fixture
+def request_mock(user):
+    return Mock(scheme="http", get_host=Mock(return_value="example.com"), user=user)

+ 0 - 0
misago/markup/tests/snapshots/__init__.py


+ 28 - 0
misago/markup/tests/snapshots/snap_test_code_bbcode.py

@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots["test_single_line_code 1"] = '<pre><code>echo("Hello!");</code></pre>'
+
+snapshots[
+    "test_multi_line_code 1"
+] = """<pre><code>echo("Hello!");
+
+echo("World!");</code></pre>"""
+
+snapshots[
+    "test_code_with_language_parameter 1"
+] = '<pre><code class="php">echo("Hello!");</code></pre>'
+
+snapshots[
+    "test_code_with_quoted_language_parameter 1"
+] = '<pre><code class="php">echo("Hello!");</code></pre>'
+
+snapshots[
+    "test_code_block_disables_parsing 1"
+] = "<pre><code>Dolor [b]met.[/b]</code></pre>"

+ 12 - 0
misago/markup/tests/snapshots/snap_test_finalization.py

@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots[
+    "test_finalization_sets_translation_strings_in_quotes 1"
+] = '<div class="quote-heading">Quoted message:</div>'

+ 14 - 0
misago/markup/tests/snapshots/snap_test_hr_bbcode.py

@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots[
+    "test_hr_bbcode_is_replaced_if_its_alone_in_paragraph 1"
+] = """<p>Lorem ipsum dolor met.</p>
+<hr/>
+<p>Sit amet elit.</p>"""

+ 46 - 0
misago/markup/tests/snapshots/snap_test_inline_bbcode.py

@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots["test_bold_bbcode 1"] = "<p>Lorem <b>ipsum</b>!</p>"
+
+snapshots["test_italics_bbcode 1"] = "<p>Lorem <i>ipsum</i>!</p>"
+
+snapshots["test_underline_bbcode 1"] = "<p>Lorem <u>ipsum</u>!</p>"
+
+snapshots[
+    "test_inline_bbcode_can_be_mixed_with_markdown 1"
+] = "<p>Lorem <b><strong>ipsum</strong></b>!</p>"
+
+snapshots[
+    "test_image_bbcode 1"
+] = '<p>Lorem <img alt="placekitten.com/g/1200/500" src="https://placekitten.com/g/1200/500"/> ipsum</p>'
+
+snapshots[
+    "test_image_bbcode_is_case_insensitive 1"
+] = '<p>Lorem <img alt="placekitten.com/g/1200/500" src="https://placekitten.com/g/1200/500"/> ipsum</p>'
+
+snapshots[
+    "test_url_bbcode 1"
+] = '<p>Lorem <a href="https://placekitten.com/g/1200/500" rel="nofollow noopener">placekitten.com/g/1200/500</a> ipsum</p>'
+
+snapshots[
+    "test_url_bbcode_with_link_text 1"
+] = '<p>Lorem <a href="https://placekitten.com/g/1200/500" rel="nofollow noopener">dolor</a> ipsum</p>'
+
+snapshots[
+    "test_url_bbcode_with_long_link_text 1"
+] = '<p>Lorem <a href="https://placekitten.com/g/1200/500" rel="nofollow noopener">dolor met</a> ipsum</p>'
+
+snapshots[
+    "test_url_bbcode_with_quotes_and_link_text 1"
+] = '<p>Lorem <a href="https://placekitten.com/g/1200/500" rel="nofollow noopener">dolor</a> ipsum</p>'
+
+snapshots[
+    "test_url_bbcode_with_quotes_and_long_link_text 1"
+] = '<p>Lorem <a href="https://placekitten.com/g/1200/500" rel="nofollow noopener">dolor met</a> ipsum</p>'

+ 41 - 0
misago/markup/tests/snapshots/snap_test_link_handling.py

@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots[
+    "test_parser_converts_unmarked_links_to_hrefs 1"
+] = '<p>Lorem ipsum <a href="http://test.com" rel="nofollow noopener">test.com</a></p>'
+
+snapshots[
+    "test_parser_skips_links_in_inline_code_markdown 1"
+] = "<p>Lorem ipsum <code>http://test.com</code></p>"
+
+snapshots[
+    "test_parser_skips_links_in_inline_code_bbcode 1"
+] = """<p>Lorem ipsum <br/>
+</p><pre><code>http://test.com</code></pre><p></p>"""
+
+snapshots[
+    "test_parser_skips_links_in_code_bbcode 1"
+] = "<pre><code>http://test.com</code></pre>"
+
+snapshots[
+    "test_absolute_link_to_site_is_changed_to_relative_link 1"
+] = '<p>clean_links step cleans <a href="/">example.com</a></p>'
+
+snapshots[
+    "test_absolute_link_to_site_without_schema_is_changed_to_relative_link 1"
+] = '<p>clean_links step cleans <a href="/">example.com</a></p>'
+
+snapshots[
+    "test_absolute_link_with_path_to_site_is_changed_to_relative_link 1"
+] = '<p>clean_links step cleans <a href="/somewhere-something/">example.com/somewhere-something/</a></p>'
+
+snapshots[
+    "test_local_image_is_changed_to_relative_link 1"
+] = '<p>clean_links step cleans <img alt="example.com/media/img.png" src="/media/img.png"/></p>'

+ 14 - 0
misago/markup/tests/snapshots/snap_test_parser.py

@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots["test_html_is_escaped 1"] = "<p>Lorem &lt;strong&gt;ipsum!&lt;/strong&gt;</p>"
+
+snapshots[
+    "test_parsed_text_is_minified 1"
+] = "<p>Lorem <strong>ipsum</strong> dolor met.</p><p>Sit amet elit.</p>"

+ 80 - 0
misago/markup/tests/snapshots/snap_test_quote_bbcode.py

@@ -0,0 +1,80 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots[
+    "test_single_line_quote 1"
+] = """<aside class="quote-block">
+<div class="quote-heading"></div>
+<blockquote class="quote-body">
+<p>Sit amet elit.</p>
+</blockquote>
+</aside>"""
+
+snapshots[
+    "test_single_line_authored_quote 1"
+] = """<aside class="quote-block">
+<div class="quote-heading">@Bob</div>
+<blockquote class="quote-body">
+<p>Sit amet elit.</p>
+</blockquote>
+</aside>"""
+
+snapshots[
+    "test_single_line_authored_quote_without_quotations 1"
+] = """<aside class="quote-block">
+<div class="quote-heading">@Bob</div>
+<blockquote class="quote-body">
+<p>Sit amet elit.</p>
+</blockquote>
+</aside>"""
+
+snapshots[
+    "test_quote_can_contain_bbcode_or_markdown 1"
+] = """<aside class="quote-block">
+<div class="quote-heading"></div>
+<blockquote class="quote-body">
+<p>Sit <strong>amet</strong> <u>elit</u>.</p>
+</blockquote>
+</aside>"""
+
+snapshots[
+    "test_multi_line_quote 1"
+] = """<aside class="quote-block">
+<div class="quote-heading"></div>
+<blockquote class="quote-body">
+<p>Sit amet elit.</p>
+<p>Another line.</p>
+</blockquote>
+</aside>"""
+
+snapshots[
+    "test_quotes_can_be_nested 1"
+] = """<aside class="quote-block">
+<div class="quote-heading"></div>
+<blockquote class="quote-body">
+<p>Sit amet elit.</p>
+<aside class="quote-block">
+<div class="quote-heading"></div>
+<blockquote class="quote-body">
+<p>Nested quote</p>
+</blockquote>
+</aside>
+</blockquote>
+</aside>"""
+
+snapshots[
+    "test_quotes_can_contain_hr_markdown 1"
+] = """<aside class="quote-block">
+<div class="quote-heading"></div>
+<blockquote class="quote-body">
+<p>Sit amet elit.</p>
+<hr/>
+<p>Another line.</p>
+</blockquote>
+</aside>"""

+ 12 - 0
misago/markup/tests/snapshots/snap_test_short_image_markdown.py

@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots[
+    "test_short_image_markdown 1"
+] = '<p><img alt="somewhere.com/image.jpg" src="http://somewhere.com/image.jpg"/></p>'

+ 10 - 0
misago/markup/tests/snapshots/snap_test_strikethrough_markdown.py

@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots["test_strikethrough_markdown 1"] = "<p>Lorem <del>ipsum</del> dolor met!</p>"

+ 0 - 94
misago/markup/tests/test_api.py

@@ -1,94 +0,0 @@
-from django.urls import reverse
-
-from ...users.test import AuthenticatedUserTestCase
-
-
-class ParseMarkupApiTests(AuthenticatedUserTestCase):
-    def setUp(self):
-        super().setUp()
-
-        self.api_link = reverse("misago:api:parse-markup")
-
-    def test_is_anonymous(self):
-        """api requires authentication"""
-        self.logout_user()
-
-        response = self.client.post(self.api_link)
-        self.assertEqual(response.status_code, 403)
-        self.assertEqual(
-            response.json(), {"detail": "This action is not available to guests."}
-        )
-
-    def test_no_data(self):
-        """api handles no data"""
-        response = self.client.post(self.api_link)
-        self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {"detail": "You have to enter a message."})
-
-    def test_invalid_data(self):
-        """api handles post that is invalid type"""
-        response = self.client.post(
-            self.api_link, "[]", content_type="application/json"
-        )
-        self.assertEqual(response.status_code, 400)
-        self.assertEqual(
-            response.json(),
-            {"detail": "Invalid data. Expected a dictionary, but got list."},
-        )
-
-        response = self.client.post(
-            self.api_link, "123", content_type="application/json"
-        )
-        self.assertEqual(response.status_code, 400)
-        self.assertEqual(
-            response.json(),
-            {"detail": "Invalid data. Expected a dictionary, but got int."},
-        )
-
-        response = self.client.post(
-            self.api_link, '"string"', content_type="application/json"
-        )
-        self.assertEqual(response.status_code, 400)
-        self.assertEqual(
-            response.json(),
-            {"detail": "Invalid data. Expected a dictionary, but got str."},
-        )
-
-        response = self.client.post(
-            self.api_link, "malformed", content_type="application/json"
-        )
-        self.assertEqual(response.status_code, 400)
-        self.assertEqual(
-            response.json(),
-            {"detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)"},
-        )
-
-    def test_empty_post(self):
-        """api handles empty post"""
-        response = self.client.post(self.api_link, {"post": ""})
-        self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {"detail": "You have to enter a message."})
-
-        # regression test for #929
-        response = self.client.post(self.api_link, {"post": "\n"})
-        self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {"detail": "You have to enter a message."})
-
-    def test_invalid_post(self):
-        """api handles invalid post type"""
-        response = self.client.post(self.api_link, {"post": 123})
-        self.assertEqual(response.status_code, 400)
-        self.assertEqual(
-            response.json(),
-            {
-                "detail": (
-                    "Posted message should be at least 5 characters long (it has 3)."
-                )
-            },
-        )
-
-    def test_valid_post(self):
-        """api returns parsed markup for valid post"""
-        response = self.client.post(self.api_link, {"post": "Lorem ipsum dolor met!"})
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {"parsed": "<p>Lorem ipsum dolor met!</p>"})

+ 18 - 9
misago/markup/tests/test_checksums.py

@@ -1,14 +1,23 @@
-from django.test import TestCase
+from ..checksums import is_checksum_valid, make_checksum
 
-from .. import checksums
+message = "Test message."
+post_pk = 123
 
 
-class ChecksumsTests(TestCase):
-    def test_checksums(self):
-        fake_message = "<p>Woow, thats awesome!</p>"
-        post_pk = 231
+def test_checksum_can_be_generated_for_post_message_and_pk():
+    assert make_checksum(message, [post_pk])
 
-        checksum = checksums.make_checksum(fake_message, [post_pk])
 
-        self.assertTrue(checksums.is_checksum_valid(fake_message, checksum, [post_pk]))
-        self.assertFalse(checksums.is_checksum_valid(fake_message, checksum, [3]))
+def test_valid_message_checksum_is_checked():
+    checksum = make_checksum(message, [post_pk])
+    assert is_checksum_valid(message, checksum, [post_pk])
+
+
+def test_checksum_invalidates_if_message_is_changed():
+    checksum = make_checksum(message, [post_pk])
+    assert not is_checksum_valid("Changed message.", checksum, [post_pk])
+
+
+def test_checksum_invalidates_if_pk_is_changed():
+    checksum = make_checksum(message, [post_pk])
+    assert not is_checksum_valid(message, checksum, [post_pk + 1])

+ 37 - 0
misago/markup/tests/test_code_bbcode.py

@@ -0,0 +1,37 @@
+from ..parser import parse
+
+
+def test_single_line_code(request_mock, user, snapshot):
+    text = '[code]echo("Hello!");[/code]'
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_multi_line_code(request_mock, user, snapshot):
+    text = """
+[code]
+echo("Hello!");
+
+echo("World!");
+[/code]
+    """
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_code_with_language_parameter(request_mock, user, snapshot):
+    text = '[code=php]echo("Hello!");[/code]'
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_code_with_quoted_language_parameter(request_mock, user, snapshot):
+    text = '[code="php"]echo("Hello!");[/code]'
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_code_block_disables_parsing(request_mock, user, snapshot):
+    text = "[code]Dolor [b]met.[/b][/code]"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])

+ 0 - 55
misago/markup/tests/test_finalise.py

@@ -1,55 +0,0 @@
-from django.test import TestCase
-
-from ..finalise import finalise_markup
-
-
-class QuoteTests(TestCase):
-    def test_finalise_markup(self):
-        """quote header is replaced"""
-        test_text = """
-<p>Lorem ipsum.</p>
-<aside class="quote-block">
-<div class="quote-heading"></div>
-<blockquote class="quote-body">
-<p>Dolor met</p>
-<aside class="quote-block">
-<div class="quote-heading"><a href="/users/user-1/">@User</a></div>
-<blockquote class="quote-body">
-<p>Dolor met</p>
-</blockquote>
-</aside>
-</blockquote>
-</aside>
-<p>Lorem ipsum dolor.</p>
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum.</p>
-<aside class="quote-block">
-<div class="quote-heading">Quoted message:</div>
-<blockquote class="quote-body">
-<p>Dolor met</p>
-<aside class="quote-block">
-<div class="quote-heading"><a href="/users/user-1/">@User</a> has written:</div>
-<blockquote class="quote-body">
-<p>Dolor met</p>
-</blockquote>
-</aside>
-</blockquote>
-</aside>
-<p>Lorem ipsum dolor.</p>
-""".strip()
-
-        self.assertEqual(expected_result, finalise_markup(test_text))
-
-    def test_finalise_minified_markup(self):
-        """header is replaced in minified post"""
-        test_text = """
-<p>Lorem ipsum.</p><aside class="quote-block"><div class="quote-heading"></div><blockquote class="quote-body"><p>Dolor met</p><aside class="quote-block"><div class="quote-heading"><a href="/users/user-1/">@User</a></div><blockquote class="quote-body"><p>Dolor met</p></blockquote></aside></blockquote></aside><p>Lorem ipsum dolor.</p>
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum.</p><aside class="quote-block"><div class="quote-heading">Quoted message:</div><blockquote class="quote-body"><p>Dolor met</p><aside class="quote-block"><div class="quote-heading"><a href="/users/user-1/">@User</a> has written:</div><blockquote class="quote-body"><p>Dolor met</p></blockquote></aside></blockquote></aside><p>Lorem ipsum dolor.</p>
-""".strip()
-
-        self.assertEqual(expected_result, finalise_markup(test_text))

+ 7 - 0
misago/markup/tests/test_finalization.py

@@ -0,0 +1,7 @@
+from ..finalize import finalize_markup
+
+
+def test_finalization_sets_translation_strings_in_quotes(snapshot):
+    test_text = '<div class="quote-heading"></div>'
+    finalized_text = finalize_markup(test_text)
+    snapshot.assert_match(finalized_text)

+ 17 - 0
misago/markup/tests/test_hr_bbcode.py

@@ -0,0 +1,17 @@
+from ..parser import parse
+
+
+def test_hr_bbcode_is_replaced_if_its_alone_in_paragraph(request_mock, user, snapshot):
+    text = """
+Lorem ipsum dolor met.
+[hr]
+Sit amet elit.
+"""
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_hr_bbcode_is_skipped_if_its_part_of_paragraph(request_mock, user, snapshot):
+    text = "Lorem ipsum[hr]dolor met."
+    result = parse(text, request_mock, user, minify=False)
+    assert result["parsed_text"] == "<p>Lorem ipsum[hr]dolor met.</p>"

+ 73 - 0
misago/markup/tests/test_inline_bbcode.py

@@ -0,0 +1,73 @@
+from ..parser import parse
+
+
+def test_bold_bbcode(request_mock, user, snapshot):
+    text = "Lorem [b]ipsum[/b]!"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_italics_bbcode(request_mock, user, snapshot):
+    text = "Lorem [i]ipsum[/i]!"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_underline_bbcode(request_mock, user, snapshot):
+    text = "Lorem [u]ipsum[/u]!"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_inline_bbcode_can_be_mixed_with_markdown(request_mock, user, snapshot):
+    text = "Lorem [b]**ipsum**[/b]!"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_image_bbcode(request_mock, user, snapshot):
+    text = "Lorem [img]https://placekitten.com/g/1200/500[/img] ipsum"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_image_bbcode_is_case_insensitive(request_mock, user, snapshot):
+    text = "Lorem [iMg]https://placekitten.com/g/1200/500[/ImG] ipsum"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_url_bbcode(request_mock, user, snapshot):
+    text = "Lorem [url]https://placekitten.com/g/1200/500[/url] ipsum"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_url_bbcode_includes_nofollow_and_noopener(request_mock, user, snapshot):
+    text = "Lorem [url]https://placekitten.com/g/1200/500[/url] ipsum"
+    result = parse(text, request_mock, user, minify=False)
+    assert 'rel="nofollow noopener"' in result["parsed_text"]
+
+
+def test_url_bbcode_with_link_text(request_mock, user, snapshot):
+    text = "Lorem [url=https://placekitten.com/g/1200/500]dolor[/url] ipsum"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_url_bbcode_with_long_link_text(request_mock, user, snapshot):
+    text = "Lorem [url=https://placekitten.com/g/1200/500]dolor met[/url] ipsum"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_url_bbcode_with_quotes_and_link_text(request_mock, user, snapshot):
+    text = 'Lorem [url="https://placekitten.com/g/1200/500"]dolor[/url] ipsum'
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_url_bbcode_with_quotes_and_long_link_text(request_mock, user, snapshot):
+    text = 'Lorem [url="https://placekitten.com/g/1200/500"]dolor met[/url] ipsum'
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])

+ 149 - 0
misago/markup/tests/test_link_handling.py

@@ -0,0 +1,149 @@
+from ..parser import parse
+
+
+def test_parser_converts_unmarked_links_to_hrefs(request_mock, user, snapshot):
+    text = "Lorem ipsum http://test.com"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_parser_skips_links_in_inline_code_markdown(request_mock, user, snapshot):
+    text = "Lorem ipsum `http://test.com`"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_parser_skips_links_in_inline_code_bbcode(request_mock, user, snapshot):
+    text = "Lorem ipsum [code]http://test.com[/code]"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_parser_skips_links_in_code_bbcode(request_mock, user, snapshot):
+    text = """
+[code]
+http://test.com
+[/code]
+    """
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_absolute_link_to_site_is_changed_to_relative_link(
+    request_mock, user, snapshot
+):
+    text = "clean_links step cleans http://example.com"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_absolute_link_to_site_is_added_to_internal_links_list(request_mock, user):
+    text = "clean_links step cleans http://example.com"
+    result = parse(text, request_mock, user, minify=False)
+    assert result["internal_links"] == ["/"]
+
+
+def test_absolute_link_to_site_without_schema_is_changed_to_relative_link(
+    request_mock, user, snapshot
+):
+    text = "clean_links step cleans example.com"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_absolute_link_to_site_without_schema_is_added_to_internal_links_list(
+    request_mock, user
+):
+    text = "clean_links step cleans example.com"
+    result = parse(text, request_mock, user, minify=False)
+    assert result["internal_links"] == ["/"]
+
+
+def test_absolute_link_with_path_to_site_is_changed_to_relative_link(
+    request_mock, user, snapshot
+):
+    text = "clean_links step cleans http://example.com/somewhere-something/"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_absolute_link_with_path_to_site_is_added_to_internal_links_list(
+    request_mock, user
+):
+    text = "clean_links step cleans http://example.com/somewhere-something/"
+    result = parse(text, request_mock, user, minify=False)
+    assert result["internal_links"] == ["/somewhere-something/"]
+
+
+def test_full_link_with_path_text_is_set_to_domain_and_path(request_mock, user):
+    text = "clean_links step cleans http://example.com/somewhere-something/"
+    result = parse(text, request_mock, user, minify=False)
+    assert ">example.com/somewhere-something/<" in result["parsed_text"]
+
+
+def test_outgoing_link_is_added_to_outgoing_links_list(request_mock, user):
+    text = "clean_links step cleans https://other.com"
+    result = parse(text, request_mock, user, minify=False)
+    assert result["outgoing_links"] == ["other.com"]
+
+
+def test_outgoing_link_without_scheme_is_added_to_outgoing_links_list(
+    request_mock, user
+):
+    text = "clean_links step cleans other.com"
+    result = parse(text, request_mock, user, minify=False)
+    assert result["outgoing_links"] == ["other.com"]
+
+
+def test_outgoing_link_with_path_is_added_to_outgoing_links_list(request_mock, user):
+    text = "clean_links step cleans other.com/some/path/"
+    result = parse(text, request_mock, user, minify=False)
+    assert result["outgoing_links"] == ["other.com/some/path/"]
+
+
+def test_local_image_is_changed_to_relative_link(request_mock, user, snapshot):
+    text = "clean_links step cleans !(example.com/media/img.png)"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_local_image_is_added_to_images_list(request_mock, user):
+    text = "clean_links step cleans !(example.com/media/img.png)"
+    result = parse(text, request_mock, user, minify=False)
+    assert result["images"] == ["/media/img.png"]
+
+
+def test_remote_image_is_added_to_images_list(request_mock, user):
+    text = "clean_links step cleans !(other.com/media/img.png)"
+    result = parse(text, request_mock, user, minify=False)
+    assert result["images"] == ["other.com/media/img.png"]
+
+
+def test_local_image_link_is_added_to_images_and_links_lists(request_mock, user):
+    text = "clean_links step cleans [!(example.com/media/img.png)](example.com/test/)"
+    result = parse(text, request_mock, user, minify=False)
+    assert result["internal_links"] == ["/test/"]
+    assert result["images"] == ["/media/img.png"]
+
+
+def test_remote_image_link_is_added_to_images_and_links_lists(request_mock, user):
+    text = "clean_links step cleans [!(other.com/media/img.png)](other.com/test/)"
+    result = parse(text, request_mock, user, minify=False)
+    assert result["outgoing_links"] == ["other.com/test/"]
+    assert result["images"] == ["other.com/media/img.png"]
+
+
+def test_parser_adds_shva_to_attachment_link_querystring_if_force_option_is_enabled(
+    request_mock, user
+):
+    text = "clean_links step cleans ![3.png](http://example.com/a/thumb/test/43/)"
+    result = parse(text, request_mock, user, minify=False, force_shva=True)
+    assert "/a/thumb/test/43/?shva=1" in result["parsed_text"]
+
+
+def test_parser_skips_shva_in_attachment_link_querystring_if_force_option_is_omitted(
+    request_mock, user
+):
+    text = "clean_links step cleans ![3.png](http://example.com/a/thumb/test/43/)"
+    result = parse(text, request_mock, user, minify=False)
+    assert "?shva=1" not in result["parsed_text"]

+ 75 - 85
misago/markup/tests/test_mentions.py

@@ -1,88 +1,78 @@
-from ...users.test import AuthenticatedUserTestCase
 from ..mentions import add_mentions
 
 
-class MockRequest:
-    def __init__(self, user):
-        self.user = user
-
-
-class MentionsTests(AuthenticatedUserTestCase):
-    def test_single_mention(self):
-        """markup extension parses single mention"""
-        TEST_CASES = [
-            ("<p>Hello, @%s!</p>", '<p>Hello, <a href="%s">@%s</a>!</p>'),
-            ("<h1>Hello, @%s!</h1>", '<h1>Hello, <a href="%s">@%s</a>!</h1>'),
-            ("<div>Hello, @%s!</div>", '<div>Hello, <a href="%s">@%s</a>!</div>'),
-            (
-                "<h1>Hello, <strong>@%s!</strong></h1>",
-                '<h1>Hello, <strong><a href="%s">@%s</a>!</strong></h1>',
-            ),
-            (
-                "<h1>Hello, <strong>@%s</strong>!</h1>",
-                '<h1>Hello, <strong><a href="%s">@%s</a></strong>!</h1>',
-            ),
-        ]
-
-        for before, after in TEST_CASES:
-            result = {"parsed_text": before % self.user.username, "mentions": []}
-
-            add_mentions(MockRequest(self.user), result)
-
-            expected_outcome = after % (
-                self.user.get_absolute_url(),
-                self.user.username,
-            )
-            self.assertEqual(result["parsed_text"], expected_outcome)
-            self.assertEqual(result["mentions"], [self.user])
-
-    def test_invalid_mentions(self):
-        """markup extension leaves invalid mentions alone"""
-        TEST_CASES = [
-            "<p>Hello, Bob!</p>",
-            "<p>Hello, @Bob!</p>",
-            '<p>Hello, <a href="/">@%s</a>!</p>' % self.user.username,
-            '<p>Hello, <a href="/"><b>@%s</b></a>!</p>' % self.user.username,
-        ]
-
-        for markup in TEST_CASES:
-            result = {"parsed_text": markup, "mentions": []}
-
-            add_mentions(MockRequest(self.user), result)
-
-            self.assertEqual(result["parsed_text"], markup)
-            self.assertFalse(result["mentions"])
-
-    def test_multiple_mentions(self):
-        """markup extension handles multiple mentions"""
-        before = "<p>Hello @{0} and @{0}, how is it going?</p>".format(
-            self.user.username
-        )
-
-        after = (
-            # pylint: disable=line-too-long
-            '<p>Hello <a href="{0}">@{1}</a> and <a href="{0}">@{1}</a>, how is it going?</p>'
-        ).format(self.user.get_absolute_url(), self.user.username)
-
-        result = {"parsed_text": before, "mentions": []}
-
-        add_mentions(MockRequest(self.user), result)
-        self.assertEqual(result["parsed_text"], after)
-        self.assertEqual(result["mentions"], [self.user])
-
-    def test_repeated_mention(self):
-        """markup extension handles mentions across document"""
-        before = "<p>Hello @{0}</p><p>@{0}, how is it going?</p>".format(
-            self.user.username
-        )
-
-        after = (
-            # pylint: disable=line-too-long
-            '<p>Hello <a href="{0}">@{1}</a></p><p><a href="{0}">@{1}</a>, how is it going?</p>'
-        ).format(self.user.get_absolute_url(), self.user.username)
-
-        result = {"parsed_text": before, "mentions": []}
-
-        add_mentions(MockRequest(self.user), result)
-        self.assertEqual(result["parsed_text"], after)
-        self.assertEqual(result["mentions"], [self.user])
+def test_util_replaces_mention_with_link_to_user_profile_in_parsed_text(
+    request_mock, user
+):
+    parsing_result = {"parsed_text": f"<p>Hello, @{user.username}!</p>", "mentions": []}
+    add_mentions(request_mock, parsing_result)
+    assert parsing_result["parsed_text"] == (
+        f'<p>Hello, <a href="{user.get_absolute_url()}">@{user.username}</a>!</p>'
+    )
+
+
+def test_util_adds_mention_to_parsig_result(request_mock, user):
+    parsing_result = {"parsed_text": f"<p>Hello, @{user.username}!</p>", "mentions": []}
+    add_mentions(request_mock, parsing_result)
+    assert parsing_result["mentions"] == [user]
+
+
+def test_mentions_arent_added_for_nonexisting_user(request_mock, user):
+    parsing_result = {"parsed_text": f"<p>Hello, @OtherUser!</p>", "mentions": []}
+    add_mentions(request_mock, parsing_result)
+    assert parsing_result["parsed_text"] == "<p>Hello, @OtherUser!</p>"
+
+
+def test_util_replaces_multiple_mentions_with_link_to_user_profiles_in_parsed_text(
+    request_mock, user, other_user
+):
+    parsing_result = {
+        "parsed_text": f"<p>Hello, @{user.username} and @{other_user.username}!</p>",
+        "mentions": [],
+    }
+    add_mentions(request_mock, parsing_result)
+    assert (
+        f'<a href="{user.get_absolute_url()}">@{user.username}</a>'
+        in parsing_result["parsed_text"]
+    )
+    assert (
+        f'<a href="{other_user.get_absolute_url()}">@{other_user.username}</a>'
+        in parsing_result["parsed_text"]
+    )
+
+
+def test_util_adds_multiple_mentions_to_parsig_result(request_mock, user, other_user):
+    parsing_result = {
+        "parsed_text": f"<p>Hello, @{user.username} and @{other_user.username}!</p>",
+        "mentions": [],
+    }
+    add_mentions(request_mock, parsing_result)
+    assert parsing_result["mentions"] == [user, other_user]
+
+
+def test_util_handles_repeated_mentions_of_same_user(request_mock, user):
+    parsing_result = {
+        "parsed_text": f"<p>Hello, @{user.username} and @{user.username}!</p>",
+        "mentions": [],
+    }
+    add_mentions(request_mock, parsing_result)
+    assert parsing_result["mentions"] == [user]
+
+
+def test_util_skips_mentions_in_links(request_mock, user, snapshot):
+    parsing_result = {
+        "parsed_text": f'<p>Hello, <a href="/">@{user.username}</a></p>',
+        "mentions": [],
+    }
+    add_mentions(request_mock, parsing_result)
+    assert parsing_result["parsed_text"] == (
+        f'<p>Hello, <a href="/">@{user.username}</a></p>'
+    )
+    assert parsing_result["mentions"] == []
+
+
+def test_util_handles_text_without_mentions(request_mock):
+    parsing_result = {"parsed_text": f"<p>Hello, world!</p>", "mentions": []}
+    add_mentions(request_mock, parsing_result)
+    assert parsing_result["parsed_text"] == ("<p>Hello, world!</p>")
+    assert parsing_result["mentions"] == []

+ 11 - 613
misago/markup/tests/test_parser.py

@@ -1,619 +1,17 @@
-from django.test import TestCase
-
-from ...users.test import create_test_user
 from ..parser import parse
 
 
-class MockRequest:
-    scheme = "http"
-
-    def __init__(self, user=None):
-        self.user = user
-
-    def get_host(self):
-        return "test.com"
-
-
-class MockPoster:
-    username = "LoremIpsum"
-    slug = "loremipsum"
-
-
-class HTMLTests(TestCase):
-    def test_html_escaped(self):
-        """parser escapes all html"""
-        test_text = """
-Lorem <strong>ipsum!</strong>
-""".strip()
-
-        expected_result = """
-<p>Lorem &lt;strong&gt;ipsum!&lt;/strong&gt;</p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result["parsed_text"])
-        self.assertEqual(result["internal_links"], [])
-        self.assertEqual(result["images"], [])
-        self.assertEqual(result["outgoing_links"], [])
-
-
-class BBCodeTests(TestCase):
-    def test_inline_text(self):
-        """inline elements are correctly parsed"""
-        test_text = """
-Lorem **ipsum**, dolor met.
-
-Lorem [b]ipsum[/b], [i]dolor[/i] [u]met[/u].
-
-Lorem [b]**ipsum**[/b] [i]dolor[/i] [u]met[/u].
-
-Lorem [b]**ipsum[/b]** [i]dolor[/i] [u]met[/u].
-
-Lorem [b]__ipsum[/b]__ [i]dolor[/i] [u]met[/u].
-
-Lorem [b][i]ipsum[/i][/b].
-
-Lorem [b][i]ipsum[/b][/i].
-
-Lorem [b]ipsum[/B].
-""".strip()
-
-        expected_result = """
-<p>Lorem <strong>ipsum</strong>, dolor met.</p>
-<p>Lorem <b>ipsum</b>, <i>dolor</i> <u>met</u>.</p>
-<p>Lorem <b><strong>ipsum</strong></b> <i>dolor</i> <u>met</u>.</p>
-<p>Lorem <b>**ipsum</b>** <i>dolor</i> <u>met</u>.</p>
-<p>Lorem <b>__ipsum</b>__ <i>dolor</i> <u>met</u>.</p>
-<p>Lorem <b><i>ipsum</i></b>.</p>
-<p>Lorem <b>[i]ipsum</b>[/i].</p>
-<p>Lorem <b>ipsum</b>.</p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result["parsed_text"])
-
-    def test_hr(self):
-        """hr bbcode is correctly parsed"""
-        test_text = """
-Lorem ipsum.
-[hr]
-Dolor met.
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum.</p>
-<hr/>
-<p>Dolor met.</p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result["parsed_text"])
-
-    def test_img(self):
-        """img bbcode is correctly parsed"""
-        test_text = """
-Lorem ipsum [img]https://placekitten.com/g/1200/500[/img]
-
-Lorem ipsum [iMg]https://placekitten.com/g/1200/500[/ImG]
-
-Lorem ipsum !(https://placekitten.com/g/1200/500)
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum <img alt="placekitten.com/g/1200/500" src="https://placekitten.com/g/1200/500"/></p>
-<p>Lorem ipsum <img alt="placekitten.com/g/1200/500" src="https://placekitten.com/g/1200/500"/></p>
-<p>Lorem ipsum <img alt="placekitten.com/g/1200/500" src="https://placekitten.com/g/1200/500"/></p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result["parsed_text"])
-
-    def test_url(self):
-        """url bbcode is correctly parsed"""
-        test_text = """
-Lorem ipsum [url]placekitten.com/g/300/300[/url]
-
-Lorem ipsum [url]https://placekitten.com/g/600/600[/url]
-
-Lorem ipsum [uRL=https://placekitten.com/g/400/400"]Label text![/UrL]
-
-Lorem ipsum [Lorem ipsum](https://placekitten.com/g/1200/500)
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum <a href="http://placekitten.com/g/300/300" rel="nofollow noopener">placekitten.com/g/300/300</a></p>
-<p>Lorem ipsum <a href="https://placekitten.com/g/600/600" rel="nofollow noopener">placekitten.com/g/600/600</a></p>
-<p>Lorem ipsum <a href="https://placekitten.com/g/400/400" rel="nofollow noopener">Label text!</a></p>
-<p>Lorem ipsum <a href="https://placekitten.com/g/1200/500" rel="nofollow noopener">Lorem ipsum</a></p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result["parsed_text"])
-
-
-class MinifyTests(TestCase):
-    def test_minified_text(self):
-        """parser minifies text successfully"""
-        test_text = """
-Lorem ipsum.
-
-Lorem ipsum.
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum.</p><p>Lorem ipsum.</p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result["parsed_text"])
-
-    def test_minified_unicode_text(self):
-        """parser minifies unicode text successfully"""
-        test_text = """
-Bżęczyszczykiewłicz ipsum.
-
-Lorem ipsum.
-""".strip()
-
-        expected_result = """
-<p>Bżęczyszczykiewłicz ipsum.</p><p>Lorem ipsum.</p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result["parsed_text"])
-
-    def test_complex_paragraph(self):
-        """parser minifies complex paragraph"""
-        user = create_test_user("User", "user@example.com")
-
-        test_text = (
-            """
-Hey there @%s, how's going?
-""".strip()
-            % user
-        )
-
-        expected_result = """
-<p>Hey there <a href="%s">@%s</a>, how's going?</p>
-""".strip() % (
-            user.get_absolute_url(),
-            user,
-        )
-
-        result = parse(test_text, MockRequest(user), user, minify=True)
-        self.assertEqual(expected_result, result["parsed_text"])
-
-
-class CleanLinksTests(TestCase):
-    def test_clean_current_link(self):
-        """clean_links step cleans http://test.com"""
-        test_text = """
-Lorem ipsum: http://test.com
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum: <a href="/">test.com</a></p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result["parsed_text"])
-        self.assertEqual(result["internal_links"], ["/"])
-        self.assertEqual(result["images"], [])
-        self.assertEqual(result["outgoing_links"], [])
-
-    def test_clean_schemaless_link(self):
-        """clean_links step cleans test.com"""
-        test_text = """
-Lorem ipsum: test.com
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum: <a href="/">test.com</a></p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result["parsed_text"])
-        self.assertEqual(result["internal_links"], ["/"])
-        self.assertEqual(result["images"], [])
-        self.assertEqual(result["outgoing_links"], [])
-
-    def test_trim_current_path(self):
-        """clean_links step leaves http://test.com path"""
-        test_text = """
-Lorem ipsum: http://test.com/somewhere-something/
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum: <a href="/somewhere-something/">test.com/somewhere-something/</a></p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result["parsed_text"])
-        self.assertEqual(result["internal_links"], ["/somewhere-something/"])
-        self.assertEqual(result["images"], [])
-        self.assertEqual(result["outgoing_links"], [])
-
-    def test_clean_outgoing_link_domain(self):
-        """clean_links step leaves outgoing domain link"""
-        test_text = """
-Lorem ipsum: http://somewhere.com
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum: <a href="http://somewhere.com" rel="nofollow noopener">somewhere.com</a></p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result["parsed_text"])
-        self.assertEqual(result["outgoing_links"], ["somewhere.com"])
-        self.assertEqual(result["images"], [])
-        self.assertEqual(result["internal_links"], [])
-
-    def test_trim_outgoing_path(self):
-        """clean_links step leaves outgoing link domain and path"""
-        test_text = """
-Lorem ipsum: http://somewhere.com/somewhere-something/
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum: <a href="http://somewhere.com/somewhere-something/" rel="nofollow noopener">somewhere.com/somewhere-something/</a></p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result["parsed_text"])
-        self.assertEqual(
-            result["outgoing_links"], ["somewhere.com/somewhere-something/"]
-        )
-        self.assertEqual(result["images"], [])
-        self.assertEqual(result["internal_links"], [])
-
-    def test_clean_local_image_src(self):
-        """clean_links step cleans local image src"""
-        test_text = """
-!(http://test.com/image.jpg)
-""".strip()
-
-        expected_result = """
-<p><img alt="test.com/image.jpg" src="/image.jpg"/></p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result["parsed_text"])
-        self.assertEqual(result["images"], ["/image.jpg"])
-        self.assertEqual(result["internal_links"], [])
-        self.assertEqual(result["outgoing_links"], [])
-
-    def test_clean_remote_image_src(self):
-        """clean_links step cleans remote image src"""
-        test_text = """
-!(http://somewhere.com/image.jpg)
-""".strip()
-
-        expected_result = """
-<p><img alt="somewhere.com/image.jpg" src="http://somewhere.com/image.jpg"/></p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result["parsed_text"])
-        self.assertEqual(result["images"], ["somewhere.com/image.jpg"])
-        self.assertEqual(result["internal_links"], [])
-        self.assertEqual(result["outgoing_links"], [])
-
-    def test_clean_linked_image(self):
-        """parser handles image element nested in link"""
-        test_text = """
-[![3.png](http://test.com/a/thumb/test/43/)](http://test.com/a/test/43/)
-        """.strip()
-
-        expected_result = """
-<p><a href="/a/test/43/"><img alt="3.png" src="/a/thumb/test/43/"/></a></p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result["parsed_text"])
-        self.assertEqual(result["images"], ["/a/thumb/test/43/"])
-        self.assertEqual(result["internal_links"], ["/a/test/43/"])
-        self.assertEqual(result["outgoing_links"], [])
-
-    def test_force_shva(self):
-        """parser appends ?shva=1 bit to attachment links if flag is present"""
-        test_text = """
-![3.png](http://test.com/a/thumb/test/43/)
-        """.strip()
-
-        expected_result = """
-<p><img alt="3.png" src="/a/thumb/test/43/?shva=1"/></p>
-""".strip()
-
-        result = parse(
-            test_text, MockRequest(), MockPoster(), minify=True, force_shva=True
-        )
-        self.assertEqual(expected_result, result["parsed_text"])
-        self.assertEqual(result["images"], ["/a/thumb/test/43/"])
-        self.assertEqual(result["internal_links"], [])
-        self.assertEqual(result["outgoing_links"], [])
-
-    def test_remove_shva(self):
-        """parser removes ?shva=1 bit from attachment links if flag is absent"""
-        test_text = """
-![3.png](http://test.com/a/thumb/test/43/?shva=1)
-        """.strip()
-
-        expected_result = """
-<p><img alt="3.png" src="/a/thumb/test/43/"/></p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result["parsed_text"])
-        self.assertEqual(result["images"], ["/a/thumb/test/43/?shva=1"])
-        self.assertEqual(result["internal_links"], [])
-        self.assertEqual(result["outgoing_links"], [])
-
-
-class LinkifyTests(TestCase):
-    def test_clean_current_link(self):
-        """clean_links step cleans http://test.com"""
-        test_text = """
-Lorem ipsum: `<http://test.com>`
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum: <code>&lt;http://test.com&gt;</code></p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result["parsed_text"])
-        self.assertEqual(result["internal_links"], [])
-        self.assertEqual(result["images"], [])
-        self.assertEqual(result["outgoing_links"], [])
-
-
-class StriketroughTests(TestCase):
-    def test_striketrough(self):
-        """striketrough markdown deletes test"""
-        test_text = """
-Lorem ~~ipsum, dolor~~ met.
-""".strip()
-
-        expected_result = """
-<p>Lorem <del>ipsum, dolor</del> met.</p>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result["parsed_text"])
-
-
-class QuoteTests(TestCase):
-    def test_quotes(self):
-        """bbcode for quote is supported"""
-        test_text = """
-Lorem ipsum.
-[quote]Dolor met[/quote]
-[quote]Dolor <b>met</b>[/quote]
-[quote]Dolor **met**[quote]Dolor met[/quote][/quote]
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum.</p>
-<aside class="quote-block">
-<div class="quote-heading"></div>
-<blockquote class="quote-body">
-<p>Dolor met</p>
-</blockquote>
-</aside>
-<aside class="quote-block">
-<div class="quote-heading"></div>
-<blockquote class="quote-body">
-<p>Dolor &lt;b&gt;met&lt;/b&gt;</p>
-</blockquote>
-</aside>
-<aside class="quote-block">
-<div class="quote-heading"></div>
-<blockquote class="quote-body">
-<p>Dolor <strong>met</strong></p>
-<aside class="quote-block">
-<div class="quote-heading"></div>
-<blockquote class="quote-body">
-<p>Dolor met</p>
-</blockquote>
-</aside>
-</blockquote>
-</aside>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result["parsed_text"])
-
-    def test_authored_quotes(self):
-        """bbcode for authored quote is supported and handles mentions as well"""
-        test_text = """
-Lorem ipsum.
-[quote]Dolor met[/quote]
-[quote=\"@Bob\"]Dolor <b>met</b>[/quote]
-[quote]Dolor **met**[quote=@Bob]Dolor met[/quote][/quote]
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum.</p>
-<aside class="quote-block">
-<div class="quote-heading"></div>
-<blockquote class="quote-body">
-<p>Dolor met</p>
-</blockquote>
-</aside>
-<aside class="quote-block">
-<div class="quote-heading">@Bob</div>
-<blockquote class="quote-body">
-<p>Dolor &lt;b&gt;met&lt;/b&gt;</p>
-</blockquote>
-</aside>
-<aside class="quote-block">
-<div class="quote-heading"></div>
-<blockquote class="quote-body">
-<p>Dolor <strong>met</strong></p>
-<aside class="quote-block">
-<div class="quote-heading">@Bob</div>
-<blockquote class="quote-body">
-<p>Dolor met</p>
-</blockquote>
-</aside>
-</blockquote>
-</aside>
-""".strip()
-
-        request = MockRequest(user=MockPoster())
-        result = parse(test_text, request, MockPoster(), minify=False)
-        self.assertEqual(expected_result, result["parsed_text"])
-
-    def test_custom_quote_title(self):
-        """parser handles custom quotetitle"""
-        test_text = """
-Lorem ipsum.
-[quote=\"Lorem ipsum very test\"]Dolor <b>met</b>[/quote]
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum.</p>
-<aside class="quote-block">
-<div class="quote-heading">Lorem ipsum very test</div>
-<blockquote class="quote-body">
-<p>Dolor &lt;b&gt;met&lt;/b&gt;</p>
-</blockquote>
-</aside>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result["parsed_text"])
-
-    def test_hr_edge_case(self):
-        """test for weird edge case in which hr gets moved outside of quote"""
-        test_text = """
-Lorem ipsum.
-[quote]
-Dolor met
-- - - - -
-Amet elit
-[/quote]
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum.</p>
-<aside class="quote-block">
-<div class="quote-heading"></div>
-<blockquote class="quote-body">
-<p>Dolor met</p>
-<hr/>
-<p>Amet elit</p>
-</blockquote>
-</aside>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result["parsed_text"])
-
-
-class CodeTests(TestCase):
-    def test_code(self):
-        """code bbcode is correctly parsed"""
-        test_text = """
-Lorem ipsum.
-[code]
-Dolor [b]met.[/b]
-[/code]
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum.</p>
-<pre><code>Dolor [b]met.[/b]</code></pre>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result["parsed_text"])
-
-    def test_inline_code(self):
-        """inline code bbcode is correctly parsed"""
-        test_text = """
-Lorem ipsum.
-
-[code]Dolor [b]met.[/b][/code]
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum.</p>
-<pre><code>Dolor [b]met.[/b]</code></pre>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result["parsed_text"])
-
-    def test_code_strip(self):
-        """code bbcode trims its content"""
-        test_text = """
-Lorem ipsum.
-
-[code]
-
-   Dolor [b]met.[/b]
-
-
-[/code]
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum.</p>
-<pre><code>   Dolor [b]met.[/b]</code></pre>
-""".strip()
-        result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result["parsed_text"])
-
-    def test_code_language(self):
-        """code bbcode with language is correctly parsed"""
-        test_text = """
-Lorem ipsum.
-
-[code="python"]
-Dolor [b]met.[/b]
-[/code]
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum.</p>
-<pre><code class="python">Dolor [b]met.[/b]</code></pre>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result["parsed_text"])
-
-    def test_code_language_optional_quotes(self):
-        """code quotes around language name are optional"""
-        test_text = """
-Lorem ipsum.
-
-[code=python"]
-Dolor [b]met.[/b]
-[/code]
-""".strip()
-
-        expected_result = """
-<p>Lorem ipsum.</p>
-<pre><code class="python">Dolor [b]met.[/b]</code></pre>
-""".strip()
-
-        result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result["parsed_text"])
-
-        test_text = """
-Lorem ipsum.
+def test_html_is_escaped(request_mock, user, snapshot):
+    text = "Lorem <strong>ipsum!</strong>"
+    result = parse(text, request_mock, user, minify=True)
+    snapshot.assert_match(result["parsed_text"])
 
-[code="python]
-Dolor [b]met.[/b]
-[/code]
-""".strip()
 
-        expected_result = """
-<p>Lorem ipsum.</p>
-<pre><code class="python">Dolor [b]met.[/b]</code></pre>
-""".strip()
+def test_parsed_text_is_minified(request_mock, user, snapshot):
+    text = """
+Lorem **ipsum** dolor met.
 
-        result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result["parsed_text"])
+Sit amet elit.
+"""
+    result = parse(text, request_mock, user, minify=True)
+    snapshot.assert_match(result["parsed_text"])

+ 69 - 0
misago/markup/tests/test_parsing_api.py

@@ -0,0 +1,69 @@
+from django.urls import reverse
+
+api_link = reverse("misago:api:parse-markup")
+
+
+def test_api_rejects_unauthenticated_user(db, client):
+    response = client.post(api_link)
+    assert response.status_code == 403
+
+
+def test_api_rejects_request_without_data(user_client):
+    response = user_client.post(api_link)
+    assert response.status_code == 400
+    assert response.json() == {"detail": "You have to enter a message."}
+
+
+def test_api_rejects_request_with_invalid_shaped_data(user_client):
+    response = user_client.post(api_link, "[]", content_type="application/json")
+    assert response.status_code == 400
+    assert response.json() == {
+        "detail": "Invalid data. Expected a dictionary, but got list."
+    }
+
+    response = user_client.post(api_link, "123", content_type="application/json")
+    assert response.status_code == 400
+    assert response.json() == {
+        "detail": "Invalid data. Expected a dictionary, but got int."
+    }
+
+    response = user_client.post(api_link, '"string"', content_type="application/json")
+    assert response.status_code == 400
+    assert response.json() == {
+        "detail": "Invalid data. Expected a dictionary, but got str."
+    }
+
+
+def test_api_rejects_request_with_malformed_data(user_client):
+    response = user_client.post(api_link, "malformed", content_type="application/json")
+    assert response.status_code == 400
+    assert response.json() == {
+        "detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)"
+    }
+
+
+def test_api_validates_that_post_has_content(user_client):
+    response = user_client.post(api_link, json={"post": ""})
+    assert response.status_code == 400
+    assert response.json() == {"detail": "You have to enter a message."}
+
+
+# regression test for #929
+def test_api_strips_whitespace_from_post_before_validating_length(user_client):
+    response = user_client.post(api_link, json={"post": "\n"})
+    assert response.status_code == 400
+    assert response.json() == {"detail": "You have to enter a message."}
+
+
+def test_api_casts_post_value_to_string(user_client):
+    response = user_client.post(api_link, json={"post": 123})
+    assert response.status_code == 400
+    assert response.json() == {
+        "detail": "Posted message should be at least 5 characters long (it has 3)."
+    }
+
+
+def test_api_returns_parsed_value(user_client):
+    response = user_client.post(api_link, json={"post": "Hello world!"})
+    assert response.status_code == 200
+    assert response.json() == {"parsed": "<p>Hello world!</p>"}

+ 61 - 0
misago/markup/tests/test_quote_bbcode.py

@@ -0,0 +1,61 @@
+from ..parser import parse
+
+
+def test_single_line_quote(request_mock, user, snapshot):
+    text = "[quote]Sit amet elit.[/quote]"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_single_line_authored_quote(request_mock, user, snapshot):
+    text = '[quote="@Bob"]Sit amet elit.[/quote]'
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_single_line_authored_quote_without_quotations(request_mock, user, snapshot):
+    text = "[quote=@Bob]Sit amet elit.[/quote]"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_quote_can_contain_bbcode_or_markdown(request_mock, user, snapshot):
+    text = "[quote]Sit **amet** [u]elit[/u].[/quote]"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_multi_line_quote(request_mock, user, snapshot):
+    text = """
+[quote]
+Sit amet elit.
+
+Another line.
+[/quote]
+"""
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+def test_quotes_can_be_nested(request_mock, user, snapshot):
+    text = """
+[quote]
+Sit amet elit.
+[quote]Nested quote[/quote]
+[/quote]
+"""
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])
+
+
+# Rtest for weird edge case in which hr gets moved outside of quote
+def test_quotes_can_contain_hr_markdown(request_mock, user, snapshot):
+    text = """
+[quote]
+Sit amet elit.
+- - - - -
+Another line.
+[/quote]
+"""
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])

+ 7 - 0
misago/markup/tests/test_short_image_markdown.py

@@ -0,0 +1,7 @@
+from ..parser import parse
+
+
+def test_short_image_markdown(request_mock, user, snapshot):
+    text = "!(http://somewhere.com/image.jpg)"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])

+ 7 - 0
misago/markup/tests/test_strikethrough_markdown.py

@@ -0,0 +1,7 @@
+from ..parser import parse
+
+
+def test_strikethrough_markdown(request_mock, user, snapshot):
+    text = "Lorem ~~ipsum~~ dolor met!"
+    result = parse(text, request_mock, user, minify=False)
+    snapshot.assert_match(result["parsed_text"])

+ 14 - 0
misago/test.py

@@ -1,5 +1,6 @@
 from django.contrib.messages.api import get_messages
 from django.contrib.messages.constants import ERROR, INFO, SUCCESS
+from django.test import Client
 
 
 def assert_contains(response, string, status_code=200):
@@ -55,3 +56,16 @@ def assert_has_message(response, message, level=None):
         raise AssertionError(
             'Message containing "%s" was not set during the request' % message
         )
+
+
+class MisagoClient(Client):
+    def post(self, *args, **kwargs):
+        if "json" in kwargs:
+            return super().post(
+                *args,
+                data=kwargs.pop("json"),
+                content_type="application/json",
+                **kwargs,
+            )
+
+        return super().post(*args, **kwargs)

+ 2 - 2
misago/threads/models/post.py

@@ -9,7 +9,7 @@ from django.utils import timezone
 
 from ...conf import settings
 from ...core.utils import parse_iso8601_string
-from ...markup import finalise_markup
+from ...markup import finalize_markup
 from ..checksums import is_post_valid, update_post_checksum
 from ..filtersearch import filter_search
 
@@ -176,7 +176,7 @@ class Post(models.Model):
     @property
     def content(self):
         if not hasattr(self, "_finalised_parsed"):
-            self._finalised_parsed = finalise_markup(self.parsed)
+            self._finalised_parsed = finalize_markup(self.parsed)
         return self._finalised_parsed
 
     @property