claire/tests/test_chat_api.py
Natalie 53921b4e94 refactor(domain): rename ChatRole persisted value clare → claire (migration 0010)
ChatRole.CLAIRE now persists as "claire" everywhere. Migration 0010
rewrites both the chat_messages.role column and the chat_message_posted
event payloads in one transaction so a future replay reconstructs the
same projection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:29:30 -06:00

143 lines
5 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 claire.web.app import create_app
return TestClient(create_app())
# ---------------------------------------------------------------------------
# JSON API
# ---------------------------------------------------------------------------
def test_chat_post_user_message_orchestrator_unconfigured(
client: TestClient,
) -> None:
# Orchestrator scope is a managed Claude session now. With no session
# configured (default for a fresh install), bare text → helpful message
# pointing to `claire orchestrator init`. Slash commands still work.
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"
assert payload["replies"] and payload["replies"][0]["role"] == "claire"
body = payload["replies"][0]["body"].lower()
assert "orchestrator session not configured" in body
assert "claire orchestrator init" in body
def test_chat_post_user_message_project_nl_unavailable(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
# Project scope still uses Haiku-based NL fallback. With no
# ANTHROPIC_API_KEY, it surfaces an "unavailable" reply.
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
client.post("/api/v1/projects", json={"name": "alpha"})
r = client.post(
"/api/v1/chat",
json={"scope": "project", "scope_ref": "alpha", "body": "hello"},
)
assert r.status_code == 201
payload = r.json()
assert "unavailable" in payload["replies"][0]["body"].lower()
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 len(payload["replies"]) == 1
assert payload["replies"][0]["role"] == "claire"
assert "alpha" in payload["replies"][0]["body"]
def test_chat_post_slash_parse_error_surfaces_as_claire_reply(client: TestClient) -> None:
r = client.post(
"/api/v1/chat",
json={"scope": "orchestrator", "scope_ref": None, "body": "/garbage"},
)
assert r.status_code == 201
replies = r.json()["replies"]
assert len(replies) == 1
assert replies[0]["role"] == "claire"
assert "Couldn't parse" in replies[0]["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()["replies"][0]["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 were deleted in R6 — the React SPA owns all browser-facing
# views. JSON API + SSE are the only server-rendered surfaces; everything
# else goes through the catch-all that serves dist/index.html.