diff --git a/pronote_sync/utils/text.py b/pronote_sync/utils/text.py index 199091b..9420a52 100644 --- a/pronote_sync/utils/text.py +++ b/pronote_sync/utils/text.py @@ -10,7 +10,9 @@ from __future__ import annotations import re import unicodedata -__all__ = ["normalize_subject"] +from bs4 import BeautifulSoup + +__all__ = ["normalize_subject", "sanitize_plaintext"] def normalize_subject(subject: str) -> str: @@ -30,3 +32,29 @@ def normalize_subject(subject: str) -> str: normalized = re.sub(r"[^\w\s]", "", normalized) normalized = re.sub(r"\s+", " ", normalized).strip() return normalized.lower() + + +# Pattern des caractères de contrôle ASCII non imprimables (à l'exception +# des tabulations ``\\t``, des sauts de ligne ``\\n`` et des retours chariot ``\\r``). +_CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]") + + +def sanitize_plaintext(text: str) -> str: + """Prépare un texte pour le corps de message XMPP en texte brut. + + Supprime les balises HTML (via ``BeautifulSoup`` avec le parseur + ``html.parser``) puis les caractères de contrôle ASCII non imprimables, + à l'exception des tabulations (``\\t``), des sauts de ligne (``\\n``) et + des retours chariot (``\\r``). Les caractères Unicode au-delà de ``\\x1f``, + notamment les emojis, sont conservés. La transformation est idempotente : + appliquée deux fois, elle produit le même résultat qu'appliquée une seule + fois. Une chaîne vide donne une chaîne vide. + + :param text: Le texte brut ou HTML à assainir. + :return: Le texte assaini, sans balises HTML ni caractères de contrôle. + :rtype: str + """ + # Étape 1 : suppression des balises HTML. + plain = BeautifulSoup(text, "html.parser").get_text() + # Étape 2 : suppression des caractères de contrôle. + return _CONTROL_CHARS_RE.sub("", plain) diff --git a/tests/unit/test_text_sanitize.py b/tests/unit/test_text_sanitize.py new file mode 100644 index 0000000..d205a9a --- /dev/null +++ b/tests/unit/test_text_sanitize.py @@ -0,0 +1,197 @@ +"""Tests unitaires pour la fonction sanitize_plaintext dans pronote_sync.utils.text. + +Ce module valide le comportement de sanitize_plaintext qui prépare du texte +pour les corps de message XMPP en appliquant plusieurs transformations : +- Suppression des balises HTML (via beautifulsoup4) +- Suppression des caractères de contrôle ASCII non imprimables +- Préservation des emojis autorisés +- Idempotence de la fonction +""" + +import pytest + +from pronote_sync.utils.text import sanitize_plaintext + + +class TestSanitizePlaintext: + """Tests de la fonction sanitize_plaintext.""" + + def test_empty_string_returns_empty(self) -> None: + """Test que la chaîne vide retourne une chaîne vide. + + :return: None + """ + assert sanitize_plaintext("") == "" + + def test_plain_text_unchanged(self) -> None: + """Test qu'un texte simple sans balises ni caractères spéciaux reste inchangé. + + :return: None + """ + assert sanitize_plaintext("Hello world") == "Hello world" + + def test_html_tags_stripped(self) -> None: + """Test que les balises HTML simples sont supprimées. + + :return: None + """ + assert sanitize_plaintext("Hello world") == "Hello world" + + def test_nested_html_stripped(self) -> None: + """Test que les balises HTML imbriquées sont supprimées. + + :return: None + """ + assert sanitize_plaintext("

Nested

