Files
pablan/backend/tests/test_templates_api.py
T
ProfessorNovaandClaude Opus 5 784b76baf7 Pablan, as it stands
Self-hosted knowledge management for SMEs: a split-screen Markdown editor
whose sections an LLM refines while you write, and RAG question answering
over the documents that result. FastAPI + Postgres/pgvector on the back,
SvelteKit on the front, everything OpenAI-compatible and self-hostable.

Squashed into a single commit; the development history stays local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
2026-09-04 09:21:37 +02:00

275 lines
10 KiB
Python

import re
from httpx import AsyncClient
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Template, User
from app.template_import import parse_template, upsert_template
VALID_YAML = """\
id: import-test
name: "Import-Test"
version: "1.0"
kind: authoring
persona: |
Du bist ein Fachredakteur.
title_template: "Import: {{user.name}} ({{date}})"
skeleton: |
## Thema
sections:
- heading: "Thema"
hint: "Worum es geht."
metadata:
visibility: department
"""
# The structured config the form builder posts — the same shape parse_template
# produces, so /build and /import share one validation guarantee.
VALID_CONFIG = {
"id": "gebaut",
"name": "Gebaute Vorlage",
"version": "1.0",
"kind": "authoring",
"locale": "de",
"description": "Aus dem Formular gebaut.",
"model": {"temperature": 0.4, "min_class_hint": None},
"persona": "Du bist ein Fachredakteur.",
"skeleton": "## Thema\n",
"sections": [{"heading": "Thema", "hint": "Worum es geht."}],
"title_template": "Gebaut: {{user.name}}",
"metadata": {"visibility": "department"},
}
async def _login(client: AsyncClient, email: str) -> None:
response = await client.post(
"/api/auth/login", json={"email": email, "password": "secret123"}
)
assert response.status_code == 200
async def _seed(db: AsyncSession, yaml: str = VALID_YAML) -> Template:
"""Put a template in the table directly, for tests that need one to exist
rather than to exercise a create endpoint."""
row, _ = await upsert_template(db, parse_template(yaml))
await db.commit()
return row
async def test_endpoints_require_auth(client: AsyncClient) -> None:
assert (await client.get("/api/templates")).status_code == 401
assert (
await client.post("/api/templates/build", json={"config": VALID_CONFIG})
).status_code == 401
async def test_list_get_roundtrip(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
row = await _seed(db)
# Members can browse templates (they pick one to write a document from).
await _login(client, "pablo@test.dev")
listing = (await client.get("/api/templates")).json()
assert [t["name"] for t in listing] == ["Import-Test"]
detail = (await client.get(f"/api/templates/{row.id}")).json()
assert detail["config"]["sections"][0]["heading"] == "Thema"
assert detail["config"]["persona"].startswith("Du bist")
async def test_every_template_is_editable_and_deletable(
client: AsyncClient, db: AsyncSession, seeded_admin: User
) -> None:
"""There is no read-only template: a template describes how a company
documents its own knowledge, so the company owns it."""
row, _ = await upsert_template(db, parse_template(VALID_YAML))
await db.commit()
await _login(client, "florian@test.dev")
edited = VALID_YAML.replace('version: "1.0"', 'version: "1.1"')
saved = await client.put(f"/api/templates/{row.id}", json={"yaml": edited})
assert saved.status_code == 200
assert saved.json()["version"] == "1.1"
# A fork gets its own config id, so the two never collide.
forked = await client.post(f"/api/templates/{row.id}/duplicate")
assert forked.status_code == 200
fork = forked.json()
assert fork["config"]["id"].endswith("-copy")
# Numbered, not worded: the name is shown in the UI, and the backend
# renders no UI-language strings.
assert fork["name"].endswith(" (2)")
assert (await client.delete(f"/api/templates/{fork['id']}")).status_code == 204
assert (await client.delete(f"/api/templates/{row.id}")).status_code == 204
async def test_editing_returns_the_yaml_and_rejects_invalid_input(
client: AsyncClient, db: AsyncSession, seeded_admin: User
) -> None:
row = await _seed(db)
await _login(client, "florian@test.dev")
# The detail carries editable YAML, not just the parsed config.
detail = (await client.get(f"/api/templates/{row.id}")).json()
assert "skeleton:" in detail["yaml"]
# Saving invalid YAML is rejected: a missing required field, or malformed
# syntax, both surface as 422 invalid_template rather than corrupting the row.
incomplete = await client.put(
f"/api/templates/{row.id}", json={"yaml": "id: x\nname: y"}
)
assert incomplete.status_code == 422
assert incomplete.json()["code"] == "invalid_template"
malformed = await client.put(f"/api/templates/{row.id}", json={"yaml": ":\n - ]["})
assert malformed.status_code == 422
async def test_template_management_requires_an_admin(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
row, _ = await upsert_template(db, parse_template(VALID_YAML))
await db.commit()
await client.post(
"/api/auth/login", json={"email": "pablo@test.dev", "password": "secret123"}
)
assert (
await client.put(f"/api/templates/{row.id}", json={"yaml": VALID_YAML})
).status_code == 403
assert (await client.post(f"/api/templates/{row.id}/duplicate")).status_code == 403
assert (await client.delete(f"/api/templates/{row.id}")).status_code == 403
async def test_catalog_lists_blueprints_and_marks_what_is_added(
client: AsyncClient, db: AsyncSession, seeded_admin: User
) -> None:
"""The catalog is what ships in templates/ — inert until someone adds
an entry."""
await _login(client, "florian@test.dev")
catalog = (await client.get("/api/templates/catalog")).json()
by_id = {entry["id"]: entry for entry in catalog}
assert "notiz" in by_id
assert "anlage" in by_id
assert by_id["anlage"]["sections"] >= 1
assert by_id["anlage"]["description"]
# Nothing is in the instance yet.
assert all(entry["added"] is False for entry in catalog)
added = await client.post("/api/templates/catalog/anlage")
assert added.status_code == 200
assert added.json()["config"]["id"] == "anlage"
catalog = (await client.get("/api/templates/catalog")).json()
assert {e["id"]: e["added"] for e in catalog}["anlage"] is True
# Adding twice would silently overwrite an edited template.
again = await client.post("/api/templates/catalog/anlage")
assert again.status_code == 409
assert again.json()["code"] == "already_added"
async def test_a_template_added_from_the_catalog_is_fully_editable(
client: AsyncClient, db: AsyncSession, seeded_admin: User
) -> None:
"""The whole point of the catalog: what you add becomes yours."""
await _login(client, "florian@test.dev")
added = (await client.post("/api/templates/catalog/person")).json()
# The detail serializes the config back to YAML, so quoting is safe_dump's.
changed = re.sub(r"^version:.*$", 'version: "0.9"', added["yaml"], flags=re.M)
saved = await client.put(f"/api/templates/{added['id']}", json={"yaml": changed})
assert saved.status_code == 200, saved.json()
assert saved.json()["version"] == "0.9"
assert (await client.delete(f"/api/templates/{added['id']}")).status_code == 204
async def test_unknown_blueprint_is_404(
client: AsyncClient, seeded_admin: User
) -> None:
await _login(client, "florian@test.dev")
assert (await client.get("/api/templates/catalog/nope")).status_code == 404
assert (await client.post("/api/templates/catalog/nope")).status_code == 404
async def test_catalog_requires_an_admin(
client: AsyncClient, seeded_user: User
) -> None:
"""Blueprints are an administration concern — a member picks from what
the admin enabled, not from the whole catalogue."""
await _login(client, "pablo@test.dev")
assert (await client.get("/api/templates/catalog")).status_code == 403
assert (await client.post("/api/templates/catalog/freies-thema")).status_code == 403
async def test_build_creates_from_config_then_updates_in_place(
client: AsyncClient, db: AsyncSession, seeded_admin: User
) -> None:
"""The form builder posts structured config instead of YAML. Creating with
template_id null makes a row; posting the row id updates it in place."""
await _login(client, "florian@test.dev")
created = await client.post(
"/api/templates/build", json={"template_id": None, "config": VALID_CONFIG}
)
assert created.status_code == 200, created.json()
body = created.json()
assert body["config"]["id"] == "gebaut"
assert body["config"]["skeleton"].startswith("## Thema")
row_id = body["id"]
reordered = {
**VALID_CONFIG,
"sections": [{"heading": "Ablauf", "hint": "Die Schritte."}],
"skeleton": "## Ablauf\n",
}
saved = await client.post(
"/api/templates/build", json={"template_id": row_id, "config": reordered}
)
assert saved.status_code == 200, saved.json()
assert saved.json()["id"] == row_id
assert saved.json()["config"]["sections"][0]["heading"] == "Ablauf"
count = (await db.execute(select(func.count(Template.id)))).scalar_one()
assert count == 1
async def test_build_uniquifies_config_id_for_new_templates(
client: AsyncClient, db: AsyncSession, seeded_admin: User
) -> None:
"""A second new template with the same name must not silently overwrite
the first: the server suffixes the derived config id instead."""
await _login(client, "florian@test.dev")
first = await client.post(
"/api/templates/build", json={"template_id": None, "config": VALID_CONFIG}
)
second = await client.post(
"/api/templates/build", json={"template_id": None, "config": VALID_CONFIG}
)
assert first.json()["config"]["id"] == "gebaut"
assert second.json()["config"]["id"] == "gebaut-2"
count = (await db.execute(select(func.count(Template.id)))).scalar_one()
assert count == 2
async def test_build_requires_admin(client: AsyncClient, seeded_user: User) -> None:
await _login(client, "pablo@test.dev")
response = await client.post(
"/api/templates/build", json={"template_id": None, "config": VALID_CONFIG}
)
assert response.status_code == 403
async def test_build_rejects_invalid_config(
client: AsyncClient, seeded_admin: User
) -> None:
"""The nested config is a Pydantic model, so a missing required field is
rejected before it can reach the table."""
await _login(client, "florian@test.dev")
without_persona = {k: v for k, v in VALID_CONFIG.items() if k != "persona"}
response = await client.post(
"/api/templates/build", json={"template_id": None, "config": without_persona}
)
assert response.status_code == 422