claire/tests/test_chat_api.py
Natalie b8d1cd6bac feat(@projects/@clare): add chat api endpoints
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-18 07:59:52 -07:00

183 lines
6.1 KiB
Python

"""End-to-end tests for the chat JSON + HTML routes."""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
from clare.web.app import create_app
return TestClient(create_app())
# ---------------------------------------------------------------------------
# JSON API
# ---------------------------------------------------------------------------
def test_chat_post_user_message_orchestrator(client: TestClient) -> None:
r = client.post(
"/api/v1/chat",
json={"scope": "orchestrator", "scope_ref": None, "body": "hello"},
)
assert r.status_code == 201, r.text
payload = r.json()
assert payload["user_message"]["body"] == "hello"
assert payload["user_message"]["role"] == "user"
# Non-slash bare text → no reply from dispatcher (NL stub for HTML form
# only; the JSON API leaves reply=None until task #6).
assert payload["reply"] is None
def test_chat_post_slash_command_dispatches(client: TestClient) -> None:
r = client.post(
"/api/v1/chat",
json={
"scope": "orchestrator", "scope_ref": None,
"body": "/project new alpha",
},
)
assert r.status_code == 201, r.text
payload = r.json()
assert payload["user_message"]["role"] == "user"
assert payload["reply"] is not None
assert payload["reply"]["role"] == "clare"
assert "alpha" in payload["reply"]["body"]
def test_chat_post_slash_parse_error_surfaces_as_clare_reply(client: TestClient) -> None:
r = client.post(
"/api/v1/chat",
json={"scope": "orchestrator", "scope_ref": None, "body": "/garbage"},
)
assert r.status_code == 201
reply = r.json()["reply"]
assert reply["role"] == "clare"
assert "Couldn't parse" in reply["body"]
def test_chat_post_project_scope_creates_chat_thread(client: TestClient) -> None:
client.post("/api/v1/projects", json={"name": "alpha"})
r = client.post(
"/api/v1/chat",
json={"scope": "project", "scope_ref": "alpha", "body": "/help"},
)
assert r.status_code == 201
assert r.json()["reply"]["body"].startswith("Slash commands")
def test_chat_post_invalid_scope_404(client: TestClient) -> None:
r = client.post(
"/api/v1/chat",
json={"scope": "project", "scope_ref": "nope", "body": "hi"},
)
assert r.status_code == 404
def test_chat_list_cursor_returns_new_only(client: TestClient) -> None:
client.post(
"/api/v1/chat",
json={"scope": "orchestrator", "scope_ref": None, "body": "first"},
)
after = client.post(
"/api/v1/chat",
json={"scope": "orchestrator", "scope_ref": None, "body": "second"},
).json()
cursor = after["user_message"]["rowid"] - 1
r = client.get(
"/api/v1/chat",
params={"scope": "orchestrator", "after_rowid": cursor},
)
assert r.status_code == 200
bodies = [m["body"] for m in r.json()["messages"]]
assert "second" in bodies
assert "first" not in bodies
def test_autocomplete_projects(client: TestClient) -> None:
client.post("/api/v1/projects", json={"name": "alpha"})
client.post("/api/v1/projects", json={"name": "beta"})
r = client.get("/api/v1/autocomplete", params={"kind": "project", "q": "al"})
assert r.status_code == 200
values = [h["value"] for h in r.json()["hits"]]
assert values == ["alpha"]
def test_autocomplete_invalid_kind_400(client: TestClient) -> None:
r = client.get("/api/v1/autocomplete", params={"kind": "garbage"})
assert r.status_code == 422 # FastAPI pattern-match → 422
# ---------------------------------------------------------------------------
# HTML routes (HTMX)
# ---------------------------------------------------------------------------
def test_html_chat_orchestrator_renders(client: TestClient) -> None:
r = client.get("/chat")
assert r.status_code == 200
assert "clare" in r.text
# The hidden cursor input is on the page even with no messages.
assert 'id="chat-after-rowid"' in r.text
def test_html_chat_project_renders_after_creation(client: TestClient) -> None:
client.post("/api/v1/projects", json={"name": "alpha"})
r = client.get("/chat/project/alpha")
assert r.status_code == 200
assert "alpha" in r.text
def test_html_chat_project_missing_404(client: TestClient) -> None:
client.post("/api/v1/projects", json={"name": "alpha"}) # need at least one
r = client.get("/chat/project/nope")
assert r.status_code == 404
def test_html_chat_post_returns_partial(client: TestClient) -> None:
r = client.post(
"/chat/post",
data={"scope": "orchestrator", "scope_ref": "", "body": "/help"},
)
assert r.status_code == 200
assert "chat-msg-user" in r.text
assert "chat-msg-clare" in r.text
# OOB hidden input present so the next poll resumes from the new rowid.
assert 'hx-swap-oob="true"' in r.text
def test_html_chat_post_nl_stub_for_bare_text(client: TestClient) -> None:
r = client.post(
"/chat/post",
data={"scope": "orchestrator", "scope_ref": "", "body": "what is happening"},
)
assert r.status_code == 200
# Apostrophe in body is HTML-escaped by Jinja; match the unambiguous prefix.
assert "Natural-language input" in r.text
def test_html_chat_log_poll_returns_only_new(client: TestClient) -> None:
# Post two messages.
r1 = client.post(
"/api/v1/chat",
json={"scope": "orchestrator", "scope_ref": None, "body": "/status"},
)
last = r1.json()["reply"]["rowid"]
r2 = client.post(
"/api/v1/chat",
json={"scope": "orchestrator", "scope_ref": None, "body": "/status"},
)
poll = client.get(
"/chat/log",
params={"scope": "orchestrator", "scope_ref": "", "after_rowid": last},
)
assert poll.status_code == 200
# /status posts user + reply → 2 new bubbles since `last`.
assert poll.text.count("chat-msg ") >= 2