mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 20:15:26 +00:00
The backend import package is now docsgpt, the name it will carry on PyPI; application was far too generic to install into anyone's site-packages. git mv plus a mechanical rewrite of every import, dotted string and path reference: 734 Python files, the compose files, Dockerfile, workflows, docs, setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage config, .gitignore. Behaviour is unchanged. Kept for one release: - A top-level application package whose meta-path finder resolves application.x.y to the already-imported docsgpt.x.y object, so old imports and entry points (celery -A application.app.celery, uvicorn application.asgi:asgi_app) keep working with a FutureWarning. - Celery registers every application.* task name as an alias of its docsgpt.* task on start-up, so messages queued by the previous release still run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries the previous release wrote are left unread instead of firing twice. The backend image builds from the repository root (docker build -f docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore allow-lists docsgpt/ and application/ and keeps caches, local data, .env files, the sample index files and the Dockerfile out. Compose and the image workflows point at the new context.
600 lines
22 KiB
Python
600 lines
22 KiB
Python
"""Unit tests for ArtifactGeneratorTool: spec validation, merge-patch, and renderer injection safety.
|
|
|
|
No DB or sandbox: these exercise the pure logic (schema gate + RFC 7386 merge)
|
|
and the FIXED renderer programs directly (executed in-process against a temp dir)
|
|
to prove a spec value containing Python/quotes is rendered as literal text.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from docsgpt.agents.tools.artifact_generator import (
|
|
_KIND_INFO,
|
|
_RENDERERS,
|
|
ArtifactGeneratorTool,
|
|
merge_patch,
|
|
)
|
|
|
|
pytest.importorskip("pptx")
|
|
pytest.importorskip("docx")
|
|
pytest.importorskip("openpyxl")
|
|
pytest.importorskip("reportlab")
|
|
|
|
|
|
def _tool():
|
|
return ArtifactGeneratorTool(
|
|
tool_config={"conversation_id": "conv-1", "tool_id": "t-1"}, user_id="u-1"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Spec validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_accepts_minimal_presentation():
|
|
assert _tool()._validate("presentation", {"slides": [{"title": "x"}]})[1] is None
|
|
|
|
|
|
def test_validate_rejects_missing_required_key():
|
|
_spec, err = _tool()._validate("presentation", {"title": "no slides"})
|
|
assert err["status"] == "error"
|
|
assert "invalid presentation spec" in err["error"]
|
|
|
|
|
|
def test_validate_rejects_unknown_key():
|
|
_spec, err = _tool()._validate("document", {"sections": [], "bogus": 1})
|
|
assert err["status"] == "error"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reversion download filename
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _patch_reversion(monkeypatch, tool):
|
|
"""Stub render + persist so _reversion runs without a sandbox or DB; capture kwargs."""
|
|
import docsgpt.agents.tools.artifact_generator as ag
|
|
|
|
captured: dict = {}
|
|
|
|
def _fake_append(**kwargs):
|
|
captured.update(kwargs)
|
|
return {"artifact_id": kwargs["artifact_id"], "version": 2, "id": "v2"}
|
|
|
|
monkeypatch.setattr(ag, "append_artifact_version", _fake_append)
|
|
monkeypatch.setattr(tool, "_render", lambda kind, spec: {"data": b"x"})
|
|
return captured
|
|
|
|
|
|
def test_reversion_preserves_title_in_filename(monkeypatch):
|
|
"""An edited/rewritten version keeps the original artifact's download name."""
|
|
tool = _tool()
|
|
captured = _patch_reversion(monkeypatch, tool)
|
|
|
|
result = tool._reversion(
|
|
"art-1", "presentation", {"slides": [{"title": "x"}]}, "edit_artifact", "Q3 Deck"
|
|
)
|
|
|
|
assert result["status"] == "ok"
|
|
assert captured["filename"] == "Q3 Deck.pptx"
|
|
|
|
|
|
def test_reversion_without_title_falls_back_to_generic(monkeypatch):
|
|
"""A missing title still yields a valid generic filename (never crashes)."""
|
|
tool = _tool()
|
|
captured = _patch_reversion(monkeypatch, tool)
|
|
|
|
tool._reversion("art-1", "presentation", {"slides": [{"title": "x"}]}, "rewrite_artifact", None)
|
|
|
|
assert captured["filename"] == "artifact.pptx"
|
|
|
|
|
|
def test_validate_rejects_wrong_type():
|
|
_spec, err = _tool()._validate("spreadsheet", {"sheets": "not-a-list"})
|
|
assert err["status"] == "error"
|
|
|
|
|
|
def test_validate_rejects_non_object_spec():
|
|
_spec, err = _tool()._validate("pdf", "just a string")
|
|
assert err["status"] == "error"
|
|
assert "spec must be a JSON object" in err["error"]
|
|
|
|
|
|
def test_create_rejects_unknown_kind():
|
|
out = _tool()._create(kind="hologram", spec={})
|
|
assert out["status"] == "error"
|
|
assert "unsupported kind" in out["error"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _render session lifecycle: cleans its scratch dir, leaves the shared session open
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakeRenderManager:
|
|
"""Records remove_path/close and returns fixed render bytes; no real sandbox."""
|
|
|
|
def __init__(self):
|
|
self.closed = []
|
|
self.removed = []
|
|
|
|
def open(self, session_id, ttl=None):
|
|
return session_id
|
|
|
|
def put_file(self, session_id, dest_path, data):
|
|
pass
|
|
|
|
def exec(self, session_id, code, timeout=None):
|
|
return SimpleNamespace(ok=True)
|
|
|
|
def get_file(self, session_id, path):
|
|
return b"%PDF-1.4 rendered"
|
|
|
|
def remove_path(self, session_id, path):
|
|
self.removed.append((session_id, path))
|
|
|
|
def close(self, session_id):
|
|
self.closed.append(session_id)
|
|
|
|
|
|
def test_render_cleans_scratch_but_leaves_session_open(monkeypatch):
|
|
"""_render drops its per-render scratch dir but must NOT close the shared session."""
|
|
manager = _FakeRenderManager()
|
|
monkeypatch.setattr(
|
|
"docsgpt.sandbox.sandbox_creator.SandboxCreator.get_manager", lambda: manager
|
|
)
|
|
|
|
out = _tool()._render("pdf", {"title": "t", "blocks": []})
|
|
|
|
assert out == {"data": b"%PDF-1.4 rendered"}
|
|
# The render owns only its scratch dir; the session is the shared conversation
|
|
# session that code_executor(persist=True) keeps warm, so it is left for the
|
|
# manager/conversation to reap.
|
|
assert manager.closed == []
|
|
assert len(manager.removed) == 1
|
|
session_id, path = manager.removed[0]
|
|
assert session_id == "conv-1"
|
|
assert path.startswith("artifacts/")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RFC 7386 JSON merge-patch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_merge_patch_adds_and_overwrites():
|
|
assert merge_patch({"a": 1, "b": 2}, {"b": 3, "c": 4}) == {"a": 1, "b": 3, "c": 4}
|
|
|
|
|
|
def test_merge_patch_null_deletes_key():
|
|
assert merge_patch({"a": 1, "b": 2}, {"b": None}) == {"a": 1}
|
|
|
|
|
|
def test_merge_patch_recurses_into_objects():
|
|
assert merge_patch({"x": {"a": 1, "b": 2}}, {"x": {"b": None, "c": 3}}) == {"x": {"a": 1, "c": 3}}
|
|
|
|
|
|
def test_merge_patch_replaces_array_wholesale():
|
|
# RFC 7386: arrays are replaced, not merged element-wise.
|
|
assert merge_patch({"l": [1, 2, 3]}, {"l": [9]}) == {"l": [9]}
|
|
|
|
|
|
def test_merge_patch_non_object_patch_replaces_target():
|
|
assert merge_patch({"a": 1}, "scalar") == "scalar"
|
|
|
|
|
|
def test_merge_patch_does_not_mutate_inputs():
|
|
target = {"a": {"b": 1}}
|
|
patch = {"a": {"c": 2}}
|
|
merge_patch(target, patch)
|
|
assert target == {"a": {"b": 1}}
|
|
assert patch == {"a": {"c": 2}}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Renderer injection safety
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _render_in_process(kind: str, spec: dict) -> str:
|
|
"""Execute the FIXED renderer program against a temp dir; return the output path."""
|
|
workdir = tempfile.mkdtemp()
|
|
spec_path = os.path.join(workdir, "spec.json")
|
|
out_path = os.path.join(workdir, f"out.{_KIND_INFO[kind]['ext']}")
|
|
with open(spec_path, "w") as handle:
|
|
json.dump(spec, handle)
|
|
program = _RENDERERS[kind].format(spec_path=spec_path, out_path=out_path)
|
|
namespace: dict = {}
|
|
exec(compile(program, "<renderer>", "exec"), namespace, namespace) # noqa: S102
|
|
return out_path
|
|
|
|
|
|
def test_renderer_does_not_execute_spec_code(capfd, tmp_path):
|
|
# A spec whose values are Python source / shell payloads must be treated as
|
|
# literal text. If the renderer string-interpolated the spec it would run
|
|
# this; instead it json.loads the spec as data, so nothing executes.
|
|
sentinel = tmp_path / "pwned.txt"
|
|
payload = (
|
|
f"'''__import__('os').system('echo PWNED > {sentinel}')'''; "
|
|
"print('SHOULD_NOT_PRINT')"
|
|
)
|
|
spec = {
|
|
"title": payload,
|
|
"slides": [{"title": payload, "bullets": [payload], "notes": payload}],
|
|
}
|
|
out_path = _render_in_process("presentation", spec)
|
|
|
|
captured = capfd.readouterr()
|
|
assert "SHOULD_NOT_PRINT" not in captured.out
|
|
assert "PWNED" not in captured.out
|
|
assert not sentinel.exists()
|
|
assert os.path.getsize(out_path) > 0
|
|
|
|
|
|
def test_renderer_keeps_injection_text_as_literal_content():
|
|
from pptx import Presentation
|
|
|
|
payload = "'''; import os; os.system('echo HACK'); x = '''"
|
|
spec = {"slides": [{"title": payload, "bullets": [payload]}]}
|
|
prs = Presentation(_render_in_process("presentation", spec))
|
|
assert len(prs.slides) == 1
|
|
assert prs.slides[0].shapes.title.text == payload
|
|
|
|
|
|
def test_spreadsheet_renderer_neutralizes_formula_injection():
|
|
# Spec content is model / prompt-injection controlled. A string cell starting
|
|
# with a formula trigger must be stored as TEXT (data_type 's'), never as a
|
|
# live formula; genuine numbers stay numeric.
|
|
from openpyxl import load_workbook
|
|
|
|
spec = {
|
|
"sheets": [
|
|
{
|
|
"name": "S",
|
|
"rows": [
|
|
['=HYPERLINK("http://evil","x")', "+1+1", "@cmd", "-danger"],
|
|
["safe text", -5, 3.14],
|
|
],
|
|
}
|
|
]
|
|
}
|
|
wb = load_workbook(_render_in_process("spreadsheet", spec))
|
|
ws = wb["S"]
|
|
# None of the trigger cells are formulas; each is a quote-prefixed string.
|
|
for coord in ("A1", "B1", "C1", "D1"):
|
|
assert ws[coord].data_type == "s", coord
|
|
assert str(ws[coord].value).startswith("'")
|
|
# Benign text is unchanged; numbers stay numeric (no spurious quoting).
|
|
assert ws["A2"].value == "safe text" and ws["A2"].data_type == "s"
|
|
assert ws["B2"].value == -5 and ws["B2"].data_type == "n"
|
|
assert ws["C2"].value == 3.14 and ws["C2"].data_type == "n"
|
|
|
|
|
|
def test_pdf_renderer_escapes_markup_and_does_not_execute():
|
|
payload = "<b>not bold</b> & \"</para>\" '''os.system('x')'''"
|
|
spec = {"title": payload, "blocks": [{"type": "paragraph", "text": payload}]}
|
|
out_path = _render_in_process("pdf", spec)
|
|
assert os.path.getsize(out_path) > 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# html kind: schema, renderer injection safety, output escaping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_accepts_html_spec():
|
|
spec = {
|
|
"title": "Report",
|
|
"blocks": [
|
|
{"type": "heading", "text": "Intro", "level": 2},
|
|
{"type": "paragraph", "text": "Body."},
|
|
{"type": "list", "ordered": True, "items": ["a", "b"]},
|
|
{"type": "table", "headers": ["h"], "rows": [["c"]]},
|
|
{"type": "code", "text": "print(1)"},
|
|
],
|
|
}
|
|
assert _tool()._validate("html", spec)[1] is None
|
|
|
|
|
|
def test_validate_rejects_html_unknown_block_key():
|
|
_spec, err = _tool()._validate("html", {"blocks": [{"type": "paragraph", "text": "x", "bogus": 1}]})
|
|
assert err["status"] == "error"
|
|
|
|
|
|
def test_validate_rejects_html_bad_heading_level():
|
|
_spec, err = _tool()._validate("html", {"blocks": [{"type": "heading", "text": "x", "level": 9}]})
|
|
assert err["status"] == "error"
|
|
|
|
|
|
def test_html_renderer_does_not_execute_spec_code(capfd, tmp_path):
|
|
# The renderer json.loads the spec as data; a value that is Python source
|
|
# must never run. If the program string-interpolated the spec it would.
|
|
sentinel = tmp_path / "pwned.txt"
|
|
payload = (
|
|
f"'''__import__('os').system('echo PWNED > {sentinel}')'''; "
|
|
"print('SHOULD_NOT_PRINT')"
|
|
)
|
|
spec = {"title": payload, "blocks": [{"type": "paragraph", "text": payload}]}
|
|
out_path = _render_in_process("html", spec)
|
|
|
|
captured = capfd.readouterr()
|
|
assert "SHOULD_NOT_PRINT" not in captured.out
|
|
assert "PWNED" not in captured.out
|
|
assert not sentinel.exists()
|
|
assert os.path.getsize(out_path) > 0
|
|
|
|
|
|
def test_html_renderer_escapes_spec_markup():
|
|
# Spec text carrying live markup must appear HTML-ESCAPED in the output, so
|
|
# no raw <script>/<img onerror> tag from spec content reaches the document.
|
|
script = "<script>alert(1)</script>"
|
|
img = '<img src=x onerror="alert(2)">'
|
|
spec = {
|
|
"title": script,
|
|
"blocks": [
|
|
{"type": "paragraph", "text": img},
|
|
{"type": "heading", "text": "<b>h</b>", "level": 1},
|
|
{"type": "list", "items": ["<i>x</i>"]},
|
|
{"type": "table", "headers": ["<u>th</u>"], "rows": [["<em>td</em>"]]},
|
|
{"type": "code", "text": "</code><script>x</script>"},
|
|
],
|
|
}
|
|
with open(_render_in_process("html", spec), encoding="utf-8") as handle:
|
|
html_doc = handle.read()
|
|
|
|
# No live tag from spec content survives.
|
|
assert "<script>alert(1)</script>" not in html_doc
|
|
assert "<img src=x onerror=" not in html_doc
|
|
assert "<b>h</b>" not in html_doc
|
|
assert "<i>x</i>" not in html_doc
|
|
assert "<u>th</u>" not in html_doc
|
|
assert "<em>td</em>" not in html_doc
|
|
# The escaped forms are present instead.
|
|
assert "<script>alert(1)</script>" in html_doc
|
|
assert "<img src=x onerror=" in html_doc
|
|
# The fixed structural HTML the renderer emits is intact.
|
|
assert "<!doctype html>" in html_doc
|
|
assert "<p>" in html_doc and "</p>" in html_doc
|
|
assert "<table>" in html_doc and "<pre><code>" in html_doc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool metadata: spec shapes are surfaced to the model
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _collect_schema_keys(schema, keys):
|
|
if isinstance(schema, dict):
|
|
for prop in schema.get("properties", {}):
|
|
keys.add(prop)
|
|
for sub in schema.values():
|
|
_collect_schema_keys(sub, keys)
|
|
elif isinstance(schema, list):
|
|
for sub in schema:
|
|
_collect_schema_keys(sub, keys)
|
|
|
|
|
|
def test_spec_synopsis_covers_every_schema_key():
|
|
"""Every kind and property in _SCHEMAS must appear in the metadata synopsis.
|
|
|
|
The synopsis is a hand-written mirror of _SCHEMAS; this guards drift when a
|
|
kind or key is added without updating what the model is told.
|
|
"""
|
|
from docsgpt.agents.tools.artifact_generator import _SCHEMAS, _SPEC_SYNOPSIS
|
|
|
|
for kind, schema in _SCHEMAS.items():
|
|
assert kind in _SPEC_SYNOPSIS
|
|
keys: set = set()
|
|
_collect_schema_keys(schema, keys)
|
|
for key in keys:
|
|
assert key in _SPEC_SYNOPSIS, f"schema key {key!r} of kind {kind!r} missing from synopsis"
|
|
|
|
|
|
def test_create_and_rewrite_metadata_embed_spec_synopsis():
|
|
from docsgpt.agents.tools.artifact_generator import _SPEC_SYNOPSIS
|
|
|
|
actions = {a["name"]: a for a in _tool().get_actions_metadata()}
|
|
create_spec = actions["create_artifact"]["parameters"]["properties"]["spec"]
|
|
rewrite_spec = actions["rewrite_artifact"]["parameters"]["properties"]["spec"]
|
|
assert _SPEC_SYNOPSIS in create_spec["description"]
|
|
assert _SPEC_SYNOPSIS in rewrite_spec["description"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# spec_append: additive edits that don't clobber arrays
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_spec_append_preserves_existing_items():
|
|
from docsgpt.agents.tools.artifact_generator import _apply_spec_append
|
|
|
|
spec = {"title": "Brief", "blocks": [{"type": "heading", "text": "Overview"}]}
|
|
out = _apply_spec_append(spec, {"blocks": [{"type": "heading", "text": "Risks"}]})
|
|
assert "error" not in out
|
|
assert [b["text"] for b in out["spec"]["blocks"]] == ["Overview", "Risks"]
|
|
# The input spec is not mutated.
|
|
assert len(spec["blocks"]) == 1
|
|
|
|
|
|
def test_spec_append_creates_missing_list():
|
|
from docsgpt.agents.tools.artifact_generator import _apply_spec_append
|
|
|
|
out = _apply_spec_append({"title": "x"}, {"blocks": [{"type": "paragraph", "text": "p"}]})
|
|
assert out["spec"]["blocks"] == [{"type": "paragraph", "text": "p"}]
|
|
|
|
|
|
def test_spec_append_rejects_non_list_values_and_targets():
|
|
from docsgpt.agents.tools.artifact_generator import _apply_spec_append
|
|
|
|
assert "error" in _apply_spec_append({}, {"blocks": "not-a-list"})
|
|
assert "error" in _apply_spec_append({"title": "t"}, {"title": ["x"]})
|
|
|
|
|
|
def test_edit_with_only_spec_append_appends_to_loaded_spec(monkeypatch):
|
|
tool = _tool()
|
|
loaded_spec = {"blocks": [{"type": "heading", "text": "Overview"}]}
|
|
monkeypatch.setattr(
|
|
tool,
|
|
"_load_current",
|
|
lambda raw_id: {"artifact_id": "a-1", "kind": "html", "spec": loaded_spec, "title": "T"},
|
|
)
|
|
captured = {}
|
|
|
|
def fake_reversion(artifact_id, kind, spec, action, title=None):
|
|
captured["spec"] = spec
|
|
return {"status": "ok", "artifact_id": artifact_id}
|
|
|
|
monkeypatch.setattr(tool, "_reversion", fake_reversion)
|
|
|
|
result = tool._edit(id="A1", spec_append={"blocks": [{"type": "paragraph", "text": "Risks…"}]})
|
|
assert result["status"] == "ok"
|
|
assert [b["text"] for b in captured["spec"]["blocks"]] == ["Overview", "Risks…"]
|
|
|
|
|
|
def test_edit_requires_patch_or_append():
|
|
err = _tool()._edit(id="A1")
|
|
assert err["status"] == "error"
|
|
assert "spec_patch and/or spec_append" in err["error"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Weak models stringify the spec, and put `level` on pdf headings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_accepts_a_json_encoded_spec_string():
|
|
"""A JSON-string spec must be parsed, not rejected.
|
|
|
|
``spec`` is declared ``{"type": "object"}`` in the tool metadata, but tool
|
|
definitions are not sent with ``strict``, so nothing forces a model to
|
|
honour it. DeepSeek-V4-Flash sends ``"spec": "{\\"blocks\\": ...}"`` on the
|
|
majority of its calls; rejecting it outright made the model abandon the
|
|
artifact tool and hand-write reportlab through ``code_executor`` instead.
|
|
"""
|
|
tool = _tool()
|
|
spec = json.dumps({"blocks": [{"type": "heading", "text": "Hi"}]})
|
|
assert tool._validate("pdf", spec)[1] is None
|
|
|
|
|
|
def test_validate_coerces_the_string_spec_for_the_caller():
|
|
"""The coerced spec must come back, not just the verdict.
|
|
|
|
Returning only the error left the caller holding the original string: it
|
|
passed validation and was then written to spec.json as a JSON *string*,
|
|
which the renderer cannot read.
|
|
"""
|
|
tool = _tool()
|
|
raw = json.dumps({"sections": [{"heading": "H", "paragraphs": ["p"]}]})
|
|
spec, err = tool._validate("document", raw)
|
|
assert err is None
|
|
assert spec == {"sections": [{"heading": "H", "paragraphs": ["p"]}]}
|
|
assert tool._coerce_spec(raw) == spec
|
|
|
|
|
|
def test_validate_still_rejects_a_json_string_that_is_not_an_object():
|
|
tool = _tool()
|
|
assert tool._validate("pdf", json.dumps(["not", "an", "object"]))[1]["status"] == "error"
|
|
assert tool._validate("pdf", "not json at all")[1]["status"] == "error"
|
|
|
|
|
|
def test_create_accepts_a_stringified_spec(monkeypatch):
|
|
"""The coercion must reach ``_create``, not just ``_validate``."""
|
|
tool = _tool()
|
|
captured = {}
|
|
|
|
def fake_render(kind, spec):
|
|
captured["kind"], captured["spec"] = kind, spec
|
|
return {"error": "stop here"}
|
|
|
|
monkeypatch.setattr(tool, "_render", fake_render)
|
|
out = tool._create(kind="pdf", spec=json.dumps({"blocks": [{"type": "paragraph", "text": "x"}]}))
|
|
assert out["error"] == "stop here"
|
|
assert captured["spec"] == {"blocks": [{"type": "paragraph", "text": "x"}]}
|
|
|
|
|
|
def test_pdf_schema_accepts_heading_level():
|
|
"""``level`` is legal on html headings and was illegal on pdf headings.
|
|
|
|
The synopsis lists both block shapes in adjacent clauses, so a model that
|
|
has just read ``"level"?: 1-3`` carries it over and loses the whole spec to
|
|
``Additional properties are not allowed ('level' was unexpected)``.
|
|
"""
|
|
spec = {
|
|
"title": "Patch Notes",
|
|
"blocks": [
|
|
{"type": "heading", "text": "Hunter", "level": 1},
|
|
{"type": "heading", "text": "Birdhouses", "level": 3},
|
|
{"type": "paragraph", "text": "Body."},
|
|
],
|
|
}
|
|
assert _tool()._validate("pdf", spec)[1] is None
|
|
|
|
|
|
def test_pdf_schema_still_rejects_an_out_of_range_level():
|
|
spec = {"blocks": [{"type": "heading", "text": "x", "level": 9}]}
|
|
assert _tool()._validate("pdf", spec)[1]["status"] == "error"
|
|
|
|
|
|
def test_pdf_renderer_honours_heading_level():
|
|
"""A level must change the rendered style, not merely pass validation."""
|
|
same_text = "Chapter"
|
|
h1 = _render_in_process("pdf", {"blocks": [{"type": "heading", "text": same_text, "level": 1}]})
|
|
h3 = _render_in_process("pdf", {"blocks": [{"type": "heading", "text": same_text, "level": 3}]})
|
|
# reportlab stamps a creation date, so the bytes are never identical —
|
|
# compare sizes instead: Heading1 and Heading3 differ in font size, so the
|
|
# content stream length differs. Equal sizes would mean ``level`` was
|
|
# silently ignored (the pre-fix renderer used Heading1 for every heading).
|
|
assert os.path.getsize(h1) != os.path.getsize(h3)
|
|
assert os.path.getsize(h1) == os.path.getsize(
|
|
_render_in_process("pdf", {"blocks": [{"type": "heading", "text": same_text, "level": 1}]})
|
|
)
|
|
|
|
|
|
def test_pdf_renderer_tolerates_a_junk_level():
|
|
out_path = _render_in_process(
|
|
"pdf", {"blocks": [{"type": "heading", "text": "x", "level": "two"}]}
|
|
)
|
|
assert os.path.getsize(out_path) > 0
|
|
|
|
|
|
def test_edit_accepts_stringified_patch_and_append(monkeypatch):
|
|
"""``spec_patch``/``spec_append`` are declared like ``spec`` and stringify the same way.
|
|
|
|
Without this, a model that has just had ``create_artifact`` accepted goes on
|
|
to hard-fail its first edit — straight back into the "the artifact tool is
|
|
broken, I'll hand-write a renderer" spiral the coercion exists to end.
|
|
"""
|
|
tool = _tool()
|
|
monkeypatch.setattr(
|
|
tool,
|
|
"_load_current",
|
|
lambda _id: {
|
|
"artifact_id": "a-1",
|
|
"kind": "pdf",
|
|
"spec": {"title": "Old", "blocks": [{"type": "paragraph", "text": "a"}]},
|
|
"title": "Old",
|
|
},
|
|
)
|
|
captured = {}
|
|
monkeypatch.setattr(tool, "_reversion", lambda *a, **k: captured.update(spec=a[2]) or {"status": "ok"})
|
|
|
|
tool._edit(id="A1", spec_patch=json.dumps({"title": "New"}))
|
|
assert captured["spec"]["title"] == "New"
|
|
|
|
captured.clear()
|
|
tool._edit(id="A1", spec_append=json.dumps({"blocks": [{"type": "paragraph", "text": "b"}]}))
|
|
assert len(captured["spec"]["blocks"]) == 2
|
|
|
|
|
|
def test_edit_still_rejects_a_non_object_patch():
|
|
tool = _tool()
|
|
assert tool._edit(id="A1", spec_patch="not json")["status"] == "error"
|
|
assert tool._edit(id="A1", spec_append=json.dumps(["a", "b"]))["status"] == "error"
|