198 lines
7.3 KiB
Python
198 lines
7.3 KiB
Python
"""Unit tests for the document-production tools (#92): create_xlsx, create_docx,
|
|
create_csv, create_pdf — WRITE risk, confirmation gating, vault persistence."""
|
|
|
|
import csv
|
|
import io
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from backend.tools.api import (
|
|
ToolConfirmationRequired,
|
|
ToolContext,
|
|
ToolError,
|
|
call_tool,
|
|
get_tool,
|
|
)
|
|
from backend.tools.context import ToolRisk
|
|
|
|
|
|
@pytest.fixture
|
|
def vault(tmp_path, monkeypatch):
|
|
"""A minimal configured vault (index entry patched, no full build)."""
|
|
vault_dir = tmp_path / "Vault"
|
|
vault_dir.mkdir()
|
|
monkeypatch.setitem(__import__("backend.indexer", fromlist=["index"]).index,
|
|
"Vault", {"name": "Vault", "path": str(vault_dir), "config": {}})
|
|
return vault_dir
|
|
|
|
|
|
def _ctx() -> ToolContext:
|
|
return ToolContext(
|
|
user={"username": "tester", "role": "admin", "vaults": ["*"]},
|
|
audit_enabled=False,
|
|
)
|
|
|
|
|
|
class TestRegistry:
|
|
@pytest.mark.parametrize("name", ["create_xlsx", "create_docx", "create_csv", "create_pdf"])
|
|
def test_write_risk_and_confirmation(self, name):
|
|
spec = get_tool(name)
|
|
assert spec is not None
|
|
assert spec.risk == ToolRisk.WRITE
|
|
assert spec.requires_confirmation is True
|
|
|
|
|
|
class TestConfirmationGating:
|
|
def test_csv_requires_confirmation(self, vault):
|
|
with pytest.raises(ToolConfirmationRequired):
|
|
call_tool("create_csv", _ctx(), {
|
|
"vault": "Vault", "path": "data.csv",
|
|
"rows": [["a", "b"], [1, 2]],
|
|
})
|
|
|
|
def test_pdf_requires_confirmation(self, vault):
|
|
with pytest.raises(ToolConfirmationRequired):
|
|
call_tool("create_pdf", _ctx(), {
|
|
"vault": "Vault", "path": "doc.pdf", "title": "T", "content": "# H\npara",
|
|
})
|
|
|
|
|
|
class TestCreateCsv:
|
|
def test_creates_file_in_vault(self, vault):
|
|
out = call_tool("create_csv", _ctx(), {
|
|
"vault": "Vault", "path": "Exports/data.csv",
|
|
"rows": [["nom", "score"], ["alice", 12], ["bob", 9.5]],
|
|
}, confirm=True)
|
|
assert out.ok and out.data["success"] is True
|
|
path = vault / "Exports" / "data.csv"
|
|
assert path.exists()
|
|
rows = list(csv.reader(io.StringIO(path.read_text(encoding="utf-8"))))
|
|
assert rows[0] == ["nom", "score"]
|
|
assert rows[2] == ["bob", "9.5"]
|
|
|
|
def test_semicolon_delimiter(self, vault):
|
|
call_tool("create_csv", _ctx(), {
|
|
"vault": "Vault", "path": "d.csv", "delimiter": ";",
|
|
"rows": [["a", "b"], [1, 2]],
|
|
}, confirm=True)
|
|
content = (vault / "d.csv").read_text(encoding="utf-8")
|
|
assert "a;b" in content
|
|
|
|
def test_wrong_extension_rejected(self, vault):
|
|
with pytest.raises(ToolError) as ei:
|
|
call_tool("create_csv", _ctx(), {
|
|
"vault": "Vault", "path": "d.txt", "rows": [["a"], [1]],
|
|
}, confirm=True)
|
|
assert ei.value.code == "invalid_arguments"
|
|
|
|
def test_empty_rows_rejected(self, vault):
|
|
with pytest.raises(ToolError):
|
|
call_tool("create_csv", _ctx(), {
|
|
"vault": "Vault", "path": "d.csv", "rows": [],
|
|
}, confirm=True)
|
|
|
|
|
|
class TestCreateXlsx:
|
|
def test_creates_readable_workbook(self, vault):
|
|
call_tool("create_xlsx", _ctx(), {
|
|
"vault": "Vault", "path": "Rapports/budget.xlsx",
|
|
"rows": [["item", "cout"], ["serveur", 1200], ["licence", 300]],
|
|
"sheet_name": "Budget",
|
|
}, confirm=True)
|
|
from openpyxl import load_workbook
|
|
|
|
wb = load_workbook(vault / "Rapports" / "budget.xlsx")
|
|
ws = wb.active
|
|
assert ws.title == "Budget"
|
|
assert ws.cell(row=1, column=1).value == "item"
|
|
assert ws.cell(row=2, column=2).value == 1200
|
|
|
|
def test_wrong_extension_rejected(self, vault):
|
|
with pytest.raises(ToolError):
|
|
call_tool("create_xlsx", _ctx(), {
|
|
"vault": "Vault", "path": "b.docx", "rows": [["a"], [1]],
|
|
}, confirm=True)
|
|
|
|
|
|
class TestCreateDocx:
|
|
def test_creates_readable_document(self, vault):
|
|
call_tool("create_docx", _ctx(), {
|
|
"vault": "Vault", "path": "rapport.docx",
|
|
"title": "Rapport hebdo", "paragraphs": ["Premier point.", "Second point."],
|
|
}, confirm=True)
|
|
import docx as docx_lib
|
|
|
|
doc = docx_lib.Document(str(vault / "rapport.docx"))
|
|
texts = [p.text for p in doc.paragraphs]
|
|
assert "Rapport hebdo" in texts
|
|
assert "Second point." in texts
|
|
|
|
def test_no_paragraphs_rejected(self, vault):
|
|
with pytest.raises(ToolError):
|
|
call_tool("create_docx", _ctx(), {
|
|
"vault": "Vault", "path": "r.docx", "paragraphs": [],
|
|
}, confirm=True)
|
|
|
|
|
|
class TestCreatePdf:
|
|
def test_creates_valid_pdf(self, vault):
|
|
call_tool("create_pdf", _ctx(), {
|
|
"vault": "Vault", "path": "docs/archi.pdf",
|
|
"title": "Architecture", "content": "# Titre\n\nUn paragraphe.\n## Sous-titre\nAutre texte.",
|
|
}, confirm=True)
|
|
raw = (vault / "docs" / "archi.pdf").read_bytes()
|
|
assert raw.startswith(b"%PDF")
|
|
|
|
def test_long_content_truncated(self, vault):
|
|
call_tool("create_pdf", _ctx(), {
|
|
"vault": "Vault", "path": "big.pdf", "title": "T", "content": "x" * 500_000,
|
|
}, confirm=True)
|
|
assert (vault / "big.pdf").exists()
|
|
|
|
def test_markdown_tables_go_through_the_export_pipeline(self, vault, monkeypatch):
|
|
# The document-page pipeline (mistune tables + WeasyPrint) must be
|
|
# used when available: capture the HTML handed to the PDF generator.
|
|
import sys
|
|
import types
|
|
|
|
captured = {}
|
|
fake = types.ModuleType("backend.pdf_export")
|
|
|
|
def fake_build(html, title, **kw):
|
|
captured["html"] = html
|
|
captured["title"] = title
|
|
return "<html>" + html + "</html>"
|
|
|
|
fake.build_pdf_html = fake_build
|
|
fake.generate_pdf = lambda html, title=None, **kw: b"%PDF-fake"
|
|
monkeypatch.setitem(sys.modules, "backend.pdf_export", fake)
|
|
out = call_tool("create_pdf", _ctx(), {
|
|
"vault": "Vault", "path": "table.pdf", "title": "Rapport",
|
|
"content": "# T\n\n| a | b |\n|---|---|\n| 1 | 2 |",
|
|
}, confirm=True)
|
|
assert out.ok
|
|
assert "<table>" in captured["html"]
|
|
assert captured["title"] == "Rapport"
|
|
assert (vault / "table.pdf").read_bytes() == b"%PDF-fake"
|
|
|
|
def test_reportlab_fallback_when_weasyprint_missing(self, vault, monkeypatch):
|
|
# sys.modules[name] = None makes `from backend.pdf_export import …`
|
|
# raise ImportError → the simplified renderer must take over.
|
|
import sys
|
|
|
|
monkeypatch.setitem(sys.modules, "backend.pdf_export", None)
|
|
call_tool("create_pdf", _ctx(), {
|
|
"vault": "Vault", "path": "fb.pdf", "title": "T", "content": "# H\ntexte",
|
|
}, confirm=True)
|
|
assert (vault / "fb.pdf").read_bytes().startswith(b"%PDF")
|
|
|
|
|
|
class TestVaultSafety:
|
|
def test_path_outside_vault_rejected(self, vault):
|
|
with pytest.raises(ToolError) as ei:
|
|
call_tool("create_csv", _ctx(), {
|
|
"vault": "Vault", "path": "../outside.csv", "rows": [["a"], [1]],
|
|
}, confirm=True)
|
|
assert ei.value.code in ("path_outside_vault", "invalid_arguments", "tool_execution_error")
|