agnes-the-ai-analyst/tests/test_claude_md_api.py
ZdenekSrotyr 955b56608d feat(api,web,cli): /admin/workspace-prompt + /api/welcome restored + da analyst writes CLAUDE.md
- app/api/claude_md.py: GET /api/welcome (analyst, auth required); GET/PUT/DELETE
  /api/admin/workspace-prompt-template; POST …/preview; two-pass Jinja2 validation
  on PUT; validation stub mirrors build_claude_md_context() shape
- app/main.py: register claude_md_router
- app/web/router.py: GET /admin/workspace-prompt → admin_workspace_prompt.html
- app/web/templates/admin_workspace_prompt.html: CodeMirror editor + live preview +
  status chip + reset modal; mirrors admin_welcome.html for Agent Setup Prompt
- app/web/templates/_app_header.html: add "Agent Workspace Prompt" nav item next to
  "Agent Setup Prompt"; extend _admin_active to cover /admin/workspace-prompt
- cli/commands/analyst.py: _init_claude_workspace now accepts server_url + token;
  _write_claude_md fetches GET /api/welcome, writes CLAUDE.md, graceful 404/5xx;
  setup command adds --no-claude-md flag to opt out; default = write CLAUDE.md
- tests: test_claude_md_api.py (16 tests); test_analyst_bootstrap.py updated with
  4 new CLAUDE.md bootstrap tests; test_welcome_template_api.py: update stale
  assertion about /api/welcome being removed (endpoint restored)
- tests/snapshots/openapi.json: regenerated
2026-05-03 22:44:14 +02:00

257 lines
8.6 KiB
Python

