diff --git a/surfsense_backend/tests/unit/utils/test_blocknote_to_markdown.py b/surfsense_backend/tests/unit/utils/test_blocknote_to_markdown.py new file mode 100644 index 000000000..ca115edea --- /dev/null +++ b/surfsense_backend/tests/unit/utils/test_blocknote_to_markdown.py @@ -0,0 +1,546 @@ +"""Tests for the blocknote_to_markdown conversion module. + +This module contains comprehensive unit tests for the blocknote_to_markdown function, +covering all block types, inline styles, lists, tables, images, links, nested content, +and edge cases. +""" + +import pytest + +from app.utils.blocknote_to_markdown import blocknote_to_markdown + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# Headings (levels 1 to 6, and clamping for >6 / <1) +# --------------------------------------------------------------------------- + + +class TestHeadingsLevelsAndClamping: + """Test heading conversion with various levels and clamping behavior.""" + + def test_heading_level_less_than_1(self): + """Heading level < 1 should be clamped to H1 (#).""" + + test_block = { + "type": "heading", + "props": {"level": 0}, + "content": [{"type": "text", "text": "My Title"}], + } + + assert blocknote_to_markdown(test_block) == "# My Title" + + def test_heading_level_1(self): + """Heading level 1 should render as H1 (#).""" + + test_block = { + "type": "heading", + "props": {"level": 1}, + "content": [{"type": "text", "text": "My Title"}], + } + + assert blocknote_to_markdown(test_block) == "# My Title" + + def test_heading_level_2(self): + """Heading level 2 should render as H2 (##).""" + + test_block = { + "type": "heading", + "props": {"level": 2}, + "content": [{"type": "text", "text": "My Title"}], + } + + assert blocknote_to_markdown(test_block) == "## My Title" + + def test_heading_level_3(self): + """Heading level 3 should render as H3 (###).""" + + test_block = { + "type": "heading", + "props": {"level": 3}, + "content": [{"type": "text", "text": "My Title"}], + } + + assert blocknote_to_markdown(test_block) == "### My Title" + + def test_heading_level_4(self): + """Heading level 4 should render as H4 (####).""" + + test_block = { + "type": "heading", + "props": {"level": 4}, + "content": [{"type": "text", "text": "My Title"}], + } + + assert blocknote_to_markdown(test_block) == "#### My Title" + + def test_heading_level_5(self): + """Heading level 5 should render as H5 (#####).""" + + test_block = { + "type": "heading", + "props": {"level": 5}, + "content": [{"type": "text", "text": "My Title"}], + } + + assert blocknote_to_markdown(test_block) == "##### My Title" + + def test_heading_level_6(self): + """Heading level 6 should render as H6 (######).""" + + test_block = { + "type": "heading", + "props": {"level": 6}, + "content": [{"type": "text", "text": "My Title"}], + } + + assert blocknote_to_markdown(test_block) == "###### My Title" + + def test_heading_level_greater_than_6(self): + """Heading level > 6 should be clamped to H6 (######).""" + + test_block = { + "type": "heading", + "props": {"level": 6}, + "content": [{"type": "text", "text": "My Title"}], + } + + assert blocknote_to_markdown(test_block) == "###### My Title" + + +# --------------------------------------------------------------------------- +# Inline styles: bold, italic, code, strikethrough +# --------------------------------------------------------------------------- + + +class TestInlineStyles: + """Test inline text styling conversion.""" + + def test_bold_inline_style(self): + """Bold text should be wrapped in double asterisks (**).""" + + test_block = { + "type": "paragraph", + "styles": {"bold": True}, + "content": [{"type": "text", "text": "Hello World!", "styles": {}}], + } + + assert blocknote_to_markdown(test_block) == "**Hello World!**" + + def test_italic_inline_style(self): + """Italic text should be wrapped in single asterisks (*).""" + + test_block = { + "type": "paragraph", + "styles": {"italic": True}, + "content": [{"type": "text", "text": "Hello World!", "styles": {}}], + } + + assert blocknote_to_markdown(test_block) == "*Hello World!*" + + def test_code_inline_style(self): + """Code text should be wrapped in backticks (`).""" + + test_block = { + "type": "paragraph", + "styles": {"code": True}, + "content": [{"type": "text", "text": "Hello World!", "styles": {}}], + } + + assert blocknote_to_markdown(test_block) == "`Hello World!`" + + def test_strikethrough_inline_style(self): + """Strikethrough text should be wrapped in double tildes (~~).""" + + test_block = { + "type": "paragraph", + "styles": {"strikethrough": True}, + "content": [{"type": "text", "text": "Hello World!", "styles": {}}], + } + + assert blocknote_to_markdown(test_block) == "~~Hello World!~~" + + +# --------------------------------------------------------------------------- +# Lists: bullet, numbered (incl. props.start and counter reset), checklist (checked/unchecked) +# --------------------------------------------------------------------------- + + +class TestBulletAndNumberLists: + """Test bullet and numbered list conversion.""" + + def test_bullet_list_item(self): + """Bullet list items should render with dash (-) prefix.""" + + test_block = [ + {"type": "bulletListItem", "content": [{"type": "text", "text": "First"}]}, + {"type": "bulletListItem", "content": [{"type": "text", "text": "Second"}]}, + ] + + assert blocknote_to_markdown(test_block) == "- First\n- Second" + + def test_numbered_list_item(self): + """Numbered list items should auto-increment from 1.""" + + test_block = [ + { + "type": "numberedListItem", + "content": [{"type": "text", "text": "First"}], + }, + { + "type": "numberedListItem", + "content": [{"type": "text", "text": "Second"}], + }, + ] + + assert blocknote_to_markdown(test_block) == "1. First\n2. Second" + + def test_numbered_list_item_with_prop_start(self): + """Numbered list with props.start should begin at specified number.""" + + test_block = [ + { + "type": "numberedListItem", + "props": {"start": 5}, + "content": [{"type": "text", "text": "First"}], + }, + { + "type": "numberedListItem", + "content": [{"type": "text", "text": "Second"}], + }, + ] + + assert blocknote_to_markdown(test_block) == "5. First\n6. Second" + + def test_numbered_list_item_with_counter_reset(self): + """Multiple numbered lists with different start values should reset counters.""" + + test_block = [ + { + "type": "numberedListItem", + "content": [{"type": "text", "text": "First"}], + }, + { + "type": "numberedListItem", + "content": [{"type": "text", "text": "Second"}], + }, + { + "type": "numberedListItem", + "props": {"start": 5}, + "content": [{"type": "text", "text": "Third"}], + }, + ] + + assert blocknote_to_markdown(test_block) == "1. First\n2. Second\n5. Third" + + +class TestCheckedAndUncheckedChecklist: + """Test checklist item conversion with checked/unchecked states.""" + + def test_checked_list_item(self): + """Checked checklist item should render with [x].""" + + test_block = { + "type": "checkListItem", + "props": {"checked": True}, + "content": [{"type": "text", "text": "Finish implementing test modules"}], + } + + assert ( + blocknote_to_markdown(test_block) + == "- [x] Finish implementing test modules" + ) + + def test_unchecked_list_item(self): + """Unchecked checklist item should render with [ ].""" + + test_block = { + "type": "checkListItem", + "props": {"checked": False}, + "content": [{"type": "text", "text": "Finish implementing test modules"}], + } + + assert ( + blocknote_to_markdown(test_block) + == "- [ ] Finish implementing test modules" + ) + + +# --------------------------------------------------------------------------- +# Code blocks (with/without language) +# --------------------------------------------------------------------------- + + +class TestCodeBlocksWithOrWithoutLanguage: + """Test code block conversion with optional language tags.""" + + def test_code_block_without_language(self): + """Code block without language should render with empty fence (```).""" + + test_block = { + "type": "codeBlock", + "content": [{"type": "text", "text": "print('hi')"}], + } + + assert blocknote_to_markdown(test_block) == "```\nprint('hi')\n```" + + def test_code_block_with_language(self): + """Code block with language should render fence with language tag.""" + + test_block = { + "type": "codeBlock", + "props": {"language": "Python"}, + "content": [{"type": "text", "text": "print('hi')"}], + } + + assert blocknote_to_markdown(test_block) == "```Python\nprint('hi')\n```" + + +# --------------------------------------------------------------------------- +# Tables (both dict and list row shapes) +# --------------------------------------------------------------------------- + + +def test_tables_dict_row_shape(): + """Table with dict row shape should render with header separator.""" + + test_block = { + "type": "table", + "content": { + "rows": [ + {"cells": ["Name", "Age", "City"]}, + {"cells": ["Alice", "25", "NYC"]}, + {"cells": ["Bob", "30", "LA"]}, + ] + }, + } + + assert ( + blocknote_to_markdown(test_block) + == "| Name | Age | City |\n| --- | --- | --- |\n| Alice | 25 | NYC |\n| Bob | 30 | LA |" + ) + + +def test_tables_list_row_shape(): + """Table with list row shape should render with header separator.""" + test_block = { + "type": "table", + "content": [ + {"cells": ["Header1", "Header2"]}, + {"cells": ["Data1", "Data2"]}, + {"cells": ["Data3", "Data4"]}, + ], + } + + assert ( + blocknote_to_markdown(test_block) + == "| Header1 | Header2 |\n| --- | --- |\n| Data1 | Data2 |\n| Data3 | Data4 |" + ) + + +# --------------------------------------------------------------------------- +# Images and links +# --------------------------------------------------------------------------- + + +def test_image_block_input(): + """Image block should render as markdown image syntax ![caption](url).""" + + test_block = { + "type": "image", + "props": {"url": "https://example.com/pic.jpg", "caption": "A picture"}, + } + + assert ( + blocknote_to_markdown(test_block) == "![A picture](https://example.com/pic.jpg)" + ) + + +def test_link_block_input_with_text(): + """Link with content should render as [text](href).""" + + test_block = { + "type": "paragraph", + "content": [ + { + "type": "link", + "href": "https://example.com", + "content": [{"type": "text", "text": "Click here"}], + } + ], + } + + assert blocknote_to_markdown(test_block) == "[Click here](https://example.com)" + + +def test_link_block_input_without_text(): + """Link without content should use href as link text.""" + + test_block = { + "type": "paragraph", + "content": [{"type": "link", "href": "https://example.com"}], + } + + assert ( + blocknote_to_markdown(test_block) + == "[https://example.com](https://example.com)" + ) + + +# --------------------------------------------------------------------------- +# Nested children (indentation) +# --------------------------------------------------------------------------- + + +def test_nested_children_indentation(): + """Nested list items should be indented with 2 spaces per level.""" + + test_block = { + "type": "bulletListItem", + "content": [{"type": "text", "text": "Parent"}], + "children": [ + { + "type": "bulletListItem", + "content": [{"type": "text", "text": "Child 1"}], + }, + { + "type": "bulletListItem", + "content": [{"type": "text", "text": "Child 2"}], + }, + { + "type": "bulletListItem", + "content": [{"type": "text", "text": "Child 3"}], + }, + ], + } + prefix = " " + + assert ( + blocknote_to_markdown(test_block) + == f"- Parent\n{prefix}- Child 1\n{prefix}- Child 2\n{prefix}- Child 3" + ) + + +def test_deep_nested_children_indentation(): + """Deeply nested items (3+ levels) should accumulate indentation.""" + + test_block = { + "type": "bulletListItem", + "content": [{"type": "text", "text": "Parent"}], + "children": [ + { + "type": "bulletListItem", + "content": [{"type": "text", "text": "Child 1"}], + "children": [ + { + "type": "bulletListItem", + "content": [{"type": "text", "text": "Child 2"}], + "children": [ + { + "type": "bulletListItem", + "content": [{"type": "text", "text": "Child 3"}], + } + ], + } + ], + } + ], + } + + prefix = " " + + assert ( + blocknote_to_markdown(test_block) + == f"- Parent\n{prefix}- Child 1\n{prefix * 2}- Child 2\n{prefix * 3}- Child 3" + ) + + +def test_mixed_deep_nested_children_indentation(): + """Mixed block types in deep nesting should preserve indentation.""" + + test_block = { + "type": "bulletListItem", + "content": [{"type": "text", "text": "Parent"}], + "children": [ + { + "type": "bulletListItem", + "content": [{"type": "text", "text": "Child 1"}], + "children": [ + { + "type": "numberedListItem", + "content": [{"type": "text", "text": "Nested Child 1"}], + "children": [ + { + "type": "numberedListItem", + "content": [{"type": "text", "text": "Nested Child 2"}], + } + ], + } + ], + }, + { + "type": "bulletListItem", + "content": [{"type": "text", "text": "Child 2"}], + }, + { + "type": "bulletListItem", + "content": [{"type": "text", "text": "Child 3"}], + }, + ], + } + + prefix = " " + + assert ( + blocknote_to_markdown(test_block) + == f"- Parent\n{prefix}- Child 1\n{prefix * 2}1. Nested Child 1\n{prefix * 3}2. Nested Child 2\n{prefix}- Child 2\n{prefix}- Child 3" + ) + + +# --------------------------------------------------------------------------- +# Edge cases: None, empty list, single dict input, unknown block type +# --------------------------------------------------------------------------- + + +def test_none_input(): + """None input should return None.""" + + test_block = None + + assert blocknote_to_markdown(test_block) is None + + +def test_empty_list_input(): + """Empty list input should return None.""" + + test_block = [] + + assert blocknote_to_markdown(test_block) is None + + +def test_single_dict_input(): + """Single dict block should be processed normally.""" + + test_block = {"type": "paragraph", "content": [{"type": "text", "text": "Hello"}]} + + assert blocknote_to_markdown(test_block) == "Hello" + + +def test_unknown_block_type_with_content(): + """Unknown block type with content should extract and render the text.""" + + test_block = { + "type": "customBlockType", + "content": [{"type": "text", "text": "Some content"}], + } + + assert blocknote_to_markdown(test_block) == "Some content" + + +def test_unknown_block_type_with_no_content(): + """Unknown block type without content should return None.""" + + test_block = {"type": "customBlockType"} + + assert blocknote_to_markdown(test_block) is None