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