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>
143 lines
5 KiB
Python
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.
|