"""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.