test_attachments_prep.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. """Tests de la préparation des pièces jointes (images, PDF, fichiers texte). Lancer depuis la racine du projet : python -m unittest"""
  2. import codecs
  3. import io
  4. import random
  5. import unittest
  6. from unittest import mock
  7. from PIL import Image
  8. from pypdf import PdfWriter
  9. from bot import images
  10. from bot.images import MAX_DIMENSION, ImageError, prepare_image
  11. from bot.pdf_files import PdfError, prepare_pdf
  12. from bot.text_files import TextFileError, decode_text_file
  13. def encode(img: Image.Image, fmt: str, **options) -> bytes:
  14. buffer = io.BytesIO()
  15. img.save(buffer, format=fmt, **options)
  16. return buffer.getvalue()
  17. def noise(size: tuple[int, int], mode: str = "RGB") -> Image.Image:
  18. """Image de bruit (toujours le même) : presque impossible à compresser."""
  19. channels = len(mode)
  20. return Image.frombytes(mode, size, random.Random(0).randbytes(size[0] * size[1] * channels))
  21. def opened(data: bytes) -> Image.Image:
  22. img = Image.open(io.BytesIO(data))
  23. img.load()
  24. return img
  25. class PrepareImageTest(unittest.TestCase):
  26. def test_small_jpeg_and_png_sent_as_is(self):
  27. for fmt, media_type in (("JPEG", "image/jpeg"), ("PNG", "image/png"),
  28. ("GIF", "image/gif"), ("WEBP", "image/webp")):
  29. with self.subTest(fmt):
  30. data = encode(Image.new("RGB", (200, 100), "red"), fmt)
  31. result = prepare_image(data)
  32. self.assertEqual(result.media_type, media_type)
  33. self.assertIs(result.data, data)
  34. def test_exactly_max_dimension_sent_as_is(self):
  35. data = encode(Image.new("RGB", (MAX_DIMENSION, 100), "red"), "PNG")
  36. self.assertIs(prepare_image(data).data, data)
  37. def test_large_png_reduced(self):
  38. result = prepare_image(encode(Image.new("RGB", (3136, 1000), "blue"), "PNG"))
  39. self.assertEqual(result.media_type, "image/png") # sans perte : le texte des captures reste net
  40. self.assertEqual(opened(result.data).size, (MAX_DIMENSION, 500))
  41. def test_large_jpeg_reduced(self):
  42. result = prepare_image(encode(Image.new("RGB", (1000, 3136), "green"), "JPEG"))
  43. self.assertEqual(result.media_type, "image/jpeg")
  44. self.assertEqual(opened(result.data).size, (500, MAX_DIMENSION))
  45. def test_unsupported_formats_converted(self):
  46. for fmt in ("BMP", "TIFF"):
  47. with self.subTest(fmt):
  48. result = prepare_image(encode(Image.new("RGB", (200, 100), "red"), fmt))
  49. self.assertEqual(result.media_type, "image/png")
  50. img = opened(result.data)
  51. self.assertEqual((img.format, img.size), ("PNG", (200, 100)))
  52. def test_transparency_kept(self):
  53. result = prepare_image(encode(Image.new("RGBA", (3000, 100), (255, 0, 0, 0)), "PNG"))
  54. self.assertEqual(result.media_type, "image/png")
  55. self.assertEqual(opened(result.data).mode, "RGBA")
  56. def test_heavy_png_becomes_jpeg(self):
  57. # Limite de l'API abaissée : le PNG la dépasse, le JPEG (avec perte) passe en dessous.
  58. img = noise((300, 300))
  59. png, jpeg = encode(img, "PNG", optimize=True), encode(img, "JPEG", quality=45, optimize=True)
  60. self.assertLess(len(jpeg), len(png))
  61. with mock.patch.object(images, "API_MAX_BYTES", (len(png) + len(jpeg)) // 2):
  62. result = prepare_image(encode(img, "PNG"))
  63. self.assertEqual(result.media_type, "image/jpeg")
  64. self.assertLessEqual(len(result.data), (len(png) + len(jpeg)) // 2)
  65. def test_heavy_transparent_png_on_white(self):
  66. img = noise((300, 300), "RGBA")
  67. with mock.patch.object(images, "API_MAX_BYTES", len(encode(img, "PNG", optimize=True)) // 2):
  68. result = prepare_image(encode(img, "PNG"))
  69. self.assertEqual(result.media_type, "image/jpeg")
  70. self.assertEqual(opened(result.data).mode, "RGB")
  71. def test_too_heavy_even_compressed(self):
  72. with mock.patch.object(images, "API_MAX_BYTES", 1000), self.assertRaises(ImageError) as ctx:
  73. prepare_image(encode(noise((300, 300)), "PNG"))
  74. self.assertIn("compresser suffisamment", ctx.exception.user_message)
  75. def test_not_an_image(self):
  76. for data in (b"", b"bonjour, ceci n'est pas une image", encode(Image.new("RGB", (50, 50)), "PNG")[:40]):
  77. with self.subTest(data=data[:10]), self.assertRaises(ImageError) as ctx:
  78. prepare_image(data)
  79. self.assertIn("Je n'arrive pas à lire cette image", ctx.exception.user_message)
  80. def test_too_many_pixels(self):
  81. # Seuil abaissé pour ne pas fabriquer une vraie image géante.
  82. with mock.patch.object(Image, "MAX_IMAGE_PIXELS", 1000), self.assertRaises(ImageError) as ctx:
  83. prepare_image(encode(Image.new("RGB", (100, 100)), "PNG"))
  84. self.assertIn("beaucoup trop grande", ctx.exception.user_message)
  85. def pdf(pages: int, password: str | None = None, owner_password: str | None = None) -> bytes:
  86. writer = PdfWriter()
  87. for _ in range(pages):
  88. writer.add_blank_page(width=200, height=200)
  89. if password is not None:
  90. writer.encrypt(user_password=password, owner_password=owner_password or "propriétaire")
  91. buffer = io.BytesIO()
  92. writer.write(buffer)
  93. return buffer.getvalue()
  94. class PreparePdfTest(unittest.TestCase):
  95. def test_valid_pdf(self):
  96. data = pdf(3)
  97. result = prepare_pdf("cours.pdf", data, max_pages=3)
  98. self.assertTrue(result.is_pdf)
  99. self.assertEqual(result.name, "cours.pdf")
  100. self.assertIs(result.data, data)
  101. def test_too_many_pages(self):
  102. with self.assertRaises(PdfError) as ctx:
  103. prepare_pdf("cours.pdf", pdf(4), max_pages=3)
  104. self.assertEqual(ctx.exception.user_message,
  105. "Ce PDF a 4 pages : je n'en lis que 3 au maximum. Envoie-moi seulement les pages utiles.")
  106. def test_no_page(self):
  107. with self.assertRaises(PdfError) as ctx:
  108. prepare_pdf("vide.pdf", pdf(0), max_pages=3)
  109. self.assertIn("aucune page", ctx.exception.user_message)
  110. def test_password(self):
  111. with self.assertRaises(PdfError) as ctx:
  112. prepare_pdf("secret.pdf", pdf(1, password="secret"), max_pages=3)
  113. self.assertIn("mot de passe", ctx.exception.user_message)
  114. def test_owner_password_only(self):
  115. # Chiffré sans mot de passe d'ouverture (seulement des droits limités) : lisible.
  116. self.assertTrue(prepare_pdf("protégé.pdf", pdf(1, password=""), max_pages=3).is_pdf)
  117. def test_not_a_pdf(self):
  118. for data in (b"", b"bonjour, ceci n'est pas un PDF", encode(Image.new("RGB", (50, 50)), "PNG")):
  119. with self.subTest(data=data[:10]), self.assertLogs("chatbot.pdf", "INFO"), \
  120. self.assertRaises(PdfError) as ctx:
  121. prepare_pdf("faux.pdf", data, max_pages=3)
  122. self.assertEqual(ctx.exception.user_message, "Je n'arrive pas à lire ce PDF.")
  123. class DecodeTextFileTest(unittest.TestCase):
  124. def test_utf8(self):
  125. self.assertEqual(decode_text_file("Été à Noël 😀".encode("utf-8")), "Été à Noël 😀")
  126. def test_utf8_with_bom(self):
  127. self.assertEqual(decode_text_file(codecs.BOM_UTF8 + "Été".encode("utf-8")), "Été")
  128. def test_windows_1252(self):
  129. # « ANSI » du Bloc-notes : pas de l'UTF-8 valide, lu en Windows-1252.
  130. self.assertEqual(decode_text_file("Été, 5 € œuvre".encode("cp1252")), "Été, 5 € œuvre")
  131. def test_bytes_undefined_in_windows_1252(self):
  132. self.assertEqual(decode_text_file(b"caf\xe9 \x81"), "café �")
  133. def test_utf16_with_bom(self):
  134. for encoding in ("utf-16-le", "utf-16-be"):
  135. with self.subTest(encoding):
  136. bom = codecs.BOM_UTF16_LE if encoding == "utf-16-le" else codecs.BOM_UTF16_BE
  137. self.assertEqual(decode_text_file(bom + "Été".encode(encoding)), "Été")
  138. def test_broken_utf16(self):
  139. with self.assertRaises(TextFileError) as ctx:
  140. decode_text_file(codecs.BOM_UTF16_LE + b"a\x00b") # nombre impair d'octets
  141. self.assertIn("pas du texte lisible", ctx.exception.user_message)
  142. def test_line_endings_and_spaces(self):
  143. self.assertEqual(decode_text_file(b"\r\n ligne 1\r\nligne 2\r\n\r\n"), "ligne 1\nligne 2")
  144. def test_empty(self):
  145. for data in (b"", b" \r\n\t ", codecs.BOM_UTF8):
  146. with self.subTest(data=data), self.assertRaises(TextFileError) as ctx:
  147. decode_text_file(data)
  148. self.assertIn("vide", ctx.exception.user_message)
  149. def test_binary(self):
  150. for data in (bytes(range(256)), encode(Image.new("RGB", (50, 50)), "PNG"), "texte".encode("utf-16-le")):
  151. with self.subTest(data=data[:10]), self.assertRaises(TextFileError) as ctx:
  152. decode_text_file(data)
  153. self.assertIn("pas du texte lisible", ctx.exception.user_message)
  154. if __name__ == "__main__":
  155. unittest.main()