183 lines
6.1 KiB
Python
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
|