package converter
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// === Markdown to Confluence XML tests ===
func TestMarkdownToConfluence_Heading(t *testing.T) {
result, err := MarkdownToConfluence([]byte("# Hello World"))
require.NoError(t, err)
assert.Contains(t, result, "
Hello World
")
}
func TestMarkdownToConfluence_AllHeadingLevels(t *testing.T) {
for i, tag := range []string{"h1", "h2", "h3", "h4", "h5", "h6"} {
prefix := strings.Repeat("#", i+1)
result, err := MarkdownToConfluence([]byte(prefix + " Heading"))
require.NoError(t, err)
assert.Contains(t, result, "<"+tag+">Heading"+tag+">", "level %d", i+1)
}
}
func TestMarkdownToConfluence_Bold(t *testing.T) {
result, err := MarkdownToConfluence([]byte("**bold text**"))
require.NoError(t, err)
assert.Contains(t, result, "bold text")
}
func TestMarkdownToConfluence_Italic(t *testing.T) {
result, err := MarkdownToConfluence([]byte("*italic text*"))
require.NoError(t, err)
assert.Contains(t, result, "italic text")
}
func TestMarkdownToConfluence_Strikethrough(t *testing.T) {
result, err := MarkdownToConfluence([]byte("~~deleted~~"))
require.NoError(t, err)
assert.Contains(t, result, "deleted")
}
func TestMarkdownToConfluence_InlineCode(t *testing.T) {
result, err := MarkdownToConfluence([]byte("`code`"))
require.NoError(t, err)
assert.Contains(t, result, "code")
}
func TestMarkdownToConfluence_CodeBlock(t *testing.T) {
input := "```go\nfmt.Println(\"hello\")\n```"
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, `ac:name="code"`)
assert.Contains(t, result, `ac:name="language">go`)
assert.Contains(t, result, ``)
}
func TestMarkdownToConfluence_CodeBlockNoLanguage(t *testing.T) {
input := "```\nsome code\n```"
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, `ac:name="code"`)
assert.NotContains(t, result, `ac:name="language"`)
}
func TestMarkdownToConfluence_Table(t *testing.T) {
input := "| A | B |\n|---|---|\n| 1 | 2 |"
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, "")
assert.Contains(t, result, "A | ")
assert.Contains(t, result, "1 | ")
}
func TestMarkdownToConfluence_Blockquote(t *testing.T) {
result, err := MarkdownToConfluence([]byte("> Important note"))
require.NoError(t, err)
assert.Contains(t, result, `ac:name="info"`)
assert.Contains(t, result, "Important note")
}
func TestMarkdownToConfluence_Image(t *testing.T) {
result, err := MarkdownToConfluence([]byte(""))
require.NoError(t, err)
assert.Contains(t, result, "Click here`)
}
func TestMarkdownToConfluence_UnorderedList(t *testing.T) {
input := "- One\n- Two\n- Three"
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, "")
assert.Contains(t, result, "- ")
}
func TestMarkdownToConfluence_OrderedList(t *testing.T) {
input := "1. First\n2. Second\n3. Third"
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, "
")
}
func TestMarkdownToConfluence_NestedList(t *testing.T) {
input := "- Parent\n - Child"
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Equal(t, 2, strings.Count(result, ""), "expected 2 ul tags for nested list")
}
func TestMarkdownToConfluence_HorizontalRule(t *testing.T) {
result, err := MarkdownToConfluence([]byte("---"))
require.NoError(t, err)
assert.Contains(t, result, "
")
}
func TestMarkdownToConfluence_TaskList(t *testing.T) {
input := "- [x] Done\n- [ ] Todo"
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, "")
assert.Contains(t, result, "")
assert.Contains(t, result, "")
assert.Contains(t, result, "complete")
assert.Contains(t, result, "incomplete")
assert.Contains(t, result, "")
assert.Contains(t, result, "")
}
func TestMarkdownToConfluence_Paragraph(t *testing.T) {
result, err := MarkdownToConfluence([]byte("Hello world"))
require.NoError(t, err)
assert.Contains(t, result, "Hello world
")
}
func TestMarkdownToConfluence_HardLineBreak(t *testing.T) {
result, err := MarkdownToConfluence([]byte("Line one \nLine two"))
require.NoError(t, err)
assert.Contains(t, result, "
")
}
func TestMarkdownToConfluence_InlineCommentMarker(t *testing.T) {
input := `Before commented text after`
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, ``)
assert.Contains(t, result, "")
assert.Contains(t, result, "commented text")
}
// === Confluence XML to Markdown tests ===
func TestConfluenceToMarkdown_Heading(t *testing.T) {
result, err := ConfluenceToMarkdown("Hello World
")
require.NoError(t, err)
assert.Contains(t, result, "# Hello World")
}
func TestConfluenceToMarkdown_Bold(t *testing.T) {
result, err := ConfluenceToMarkdown("bold
")
require.NoError(t, err)
assert.Contains(t, result, "**bold**")
}
func TestConfluenceToMarkdown_Italic(t *testing.T) {
result, err := ConfluenceToMarkdown("italic
")
require.NoError(t, err)
assert.Contains(t, result, "*italic*")
}
func TestConfluenceToMarkdown_Strikethrough(t *testing.T) {
result, err := ConfluenceToMarkdown("deleted
")
require.NoError(t, err)
assert.Contains(t, result, "~~deleted~~")
}
func TestConfluenceToMarkdown_InlineCode(t *testing.T) {
result, err := ConfluenceToMarkdown("code
")
require.NoError(t, err)
assert.Contains(t, result, "`code`")
}
func TestConfluenceToMarkdown_CodeBlock(t *testing.T) {
input := `python`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, "```python")
assert.Contains(t, result, `print("hello")`)
}
func TestConfluenceToMarkdown_Link(t *testing.T) {
result, err := ConfluenceToMarkdown(`Click
`)
require.NoError(t, err)
assert.Contains(t, result, "[Click](https://example.com)")
}
func TestConfluenceToMarkdown_UnorderedList(t *testing.T) {
result, err := ConfluenceToMarkdown("")
require.NoError(t, err)
assert.Contains(t, result, "- One")
assert.Contains(t, result, "- Two")
}
func TestConfluenceToMarkdown_OrderedList(t *testing.T) {
result, err := ConfluenceToMarkdown("\n- First
\n- Second
\n
")
require.NoError(t, err)
assert.Contains(t, result, "1. First")
assert.Contains(t, result, "2. Second")
}
func TestConfluenceToMarkdown_Table(t *testing.T) {
input := ``
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, "| Name |")
assert.Contains(t, result, "| Alice |")
assert.Contains(t, result, "|---|---|")
}
func TestConfluenceToMarkdown_InfoPanel(t *testing.T) {
input := `Important note
`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, "> Important note")
}
func TestConfluenceToMarkdown_HorizontalRule(t *testing.T) {
result, err := ConfluenceToMarkdown("
")
require.NoError(t, err)
assert.Contains(t, result, "---")
}
func TestConfluenceToMarkdown_Emoticons(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"plus", ``, "(+)"},
{"minus", ``, "(-)"},
{"question", ``, "(?)"},
{"tick", ``, "(v)"},
{"cross", ``, "(x)"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := ConfluenceToMarkdown(tc.input)
require.NoError(t, err)
assert.Contains(t, result, tc.expected)
})
}
}
func TestConfluenceToMarkdown_InlineCommentMarker(t *testing.T) {
input := `Before commented text after
`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, ``)
assert.Contains(t, result, "commented text")
assert.Contains(t, result, "")
}
// === User reference tests ===
func TestConfluenceToMarkdown_UserReference(t *testing.T) {
input := `Author:
`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, ``)
}
func TestMarkdownToConfluence_UserReference(t *testing.T) {
input := `Author: `
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, ``)
}
func TestRoundTrip_UserReference(t *testing.T) {
xmlInput := `Author: wrote this
`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
require.Contains(t, md, `data-user-key="abc123"`)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOutput, `ri:userkey="abc123"`)
}
// === Attachment image tests ===
func TestConfluenceToMarkdown_AttachmentImage(t *testing.T) {
input := `
`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, ``)
}
func TestConfluenceToMarkdown_AttachmentImageWithAlt(t *testing.T) {
input := `
`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, `data-attachment="screenshot.png"`)
assert.Contains(t, result, `data-alt="My Screenshot"`)
}
func TestMarkdownToConfluence_AttachmentImage(t *testing.T) {
input := `Image: `
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, ``)
}
func TestMarkdownToConfluence_AttachmentImageWithAlt(t *testing.T) {
input := ``
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, `ac:alt="My Shot"`)
assert.Contains(t, result, `ri:filename="screenshot.png"`)
}
func TestRoundTrip_AttachmentImage(t *testing.T) {
xmlInput := `
`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
require.Contains(t, md, `data-attachment="Pasted image 20260325004147.png"`)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOutput, `ri:filename="Pasted image 20260325004147.png"`)
}
// === Inline spacing tests ===
func TestConfluenceToMarkdown_SpaceAfterBold(t *testing.T) {
input := ``
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, "**ATE:** Запуск плейбуков")
}
func TestConfluenceToMarkdown_SpaceBetweenInlineElements(t *testing.T) {
input := ``
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, "**Bold** then *italic* text")
}
// === Layout tests ===
func TestConfluenceToMarkdown_Layout(t *testing.T) {
input := `Content
`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, "")
assert.Contains(t, result, ``)
assert.Contains(t, result, "")
assert.Contains(t, result, "Content")
assert.Contains(t, result, "")
assert.Contains(t, result, "")
assert.Contains(t, result, "")
}
func TestConfluenceToMarkdown_LayoutTwoColumn(t *testing.T) {
input := `Left
Right
`
result, err := ConfluenceToMarkdown(input)
require.NoError(t, err)
assert.Contains(t, result, `type="two_equal"`)
assert.Contains(t, result, "Left")
assert.Contains(t, result, "Right")
assert.Equal(t, 2, strings.Count(result, ""))
assert.Equal(t, 2, strings.Count(result, ""))
}
func TestMarkdownToConfluence_Layout(t *testing.T) {
input := `
Hello world
`
result, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
assert.Contains(t, result, "")
assert.Contains(t, result, ``)
assert.Contains(t, result, "")
assert.Contains(t, result, "Hello world")
assert.Contains(t, result, "")
assert.Contains(t, result, "")
assert.Contains(t, result, "")
}
func TestRoundTrip_Layout(t *testing.T) {
xmlInput := `Left column
Right column
`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOutput, "")
assert.Contains(t, xmlOutput, `ac:type="two_equal"`)
assert.Contains(t, xmlOutput, "")
assert.Contains(t, xmlOutput, "Left column")
assert.Contains(t, xmlOutput, "Right column")
assert.Contains(t, xmlOutput, "")
assert.Contains(t, xmlOutput, "")
assert.Contains(t, xmlOutput, "")
}
// === Round-trip tests ===
func TestRoundTrip_Basic(t *testing.T) {
input := `# Test
Some **bold** and *italic* text.
## Code
` + "```go\nfunc main() {}\n```" + `
## List
- One
- Two
- Three
| H1 | H2 |
|----|-----|
| A | B |
`
xml, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
md, err := ConfluenceToMarkdown(xml)
require.NoError(t, err)
for _, check := range []string{"# Test", "**bold**", "*italic*", "```go", "func main() {}", "- One", "| H1", "| A"} {
assert.Contains(t, md, check, "round-trip should preserve %q", check)
}
}
func TestRoundTrip_Blockquote(t *testing.T) {
input := "> This is a blockquote\n"
xml, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
md, err := ConfluenceToMarkdown(xml)
require.NoError(t, err)
assert.Contains(t, md, "> This is a blockquote")
}
func TestRoundTrip_Link(t *testing.T) {
input := "[Example](https://example.com)\n"
xml, err := MarkdownToConfluence([]byte(input))
require.NoError(t, err)
md, err := ConfluenceToMarkdown(xml)
require.NoError(t, err)
assert.Contains(t, md, "[Example](https://example.com)")
}
func TestRoundTrip_InlineCommentMarker(t *testing.T) {
xmlInput := `Hello important text world
`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
require.Contains(t, md, `data-inline-comment="b2f6ce98-4dc9-45e0-a9b6-b4a5109657ca"`)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOutput, ``)
assert.Contains(t, xmlOutput, "")
assert.Contains(t, xmlOutput, "important text")
}
func TestConfluenceToMarkdown_AdjacentCodeMerge(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "directly adjacent code elements merged",
input: `helloworld
`,
expected: "`helloworld`",
},
{
name: "adjacent code with span inside",
input: `plan : vclock
`,
expected: "`plan : vclock`",
},
{
name: "single code element unchanged",
input: `single
`,
expected: "`single`",
},
{
name: "non-adjacent code elements stay separate",
input: `a text b
`,
expected: "`a` text `b`",
},
{
// Regression: whitespace-only text between two elements
// previously caused the merge logic to swallow the gap and
// concatenate the spans.
name: "whitespace-separated code elements stay separate",
input: `Благодаря previous_backup_id tt restore plan видит разрыв.
`,
expected: "`previous_backup_id` `tt restore plan`",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ConfluenceToMarkdown(tt.input)
require.NoError(t, err)
assert.Contains(t, result, tt.expected)
})
}
}
func TestRoundTrip_ListNoTrailingWhitespace(t *testing.T) {
xmlInput := ``
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOutput, "- item 1
")
assert.Contains(t, xmlOutput, "- item 2
")
assert.Contains(t, xmlOutput, "- item 3
")
assert.NotContains(t, xmlOutput, "item 1\n
")
assert.NotContains(t, xmlOutput, "item 1 ")
}
func TestRoundTrip_InlineCommentFromRealXML(t *testing.T) {
xmlInput := `Товарищ! Не майся дурью, копируй этот шаблон и редактируй его! Ускоришь написание RFC!
`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOutput, `ac:ref="b2f6ce98-4dc9-45e0-a9b6-b4a5109657ca"`)
assert.Contains(t, xmlOutput, "Не майся дурью")
}
// TestRoundTrip_TablePipeInCode locks in the fix for table cells whose
// contents include a literal `|`. Without escaping, the pipe was treated as a
// column separator at re-parse time, splitting a single cell across columns
// and dropping content. Cells must escape `|` as `\|` per the GFM spec.
func TestRoundTrip_TablePipeInCode(t *testing.T) {
xmlInput := `` +
`Команда | Где | Действие |
` +
`mode --target=<incremental|full> --storage=<config>
| ` +
`менеджер | ` +
`Возвращает mode + from_vclock. |
` +
`
`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
// The pipe inside the code span must be escaped in the markdown so the
// table parser does not treat it as a column boundary.
assert.Contains(t, md, `\|`)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
// All three columns must survive the round-trip.
assert.Contains(t, xmlOutput, `mode --target=<incremental|full> --storage=<config>`)
assert.Contains(t, xmlOutput, "менеджер")
assert.Contains(t, xmlOutput, "from_vclock")
}
// TestEscapeTablePipes_NoDoubleEscape guards the helper against doubling up
// already-escaped pipes (e.g. when a cell's rendered markdown was produced by
// an upstream that already escaped some pipes itself).
func TestEscapeTablePipes_NoDoubleEscape(t *testing.T) {
cases := map[string]string{
"": "",
"plain": "plain",
"a|b": `a\|b`,
`already\|done`: `already\|done`,
"`a|b`": "`a\\|b`",
}
for in, want := range cases {
got := escapeTablePipes(in)
assert.Equal(t, want, got, "input=%q", in)
}
}
// TestRoundTrip_PageLink locks in the fix for .
// These page references have no plain markdown equivalent and previously got
// dropped on the way to MD, leaving an empty paragraph after a round-trip.
func TestRoundTrip_PageLink(t *testing.T) {
xmlInput := `Основной документ:
`
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
require.Contains(t, md, `data-page-link`)
require.Contains(t, md, `data-space-key="TSC"`)
require.Contains(t, md, `data-content-title="Бекапы и Point-in-time recovery в кластере Tarantool"`)
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOutput, ``)
assert.Contains(t, xmlOutput, "Основной документ:")
}
// TestConfluenceToMarkdown_CommentInHeading guards against the regression
// where an HTML comment that is the sole content of a heading — e.g. a TOC
// macro marker md2xml emits as `` —
// was silently dropped by xml2md. A CommentNode is neither a TextNode nor an
// ElementNode, so it used to fall through to walkChildren (a comment has no
// children) and vanish, collapsing `# ` to a bare `#`.
func TestConfluenceToMarkdown_CommentInHeading(t *testing.T) {
xmlInput := ``
md, err := ConfluenceToMarkdown(xmlInput)
require.NoError(t, err)
assert.Contains(t, md, `# `)
// And the comment survives a full markdown round-trip.
xmlOutput, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
mdBack, err := ConfluenceToMarkdown(xmlOutput)
require.NoError(t, err)
assert.Equal(t, strings.TrimSpace(md), strings.TrimSpace(mdBack))
}
// TestRoundTrip_LiteralAngleBracketText guards against the catastrophic
// regression where literal angle-bracket text stored as `<table>` (e.g.
// the function signature `box.backup.start()`) round-tripped into a real
// `` element: xml2md emitted a bare ``, goldmark parsed it as raw
// HTML, and the phantom table swallowed the rest of the document. xml2md now
// backslash-escapes a tag-like `<`, and renderText unescapes it back to `<`.
func TestRoundTrip_LiteralAngleBracketText(t *testing.T) {
for _, where := range []string{
`call box.backup.start(<table>) now
`,
`- call box.backup.start(<table>) now
`,
} {
md, err := ConfluenceToMarkdown(where)
require.NoError(t, err)
// The '<' must be escaped so goldmark won't parse it as a table tag.
assert.Contains(t, md, `\`, "input %q", where)
xmlOut, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOut, "box.backup.start(<table>)", "input %q", where)
assert.NotContains(t, xmlOut, "", "literal text must not become a table element")
}
// A non-tag '<' (comparison operator) is left unescaped and still survives.
md, err := ConfluenceToMarkdown(`t <= time_end
`)
require.NoError(t, err)
assert.NotContains(t, md, `\<`, "a non-tag '<' should not be escaped")
xmlOut, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOut, "t <= time_end")
}
// TestRoundTrip_EmphasisInteriorSpaces guards against ` x `
// (whitespace just inside the tag) round-tripping to literal `** x **` asterisks
// — CommonMark does not treat a whitespace-flanked run as emphasis. xml2md now
// moves the spaces outside the markers (` **x** `).
func TestRoundTrip_EmphasisInteriorSpaces(t *testing.T) {
md, err := ConfluenceToMarkdown(`Вызов box.backup.start() будет
`)
require.NoError(t, err)
assert.Contains(t, md, "**box.backup.start()**", "spaces must move outside the markers")
assert.NotContains(t, md, "** box.backup.start", "no whitespace just inside the markers")
xmlOut, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOut, "box.backup.start()")
assert.NotContains(t, xmlOut, "**", "asterisks must not leak as literal text")
}
// TestRoundTrip_TocMacroInHeading guards against the TOC macro that lives inside
// a heading round-tripping lossily. xml2md emits it as `# `;
// goldmark parses that comment as *inline* RawHTML, so md2xml must reconstruct
// the structured-macro from the inline path (renderRawHTML), not only the block
// path — otherwise it leaks back out as a literal comment instead of a macro.
func TestRoundTrip_TocMacroInHeading(t *testing.T) {
xmlIn := `
`
md, err := ConfluenceToMarkdown(xmlIn)
require.NoError(t, err)
xmlOut, err := MarkdownToConfluence([]byte(md))
require.NoError(t, err)
assert.Contains(t, xmlOut, `ac:name="toc"`, "TOC macro must be reconstructed, not left as a comment")
assert.Contains(t, xmlOut, `ac:macro-id="f9579fa5-2198-4556-92de-04db447accec"`)
assert.NotContains(t, xmlOut, "