test_document_converter.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. """DocumentConverter 单元测试,不依赖真实 LibreOffice。"""
  2. from __future__ import annotations
  3. import subprocess
  4. import sys
  5. import threading
  6. import time
  7. from pathlib import Path
  8. import pytest
  9. import fitz
  10. from dms.common.errors import (
  11. PreviewConversionFailedError,
  12. PreviewConversionTimeoutError,
  13. PreviewConverterUnavailableError,
  14. )
  15. from dms.services.document_converter import (
  16. FakeDocumentConverter,
  17. LibreOfficeDocumentConverter,
  18. _version_at_least_7_4,
  19. )
  20. def test_fake_converter_available_by_default() -> None:
  21. converter = FakeDocumentConverter()
  22. assert converter.available is True
  23. def test_fake_converter_returns_pdf_bytes(tmp_path: Path) -> None:
  24. converter = FakeDocumentConverter()
  25. source = tmp_path / "test.docx"
  26. source.write_bytes(b"hello document")
  27. result = converter.convert_to_pdf(source, "abcd1234")
  28. assert result.startswith(b"%PDF-")
  29. with fitz.open(stream=result, filetype="pdf") as document:
  30. assert "hello document" in "".join(page.get_text() for page in document)
  31. assert len(converter.calls) == 1
  32. assert converter.calls[0] == (source, "abcd1234")
  33. def test_fake_converter_unavailable_raises() -> None:
  34. converter = FakeDocumentConverter(enabled=False)
  35. assert converter.available is False
  36. source = Path("/nonexistent/test.docx")
  37. with pytest.raises(PreviewConverterUnavailableError):
  38. converter.convert_to_pdf(source, "hash")
  39. def test_libreoffice_unavailable_when_disabled() -> None:
  40. converter = LibreOfficeDocumentConverter(
  41. executable="/usr/bin/soffice",
  42. timeout_seconds=60,
  43. max_concurrency=2,
  44. enabled=False,
  45. )
  46. assert converter.available is False
  47. def test_version_at_least_7_4() -> None:
  48. assert _version_at_least_7_4("LibreOffice 7.4") is True
  49. assert _version_at_least_7_4("LibreOffice 7.6.7.2 (X86_64)") is True
  50. assert _version_at_least_7_4("LibreOffice 24.8.4.2 (X86_64)") is True
  51. assert _version_at_least_7_4("LibreOffice 25.2 (x86_64)") is True
  52. assert _version_at_least_7_4("LibreOffice 7.3.7.2") is False
  53. assert _version_at_least_7_4("LibreOffice 6.4.7.2") is False
  54. assert _version_at_least_7_4("") is False
  55. assert _version_at_least_7_4("no version here") is False
  56. @pytest.mark.skipif(sys.platform != "win32", reason="soffice.com 仅 Windows 存在")
  57. def test_version_probe_prefers_soffice_com_on_windows(tmp_path: Path) -> None:
  58. exe = tmp_path / "soffice.exe"
  59. exe.write_bytes(b"MZ")
  60. com = tmp_path / "soffice.com"
  61. com.write_bytes(b"MZ")
  62. converter = LibreOfficeDocumentConverter(
  63. executable=str(exe),
  64. timeout_seconds=5,
  65. max_concurrency=1,
  66. )
  67. assert converter._version_probe_executable() == str(com)
  68. def test_version_probe_falls_back_when_com_missing(tmp_path: Path) -> None:
  69. exe = tmp_path / "soffice.exe"
  70. exe.write_bytes(b"MZ")
  71. converter = LibreOfficeDocumentConverter(
  72. executable=str(exe),
  73. timeout_seconds=5,
  74. max_concurrency=1,
  75. )
  76. assert converter._version_probe_executable() == str(exe)
  77. class _CompletedVersion:
  78. def __init__(self, stdout: str) -> None:
  79. self.stdout = stdout
  80. self.stderr = ""
  81. def test_single_page_detect_success_cached_permanently(
  82. tmp_path: Path, monkeypatch
  83. ) -> None:
  84. exe = tmp_path / "soffice.exe"
  85. exe.write_bytes(b"MZ")
  86. converter = LibreOfficeDocumentConverter(
  87. executable=str(exe),
  88. timeout_seconds=5,
  89. max_concurrency=1,
  90. )
  91. calls: list[tuple] = []
  92. def fake_run(*args, **kwargs):
  93. calls.append(args)
  94. return _CompletedVersion("LibreOffice 26.2.5.2")
  95. monkeypatch.setattr(subprocess, "run", fake_run)
  96. assert converter._supports_single_page_sheets() is True
  97. assert converter._supports_single_page_sheets() is True
  98. assert len(calls) == 1, "探测成功应永久缓存,不重复执行 --version"
  99. def test_single_page_detect_failure_retried_after_ttl(
  100. tmp_path: Path, monkeypatch
  101. ) -> None:
  102. exe = tmp_path / "soffice.exe"
  103. exe.write_bytes(b"MZ")
  104. converter = LibreOfficeDocumentConverter(
  105. executable=str(exe),
  106. timeout_seconds=5,
  107. max_concurrency=1,
  108. )
  109. calls: list[tuple] = []
  110. state = {"fail": True}
  111. def fake_run(*args, **kwargs):
  112. calls.append(args)
  113. if state["fail"]:
  114. raise OSError("probe failed")
  115. return _CompletedVersion("LibreOffice 26.2.5.2")
  116. monkeypatch.setattr(subprocess, "run", fake_run)
  117. assert converter._supports_single_page_sheets() is False
  118. assert converter._supports_single_page_sheets() is False
  119. assert len(calls) == 1, "TTL 内的失败结果应被缓存,不重复探测"
  120. # TTL 过期后允许重试;恢复成功后转为永久缓存
  121. monkeypatch.setattr(
  122. "dms.services.document_converter._SINGLE_PAGE_DETECT_RETRY_SECONDS", 0.0
  123. )
  124. state["fail"] = False
  125. assert converter._supports_single_page_sheets() is True
  126. assert len(calls) == 2
  127. assert converter._supports_single_page_sheets() is True
  128. assert len(calls) == 2, "重试成功后应转为永久缓存"
  129. def test_libreoffice_unavailable_when_executable_missing() -> None:
  130. converter = LibreOfficeDocumentConverter(
  131. executable="/definitely/not/exists/soffice",
  132. timeout_seconds=60,
  133. max_concurrency=2,
  134. )
  135. assert converter.available is False
  136. source = Path(__file__)
  137. with pytest.raises(PreviewConverterUnavailableError):
  138. converter.convert_to_pdf(source, "hash")
  139. class FakeProcess:
  140. def __init__(self, returncode: int = 0, timeout: bool = False) -> None:
  141. self._returncode = returncode
  142. self._timeout = timeout
  143. def communicate(self, timeout: float | None = None) -> tuple[bytes, bytes]:
  144. if self._timeout:
  145. raise subprocess.TimeoutExpired(cmd=["soffice"], timeout=timeout or 1)
  146. return b"", b""
  147. def poll(self) -> int | None:
  148. return self._returncode
  149. def terminate(self) -> None:
  150. pass
  151. def wait(self, timeout: float | None = None) -> int:
  152. return self._returncode
  153. @property
  154. def returncode(self) -> int:
  155. return self._returncode
  156. def test_libreoffice_conversion_fails_with_non_zero_exit(tmp_path: Path, monkeypatch) -> None:
  157. real_exe = tmp_path / "soffice.exe"
  158. real_exe.write_bytes(b"MZ")
  159. converter = LibreOfficeDocumentConverter(
  160. executable=str(real_exe),
  161. timeout_seconds=5,
  162. max_concurrency=1,
  163. )
  164. assert converter.available is True
  165. def fake_popen(*args, **kwargs):
  166. return FakeProcess(returncode=1)
  167. monkeypatch.setattr(subprocess, "Popen", fake_popen)
  168. source = tmp_path / "test.docx"
  169. source.write_bytes(b"not a real docx")
  170. with pytest.raises(PreviewConversionFailedError):
  171. converter.convert_to_pdf(source, "hash")
  172. def test_libreoffice_timeout_cleans_up(tmp_path: Path, monkeypatch) -> None:
  173. real_exe = tmp_path / "soffice.exe"
  174. real_exe.write_bytes(b"MZ")
  175. converter = LibreOfficeDocumentConverter(
  176. executable=str(real_exe),
  177. timeout_seconds=1,
  178. max_concurrency=1,
  179. )
  180. assert converter.available is True
  181. def fake_popen(*args, **kwargs):
  182. return FakeProcess(timeout=True)
  183. monkeypatch.setattr(subprocess, "Popen", fake_popen)
  184. source = tmp_path / "test.docx"
  185. source.write_bytes(b"not a real docx")
  186. with pytest.raises(PreviewConversionTimeoutError):
  187. converter.convert_to_pdf(source, "hash")
  188. def test_libreoffice_concurrent_same_hash_deduplicates(tmp_path: Path, monkeypatch) -> None:
  189. real_exe = tmp_path / "soffice.exe"
  190. real_exe.write_bytes(b"MZ")
  191. converter = LibreOfficeDocumentConverter(
  192. executable=str(real_exe),
  193. timeout_seconds=5,
  194. max_concurrency=2,
  195. )
  196. calls: list[tuple] = []
  197. lock = threading.Lock()
  198. ready = threading.Event()
  199. def fake_popen(*args, **kwargs):
  200. with lock:
  201. calls.append(args)
  202. ready.set()
  203. time.sleep(0.25)
  204. argv = args[0]
  205. out_dir = Path(argv[argv.index("--outdir") + 1])
  206. input_file = Path(argv[-1])
  207. out_dir.mkdir(parents=True, exist_ok=True)
  208. (out_dir / f"{input_file.stem}.pdf").write_bytes(b"%PDF-1.4 fake")
  209. return FakeProcess(returncode=0)
  210. monkeypatch.setattr(subprocess, "Popen", fake_popen)
  211. source = tmp_path / "test.docx"
  212. source.write_bytes(b"shared")
  213. results: list[bytes] = []
  214. def call() -> None:
  215. results.append(converter.convert_to_pdf(source, "same_hash"))
  216. t1 = threading.Thread(target=call)
  217. t2 = threading.Thread(target=call)
  218. t1.start()
  219. ready.wait(timeout=1)
  220. t2.start()
  221. t1.join(timeout=5)
  222. t2.join(timeout=5)
  223. assert len(results) == 2
  224. assert all(r == results[0] for r in results)
  225. assert len(calls) == 1, "同一文件哈希应只触发一次真实转换"