mirror of
https://github.com/karpathy/nanochat.git
synced 2026-05-07 16:30:11 +00:00
- 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>
111 lines
3.3 KiB
Python
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
|