Files
PlantAI-rag-new/review_service/tests/test_compile.py
potaser 649e66f41d feat(review): tectonic PDF compile API (P3a) + rename journals panel
- POST/GET /api/review/compile[/{id}[/pdf|/log]]: isolated workdir,
  --untrusted, 60s timeout with process-group kill, single-slot queue
  (429 QUEUE_FULL), 7-day retention sweep
- tools/install_tectonic.sh: pinned tectonic 0.17.0 (sha256) + README
- Rename menu/panel title to 学术期刊情报
- Compile UI stays a placeholder per product decision (P3 deferred)
2026-10-11 12:19:18 +08:00

229 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""tests/test_compile.py —— P3a LaTeX 编译单元/集成测试。
真实调用 tools/tectonic(宏包已预热缓存);二进制缺失时集成用例 skipif 跳过,
校验/队列/清扫等纯逻辑用例照常运行。
运行: cd review_service && <venv>/bin/python -m pytest -q
"""
from __future__ import annotations
import os
import time
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.config import get_settings
from app.main import app
from app.services import latex_compile as lc
MINIMAL = "\\documentclass[11pt]{article}\n\\begin{document}\nhello p3\n\\end{document}\n"
_TECTONIC_OK = get_settings().tectonic_abs.is_file()
needs_tectonic = pytest.mark.skipif(
not _TECTONIC_OK, reason="tools/tectonic 未安装(bash tools/install_tectonic.sh)"
)
@pytest.fixture()
def client(tmp_path, monkeypatch):
settings = get_settings()
monkeypatch.setattr(settings, "reformat_dir", str(tmp_path / "jobs"))
# 缓存目录不注入 tmp:用部署预热好的 data/tectonic-cache(否则每次测试重拉 bundle)
monkeypatch.setattr(settings, "compile_timeout_s", 60.0)
monkeypatch.setattr(lc, "_pending", 0)
with TestClient(app) as c:
yield c
# ================================================================ 校验(无需 tectonic)
def test_validation_no_input(client: TestClient):
r = client.post("/api/review/compile", data={})
body = r.json()
assert r.status_code == 200 and body["ok"] is False
assert body["error"]["code"] == "VALIDATION"
def test_validation_bad_job_id(client: TestClient):
r = client.post("/api/review/compile", data={"job_id": "../evil"})
assert r.json()["error"]["code"] == "VALIDATION"
def test_validation_job_not_found(client: TestClient):
r = client.post("/api/review/compile", data={"job_id": "0123456789ab"})
assert r.json()["error"]["code"] == "VALIDATION"
def test_validation_path_traversal_filename(client: TestClient):
r = client.post(
"/api/review/compile",
files={"file": ("../evil.tex", b"x", "application/x-tex")},
)
assert r.json()["error"]["code"] == "VALIDATION"
def test_validation_non_tex(client: TestClient):
r = client.post(
"/api/review/compile",
files={"file": ("paper.pdf", b"x", "application/pdf")},
)
assert r.json()["error"]["code"] == "VALIDATION"
def test_validation_oversize(client: TestClient, monkeypatch):
monkeypatch.setattr(get_settings(), "max_tex_bytes", 10)
r = client.post(
"/api/review/compile",
files={"file": ("paper.tex", b"01234567890", "application/x-tex")},
)
assert r.json()["error"]["code"] == "VALIDATION"
# ================================================================ 编译(真实 tectonic)
@needs_tectonic
def test_compile_happy_path(client: TestClient):
r = client.post(
"/api/review/compile",
files={"file": ("paper.tex", MINIMAL.encode(), "application/x-tex")},
)
body = r.json()
assert r.status_code == 200 and body["ok"] is True, body
cid = body["compile_id"]
assert len(cid) == 12
pdf = client.get(body["pdf_url"])
assert pdf.status_code == 200
assert pdf.headers["content-type"].startswith("application/pdf")
assert pdf.content[:5] == b"%PDF-"
log = client.get(body["log_url"])
assert log.status_code == 200
assert "LaTeX2e" in log.text and "Output written" in log.text
meta = client.get(f"/api/review/compile/{cid}")
assert meta.json()["ok"] is True
assert meta.json()["meta"]["pdf_bytes"] > 0
@needs_tectonic
def test_compile_by_job_id(client: TestClient):
tex = (
"\\documentclass[11pt]{article}\n\\begin{document}\nvia job\n\\end{document}\n"
)
rf = client.post(
"/api/review/reformat",
files={"file": ("paper.tex", tex.encode(), "application/x-tex")},
data={"target_journal": "default"},
).json()
assert rf["ok"] is True
r = client.post("/api/review/compile", data={"job_id": rf["job_id"]})
body = r.json()
assert body["ok"] is True and body["compile_id"] == rf["job_id"]
pdf = client.get(body["pdf_url"])
assert pdf.content[:5] == b"%PDF-"
@needs_tectonic
def test_compile_malicious_write18(client: TestClient, tmp_path):
canary = tmp_path / "canary_p3"
evil = (
"\\documentclass{article}\n"
f"\\write18{{touch {canary}}}\n"
"\\begin{document}\nno shell please\n\\end{document}\n"
)
r = client.post(
"/api/review/compile",
files={"file": ("evil.tex", evil.encode(), "application/x-tex")},
)
body = r.json()
# 系统无损即通过(信封 ok 与否均可):canary 绝不能出现
assert not canary.exists(), r"\write18 竟然执行了!"
if body["ok"]:
pdf = client.get(body["pdf_url"])
assert pdf.content[:5] == b"%PDF-"
else:
assert body["error"]["code"] in ("COMPILE_FAILED", "TIMEOUT")
@needs_tectonic
def test_compile_failed_returns_log_tail(client: TestClient):
bad = "\\documentclass{article}\n\\begin{document}\n\\undefinedcommandxyz\n"
r = client.post(
"/api/review/compile",
files={"file": ("bad.tex", bad.encode(), "application/x-tex")},
)
body = r.json()
assert body["ok"] is False
assert body["error"]["code"] == "COMPILE_FAILED"
assert "log" in body["error"]["message"].lower() or body["error"].get("detail")
@needs_tectonic
def test_compile_timeout(client: TestClient, monkeypatch):
monkeypatch.setattr(get_settings(), "compile_timeout_s", 5.0)
loop_tex = (
"\\documentclass{article}\n\\begin{document}\n"
"\\count255=0\n\\loop\\iftrue\\advance\\count255 by 1\\repeat\n"
"\\end{document}\n"
)
started = time.monotonic()
r = client.post(
"/api/review/compile",
files={"file": ("loop.tex", loop_tex.encode(), "application/x-tex")},
)
elapsed = time.monotonic() - started
body = r.json()
assert body["ok"] is False
assert body["error"]["code"] == "TIMEOUT"
assert elapsed < 15.0, f"超时回收过慢:{elapsed:.1f}s"
# ================================================================ 队列 / 清扫
def test_queue_full(client: TestClient, monkeypatch):
monkeypatch.setattr(lc, "_pending", 5)
monkeypatch.setattr(get_settings(), "compile_queue_limit", 5)
r = client.post(
"/api/review/compile",
files={"file": ("paper.tex", MINIMAL.encode(), "application/x-tex")},
)
assert r.status_code == 429
body = r.json()
assert body["ok"] is False and body["error"]["code"] == "QUEUE_FULL"
def test_tool_unavailable(client: TestClient, monkeypatch):
monkeypatch.setattr(get_settings(), "tectonic_path", "tools/definitely-missing")
r = client.post(
"/api/review/compile",
files={"file": ("paper.tex", MINIMAL.encode(), "application/x-tex")},
)
assert r.status_code == 200
assert r.json()["error"]["code"] == "TOOL_UNAVAILABLE"
assert "install_tectonic" in r.json()["error"]["message"]
def test_sweep_expired(tmp_path, monkeypatch):
settings = get_settings()
monkeypatch.setattr(settings, "reformat_dir", str(tmp_path / "jobs"))
base = tmp_path / "jobs"
stale = base / "aabbccddeeff"
stale.mkdir(parents=True)
(stale / "output.pdf").write_bytes(b"%PDF-fake")
old = time.time() - 8 * 86400
os.utime(stale, (old, old))
fresh = base / "112233445566"
fresh.mkdir()
noise = base / "not-a-job"
noise.mkdir()
removed = lc.sweep_expired()
assert removed == 1
assert not stale.exists()
assert fresh.exists()
assert noise.exists()