nanochat/services/chat-api/src/tests/test_conversations.py
Manmohan Sharma 8153a4fadf
feat(chat-api): conversation orchestration + SSE streaming proxy (#6)
- FastAPI service that manages conversations and messages in PostgreSQL
  (SQLAlchemy 2.0 async + asyncpg) and streams assistant responses back
  to the client via sse-starlette, forwarding the inference service SSE
  contract unchanged.
- Auth guard validates every request against the auth service
  /auth/validate endpoint (X-Internal-API-Key) and caches results in an
  in-process TTL cache (5 min, 1024 entries) to absorb request bursts.
- Every query filters by authenticated user_id; cross-user access
  returns 404. Message send flow auto-titles the first message,
  persists the streamed assistant response after the client disconnects,
  and records token_count + inference_time_ms.
- /api/models{,/swap} proxies the inference admin surface; swap
  requires is_admin on the validated user.
- Structured JSON logging via structlog with trace_id + user_id
  ContextVars attached to every log line.
- Test suite (pytest + aiosqlite + respx) covers CRUD, user scoping,
  streaming SSE persistence, regenerate, model proxy admin gate,
  and the stream proxy error path. 16/16 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 11:49:51 -07:00

111 lines
3.3 KiB
Python

import pytest
import respx
from .conftest import stub_auth_validate
@pytest.mark.asyncio
@respx.mock
async def test_requires_authorization_header(client):
response = await client.get("/api/conversations")
assert response.status_code == 401
@pytest.mark.asyncio
@respx.mock
async def test_create_and_list_conversation(client, seeded_user):
stub_auth_validate(respx.mock, seeded_user)
headers = {"Authorization": "Bearer valid-token"}
create = await client.post(
"/api/conversations", json={"title": "my first chat"}, headers=headers
)
assert create.status_code == 201
convo = create.json()
assert convo["title"] == "my first chat"
assert convo["user_id"] == seeded_user["id"]
listed = await client.get("/api/conversations", headers=headers)
assert listed.status_code == 200
payload = listed.json()
assert len(payload["items"]) == 1
assert payload["items"][0]["id"] == convo["id"]
assert payload["grouped"] # at least one date bucket
@pytest.mark.asyncio
@respx.mock
async def test_update_conversation_title(client, seeded_user):
stub_auth_validate(respx.mock, seeded_user)
headers = {"Authorization": "Bearer valid-token"}
create = await client.post("/api/conversations", json={}, headers=headers)
convo_id = create.json()["id"]
updated = await client.put(
f"/api/conversations/{convo_id}",
json={"title": "Renamed"},
headers=headers,
)
assert updated.status_code == 200
assert updated.json()["title"] == "Renamed"
@pytest.mark.asyncio
@respx.mock
async def test_delete_conversation(client, seeded_user):
stub_auth_validate(respx.mock, seeded_user)
headers = {"Authorization": "Bearer valid-token"}
create = await client.post("/api/conversations", json={}, headers=headers)
convo_id = create.json()["id"]
deleted = await client.delete(f"/api/conversations/{convo_id}", headers=headers)
assert deleted.status_code == 204
missing = await client.get(f"/api/conversations/{convo_id}", headers=headers)
assert missing.status_code == 404
@pytest.mark.asyncio
@respx.mock
async def test_user_scoping_prevents_cross_user_access(
client, seeded_user, other_user
):
stub_auth_validate(respx.mock, seeded_user, token="alice-token")
stub_auth_validate(respx.mock, other_user, token="bob-token")
alice_headers = {"Authorization": "Bearer alice-token"}
bob_headers = {"Authorization": "Bearer bob-token"}
alice_convo = await client.post(
"/api/conversations",
json={"title": "alice only"},
headers=alice_headers,
)
convo_id = alice_convo.json()["id"]
bob_view = await client.get(
f"/api/conversations/{convo_id}", headers=bob_headers
)
assert bob_view.status_code == 404
bob_delete = await client.delete(
f"/api/conversations/{convo_id}", headers=bob_headers
)
assert bob_delete.status_code == 404
bob_list = await client.get("/api/conversations", headers=bob_headers)
assert bob_list.json()["items"] == []
@pytest.mark.asyncio
@respx.mock
async def test_invalid_token_is_rejected(client, seeded_user):
stub_auth_validate(respx.mock, seeded_user, token="valid-token")
response = await client.get(
"/api/conversations",
headers={"Authorization": "Bearer wrong-token"},
)
assert response.status_code == 401