"""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 落一个 .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()