124 lines
5.2 KiB
Python
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()
|