384 lines
12 KiB
Python
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())
|