"""End-to-end tests for the agent-workspace-prompt API endpoints.
GET /api/welcome — analyst-facing rendered CLAUDE.md
GET /api/admin/workspace-prompt-template — admin: get template + default
PUT /api/admin/workspace-prompt-template — admin: set override
DELETE /api/admin/workspace-prompt-template — admin: reset to default
POST /api/admin/workspace-prompt-template/preview — admin: live preview
"""
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
# ---------------------------------------------------------------------------
# GET /api/welcome — analyst-facing rendered CLAUDE.md
# ---------------------------------------------------------------------------
def test_get_welcome_requires_auth(seeded_app):
"""Unauthenticated GET /api/welcome must return 401 or 422."""
c = seeded_app["client"]
resp = c.get("/api/welcome", params={"server_url": "https://example.com"})
assert resp.status_code in (401, 422)
def test_get_welcome_returns_rendered_markdown(seeded_app):
c = seeded_app["client"]
analyst = _auth(seeded_app["analyst_token"])
resp = c.get(
"/api/welcome",
params={"server_url": "https://example.com"},
headers=analyst,
)
assert resp.status_code == 200
body = resp.json()
assert "content" in body
assert isinstance(body["content"], str)
assert body["content"].strip() != ""
def test_get_welcome_uses_override_when_set(seeded_app):
c = seeded_app["client"]
admin = _auth(seeded_app["admin_token"])
analyst = _auth(seeded_app["analyst_token"])
# Set an override
r = c.put(
"/api/admin/workspace-prompt-template",
json={"content": "# Custom CLAUDE.md for {{ user.email }}"},
headers=admin,
)
assert r.status_code == 200
# Analyst fetch should include the override
resp = c.get(
"/api/welcome",
params={"server_url": "https://example.com"},
headers=analyst,
)
assert resp.status_code == 200
assert "Custom CLAUDE.md" in resp.json()["content"]
assert "analyst@test.com" in resp.json()["content"]
# Reset
c.delete("/api/admin/workspace-prompt-template", headers=admin)
# ---------------------------------------------------------------------------
# GET /api/admin/workspace-prompt-template — admin get
# ---------------------------------------------------------------------------
def test_admin_get_template_initially_null(seeded_app):
c = seeded_app["client"]
admin = _auth(seeded_app["admin_token"])
r = c.get("/api/admin/workspace-prompt-template", headers=admin)
assert r.status_code == 200
body = r.json()
assert body["content"] is None
assert "default" in body
assert body["default"] # non-empty default
def test_admin_get_template_default_contains_instance_name(seeded_app):
c = seeded_app["client"]
admin = _auth(seeded_app["admin_token"])
r = c.get("/api/admin/workspace-prompt-template", headers=admin)
assert r.status_code == 200
body = r.json()
# Default template renders the instance name
assert body["default"] != ""
def test_non_admin_cannot_get_template(seeded_app):
c = seeded_app["client"]
analyst = _auth(seeded_app["analyst_token"])
r = c.get("/api/admin/workspace-prompt-template", headers=analyst)
assert r.status_code == 403
# ---------------------------------------------------------------------------
# PUT /api/admin/workspace-prompt-template — save override
# ---------------------------------------------------------------------------
def test_admin_can_set_and_reset_template(seeded_app):
c = seeded_app["client"]
admin = _auth(seeded_app["admin_token"])
# PUT override
r = c.put(
"/api/admin/workspace-prompt-template",
json={"content": "# Hello {{ user.email }}"},
headers=admin,
)
assert r.status_code == 200
# GET reflects override
r = c.get("/api/admin/workspace-prompt-template", headers=admin)
assert r.status_code == 200
assert r.json()["content"] == "# Hello {{ user.email }}"
# DELETE = reset
r = c.delete("/api/admin/workspace-prompt-template", headers=admin)
assert r.status_code == 204
r = c.get("/api/admin/workspace-prompt-template", headers=admin)
assert r.json()["content"] is None
def test_non_admin_cannot_put_template(seeded_app):
c = seeded_app["client"]
analyst = _auth(seeded_app["analyst_token"])
r = c.put(
"/api/admin/workspace-prompt-template",
json={"content": "# evil override"},
headers=analyst,
)
assert r.status_code == 403
def test_invalid_jinja2_returns_400(seeded_app):
c = seeded_app["client"]
admin = _auth(seeded_app["admin_token"])
r = c.put(
"/api/admin/workspace-prompt-template",
json={"content": "{% for x in y %}"}, # unclosed loop
headers=admin,
)
assert r.status_code == 400
assert "invalid" in r.json()["detail"].lower()
def test_put_rejects_undefined_placeholder(seeded_app):
c = seeded_app["client"]
admin = _auth(seeded_app["admin_token"])
r = c.put(
"/api/admin/workspace-prompt-template",
json={"content": "{{ no_such_variable }}"},
headers=admin,
)
assert r.status_code == 400
# ---------------------------------------------------------------------------
# DELETE /api/admin/workspace-prompt-template
# ---------------------------------------------------------------------------
def test_non_admin_cannot_delete_template(seeded_app):
c = seeded_app["client"]
analyst = _auth(seeded_app["analyst_token"])
r = c.delete("/api/admin/workspace-prompt-template", headers=analyst)
assert r.status_code == 403
# ---------------------------------------------------------------------------
# POST /api/admin/workspace-prompt-template/preview
# ---------------------------------------------------------------------------
def test_admin_preview_renders_content(seeded_app):
c = seeded_app["client"]
admin = _auth(seeded_app["admin_token"])
r = c.post(
"/api/admin/workspace-prompt-template/preview",
json={"content": "# Preview for {{ user.email }}"},
headers=admin,
)
assert r.status_code == 200
assert r.json()["content"].startswith("# Preview for admin@test.com")
def test_preview_rejects_invalid_template(seeded_app):
c = seeded_app["client"]
admin = _auth(seeded_app["admin_token"])
r = c.post(
"/api/admin/workspace-prompt-template/preview",
json={"content": "{% for x in y %}"},
headers=admin,
)
assert r.status_code == 400
def test_preview_requires_admin(seeded_app):
c = seeded_app["client"]
analyst = _auth(seeded_app["analyst_token"])
r = c.post(
"/api/admin/workspace-prompt-template/preview",
json={"content": "# Preview"},
headers=analyst,
)
assert r.status_code == 403
def test_preview_uses_live_context(seeded_app):
"""Preview should include live table data from context."""
c = seeded_app["client"]
admin = _auth(seeded_app["admin_token"])
r = c.post(
"/api/admin/workspace-prompt-template/preview",
json={"content": "tables: {{ tables | length }}, metrics: {{ metrics.count }}"},
headers=admin,
)
assert r.status_code == 200
# Content must be a rendered string (not raise), numbers may be 0 on fresh DB
assert "tables:" in r.json()["content"]
# ---------------------------------------------------------------------------
# Validation stub vs. build_claude_md_context shape alignment
# ---------------------------------------------------------------------------
def test_validation_stub_matches_build_context_shape(seeded_app, tmp_path, monkeypatch):
"""_VALIDATION_STUB_CONTEXT top-level keys must match build_claude_md_context() output."""
from app.api.claude_md import _VALIDATION_STUB_CONTEXT
from src.db import _ensure_schema, get_system_db
import duckdb
db_path = tmp_path / "system.duckdb"
c = duckdb.connect(str(db_path))
_ensure_schema(c)
user = {
"id": "u1",
"email": "admin@test.com",
"name": "Admin",
"is_admin": True,
"groups": ["Admin"],
}
from src.claude_md import build_claude_md_context
real_ctx = build_claude_md_context(c, user=user, server_url="https://example.com")
assert set(_VALIDATION_STUB_CONTEXT.keys()) == set(real_ctx.keys()), (
f"_VALIDATION_STUB_CONTEXT top-level keys differ from build_claude_md_context output. "
f"Stub: {set(_VALIDATION_STUB_CONTEXT.keys())}, "
f"real: {set(real_ctx.keys())}"
)
c.close()