"""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 + "" 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 "" 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")