Files
simple-chat-api/tests/test_api.py
T
2026-07-03 21:31:25 +08:00

384 lines
12 KiB
Python

import asyncio
from datetime import datetime
import json
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from fastapi.testclient import TestClient
from openai import APIConnectionError
import pytest
from app import create_app
from config import Settings, _parse_api_key_whitelist
from service import ChatService
from storage import JsonSessionStorage
TEST_USER_ID = "11111111-1111-4111-8111-111111111111"
OTHER_USER_ID = "22222222-2222-4222-8222-222222222222"
class FakeCompletions:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
self.error: Exception | None = None
self.delay = 0.0
async def create(self, **kwargs: Any) -> SimpleNamespace:
self.calls.append(kwargs)
if self.delay:
await asyncio.sleep(self.delay)
if self.error:
raise self.error
return SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(content=f"reply-{len(self.calls)}")
)
],
usage=SimpleNamespace(
prompt_tokens=10,
completion_tokens=2,
total_tokens=12,
),
)
class FakeClient:
def __init__(self) -> None:
self.completions = FakeCompletions()
self.chat = SimpleNamespace(completions=self.completions)
@pytest.fixture
def client_and_provider(tmp_path: Path) -> tuple[TestClient, FakeClient]:
provider = FakeClient()
settings = Settings(
port=8000,
api_key=None,
base_url="https://api.deepseek.com",
model="deepseek-v4-flash",
default_system_prompt="default prompt",
data_dir=tmp_path,
api_key_whitelist={
TEST_USER_ID: "test-api-key",
OTHER_USER_ID: "other-api-key",
},
)
app = create_app(settings=settings, client=provider) # type: ignore[arg-type]
with TestClient(app) as client:
client.headers["X-API-Key"] = "test-api-key"
yield client, provider
def create_session(client: TestClient, **body: str) -> str:
response = client.post("/sessions", json=body)
assert response.status_code == 201
return response.json()["session_id"]
def test_settings_load_api_key_from_dotenv(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
(tmp_path / ".env").write_text("DEEPSEEK_API_KEY=test-key\n")
settings = Settings.from_env()
assert settings.api_key == "test-key"
def test_api_key_whitelist_requires_and_normalizes_uuid() -> None:
parsed = _parse_api_key_whitelist(
'{"7A007F92-86D0-4F51-931F-007B9F0B8B0E":"key"}'
)
assert parsed == {
"7a007f92-86d0-4f51-931f-007b9f0b8b0e": "key"
}
with pytest.raises(ValueError, match="valid UUIDs"):
_parse_api_key_whitelist('{"custom-name":"key"}')
def test_all_api_endpoints_require_valid_api_key(
client_and_provider: tuple[TestClient, FakeClient]
) -> None:
client, _ = client_and_provider
del client.headers["X-API-Key"]
missing = client.post("/sessions")
invalid = client.get("/usage", headers={"X-API-Key": "invalid"})
assert missing.status_code == 401
assert missing.json() == {"detail": "api_key is required"}
assert invalid.status_code == 401
assert invalid.json() == {"detail": "api_key is required"}
def test_usage_endpoint_returns_current_user_totals(
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path
) -> None:
client, _ = client_and_provider
session_id = create_session(client)
message = client.post(
f"/sessions/{session_id}/messages", json={"content": "hello"}
)
response = client.get("/usage")
assert message.status_code == 200
assert response.status_code == 200
assert response.json()["user_id"] == TEST_USER_ID
assert response.json()["api_calls"] == 1
assert response.json()["prompt_tokens"] == 10
assert response.json()["completion_tokens"] == 2
assert response.json()["total_tokens"] == 12
assert not (tmp_path / "usage.json").exists()
def test_create_session_uses_default_prompt(
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path
) -> None:
client, _ = client_and_provider
session_id = create_session(client)
data = json.loads((tmp_path / f"{session_id}.json").read_text())
assert data["user_id"] == TEST_USER_ID
assert data["system_prompt"] == "default prompt"
assert data["messages"] == []
def test_create_session_without_body(
client_and_provider: tuple[TestClient, FakeClient]
) -> None:
client, _ = client_and_provider
response = client.post("/sessions")
assert response.status_code == 201
assert response.json()["system_prompt"] == "default prompt"
def test_create_session_accepts_custom_prompt(
client_and_provider: tuple[TestClient, FakeClient]
) -> None:
client, _ = client_and_provider
response = client.post("/sessions", json={"system_prompt": "回答中文"})
assert response.status_code == 201
assert response.json()["system_prompt"] == "回答中文"
def test_multi_turn_request_contains_full_history(
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path
) -> None:
client, provider = client_and_provider
session_id = create_session(client, system_prompt="system")
first = client.post(
f"/sessions/{session_id}/messages", json={"content": "first"}
)
second = client.post(
f"/sessions/{session_id}/messages", json={"content": "second"}
)
assert first.status_code == second.status_code == 200
assert provider.completions.calls[1]["messages"] == [
{"role": "system", "content": "system"},
{"role": "user", "content": "first"},
{"role": "assistant", "content": "reply-1"},
{"role": "user", "content": "second"},
]
assert second.json()["usage"] == {
"prompt_tokens": 10,
"completion_tokens": 2,
"total_tokens": 12,
}
assert second.json()["tools_use"] == []
datetime.fromisoformat(second.json()["message"]["created_at"])
assert "tool_calls" not in second.json()["message"]
data = json.loads((tmp_path / f"{session_id}.json").read_text())
assert [message["content"] for message in data["messages"]] == [
"first",
"reply-1",
"second",
"reply-2",
]
assert all(message["created_at"].endswith("Z") for message in data["messages"])
def test_invalid_session_and_blank_message(
client_and_provider: tuple[TestClient, FakeClient]
) -> None:
client, _ = client_and_provider
missing = client.post(
"/sessions/00000000-0000-0000-0000-000000000000/messages",
json={"content": "hello"},
)
blank = client.post(
"/sessions/00000000-0000-0000-0000-000000000000/messages",
json={"content": " "},
)
assert missing.status_code == 404
assert blank.status_code == 422
def test_upstream_failure_does_not_change_history(
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path
) -> None:
client, provider = client_and_provider
session_id = create_session(client)
before = (tmp_path / f"{session_id}.json").read_text()
provider.completions.error = APIConnectionError(request=object()) # type: ignore[arg-type]
response = client.post(
f"/sessions/{session_id}/messages", json={"content": "hello"}
)
assert response.status_code == 502
assert (tmp_path / f"{session_id}.json").read_text() == before
def test_corrupt_session_returns_500(
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path
) -> None:
client, _ = client_and_provider
session_id = create_session(client)
(tmp_path / f"{session_id}.json").write_text("not-json")
response = client.post(
f"/sessions/{session_id}/messages", json={"content": "hello"}
)
assert response.status_code == 500
def test_legacy_messages_without_timestamp_remain_usable(
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path
) -> None:
client, _ = client_and_provider
session_id = create_session(client)
path = tmp_path / f"{session_id}.json"
data = json.loads(path.read_text())
data.pop("user_id")
data["messages"] = [{"role": "user", "content": "legacy"}]
path.write_text(json.dumps(data))
response = client.post(
f"/sessions/{session_id}/messages", json={"content": "new"}
)
assert response.status_code == 200
migrated = json.loads(path.read_text())
assert migrated["user_id"] == TEST_USER_ID
assert all("created_at" in message for message in migrated["messages"])
def test_session_cannot_be_accessed_by_another_user(
client_and_provider: tuple[TestClient, FakeClient]
) -> None:
client, _ = client_and_provider
session_id = create_session(client)
other_headers = {"X-API-Key": "other-api-key"}
history = client.get(
f"/sessions/{session_id}/messages", headers=other_headers
)
message = client.post(
f"/sessions/{session_id}/messages",
headers=other_headers,
json={"content": "hello"},
)
assert history.status_code == 404
assert message.status_code == 404
def test_stream_parameter_is_rejected(
client_and_provider: tuple[TestClient, FakeClient]
) -> None:
client, provider = client_and_provider
session_id = create_session(client)
response = client.post(
f"/sessions/{session_id}/messages",
json={"content": "hello", "stream": True},
)
assert response.status_code == 422
assert provider.completions.calls == []
def test_get_session_history_returns_all_messages(
client_and_provider: tuple[TestClient, FakeClient]
) -> None:
client, _ = client_and_provider
session_id = create_session(client, system_prompt="history prompt")
client.post(
f"/sessions/{session_id}/messages", json={"content": "first"}
)
client.post(
f"/sessions/{session_id}/messages", json={"content": "second"}
)
response = client.get(f"/sessions/{session_id}/messages")
assert response.status_code == 200
history = response.json()
assert history["session_id"] == session_id
assert history["system_prompt"] == "history prompt"
assert history["created_at"].endswith("Z")
assert history["updated_at"].endswith("Z")
assert [message["content"] for message in history["messages"]] == [
"first",
"reply-1",
"second",
"reply-2",
]
assert all(message["created_at"].endswith("Z") for message in history["messages"])
def test_get_session_history_handles_empty_and_missing_sessions(
client_and_provider: tuple[TestClient, FakeClient]
) -> None:
client, _ = client_and_provider
session_id = create_session(client)
empty = client.get(f"/sessions/{session_id}/messages")
missing = client.get(
"/sessions/00000000-0000-0000-0000-000000000000/messages"
)
assert empty.status_code == 200
assert empty.json()["messages"] == []
assert missing.status_code == 404
def test_same_session_concurrent_messages_are_serialized(tmp_path: Path) -> None:
async def scenario() -> None:
provider = FakeClient()
provider.completions.delay = 0.01
storage = JsonSessionStorage(tmp_path)
session = await storage.create("system")
service = ChatService(
storage=storage,
client=provider, # type: ignore[arg-type]
model="deepseek-v4-flash",
)
await asyncio.gather(
service.generate_response(session.session_id, "first"),
service.generate_response(session.session_id, "second"),
)
assert provider.completions.calls[1]["messages"] == [
{"role": "system", "content": "system"},
{"role": "user", "content": "first"},
{"role": "assistant", "content": "reply-1"},
{"role": "user", "content": "second"},
]
asyncio.run(scenario())