") == "Nested" + + def test_html_entities_decoded(self) -> None: + """Test que les entités HTML sont décodées. + + La fonction doit décoder les entités HTML comme & en &. + Si beautifulsoup4 décode les entités, le résultat attendu est "&". + + :return: None + """ + result = sanitize_plaintext("&") + # beautifulsoup4 décode les entités par défaut, donc & devient & + assert result == "&" + + @pytest.mark.parametrize( + "input_text,expected", + [ + ("Hello\x00\x01\x02world", "Helloworld"), + ("Hello\x03world", "Helloworld"), + ("Hello\x04world", "Helloworld"), + ("Hello\x05world", "Helloworld"), + ("Hello\x06world", "Helloworld"), + ("Hello\x07world", "Helloworld"), + ("Hello\x08world", "Helloworld"), + ("Hello\x0e\x0fworld", "Helloworld"), + ("Hello\x10\x11\x12world", "Helloworld"), + ("Hello\x13\x14\x15\x16\x17world", "Helloworld"), + ("Hello\x18\x19\x1a\x1b\x1c\x1d\x1e\x1fworld", "Helloworld"), + ], + ) + def test_control_chars_stripped(self, input_text: str, expected: str) -> None: + """Test que les caractères de contrôle ASCII non imprimables sont supprimés. + + Les caractères à supprimer sont : \x00-\x08, \x0b, \x0c, \x0e-\x1f + Les caractères à préserver sont : \t, \n, \r + + :param input_text: Texte avec caractères de contrôle + :param expected: Texte attendu après nettoyage + :return: None + """ + assert sanitize_plaintext(input_text) == expected + + def test_tab_preserved(self) -> None: + """Test que la tabulation est préservée. + + :return: None + """ + assert sanitize_plaintext("Hello\tworld") == "Hello\tworld" + + def test_newline_preserved(self) -> None: + """Test que le saut de ligne est préservé. + + :return: None + """ + assert sanitize_plaintext("Hello\nworld") == "Hello\nworld" + + def test_carriage_return_preserved(self) -> None: + """Test que le retour chariot est préservé. + + :return: None + """ + assert sanitize_plaintext("Hello\rworld") == "Hello\rworld" + + def test_vertical_tab_stripped(self) -> None: + """Test que la tabulation verticale est supprimée. + + :return: None + """ + assert sanitize_plaintext("Hello\x0bworld") == "Helloworld" + + def test_form_feed_stripped(self) -> None: + """Test que le saut de page est supprimé. + + :return: None + """ + assert sanitize_plaintext("Hello\x0cworld") == "Helloworld" + + def test_emojis_preserved(self) -> None: + """Test que les emojis autorisés sont préservés. + + :return: None + """ + assert sanitize_plaintext("📌📅📚💬📢") == "📌📅📚💬📢" + + def test_emoji_with_text(self) -> None: + """Test qu'un emoji combiné avec du texte est préservé. + + :return: None + """ + assert sanitize_plaintext("📌 Devoir: Math") == "📌 Devoir: Math" + + @pytest.mark.parametrize( + "test_input", + [ + "", + "Hello world", + "Hello", + "Hello\x00world", + "📌📅", + "&", + "
Test
", + ], + ) + def test_idempotent(self, test_input: str) -> None: + """Test que la fonction est idempotente. + + Pour tout texte d'entrée x, sanitize_plaintext(sanitize_plaintext(x)) doit + être égal à sanitize_plaintext(x). + + :param test_input: Texte à tester + :return: None + """ + first_pass = sanitize_plaintext(test_input) + second_pass = sanitize_plaintext(first_pass) + assert second_pass == first_pass + + def test_mixed_html_control_emoji(self) -> None: + """Test une combinaison de balises HTML, caractères de contrôle et emojis. + + :return: None + """ + assert sanitize_plaintext("📌\x00 Hello") == "📌 Hello" + + def test_unicode_text_preserved(self) -> None: + """Test que le texte Unicode avec accents est préservé. + + :return: None + """ + assert sanitize_plaintext("Café résumé") == "Café résumé" + + def test_del_char_stripped(self) -> None: + """Test que le caractère ASCII DEL (\\x7f) est supprimé. + + :return: None + """ + assert sanitize_plaintext("a\x7fb") == "ab" + + @pytest.mark.parametrize("c1_char", ["\x80", "\x85", "\x9f"]) + def test_c1_controls_stripped(self, c1_char: str) -> None: + """Test que les caractères de contrôle C1 (\\x80-\\x9f) sont supprimés. + + :param c1_char: Caractère de contrôle C1 à tester + :return: None + """ + assert sanitize_plaintext(f"a{c1_char}b") == "ab" + + def test_del_and_c1_idempotent(self) -> None: + """Test que la suppression de DEL et des contrôles C1 est idempotente. + + :return: None + """ + text = "a\x7f\x80\x9fb" + assert sanitize_plaintext(sanitize_plaintext(text)) == sanitize_plaintext(text)