zcbot/tests/test_pptx_render.py

124 lines
5.2 KiB
Python

"""web/pptx_render.py 的 focused tests(DESIGN §8.3 验收)。
soffice 本机不一定装,全部 mock 掉 `_run_soffice` / `find_soffice` —— 只验缓存 / 失效 /
降级 / prune 这些纯逻辑;真 soffice 转换由部署环境兜底,不在单测里跑。
"""
import os
import time
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
import web.pptx_render as R
from web.app import _safe_join # 路径越界拒绝
def _fake_soffice_run(soffice, pptx_path, outdir, timeout):
"""假装 soffice:在 outdir 落一个 <stem>.pdf,返回其路径。"""
out = Path(outdir) / f"{Path(pptx_path).stem}.pdf"
out.write_bytes(b"%PDF-1.4 fake\n")
return out
class PptxRenderTests(unittest.TestCase):
def _make_pptx(self, d: Path, name: str = "deck.pptx") -> Path:
p = d / name
p.write_bytes(b"PK\x03\x04 fake pptx bytes")
return p
def test_convert_success_lands_pdf_in_preview_dir(self):
with TemporaryDirectory() as tmp:
pptx = self._make_pptx(Path(tmp))
with mock.patch.object(R, "find_soffice", return_value="soffice"), \
mock.patch.object(R, "_run_soffice", side_effect=_fake_soffice_run):
pdf = R.pptx_to_pdf(pptx)
self.assertTrue(pdf.exists())
self.assertEqual(pdf.parent.name, ".preview")
self.assertEqual(pdf.suffix, ".pdf")
self.assertTrue(pdf.name.startswith("deck."))
def test_cache_hit_skips_soffice(self):
with TemporaryDirectory() as tmp:
pptx = self._make_pptx(Path(tmp))
with mock.patch.object(R, "find_soffice", return_value="soffice"), \
mock.patch.object(R, "_run_soffice", side_effect=_fake_soffice_run):
first = R.pptx_to_pdf(pptx)
# 第二次:soffice 一律不该被调用(命中缓存);find_soffice 也不必走
with mock.patch.object(R, "_run_soffice") as run, \
mock.patch.object(R, "find_soffice") as find:
second = R.pptx_to_pdf(pptx)
self.assertEqual(first, second)
run.assert_not_called()
find.assert_not_called()
def test_source_change_invalidates_and_prunes_old(self):
with TemporaryDirectory() as tmp:
pptx = self._make_pptx(Path(tmp))
with mock.patch.object(R, "find_soffice", return_value="soffice"), \
mock.patch.object(R, "_run_soffice", side_effect=_fake_soffice_run):
old_pdf = R.pptx_to_pdf(pptx)
# 改源内容 + 推后 mtime → hash 变 → 重转 + 删旧 hash
time.sleep(0.01)
pptx.write_bytes(b"PK\x03\x04 fake pptx bytes CHANGED MORE")
new_mtime = time.time() + 5
os.utime(pptx, (new_mtime, new_mtime))
new_pdf = R.pptx_to_pdf(pptx)
self.assertNotEqual(old_pdf.name, new_pdf.name)
self.assertTrue(new_pdf.exists())
self.assertFalse(old_pdf.exists(), "旧 hash 缓存应被 prune")
# .preview 下只剩这一份
survivors = list(new_pdf.parent.glob("deck.*.pdf"))
self.assertEqual(survivors, [new_pdf])
def test_missing_soffice_raises_not_found(self):
with TemporaryDirectory() as tmp:
pptx = self._make_pptx(Path(tmp))
with mock.patch.object(R, "find_soffice",
side_effect=R.SofficeNotFoundError("no soffice")):
with self.assertRaises(R.SofficeNotFoundError):
R.pptx_to_pdf(pptx)
def test_missing_pptx_raises_convert_error(self):
with TemporaryDirectory() as tmp:
with self.assertRaises(R.PptxConvertError):
R.pptx_to_pdf(Path(tmp) / "nope.pptx")
def test_find_soffice_uses_path_fallback(self):
with mock.patch("pathlib.Path.exists", return_value=False), \
mock.patch("shutil.which", return_value="/usr/bin/soffice") as which:
self.assertEqual(R.find_soffice(), "/usr/bin/soffice")
self.assertTrue(which.called)
def test_find_soffice_missing_raises(self):
with mock.patch("pathlib.Path.exists", return_value=False), \
mock.patch("shutil.which", return_value=None):
with self.assertRaises(R.SofficeNotFoundError):
R.find_soffice()
class SafePathTraversalTests(unittest.TestCase):
"""preview_pdf 端点复用 _safe_join 做边界:越界路径必须被挡。"""
def test_rejects_parent_traversal(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
with self.assertRaises(Exception):
_safe_join(root, "../../etc/passwd")
def test_rejects_absolute(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
with self.assertRaises(Exception):
_safe_join(root, "/etc/passwd")
def test_allows_inside(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
got = _safe_join(root, "sub/deck.pptx")
self.assertEqual(got, (root / "sub" / "deck.pptx").resolve())
if __name__ == "__main__":
unittest.main()