From b96b91e5b7582730811737ed42984d6d93897a04 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 20 Apr 2026 13:25:36 +0800 Subject: [PATCH] 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 --- .gitignore | 1 + tests/TEST_PLAN.md | 53 ++++++++++++++++++ tests/test_auth_concurrency.py | 86 +++++++++++++++++++++++++++++ tests/test_schema_normalization.py | 73 ++++++++++++++++++++++++ tests/test_session_cache_tooling.py | 46 +++++++++++++++ 5 files changed, 259 insertions(+) create mode 100644 tests/TEST_PLAN.md create mode 100644 tests/test_auth_concurrency.py create mode 100644 tests/test_schema_normalization.py create mode 100644 tests/test_session_cache_tooling.py diff --git a/.gitignore b/.gitignore index 71cd873..6853ad3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ data/* !data/.gitkeep secrets/* !secrets/.gitkeep +.gitnexus diff --git a/tests/TEST_PLAN.md b/tests/TEST_PLAN.md new file mode 100644 index 0000000..2a5d8a4 --- /dev/null +++ b/tests/TEST_PLAN.md @@ -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" +``` diff --git a/tests/test_auth_concurrency.py b/tests/test_auth_concurrency.py new file mode 100644 index 0000000..d8699c1 --- /dev/null +++ b/tests/test_auth_concurrency.py @@ -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() diff --git a/tests/test_schema_normalization.py b/tests/test_schema_normalization.py new file mode 100644 index 0000000..fbfbd95 --- /dev/null +++ b/tests/test_schema_normalization.py @@ -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() diff --git a/tests/test_session_cache_tooling.py b/tests/test_session_cache_tooling.py new file mode 100644 index 0000000..e168d65 --- /dev/null +++ b/tests/test_session_cache_tooling.py @@ -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()