241 lines
8.8 KiB
Python
241 lines
8.8 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
from types import SimpleNamespace
|
|
from urllib.parse import urlparse
|
|
|
|
import pytest
|
|
|
|
# Optional Office parsers — importorskip so a lean install (parsers absent)
|
|
# skips this file cleanly rather than aborting collection suite-wide.
|
|
pytest.importorskip("docx")
|
|
pytest.importorskip("openpyxl")
|
|
|
|
from docx import Document as DocxDocument
|
|
from openpyxl import Workbook
|
|
|
|
import api.office_documents as office_documents
|
|
import api.routes as routes
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
WORKSPACE_JS = (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
|
|
NODE = shutil.which("node")
|
|
|
|
|
|
def _simple_docx_bytes(*paragraphs: str) -> bytes:
|
|
document = DocxDocument()
|
|
for paragraph in paragraphs or ("",):
|
|
document.add_paragraph(paragraph)
|
|
buffer = io.BytesIO()
|
|
document.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _simple_xlsx_bytes() -> bytes:
|
|
workbook = Workbook()
|
|
sheet = workbook.active
|
|
sheet["A1"] = "alpha"
|
|
buffer = io.BytesIO()
|
|
workbook.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _extract_workspace_block(start_marker: str, end_marker: str) -> str:
|
|
start = WORKSPACE_JS.find(start_marker)
|
|
assert start >= 0, f"{start_marker!r} not found in static/workspace.js"
|
|
end = WORKSPACE_JS.find(end_marker, start)
|
|
assert end >= 0, f"{end_marker!r} not found after {start_marker!r}"
|
|
return WORKSPACE_JS[start:end]
|
|
|
|
|
|
def _extract_workspace_function(name: str) -> str:
|
|
marker = f"async function {name}("
|
|
start = WORKSPACE_JS.find(marker)
|
|
assert start >= 0, f"{name} not found in static/workspace.js"
|
|
params_depth = 0
|
|
body_start = -1
|
|
for idx in range(start, len(WORKSPACE_JS)):
|
|
char = WORKSPACE_JS[idx]
|
|
if char == "(":
|
|
params_depth += 1
|
|
elif char == ")":
|
|
params_depth -= 1
|
|
elif char == "{" and params_depth == 0:
|
|
body_start = idx
|
|
break
|
|
assert body_start >= 0, f"could not find function body for {name}"
|
|
depth = 0
|
|
for idx in range(body_start, len(WORKSPACE_JS)):
|
|
char = WORKSPACE_JS[idx]
|
|
if char == "{":
|
|
depth += 1
|
|
elif char == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return WORKSPACE_JS[start:idx + 1]
|
|
raise AssertionError(f"could not find balanced function body for {name}")
|
|
|
|
|
|
def _patch_file_ops(monkeypatch, workspace: Path):
|
|
session = SimpleNamespace(workspace=str(workspace))
|
|
captured = {}
|
|
|
|
def fake_j(handler, payload, status=200, extra_headers=None):
|
|
captured["ok"] = payload
|
|
captured["status"] = status
|
|
return True
|
|
|
|
def fake_bad(handler, message, status=400):
|
|
captured["bad"] = (message, status)
|
|
return True
|
|
|
|
monkeypatch.setattr(routes, "get_session_for_file_ops", lambda sid: session)
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
monkeypatch.setattr(routes, "bad", fake_bad)
|
|
return captured
|
|
|
|
|
|
def test_file_read_returns_office_preview_payload_for_docx(tmp_path, monkeypatch):
|
|
workspace = tmp_path / "ws"
|
|
workspace.mkdir()
|
|
(workspace / "story.docx").write_bytes(_simple_docx_bytes("alpha", "beta"))
|
|
captured = _patch_file_ops(monkeypatch, workspace)
|
|
|
|
routes._handle_file_read(object(), urlparse("/api/file?session_id=sid&path=story.docx"))
|
|
|
|
payload = captured["ok"]
|
|
assert payload["preview_kind"] == "office"
|
|
assert payload["office_format"] == "docx"
|
|
assert payload["render_mode"] == "code"
|
|
assert payload["editable"] is True
|
|
assert payload["content"] == "alpha\nbeta"
|
|
|
|
|
|
def test_file_read_returns_503_when_office_parsers_are_missing(tmp_path, monkeypatch):
|
|
workspace = tmp_path / "ws"
|
|
workspace.mkdir()
|
|
(workspace / "story.docx").write_bytes(_simple_docx_bytes("alpha"))
|
|
captured = _patch_file_ops(monkeypatch, workspace)
|
|
|
|
def fail_read(*_args, **_kwargs):
|
|
raise ImportError(office_documents.OFFICE_DEPENDENCY_HINT)
|
|
|
|
monkeypatch.setattr(routes, "read_file_content", fail_read)
|
|
|
|
routes._handle_file_read(object(), urlparse("/api/file?session_id=sid&path=story.docx"))
|
|
|
|
assert captured["bad"] == (office_documents.OFFICE_DEPENDENCY_HINT, 503)
|
|
|
|
|
|
def test_office_save_route_accepts_safe_docx_and_rejects_preview_only_formats(tmp_path, monkeypatch):
|
|
workspace = tmp_path / "ws"
|
|
workspace.mkdir()
|
|
docx_path = workspace / "story.docx"
|
|
docx_path.write_bytes(_simple_docx_bytes("alpha", "beta"))
|
|
xlsx_path = workspace / "budget.xlsx"
|
|
xlsx_path.write_bytes(_simple_xlsx_bytes())
|
|
|
|
captured = _patch_file_ops(monkeypatch, workspace)
|
|
|
|
routes._handle_office_file_save(
|
|
object(),
|
|
{"session_id": "sid", "path": "story.docx", "content": "alpha\nbeta\ngamma"},
|
|
)
|
|
saved = captured["ok"]
|
|
assert saved["preview_kind"] == "office"
|
|
assert saved["office_format"] == "docx"
|
|
assert saved["editable"] is True
|
|
assert saved["content"] == "alpha\nbeta\ngamma"
|
|
assert [paragraph.text for paragraph in DocxDocument(io.BytesIO(docx_path.read_bytes())).paragraphs] == [
|
|
"alpha",
|
|
"beta",
|
|
"gamma",
|
|
]
|
|
|
|
captured.clear()
|
|
routes._handle_office_file_save(
|
|
object(),
|
|
{"session_id": "sid", "path": "budget.xlsx", "content": "ignored"},
|
|
)
|
|
assert captured["bad"][1] == 400
|
|
assert "preview-only" in captured["bad"][0]
|
|
|
|
|
|
def test_office_save_route_returns_503_when_office_parsers_are_missing(tmp_path, monkeypatch):
|
|
workspace = tmp_path / "ws"
|
|
workspace.mkdir()
|
|
(workspace / "story.docx").write_bytes(_simple_docx_bytes("alpha"))
|
|
captured = _patch_file_ops(monkeypatch, workspace)
|
|
|
|
def fail_save(*_args, **_kwargs):
|
|
raise ImportError(office_documents.OFFICE_DEPENDENCY_HINT)
|
|
|
|
monkeypatch.setattr(office_documents, "save_office_document", fail_save)
|
|
|
|
routes._handle_office_file_save(
|
|
object(),
|
|
{"session_id": "sid", "path": "story.docx", "content": "beta"},
|
|
)
|
|
|
|
assert captured["bad"] == (office_documents.OFFICE_DEPENDENCY_HINT, 503)
|
|
|
|
|
|
def test_workspace_js_docx_routes_through_file_read_not_download():
|
|
assert "'.docx'" not in WORKSPACE_JS.split("const DOWNLOAD_EXTS = new Set([", 1)[1].split("]);", 1)[0]
|
|
assert "'.xlsx'" not in WORKSPACE_JS.split("const DOWNLOAD_EXTS = new Set([", 1)[1].split("]);", 1)[0]
|
|
assert "'.pptx'" not in WORKSPACE_JS.split("const DOWNLOAD_EXTS = new Set([", 1)[1].split("]);", 1)[0]
|
|
assert "'.doc'" in WORKSPACE_JS
|
|
assert "'.xls'" in WORKSPACE_JS
|
|
assert "'.ppt'" in WORKSPACE_JS
|
|
assert "data.preview_kind==='office'" in WORKSPACE_JS
|
|
assert "/api/file/office-save" in WORKSPACE_JS
|
|
|
|
|
|
def test_workspace_js_edit_button_uses_server_editable_flag():
|
|
assert "_previewServerEditable" in WORKSPACE_JS
|
|
assert "_previewServerEditable===false" in WORKSPACE_JS
|
|
assert "This Office document is preview-only." in WORKSPACE_JS
|
|
assert "_previewSaveRoute" in WORKSPACE_JS
|
|
assert "_previewSaveRoute = data.preview_kind==='office' ? '/api/file/office-save' : '/api/file/save';" in WORKSPACE_JS
|
|
|
|
|
|
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
def test_openfile_download_only_path_preserves_existing_office_save_route():
|
|
js = (
|
|
_extract_workspace_block("const DOWNLOAD_EXTS = new Set([", "function fileExt")
|
|
+ "function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; }\n"
|
|
+ _extract_workspace_function("openFile")
|
|
+ "\nconst S = { session: { session_id: 'sid-1' } };\n"
|
|
+ "let _previewServerEditable = true;\n"
|
|
+ "let _previewSaveRoute = '/api/file/office-save';\n"
|
|
+ "let _previewOfficeFormat = 'docx';\n"
|
|
+ "let _previewPreviewKind = 'office';\n"
|
|
+ "let downloaded = null;\n"
|
|
+ "function downloadFile(path){ downloaded = path; }\n"
|
|
+ "(async()=>{ await openFile('legacy.doc'); console.log(JSON.stringify({downloaded,_previewServerEditable,_previewSaveRoute,_previewOfficeFormat,_previewPreviewKind})); })();\n"
|
|
)
|
|
result = subprocess.run([NODE, "-e", js], check=True, capture_output=True, text=True, timeout=30)
|
|
state = json.loads(result.stdout.strip().splitlines()[-1])
|
|
|
|
assert state["downloaded"] == "legacy.doc"
|
|
assert state["_previewSaveRoute"] == "/api/file/office-save"
|
|
assert state["_previewServerEditable"] is True
|
|
assert state["_previewOfficeFormat"] == "docx"
|
|
assert state["_previewPreviewKind"] == "office"
|
|
|
|
|
|
def test_pdf_preview_path_is_unchanged():
|
|
assert "showPreview('pdf')" in WORKSPACE_JS
|
|
assert "_workspaceRouteForPath(path, 'raw', {inline:true})" in WORKSPACE_JS
|
|
|
|
|
|
def test_zip_and_legacy_office_formats_still_download():
|
|
download_block = WORKSPACE_JS.split("const DOWNLOAD_EXTS = new Set([", 1)[1].split("]);", 1)[0]
|
|
for ext in [".doc", ".xls", ".ppt", ".odt", ".ods", ".odp", ".zip", ".tar"]:
|
|
assert ext in download_block
|