test_document_converter.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. """DocumentConverter 单元测试,不依赖真实 LibreOffice。"""
  2. from __future__ import annotations
  3. import subprocess
  4. import threading
  5. import time
  6. from pathlib import Path
  7. import pytest
  8. import fitz
  9. from dms.common.errors import (
  10. PreviewConversionFailedError,
  11. PreviewConversionTimeoutError,
  12. PreviewConverterUnavailableError,
  13. )
  14. from dms.services.document_converter import (
  15. FakeDocumentConverter,
  16. LibreOfficeDocumentConverter,
  17. )
  18. def test_fake_converter_available_by_default() -> None:
  19. converter = FakeDocumentConverter()
  20. assert converter.available is True
  21. def test_fake_converter_returns_pdf_bytes(tmp_path: Path) -> None:
  22. converter = FakeDocumentConverter()
  23. source = tmp_path / "test.docx"
  24. source.write_bytes(b"hello document")
  25. result = converter.convert_to_pdf(source, "abcd1234")
  26. assert result.startswith(b"%PDF-")
  27. with fitz.open(stream=result, filetype="pdf") as document:
  28. assert "hello document" in "".join(page.get_text() for page in document)
  29. assert len(converter.calls) == 1
  30. assert converter.calls[0] == (source, "abcd1234")
  31. def test_fake_converter_unavailable_raises() -> None:
  32. converter = FakeDocumentConverter(enabled=False)
  33. assert converter.available is False
  34. source = Path("/nonexistent/test.docx")
  35. with pytest.raises(PreviewConverterUnavailableError):
  36. converter.convert_to_pdf(source, "hash")
  37. def test_libreoffice_unavailable_when_disabled() -> None:
  38. converter = LibreOfficeDocumentConverter(
  39. executable="/usr/bin/soffice",
  40. timeout_seconds=60,
  41. max_concurrency=2,
  42. enabled=False,
  43. )
  44. assert converter.available is False
  45. def test_libreoffice_unavailable_when_executable_missing() -> None:
  46. converter = LibreOfficeDocumentConverter(
  47. executable="/definitely/not/exists/soffice",
  48. timeout_seconds=60,
  49. max_concurrency=2,
  50. )
  51. assert converter.available is False
  52. source = Path(__file__)
  53. with pytest.raises(PreviewConverterUnavailableError):
  54. converter.convert_to_pdf(source, "hash")
  55. class FakeProcess:
  56. def __init__(self, returncode: int = 0, timeout: bool = False) -> None:
  57. self._returncode = returncode
  58. self._timeout = timeout
  59. def communicate(self, timeout: float | None = None) -> tuple[bytes, bytes]:
  60. if self._timeout:
  61. raise subprocess.TimeoutExpired(cmd=["soffice"], timeout=timeout or 1)
  62. return b"", b""
  63. def poll(self) -> int | None:
  64. return self._returncode
  65. def terminate(self) -> None:
  66. pass
  67. def wait(self, timeout: float | None = None) -> int:
  68. return self._returncode
  69. @property
  70. def returncode(self) -> int:
  71. return self._returncode
  72. def test_libreoffice_conversion_fails_with_non_zero_exit(tmp_path: Path, monkeypatch) -> None:
  73. real_exe = tmp_path / "soffice.exe"
  74. real_exe.write_bytes(b"MZ")
  75. converter = LibreOfficeDocumentConverter(
  76. executable=str(real_exe),
  77. timeout_seconds=5,
  78. max_concurrency=1,
  79. )
  80. assert converter.available is True
  81. def fake_popen(*args, **kwargs):
  82. return FakeProcess(returncode=1)
  83. monkeypatch.setattr(subprocess, "Popen", fake_popen)
  84. source = tmp_path / "test.docx"
  85. source.write_bytes(b"not a real docx")
  86. with pytest.raises(PreviewConversionFailedError):
  87. converter.convert_to_pdf(source, "hash")
  88. def test_libreoffice_timeout_cleans_up(tmp_path: Path, monkeypatch) -> None:
  89. real_exe = tmp_path / "soffice.exe"
  90. real_exe.write_bytes(b"MZ")
  91. converter = LibreOfficeDocumentConverter(
  92. executable=str(real_exe),
  93. timeout_seconds=1,
  94. max_concurrency=1,
  95. )
  96. assert converter.available is True
  97. def fake_popen(*args, **kwargs):
  98. return FakeProcess(timeout=True)
  99. monkeypatch.setattr(subprocess, "Popen", fake_popen)
  100. source = tmp_path / "test.docx"
  101. source.write_bytes(b"not a real docx")
  102. with pytest.raises(PreviewConversionTimeoutError):
  103. converter.convert_to_pdf(source, "hash")
  104. def test_libreoffice_concurrent_same_hash_deduplicates(tmp_path: Path, monkeypatch) -> None:
  105. real_exe = tmp_path / "soffice.exe"
  106. real_exe.write_bytes(b"MZ")
  107. converter = LibreOfficeDocumentConverter(
  108. executable=str(real_exe),
  109. timeout_seconds=5,
  110. max_concurrency=2,
  111. )
  112. calls: list[tuple] = []
  113. lock = threading.Lock()
  114. ready = threading.Event()
  115. def fake_popen(*args, **kwargs):
  116. with lock:
  117. calls.append(args)
  118. ready.set()
  119. time.sleep(0.25)
  120. argv = args[0]
  121. out_dir = Path(argv[argv.index("--outdir") + 1])
  122. input_file = Path(argv[-1])
  123. out_dir.mkdir(parents=True, exist_ok=True)
  124. (out_dir / f"{input_file.stem}.pdf").write_bytes(b"%PDF-1.4 fake")
  125. return FakeProcess(returncode=0)
  126. monkeypatch.setattr(subprocess, "Popen", fake_popen)
  127. source = tmp_path / "test.docx"
  128. source.write_bytes(b"shared")
  129. results: list[bytes] = []
  130. def call() -> None:
  131. results.append(converter.convert_to_pdf(source, "same_hash"))
  132. t1 = threading.Thread(target=call)
  133. t2 = threading.Thread(target=call)
  134. t1.start()
  135. ready.wait(timeout=1)
  136. t2.start()
  137. t1.join(timeout=5)
  138. t2.join(timeout=5)
  139. assert len(results) == 2
  140. assert all(r == results[0] for r in results)
  141. assert len(calls) == 1, "同一文件哈希应只触发一次真实转换"