test_telegram_format.py
1 """Tests for Telegram MarkdownV2 formatting in gateway/platforms/telegram.py. 2 3 Covers: _escape_mdv2 (pure function), format_message (markdown-to-MarkdownV2 4 conversion pipeline), and edge cases that could produce invalid MarkdownV2 5 or corrupt user-visible content. 6 """ 7 8 import re 9 import sys 10 from unittest.mock import AsyncMock, MagicMock 11 12 import pytest 13 14 from gateway.config import PlatformConfig 15 16 17 # --------------------------------------------------------------------------- 18 # Mock the telegram package if it's not installed 19 # --------------------------------------------------------------------------- 20 21 def _ensure_telegram_mock(): 22 if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): 23 return 24 mod = MagicMock() 25 mod.ext.ContextTypes.DEFAULT_TYPE = type(None) 26 mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" 27 mod.constants.ChatType.GROUP = "group" 28 mod.constants.ChatType.SUPERGROUP = "supergroup" 29 mod.constants.ChatType.CHANNEL = "channel" 30 mod.constants.ChatType.PRIVATE = "private" 31 for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): 32 sys.modules.setdefault(name, mod) 33 34 35 _ensure_telegram_mock() 36 37 from gateway.platforms.telegram import ( # noqa: E402 38 TelegramAdapter, 39 _escape_mdv2, 40 _strip_mdv2, 41 _wrap_markdown_tables, 42 ) 43 44 45 # --------------------------------------------------------------------------- 46 # Fixtures 47 # --------------------------------------------------------------------------- 48 49 @pytest.fixture() 50 def adapter(): 51 config = PlatformConfig(enabled=True, token="fake-token") 52 return TelegramAdapter(config) 53 54 55 # ========================================================================= 56 # _escape_mdv2 57 # ========================================================================= 58 59 60 class TestEscapeMdv2: 61 def test_escapes_all_special_characters(self): 62 special = r'_*[]()~`>#+-=|{}.!\ ' 63 escaped = _escape_mdv2(special) 64 # Every special char should be preceded by backslash 65 for ch in r'_*[]()~`>#+-=|{}.!\ ': 66 if ch == ' ': 67 continue 68 assert f'\\{ch}' in escaped 69 70 def test_empty_string(self): 71 assert _escape_mdv2("") == "" 72 73 def test_no_special_characters(self): 74 assert _escape_mdv2("hello world 123") == "hello world 123" 75 76 def test_backslash_escaped(self): 77 assert _escape_mdv2("a\\b") == "a\\\\b" 78 79 def test_dot_escaped(self): 80 assert _escape_mdv2("v2.0") == "v2\\.0" 81 82 def test_exclamation_escaped(self): 83 assert _escape_mdv2("wow!") == "wow\\!" 84 85 def test_mixed_text_and_specials(self): 86 result = _escape_mdv2("Hello (world)!") 87 assert result == "Hello \\(world\\)\\!" 88 89 90 # ========================================================================= 91 # format_message - basic conversions 92 # ========================================================================= 93 94 95 class TestFormatMessageBasic: 96 def test_empty_string(self, adapter): 97 assert adapter.format_message("") == "" 98 99 def test_none_input(self, adapter): 100 # content is falsy, returned as-is 101 assert adapter.format_message(None) is None 102 103 def test_plain_text_specials_escaped(self, adapter): 104 result = adapter.format_message("Price is $5.00!") 105 assert "\\." in result 106 assert "\\!" in result 107 108 def test_plain_text_no_markdown(self, adapter): 109 result = adapter.format_message("Hello world") 110 assert result == "Hello world" 111 112 113 # ========================================================================= 114 # format_message - code blocks 115 # ========================================================================= 116 117 118 class TestFormatMessageCodeBlocks: 119 def test_fenced_code_block_preserved(self, adapter): 120 text = "Before\n```python\nprint('hello')\n```\nAfter" 121 result = adapter.format_message(text) 122 # Code block contents must NOT be escaped 123 assert "```python\nprint('hello')\n```" in result 124 # But "After" should have no escaping needed (plain text) 125 assert "After" in result 126 127 def test_inline_code_preserved(self, adapter): 128 text = "Use `my_var` here" 129 result = adapter.format_message(text) 130 # Inline code content must NOT be escaped 131 assert "`my_var`" in result 132 # The surrounding text's underscore-free content should be fine 133 assert "Use" in result 134 135 def test_code_block_special_chars_not_escaped(self, adapter): 136 text = "```\nif (x > 0) { return !x; }\n```" 137 result = adapter.format_message(text) 138 # Inside code block, > and ! and { should NOT be escaped 139 assert "if (x > 0) { return !x; }" in result 140 141 def test_inline_code_special_chars_not_escaped(self, adapter): 142 text = "Run `rm -rf ./*` carefully" 143 result = adapter.format_message(text) 144 assert "`rm -rf ./*`" in result 145 146 def test_multiple_code_blocks(self, adapter): 147 text = "```\nblock1\n```\ntext\n```\nblock2\n```" 148 result = adapter.format_message(text) 149 assert "block1" in result 150 assert "block2" in result 151 # "text" between blocks should be present 152 assert "text" in result 153 154 def test_inline_code_backslashes_escaped(self, adapter): 155 r"""Backslashes in inline code must be escaped for MarkdownV2.""" 156 text = r"Check `C:\ProgramData\VMware\` path" 157 result = adapter.format_message(text) 158 assert r"`C:\\ProgramData\\VMware\\`" in result 159 160 def test_fenced_code_block_backslashes_escaped(self, adapter): 161 r"""Backslashes in fenced code blocks must be escaped for MarkdownV2.""" 162 text = "```\npath = r'C:\\Users\\test'\n```" 163 result = adapter.format_message(text) 164 assert r"C:\\Users\\test" in result 165 166 def test_fenced_code_block_backticks_escaped(self, adapter): 167 r"""Backticks inside fenced code blocks must be escaped for MarkdownV2.""" 168 text = "```\necho `hostname`\n```" 169 result = adapter.format_message(text) 170 assert r"echo \`hostname\`" in result 171 172 def test_inline_code_no_double_escape(self, adapter): 173 r"""Already-escaped backslashes should not be quadruple-escaped.""" 174 text = r"Use `\\server\share`" 175 result = adapter.format_message(text) 176 # \\ in input → \\\\ in output (each \ escaped once) 177 assert r"`\\\\server\\share`" in result 178 179 180 # ========================================================================= 181 # format_message - bold and italic 182 # ========================================================================= 183 184 185 class TestFormatMessageBoldItalic: 186 def test_bold_converted(self, adapter): 187 result = adapter.format_message("This is **bold** text") 188 # MarkdownV2 bold uses single * 189 assert "*bold*" in result 190 # Original ** should be gone 191 assert "**" not in result 192 193 def test_italic_converted(self, adapter): 194 result = adapter.format_message("This is *italic* text") 195 # MarkdownV2 italic uses _ 196 assert "_italic_" in result 197 198 def test_bold_with_special_chars(self, adapter): 199 result = adapter.format_message("**hello.world!**") 200 # Content inside bold should be escaped 201 assert "*hello\\.world\\!*" in result 202 203 def test_italic_with_special_chars(self, adapter): 204 result = adapter.format_message("*hello.world*") 205 assert "_hello\\.world_" in result 206 207 def test_bold_and_italic_in_same_line(self, adapter): 208 result = adapter.format_message("**bold** and *italic*") 209 assert "*bold*" in result 210 assert "_italic_" in result 211 212 213 # ========================================================================= 214 # format_message - headers 215 # ========================================================================= 216 217 218 class TestFormatMessageHeaders: 219 def test_h1_converted_to_bold(self, adapter): 220 result = adapter.format_message("# Title") 221 # Header becomes bold in MarkdownV2 222 assert "*Title*" in result 223 # Hash should be removed 224 assert "#" not in result 225 226 def test_h2_converted(self, adapter): 227 result = adapter.format_message("## Subtitle") 228 assert "*Subtitle*" in result 229 230 def test_header_with_inner_bold_stripped(self, adapter): 231 # Headers strip redundant **...** inside 232 result = adapter.format_message("## **Important**") 233 # Should be *Important* not ***Important*** 234 assert "*Important*" in result 235 count = result.count("*") 236 # Should have exactly 2 asterisks (open + close) 237 assert count == 2 238 239 def test_header_with_special_chars(self, adapter): 240 result = adapter.format_message("# Hello (World)!") 241 assert "\\(" in result 242 assert "\\)" in result 243 assert "\\!" in result 244 245 def test_multiline_headers(self, adapter): 246 text = "# First\nSome text\n## Second" 247 result = adapter.format_message(text) 248 assert "*First*" in result 249 assert "*Second*" in result 250 assert "Some text" in result 251 252 253 # ========================================================================= 254 # format_message - links 255 # ========================================================================= 256 257 258 class TestFormatMessageLinks: 259 def test_markdown_link_converted(self, adapter): 260 result = adapter.format_message("[Click here](https://example.com)") 261 assert "[Click here](https://example.com)" in result 262 263 def test_link_display_text_escaped(self, adapter): 264 result = adapter.format_message("[Hello!](https://example.com)") 265 # The ! in display text should be escaped 266 assert "Hello\\!" in result 267 268 def test_link_url_parentheses_escaped(self, adapter): 269 result = adapter.format_message("[link](https://example.com/path_(1))") 270 # The ) in URL should be escaped 271 assert "\\)" in result 272 273 def test_link_with_surrounding_text(self, adapter): 274 result = adapter.format_message("Visit [Google](https://google.com) today.") 275 assert "[Google](https://google.com)" in result 276 assert "today\\." in result 277 278 279 # ========================================================================= 280 # format_message - BUG: italic regex spans newlines 281 # ========================================================================= 282 283 284 class TestItalicNewlineBug: 285 r"""Italic regex ``\*([^*]+)\*`` matched across newlines, corrupting content. 286 287 This affects bullet lists using * markers and any text where * appears 288 at the end of one line and start of another. 289 """ 290 291 def test_bullet_list_not_corrupted(self, adapter): 292 """Bullet list items using * must NOT be merged into italic.""" 293 text = "* Item one\n* Item two\n* Item three" 294 result = adapter.format_message(text) 295 # Each item should appear in the output (not eaten by italic conversion) 296 assert "Item one" in result 297 assert "Item two" in result 298 assert "Item three" in result 299 # Should NOT contain _ (italic markers) wrapping list items 300 assert "_" not in result or "Item" not in result.split("_")[1] if "_" in result else True 301 302 def test_asterisk_list_items_preserved(self, adapter): 303 """Each * list item should remain as a separate line, not become italic.""" 304 text = "* Alpha\n* Beta" 305 result = adapter.format_message(text) 306 # Both items must be present in output 307 assert "Alpha" in result 308 assert "Beta" in result 309 # The text between first * and second * must NOT become italic 310 lines = result.split("\n") 311 assert len(lines) >= 2 312 313 def test_italic_does_not_span_lines(self, adapter): 314 """*text on\nmultiple lines* should NOT become italic.""" 315 text = "Start *across\nlines* end" 316 result = adapter.format_message(text) 317 # Should NOT have underscore italic markers wrapping cross-line text 318 # If this fails, the italic regex is matching across newlines 319 assert "_across\nlines_" not in result 320 321 def test_single_line_italic_still_works(self, adapter): 322 """Normal single-line italic must still convert correctly.""" 323 text = "This is *italic* text" 324 result = adapter.format_message(text) 325 assert "_italic_" in result 326 327 328 # ========================================================================= 329 # format_message - strikethrough 330 # ========================================================================= 331 332 333 class TestFormatMessageStrikethrough: 334 def test_strikethrough_converted(self, adapter): 335 result = adapter.format_message("This is ~~deleted~~ text") 336 assert "~deleted~" in result 337 assert "~~" not in result 338 339 def test_strikethrough_with_special_chars(self, adapter): 340 result = adapter.format_message("~~hello.world!~~") 341 assert "~hello\\.world\\!~" in result 342 343 def test_strikethrough_in_code_not_converted(self, adapter): 344 result = adapter.format_message("`~~not struck~~`") 345 assert "`~~not struck~~`" in result 346 347 def test_strikethrough_with_bold(self, adapter): 348 result = adapter.format_message("**bold** and ~~struck~~") 349 assert "*bold*" in result 350 assert "~struck~" in result 351 352 353 # ========================================================================= 354 # format_message - spoiler 355 # ========================================================================= 356 357 358 class TestFormatMessageSpoiler: 359 def test_spoiler_converted(self, adapter): 360 result = adapter.format_message("This is ||hidden|| text") 361 assert "||hidden||" in result 362 363 def test_spoiler_with_special_chars(self, adapter): 364 result = adapter.format_message("||hello.world!||") 365 assert "||hello\\.world\\!||" in result 366 367 def test_spoiler_in_code_not_converted(self, adapter): 368 result = adapter.format_message("`||not spoiler||`") 369 assert "`||not spoiler||`" in result 370 371 def test_spoiler_pipes_not_escaped(self, adapter): 372 """The || delimiters must not be escaped as \\|\\|.""" 373 result = adapter.format_message("||secret||") 374 assert "\\|\\|" not in result 375 assert "||secret||" in result 376 377 378 # ========================================================================= 379 # format_message - blockquote 380 # ========================================================================= 381 382 383 class TestFormatMessageBlockquote: 384 def test_blockquote_converted(self, adapter): 385 result = adapter.format_message("> This is a quote") 386 assert "> This is a quote" in result 387 # > must NOT be escaped 388 assert "\\>" not in result 389 390 def test_blockquote_with_special_chars(self, adapter): 391 result = adapter.format_message("> Hello (world)!") 392 assert "> Hello \\(world\\)\\!" in result 393 assert "\\>" not in result 394 395 def test_blockquote_multiline(self, adapter): 396 text = "> Line one\n> Line two" 397 result = adapter.format_message(text) 398 assert "> Line one" in result 399 assert "> Line two" in result 400 assert "\\>" not in result 401 402 def test_blockquote_in_code_not_converted(self, adapter): 403 result = adapter.format_message("```\n> not a quote\n```") 404 assert "> not a quote" in result 405 406 def test_nested_blockquote(self, adapter): 407 result = adapter.format_message(">> Nested quote") 408 assert ">> Nested quote" in result 409 assert "\\>" not in result 410 411 def test_gt_in_middle_of_line_still_escaped(self, adapter): 412 """Only > at line start is a blockquote; mid-line > should be escaped.""" 413 result = adapter.format_message("5 > 3") 414 assert "\\>" in result 415 416 def test_expandable_blockquote(self, adapter): 417 """Expandable blockquote prefix **> and trailing || must NOT be escaped.""" 418 result = adapter.format_message("**> Hidden content||") 419 assert "**>" in result 420 assert "||" in result 421 assert "\\*" not in result # asterisks in prefix must not be escaped 422 assert "\\>" not in result # > in prefix must not be escaped 423 424 def test_single_asterisk_gt_not_blockquote(self, adapter): 425 """Single asterisk before > should not be treated as blockquote prefix.""" 426 result = adapter.format_message("*> not a quote") 427 assert "\\*" in result 428 assert "\\>" in result 429 430 def test_regular_blockquote_with_pipes_escaped(self, adapter): 431 """Regular blockquote ending with || should escape the pipes.""" 432 result = adapter.format_message("> not expandable||") 433 assert "> not expandable" in result 434 assert "\\|" in result 435 assert "\\>" not in result 436 437 438 # ========================================================================= 439 # format_message - mixed/complex 440 # ========================================================================= 441 442 443 class TestFormatMessageComplex: 444 def test_code_block_with_bold_outside(self, adapter): 445 text = "**Note:**\n```\ncode here\n```" 446 result = adapter.format_message(text) 447 assert "*Note:*" in result or "*Note\\:*" in result 448 assert "```\ncode here\n```" in result 449 450 def test_bold_inside_code_not_converted(self, adapter): 451 """Bold markers inside code blocks should not be converted.""" 452 text = "```\n**not bold**\n```" 453 result = adapter.format_message(text) 454 assert "**not bold**" in result 455 456 def test_link_inside_code_not_converted(self, adapter): 457 text = "`[not a link](url)`" 458 result = adapter.format_message(text) 459 assert "`[not a link](url)`" in result 460 461 def test_header_after_code_block(self, adapter): 462 text = "```\ncode\n```\n## Title" 463 result = adapter.format_message(text) 464 assert "*Title*" in result 465 assert "```\ncode\n```" in result 466 467 def test_multiple_bold_segments(self, adapter): 468 result = adapter.format_message("**a** and **b** and **c**") 469 assert result.count("*") >= 6 # 3 bold pairs = 6 asterisks 470 471 def test_special_chars_in_plain_text(self, adapter): 472 result = adapter.format_message("Price: $5.00 (50% off!)") 473 assert "\\." in result 474 assert "\\(" in result 475 assert "\\)" in result 476 assert "\\!" in result 477 478 def test_empty_bold(self, adapter): 479 """**** (empty bold) should not crash.""" 480 result = adapter.format_message("****") 481 assert result is not None 482 483 def test_empty_code_block(self, adapter): 484 result = adapter.format_message("```\n```") 485 assert "```" in result 486 487 def test_placeholder_collision(self, adapter): 488 """Many formatting elements should not cause placeholder collisions.""" 489 text = ( 490 "# Header\n" 491 "**bold1** *italic1* `code1`\n" 492 "**bold2** *italic2* `code2`\n" 493 "```\nblock\n```\n" 494 "[link](https://url.com)" 495 ) 496 result = adapter.format_message(text) 497 # No placeholder tokens should leak into output 498 assert "\x00" not in result 499 # All elements should be present 500 assert "Header" in result 501 assert "block" in result 502 assert "url.com" in result 503 504 505 # ========================================================================= 506 # _strip_mdv2 — plaintext fallback 507 # ========================================================================= 508 509 510 class TestStripMdv2: 511 def test_removes_escape_backslashes(self): 512 assert _strip_mdv2(r"hello\.world\!") == "hello.world!" 513 514 def test_removes_bold_markers(self): 515 assert _strip_mdv2("*bold text*") == "bold text" 516 517 def test_removes_italic_markers(self): 518 assert _strip_mdv2("_italic text_") == "italic text" 519 520 def test_removes_both_bold_and_italic(self): 521 result = _strip_mdv2("*bold* and _italic_") 522 assert result == "bold and italic" 523 524 def test_preserves_snake_case(self): 525 assert _strip_mdv2("my_variable_name") == "my_variable_name" 526 527 def test_preserves_multi_underscore_identifier(self): 528 assert _strip_mdv2("some_func_call here") == "some_func_call here" 529 530 def test_plain_text_unchanged(self): 531 assert _strip_mdv2("plain text") == "plain text" 532 533 def test_empty_string(self): 534 assert _strip_mdv2("") == "" 535 536 def test_removes_strikethrough_markers(self): 537 assert _strip_mdv2("~struck text~") == "struck text" 538 539 def test_removes_spoiler_markers(self): 540 assert _strip_mdv2("||hidden text||") == "hidden text" 541 542 543 # ========================================================================= 544 # Markdown table auto-wrap 545 # ========================================================================= 546 547 548 class TestWrapMarkdownTables: 549 """_wrap_markdown_tables rewrites GFM pipe tables into Telegram-friendly 550 row groups instead of leaving noisy pipe syntax in the final message.""" 551 552 def test_basic_table_rewritten_as_row_groups(self): 553 text = ( 554 "Scores:\n\n" 555 "| Player | Score |\n" 556 "|--------|-------|\n" 557 "| Alice | 150 |\n" 558 "| Bob | 120 |\n" 559 "\nEnd." 560 ) 561 out = _wrap_markdown_tables(text) 562 assert "**Alice**" in out 563 assert "• Player: Alice" in out 564 assert "• Score: 150" in out 565 assert "**Bob**" in out 566 assert "• Score: 120" in out 567 # Surrounding prose is preserved 568 assert out.startswith("Scores:") 569 assert out.endswith("End.") 570 571 def test_bare_pipe_table_rewritten(self): 572 """Tables without outer pipes (GFM allows this) are still detected.""" 573 text = "head1 | head2\n--- | ---\na | b\nc | d" 574 out = _wrap_markdown_tables(text) 575 assert out.startswith("**a**") 576 assert "• head1: a" in out 577 assert "• head2: b" in out 578 assert "**c**" in out 579 580 def test_alignment_separators(self): 581 """Separator rows with :--- / ---: / :---: alignment markers match.""" 582 text = ( 583 "| Name | Age | City |\n" 584 "|:-----|----:|:----:|\n" 585 "| Ada | 30 | NYC |" 586 ) 587 out = _wrap_markdown_tables(text) 588 assert "**Ada**" in out 589 assert "• Age: 30" in out 590 assert "• City: NYC" in out 591 592 def test_two_consecutive_tables_rewritten_separately(self): 593 text = ( 594 "| A | B |\n" 595 "|---|---|\n" 596 "| 1 | 2 |\n" 597 "\n" 598 "| X | Y |\n" 599 "|---|---|\n" 600 "| 9 | 8 |" 601 ) 602 out = _wrap_markdown_tables(text) 603 assert out.count("**1**") == 1 604 assert out.count("**9**") == 1 605 assert "• A: 1" in out 606 assert "• X: 9" in out 607 608 def test_plain_text_with_pipes_not_wrapped(self): 609 """A bare pipe in prose must NOT trigger wrapping.""" 610 text = "Use the | pipe operator to chain commands." 611 assert _wrap_markdown_tables(text) == text 612 613 def test_horizontal_rule_not_wrapped(self): 614 """A lone '---' horizontal rule must not be mistaken for a separator.""" 615 text = "Section A\n\n---\n\nSection B" 616 assert _wrap_markdown_tables(text) == text 617 618 def test_existing_code_block_with_pipes_left_alone(self): 619 """A table already inside a fenced code block must not be re-wrapped.""" 620 text = ( 621 "```\n" 622 "| a | b |\n" 623 "|---|---|\n" 624 "| 1 | 2 |\n" 625 "```" 626 ) 627 assert _wrap_markdown_tables(text) == text 628 629 def test_no_pipe_character_short_circuits(self): 630 text = "Plain **bold** text with no table." 631 assert _wrap_markdown_tables(text) == text 632 633 def test_no_dash_short_circuits(self): 634 text = "a | b\nc | d" # has pipes but no '-' separator row 635 assert _wrap_markdown_tables(text) == text 636 637 def test_single_column_separator_not_matched(self): 638 """Single-column tables (rare) are not detected — we require at 639 least one internal pipe in the separator row to avoid false 640 positives on formatting rules.""" 641 text = "| a |\n| - |\n| b |" 642 assert _wrap_markdown_tables(text) == text 643 644 645 class TestFormatMessageTables: 646 """End-to-end: pipe tables become readable Telegram-native text instead 647 of escaped pipe syntax or fenced code blocks.""" 648 649 def test_table_rendered_as_bullets(self, adapter): 650 text = ( 651 "Data:\n\n" 652 "| Col1 | Col2 |\n" 653 "|------|------|\n" 654 "| A | B |\n" 655 ) 656 out = adapter.format_message(text) 657 assert "*A*" in out 658 assert "• Col1: A" in out 659 assert "• Col2: B" in out 660 assert "```" not in out 661 assert "\\|" not in out 662 663 def test_text_after_table_still_formatted(self, adapter): 664 text = ( 665 "| A | B |\n" 666 "|---|---|\n" 667 "| 1 | 2 |\n" 668 "\n" 669 "Nice **work** team!" 670 ) 671 out = adapter.format_message(text) 672 # MarkdownV2 bold conversion still happens outside the table 673 assert "*work*" in out 674 # Exclamation outside fence is escaped 675 assert "\\!" in out 676 assert "*1*" in out 677 assert "• A: 1" in out 678 679 def test_multiple_tables_in_single_message(self, adapter): 680 text = ( 681 "First:\n" 682 "| A | B |\n" 683 "|---|---|\n" 684 "| 1 | 2 |\n" 685 "\n" 686 "Second:\n" 687 "| X | Y |\n" 688 "|---|---|\n" 689 "| 9 | 8 |\n" 690 ) 691 out = adapter.format_message(text) 692 assert out.count("*1*") == 1 693 assert out.count("*9*") == 1 694 assert "• X: 9" in out 695 696 697 @pytest.mark.asyncio 698 async def test_send_escapes_chunk_indicator_for_markdownv2(adapter): 699 adapter.MAX_MESSAGE_LENGTH = 80 700 adapter._bot = MagicMock() 701 702 sent_texts = [] 703 704 async def _fake_send_message(**kwargs): 705 sent_texts.append(kwargs["text"]) 706 msg = MagicMock() 707 msg.message_id = len(sent_texts) 708 return msg 709 710 adapter._bot.send_message = AsyncMock(side_effect=_fake_send_message) 711 712 content = ("**bold** chunk content " * 12).strip() 713 result = await adapter.send("123", content) 714 715 assert result.success is True 716 assert len(sent_texts) > 1 717 assert re.search(r" \\\([0-9]+/[0-9]+\\\)$", sent_texts[0]) 718 assert re.search(r" \\\([0-9]+/[0-9]+\\\)$", sent_texts[-1])