test: add baseline gateway regression suites

Add focused unittest coverage for auth/concurrency, schema normalization, and session-cache tooling behavior, and ignore local .gitnexus index artifacts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
GitHub Actions
2026-04-20 13:25:36 +08:00
parent c08dea89a2
commit b96b91e5b7
5 changed files with 259 additions and 0 deletions

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ data/*
!data/.gitkeep
secrets/*
!secrets/.gitkeep
.gitnexus

53
tests/TEST_PLAN.md Normal file
View File

@@ -0,0 +1,53 @@
# lingma-openai-gateway 测试计划tests
## 1. 目标
- 覆盖网关核心稳定性路径:认证、并发限流、会话复用、协议内容规范化。
- 在不引入外部依赖Lingma 进程/Playwright的前提下使用 `unittest` 完成可重复回归。
- 与现有 `tests/test_tool_call_bridge.py` 互补:该文件聚焦工具桥接,本计划补齐基础模块行为。
## 2. 范围与优先级
- **P0必须**
1) 认证行为(`app/auth.py`
2) 并发守卫行为(`app/concurrency.py`
3) 会话缓存与工具配置指纹(`app/session_cache.py`
- **P1应覆盖**
4) OpenAI/Anthropic 内容规范化(`app/openai_schema.py`, `app/anthropic_schema.py`
## 3. 用例矩阵
| 用例ID | 优先级 | 模块 | 场景 | 预期 |
|---|---|---|---|---|
| TC-AUTH-01 | P0 | auth | Bearer 正确 token | 认证通过 |
| TC-AUTH-02 | P0 | auth | 缺失/错误 Authorization | 401 + `invalid_api_key` |
| TC-AUTH-03 | P0 | auth | Anthropic `x-api-key` 与 Bearer 兜底 | 正确 key 通过,缺失时报 `AnthropicAuthError` |
| TC-AUTH-04 | P0 | auth | metrics 在未配置 token 且非 public | 503 + `metrics_disabled` |
| TC-CONC-01 | P0 | concurrency | `max_in_flight<=0` 无限制模式 | 获取/释放计数正确release 幂等 |
| TC-CONC-02 | P0 | concurrency | 单槽占用后第二请求超时 | 抛 `BackpressureRejected`rejected 计数+1 |
| TC-SESS-01 | P0 | session_cache | `hash_user_context` 忽略 assistant/tool | 哈希不受 assistant/tool 变化影响 |
| TC-SESS-02 | P0 | session_cache | key 包含 tool_config 指纹 | 同语义配置同 key配置变化 key 变化 |
| TC-SESS-03 | P0 | session_cache | LRU 淘汰 | 超限后旧项淘汰,`evict_total` 增加 |
| TC-SESS-04 | P0 | session_cache | TTL 过期 | 读取 miss`expire_total` 增加 |
| TC-SCHEMA-01 | P1 | openai_schema | 多类型 content flatten | 文本合并,图片/音频占位 |
| TC-SCHEMA-02 | P1 | anthropic_schema | tool_use/tool_result flatten | 生成可读文本片段 |
| TC-SCHEMA-03 | P1 | anthropic_schema | `anthropic_to_internal_messages` | system + messages 正确映射 |
| TC-SCHEMA-04 | P1 | anthropic_schema | `affinity_key_for_anthropic` 优先级 | `metadata.user_id` 优先fallback 为 hash 前缀 |
## 4. 测试文件落地
- 既有:`tests/test_tool_call_bridge.py`
- 新增:
- `tests/test_auth_concurrency.py`
- `tests/test_session_cache_tooling.py`
- `tests/test_schema_normalization.py`
## 5. 执行步骤
1. 定点执行新增测试文件。
2. 全量执行 `tests/``test_*.py`
3. 汇总通过率与失败项(若失败,给出定位与修复建议)。
## 6. 执行命令
```bash
python3 -m unittest tests/test_auth_concurrency.py
python3 -m unittest tests/test_session_cache_tooling.py
python3 -m unittest tests/test_schema_normalization.py
python3 -m unittest tests/test_tool_call_bridge.py
python3 -m unittest discover -s tests -p "test_*.py"
```

View File

@@ -0,0 +1,86 @@
from __future__ import annotations
import asyncio
import unittest
from fastapi import HTTPException
from starlette.requests import Request
from app.auth import AnthropicAuthError, require_anthropic_key, require_bearer, require_metrics_access
from app.concurrency import BackpressureRejected, InFlightGuard
def _req(headers: dict[str, str] | None = None) -> Request:
pairs = []
for k, v in (headers or {}).items():
pairs.append((k.lower().encode("latin-1"), v.encode("latin-1")))
scope = {
"type": "http",
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": "/x",
"raw_path": b"/x",
"query_string": b"",
"headers": pairs,
"client": ("test", 1),
"server": ("test", 80),
"root_path": "",
}
return Request(scope)
class AuthAndConcurrencyTests(unittest.IsolatedAsyncioTestCase):
def test_require_bearer_accepts_valid_token(self) -> None:
request = _req({"authorization": "Bearer good"})
require_bearer(request, ["good"])
def test_require_bearer_rejects_invalid_token(self) -> None:
request = _req({"authorization": "Bearer bad"})
with self.assertRaises(HTTPException) as ctx:
require_bearer(request, ["good"])
self.assertEqual(ctx.exception.status_code, 401)
self.assertEqual(ctx.exception.detail["error"]["code"], "invalid_api_key")
def test_require_anthropic_key_accepts_x_api_key_or_bearer(self) -> None:
request_x = _req({"x-api-key": "k1"})
require_anthropic_key(request_x, ["k1"])
request_b = _req({"authorization": "Bearer k2"})
require_anthropic_key(request_b, ["k2"])
def test_require_anthropic_key_raises_on_missing(self) -> None:
request = _req()
with self.assertRaises(AnthropicAuthError) as ctx:
require_anthropic_key(request, ["k"])
self.assertEqual(ctx.exception.status_code, 401)
self.assertEqual(ctx.exception.error_type, "authentication_error")
def test_require_metrics_access_503_when_no_tokens_configured(self) -> None:
request = _req({"authorization": "Bearer any"})
with self.assertRaises(HTTPException) as ctx:
require_metrics_access(request, api_keys=[], metrics_token="", public=False)
self.assertEqual(ctx.exception.status_code, 503)
self.assertEqual(ctx.exception.detail["error"]["code"], "metrics_disabled")
async def test_inflight_guard_unlimited_and_release_idempotent(self) -> None:
guard = InFlightGuard(max_in_flight=0, queue_timeout_sec=0.01)
ticket = await guard.try_acquire()
self.assertEqual(guard.in_flight, 1)
ticket.release()
ticket.release()
self.assertEqual(guard.in_flight, 0)
self.assertEqual(guard.accepted_total, 1)
async def test_inflight_guard_rejects_when_queue_timeout(self) -> None:
guard = InFlightGuard(max_in_flight=1, queue_timeout_sec=0.01)
first = await guard.try_acquire()
with self.assertRaises(BackpressureRejected):
await guard.try_acquire()
self.assertEqual(guard.rejected_total, 1)
first.release()
self.assertEqual(guard.in_flight, 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
import unittest
from app.anthropic_schema import (
AnthropicMessagesRequest,
affinity_key_for_anthropic,
anthropic_to_internal_messages,
flatten_anthropic_content,
)
from app.openai_schema import flatten_content
class SchemaNormalizationTests(unittest.TestCase):
def test_openai_flatten_content_with_multimodal_parts(self) -> None:
out = flatten_content(
[
{"type": "text", "text": "hello"},
{"type": "image_url", "image_url": {"url": "x"}},
{"type": "input_audio", "input_audio": {"data": "x"}},
{"type": "text", "text": "world"},
]
)
self.assertEqual(out, "hello\n[image]\n[audio]\nworld")
def test_anthropic_flatten_content_with_tool_blocks(self) -> None:
out = flatten_anthropic_content(
[
{"type": "text", "text": "before"},
{"type": "tool_use", "name": "search", "input": {"q": "hi"}},
{"type": "tool_result", "content": "ok"},
]
)
self.assertIn("before", out)
self.assertIn("[tool_use]", out)
self.assertIn("[tool_result] ok", out)
def test_anthropic_to_internal_messages_maps_system_and_messages(self) -> None:
req = AnthropicMessagesRequest(
model="org_auto",
max_tokens=64,
system="sys",
messages=[
{"role": "user", "content": "u1"},
{"role": "assistant", "content": "a1"},
],
)
out = anthropic_to_internal_messages(req)
self.assertEqual(out[0], {"role": "system", "content": "sys"})
self.assertEqual(out[1], {"role": "user", "content": "u1"})
self.assertEqual(out[2], {"role": "assistant", "content": "a1"})
def test_affinity_key_for_anthropic_priority(self) -> None:
req_user = AnthropicMessagesRequest(
model="org_auto",
max_tokens=64,
metadata={"user_id": "u-1"},
messages=[{"role": "user", "content": "hello"}],
)
self.assertEqual(affinity_key_for_anthropic(req_user), "u-1")
req_fallback = AnthropicMessagesRequest(
model="org_auto",
max_tokens=64,
messages=[{"role": "user", "content": "hello"}],
)
key = affinity_key_for_anthropic(req_fallback)
self.assertIsInstance(key, str)
self.assertTrue(key.startswith("first:"))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
import unittest
from app.session_cache import SessionCache, hash_user_context
class SessionCacheToolingTests(unittest.IsolatedAsyncioTestCase):
def test_hash_user_context_ignores_assistant_and_tool(self) -> None:
base = [
{"role": "system", "content": "S"},
{"role": "user", "content": "U"},
]
with_extra = base + [
{"role": "assistant", "content": "A1"},
{"role": "tool", "content": "T1"},
]
self.assertEqual(hash_user_context(base), hash_user_context(with_extra))
def test_build_key_changes_with_tool_config(self) -> None:
cache = SessionCache(max_entries=8, ttl_sec=60)
msgs = [{"role": "user", "content": "hi"}]
key1 = cache.build_key("k", msgs, tool_config={"a": 1, "b": 2})
key2 = cache.build_key("k", msgs, tool_config={"b": 2, "a": 1})
key3 = cache.build_key("k", msgs, tool_config={"a": 1})
self.assertEqual(key1, key2)
self.assertNotEqual(key1, key3)
async def test_lru_evicts_oldest(self) -> None:
cache = SessionCache(max_entries=2, ttl_sec=600)
await cache.put("k1", "s1")
await cache.put("k2", "s2")
await cache.put("k3", "s3")
self.assertIsNone(await cache.get("k1"))
self.assertEqual(cache.evict_total, 1)
async def test_ttl_expiry_increments_expire_counter(self) -> None:
cache = SessionCache(max_entries=4, ttl_sec=0.001)
await cache.put("k1", "s1")
await __import__("asyncio").sleep(0.01)
self.assertIsNone(await cache.get("k1"))
self.assertEqual(cache.expire_total, 1)
if __name__ == "__main__":
unittest.main()