@kernel-sig/console 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +50 -0
- package/LICENSE +127 -0
- package/Makefile +66 -0
- package/README.md +279 -0
- package/backend/.dockerignore +12 -0
- package/backend/Dockerfile +43 -0
- package/backend/alembic/env.py +75 -0
- package/backend/alembic/script.py.mako +25 -0
- package/backend/alembic/versions/0001_initial.py +88 -0
- package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
- package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
- package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
- package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
- package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
- package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
- package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
- package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
- package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
- package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
- package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
- package/backend/alembic.ini +40 -0
- package/backend/app/__init__.py +0 -0
- package/backend/app/api/__init__.py +0 -0
- package/backend/app/api/deps.py +74 -0
- package/backend/app/api/v1/__init__.py +0 -0
- package/backend/app/api/v1/ai.py +386 -0
- package/backend/app/api/v1/analytics.py +48 -0
- package/backend/app/api/v1/attention.py +142 -0
- package/backend/app/api/v1/auth.py +106 -0
- package/backend/app/api/v1/classification.py +192 -0
- package/backend/app/api/v1/health.py +29 -0
- package/backend/app/api/v1/issues.py +143 -0
- package/backend/app/api/v1/pulls.py +388 -0
- package/backend/app/api/v1/repositories.py +273 -0
- package/backend/app/api/v1/router.py +32 -0
- package/backend/app/api/v1/sig.py +565 -0
- package/backend/app/api/v1/users.py +87 -0
- package/backend/app/api/v1/webhooks.py +135 -0
- package/backend/app/core/__init__.py +0 -0
- package/backend/app/core/config.py +72 -0
- package/backend/app/core/crypto.py +74 -0
- package/backend/app/core/db.py +79 -0
- package/backend/app/core/exceptions.py +60 -0
- package/backend/app/core/logging.py +69 -0
- package/backend/app/core/permissions.py +87 -0
- package/backend/app/core/queue.py +57 -0
- package/backend/app/core/security.py +69 -0
- package/backend/app/domain/__init__.py +0 -0
- package/backend/app/domain/attention.py +618 -0
- package/backend/app/domain/classification.py +689 -0
- package/backend/app/domain/meeting.py +408 -0
- package/backend/app/domain/release.py +163 -0
- package/backend/app/domain/review.py +416 -0
- package/backend/app/domain/sig.py +178 -0
- package/backend/app/domain/sig_info.py +201 -0
- package/backend/app/integrations/__init__.py +0 -0
- package/backend/app/integrations/atomgit/__init__.py +0 -0
- package/backend/app/integrations/atomgit/client.py +505 -0
- package/backend/app/integrations/atomgit/models.py +311 -0
- package/backend/app/integrations/llm/__init__.py +0 -0
- package/backend/app/integrations/llm/prompts.py +222 -0
- package/backend/app/integrations/llm/provider.py +326 -0
- package/backend/app/main.py +242 -0
- package/backend/app/middleware/__init__.py +0 -0
- package/backend/app/middleware/audit.py +129 -0
- package/backend/app/middleware/request_context.py +42 -0
- package/backend/app/models/__init__.py +112 -0
- package/backend/app/models/ai.py +214 -0
- package/backend/app/models/attention.py +131 -0
- package/backend/app/models/attention_settings.py +61 -0
- package/backend/app/models/audit.py +53 -0
- package/backend/app/models/base.py +35 -0
- package/backend/app/models/classification.py +144 -0
- package/backend/app/models/credential.py +56 -0
- package/backend/app/models/issue.py +132 -0
- package/backend/app/models/meeting.py +189 -0
- package/backend/app/models/pull_request.py +288 -0
- package/backend/app/models/repository.py +196 -0
- package/backend/app/models/sig.py +194 -0
- package/backend/app/models/user.py +44 -0
- package/backend/app/schemas/__init__.py +0 -0
- package/backend/app/schemas/ai.py +120 -0
- package/backend/app/schemas/attention.py +43 -0
- package/backend/app/schemas/auth.py +26 -0
- package/backend/app/schemas/classification.py +58 -0
- package/backend/app/schemas/common.py +43 -0
- package/backend/app/schemas/pull_request.py +214 -0
- package/backend/app/schemas/repository.py +94 -0
- package/backend/app/schemas/sig.py +246 -0
- package/backend/app/schemas/user.py +79 -0
- package/backend/app/services/__init__.py +0 -0
- package/backend/app/services/ai_service.py +569 -0
- package/backend/app/services/analytics_service.py +390 -0
- package/backend/app/services/attention_queue.py +451 -0
- package/backend/app/services/attention_service.py +432 -0
- package/backend/app/services/auth_service.py +74 -0
- package/backend/app/services/classification_service.py +658 -0
- package/backend/app/services/credential_service.py +105 -0
- package/backend/app/services/pull_query.py +273 -0
- package/backend/app/services/release_service.py +446 -0
- package/backend/app/services/repository_service.py +132 -0
- package/backend/app/services/sig_service.py +385 -0
- package/backend/app/services/sync_service.py +752 -0
- package/backend/app/services/user_service.py +68 -0
- package/backend/app/worker.py +389 -0
- package/backend/entrypoint.sh +10 -0
- package/backend/pyproject.toml +68 -0
- package/backend/tests/test_analysis_api.py +154 -0
- package/backend/tests/test_atomgit_client.py +360 -0
- package/backend/tests/test_atomgit_models.py +257 -0
- package/backend/tests/test_attention.py +291 -0
- package/backend/tests/test_audit_middleware.py +135 -0
- package/backend/tests/test_classification.py +498 -0
- package/backend/tests/test_config.py +41 -0
- package/backend/tests/test_crypto.py +68 -0
- package/backend/tests/test_exceptions.py +62 -0
- package/backend/tests/test_health.py +63 -0
- package/backend/tests/test_llm_provider.py +320 -0
- package/backend/tests/test_meeting_domain.py +169 -0
- package/backend/tests/test_permissions.py +69 -0
- package/backend/tests/test_pull_query_wiring.py +66 -0
- package/backend/tests/test_pull_schemas.py +82 -0
- package/backend/tests/test_release_domain.py +82 -0
- package/backend/tests/test_review_parser.py +301 -0
- package/backend/tests/test_schemas_user.py +97 -0
- package/backend/tests/test_security.py +92 -0
- package/cli/index.js +338 -0
- package/compose.yaml +105 -0
- package/frontend/.dockerignore +4 -0
- package/frontend/Dockerfile +27 -0
- package/frontend/index.html +14 -0
- package/frontend/nginx.conf +47 -0
- package/frontend/package-lock.json +5020 -0
- package/frontend/package.json +35 -0
- package/frontend/src/api/ai.ts +124 -0
- package/frontend/src/api/analytics.ts +66 -0
- package/frontend/src/api/attention.ts +181 -0
- package/frontend/src/api/auth.ts +74 -0
- package/frontend/src/api/classification.ts +170 -0
- package/frontend/src/api/issues.ts +90 -0
- package/frontend/src/api/pulls.ts +233 -0
- package/frontend/src/api/repositories.ts +95 -0
- package/frontend/src/api/sig.ts +232 -0
- package/frontend/src/app/antd-theme.ts +94 -0
- package/frontend/src/app/providers.tsx +59 -0
- package/frontend/src/app/router.tsx +411 -0
- package/frontend/src/app/search.ts +30 -0
- package/frontend/src/components/ClassificationBadge.tsx +58 -0
- package/frontend/src/components/GateBadge.tsx +13 -0
- package/frontend/src/components/SeverityBadge.tsx +20 -0
- package/frontend/src/components/layout/AppShell.tsx +16 -0
- package/frontend/src/components/layout/AuthLayout.tsx +32 -0
- package/frontend/src/components/layout/Sidebar.tsx +223 -0
- package/frontend/src/components/layout/TopBar.tsx +47 -0
- package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
- package/frontend/src/components/pulls/FacetRail.tsx +199 -0
- package/frontend/src/components/pulls/LabelChips.tsx +87 -0
- package/frontend/src/components/ui/alert.tsx +30 -0
- package/frontend/src/components/ui/badge.tsx +53 -0
- package/frontend/src/components/ui/button.tsx +51 -0
- package/frontend/src/components/ui/card.tsx +64 -0
- package/frontend/src/components/ui/chart-theme.ts +65 -0
- package/frontend/src/components/ui/data-table.tsx +39 -0
- package/frontend/src/components/ui/echart.tsx +70 -0
- package/frontend/src/components/ui/empty-state.tsx +22 -0
- package/frontend/src/components/ui/input.tsx +39 -0
- package/frontend/src/components/ui/lazy-chart.tsx +21 -0
- package/frontend/src/components/ui/skeleton.tsx +19 -0
- package/frontend/src/hooks/use-current-repository.ts +44 -0
- package/frontend/src/hooks/use-current-user.ts +38 -0
- package/frontend/src/lib/api-client.ts +93 -0
- package/frontend/src/lib/css-color.ts +60 -0
- package/frontend/src/lib/utils.ts +45 -0
- package/frontend/src/main.tsx +22 -0
- package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
- package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
- package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
- package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
- package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
- package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
- package/frontend/src/pages/login/LoginPage.tsx +95 -0
- package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
- package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
- package/frontend/src/pages/members/MembersPage.tsx +534 -0
- package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
- package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
- package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
- package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
- package/frontend/src/pages/setup/SetupPage.tsx +144 -0
- package/frontend/src/styles/globals.css +147 -0
- package/frontend/tsconfig.json +22 -0
- package/frontend/vite.config.ts +57 -0
- package/package.json +48 -0
- package/scripts/e2e-auth-flow.py +131 -0
- package/scripts/e2e-pr-detail.py +128 -0
- package/scripts/e2e-verify.py +773 -0
- package/scripts/verify-ai-pipeline.py +616 -0
- package/scripts/verify-analysis-pipeline.py +303 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from fastapi.testclient import TestClient
|
|
3
|
+
|
|
4
|
+
from app.core.config import get_settings
|
|
5
|
+
|
|
6
|
+
SECRET = "0123456789abcdef0123456789abcdef"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def client(monkeypatch):
|
|
11
|
+
monkeypatch.setenv("KSC_SECRET_KEY", SECRET)
|
|
12
|
+
get_settings.cache_clear()
|
|
13
|
+
from app.main import create_app
|
|
14
|
+
|
|
15
|
+
with TestClient(create_app()) as c:
|
|
16
|
+
yield c
|
|
17
|
+
get_settings.cache_clear()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_healthz_returns_ok(client):
|
|
21
|
+
resp = client.get("/api/v1/healthz")
|
|
22
|
+
assert resp.status_code == 200
|
|
23
|
+
assert resp.json() == {"status": "ok"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_healthz_sets_request_id_header(client):
|
|
27
|
+
resp = client.get("/api/v1/healthz")
|
|
28
|
+
assert len(resp.headers["x-request-id"]) == 32
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_inbound_request_id_is_echoed(client):
|
|
32
|
+
resp = client.get("/api/v1/healthz", headers={"X-Request-ID": "abc123"})
|
|
33
|
+
assert resp.headers["x-request-id"] == "abc123"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_response_time_header_present(client):
|
|
37
|
+
resp = client.get("/api/v1/healthz")
|
|
38
|
+
assert float(resp.headers["x-response-time-ms"]) >= 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_unauthenticated_request_returns_problem_details(client):
|
|
42
|
+
resp = client.get("/api/v1/auth/me")
|
|
43
|
+
assert resp.status_code == 401
|
|
44
|
+
body = resp.json()
|
|
45
|
+
assert body["code"] == "unauthorized"
|
|
46
|
+
assert body["status"] == 401
|
|
47
|
+
assert body["instance"] == "/api/v1/auth/me"
|
|
48
|
+
assert "type" in body
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_validation_error_returns_problem_details(client):
|
|
52
|
+
"""登录请求缺少 password 字段 → 422 且符合 Problem Details 格式。"""
|
|
53
|
+
resp = client.post("/api/v1/auth/login", json={"username": "x"})
|
|
54
|
+
assert resp.status_code == 422
|
|
55
|
+
body = resp.json()
|
|
56
|
+
assert body["code"] == "validation_error"
|
|
57
|
+
assert body["context"]["errors"][0]["field"] == "password"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_openapi_schema_is_available(client):
|
|
61
|
+
resp = client.get("/api/openapi.json")
|
|
62
|
+
assert resp.status_code == 200
|
|
63
|
+
assert "paths" in resp.json()
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""LLM 网关行为测试。
|
|
2
|
+
|
|
3
|
+
用 MockTransport 断言**实际发出的请求**(鉴权头、payload、重试次数),
|
|
4
|
+
以及对各种畸形响应的处理 —— 模型返回不合规格式是常态,不是异常。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from app.integrations.llm.provider import (
|
|
13
|
+
ChatMessage,
|
|
14
|
+
CompletionRequest,
|
|
15
|
+
LLMAuthError,
|
|
16
|
+
LLMRateLimitError,
|
|
17
|
+
LLMResponseError,
|
|
18
|
+
LLMUpstreamError,
|
|
19
|
+
OpenAICompatibleProvider,
|
|
20
|
+
extract_json,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
BASE = "https://api.example.com/v1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture(autouse=True)
|
|
27
|
+
def _no_sleep(monkeypatch):
|
|
28
|
+
async def _instant(*args, **kwargs):
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
monkeypatch.setattr(OpenAICompatibleProvider, "_backoff", staticmethod(_instant))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def make_provider(handler, **kwargs) -> OpenAICompatibleProvider:
|
|
35
|
+
return OpenAICompatibleProvider(
|
|
36
|
+
name="test",
|
|
37
|
+
base_url=BASE,
|
|
38
|
+
api_key="sk-test",
|
|
39
|
+
default_model="test-model",
|
|
40
|
+
transport=httpx.MockTransport(handler),
|
|
41
|
+
**kwargs,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ok(content: str, **usage) -> httpx.Response:
|
|
46
|
+
return httpx.Response(
|
|
47
|
+
200,
|
|
48
|
+
json={
|
|
49
|
+
"model": "test-model",
|
|
50
|
+
"choices": [{"message": {"content": content}, "finish_reason": "stop"}],
|
|
51
|
+
"usage": {"prompt_tokens": 10, "completion_tokens": 20, **usage},
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _request(**overrides) -> CompletionRequest:
|
|
57
|
+
base: dict = {"messages": [ChatMessage(role="user", content="hi")], "model": "test-model"}
|
|
58
|
+
base.update(overrides)
|
|
59
|
+
return CompletionRequest(**base)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- 请求构造 ---------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def test_sends_bearer_auth_header():
|
|
66
|
+
seen: dict[str, str] = {}
|
|
67
|
+
|
|
68
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
69
|
+
seen.update(dict(request.headers))
|
|
70
|
+
return _ok("hello")
|
|
71
|
+
|
|
72
|
+
async with make_provider(handler) as provider:
|
|
73
|
+
await provider.complete(_request())
|
|
74
|
+
|
|
75
|
+
assert seen.get("authorization") == "Bearer sk-test"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def test_omits_auth_header_when_no_key():
|
|
79
|
+
"""本地 vLLM / Ollama 通常不校验鉴权,无 key 时不应发空头。"""
|
|
80
|
+
seen: dict[str, str] = {}
|
|
81
|
+
|
|
82
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
83
|
+
seen.update(dict(request.headers))
|
|
84
|
+
return _ok("hi")
|
|
85
|
+
|
|
86
|
+
provider = OpenAICompatibleProvider(
|
|
87
|
+
name="local",
|
|
88
|
+
base_url="http://localhost:11434/v1",
|
|
89
|
+
api_key="",
|
|
90
|
+
transport=httpx.MockTransport(handler),
|
|
91
|
+
)
|
|
92
|
+
async with provider:
|
|
93
|
+
await provider.complete(_request())
|
|
94
|
+
assert "authorization" not in seen
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def test_posts_to_chat_completions():
|
|
98
|
+
captured: dict = {}
|
|
99
|
+
|
|
100
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
101
|
+
captured["path"] = request.url.path
|
|
102
|
+
captured["body"] = json.loads(request.content)
|
|
103
|
+
return _ok("hi")
|
|
104
|
+
|
|
105
|
+
async with make_provider(handler) as provider:
|
|
106
|
+
await provider.complete(_request(temperature=0.3, max_tokens=100))
|
|
107
|
+
|
|
108
|
+
assert captured["path"] == "/v1/chat/completions"
|
|
109
|
+
body = captured["body"]
|
|
110
|
+
assert body["model"] == "test-model"
|
|
111
|
+
assert body["temperature"] == 0.3
|
|
112
|
+
assert body["max_tokens"] == 100
|
|
113
|
+
assert body["messages"] == [{"role": "user", "content": "hi"}]
|
|
114
|
+
assert body["stream"] is False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def test_json_mode_sets_response_format():
|
|
118
|
+
captured: dict = {}
|
|
119
|
+
|
|
120
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
121
|
+
captured.update(json.loads(request.content))
|
|
122
|
+
return _ok("{}")
|
|
123
|
+
|
|
124
|
+
async with make_provider(handler) as provider:
|
|
125
|
+
await provider.complete(_request(json_mode=True))
|
|
126
|
+
|
|
127
|
+
assert captured["response_format"] == {"type": "json_object"}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def test_falls_back_to_default_model():
|
|
131
|
+
captured: dict = {}
|
|
132
|
+
|
|
133
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
134
|
+
captured.update(json.loads(request.content))
|
|
135
|
+
return _ok("hi")
|
|
136
|
+
|
|
137
|
+
async with make_provider(handler) as provider:
|
|
138
|
+
await provider.complete(_request(model=""))
|
|
139
|
+
|
|
140
|
+
assert captured["model"] == "test-model"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def test_empty_base_url_rejected():
|
|
144
|
+
with pytest.raises(ValueError, match="base_url"):
|
|
145
|
+
OpenAICompatibleProvider(name="x", base_url="", api_key="k")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# --- 响应解析 ---------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def test_returns_content_and_usage():
|
|
152
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
153
|
+
return _ok("结果", prompt_tokens=7, completion_tokens=3)
|
|
154
|
+
|
|
155
|
+
async with make_provider(handler) as provider:
|
|
156
|
+
result = await provider.complete(_request())
|
|
157
|
+
|
|
158
|
+
assert result.content == "结果"
|
|
159
|
+
assert result.prompt_tokens == 7
|
|
160
|
+
assert result.completion_tokens == 3
|
|
161
|
+
assert result.total_tokens == 10
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def test_tolerates_missing_usage():
|
|
165
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
166
|
+
return httpx.Response(200, json={"choices": [{"message": {"content": "x"}}], "model": "m"})
|
|
167
|
+
|
|
168
|
+
async with make_provider(handler) as provider:
|
|
169
|
+
result = await provider.complete(_request())
|
|
170
|
+
assert result.total_tokens == 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def test_malformed_response_raises():
|
|
174
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
175
|
+
return httpx.Response(200, json={"unexpected": "shape"})
|
|
176
|
+
|
|
177
|
+
async with make_provider(handler) as provider:
|
|
178
|
+
with pytest.raises(LLMResponseError, match="不符合预期"):
|
|
179
|
+
await provider.complete(_request())
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# --- 错误映射 ---------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def test_401_raises_auth_error():
|
|
186
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
187
|
+
return httpx.Response(401, json={"error": {"message": "invalid key"}})
|
|
188
|
+
|
|
189
|
+
async with make_provider(handler) as provider:
|
|
190
|
+
with pytest.raises(LLMAuthError, match="凭据无效"):
|
|
191
|
+
await provider.complete(_request())
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def test_429_preserves_rate_limit_semantics():
|
|
195
|
+
calls = {"n": 0}
|
|
196
|
+
|
|
197
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
198
|
+
calls["n"] += 1
|
|
199
|
+
return httpx.Response(429, json={"error": {"message": "slow down"}})
|
|
200
|
+
|
|
201
|
+
async with make_provider(handler) as provider:
|
|
202
|
+
with pytest.raises(LLMRateLimitError):
|
|
203
|
+
await provider.complete(_request())
|
|
204
|
+
assert calls["n"] == 3
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def test_retries_then_succeeds():
|
|
208
|
+
calls = {"n": 0}
|
|
209
|
+
|
|
210
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
211
|
+
calls["n"] += 1
|
|
212
|
+
return (
|
|
213
|
+
httpx.Response(500, json={"error": {"message": "boom"}})
|
|
214
|
+
if calls["n"] < 3
|
|
215
|
+
else _ok("ok")
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
async with make_provider(handler) as provider:
|
|
219
|
+
result = await provider.complete(_request())
|
|
220
|
+
|
|
221
|
+
assert calls["n"] == 3
|
|
222
|
+
assert result.content == "ok"
|
|
223
|
+
assert provider.retries == 2
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def test_network_error_wrapped():
|
|
227
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
228
|
+
raise httpx.ConnectTimeout("timeout")
|
|
229
|
+
|
|
230
|
+
async with make_provider(handler) as provider:
|
|
231
|
+
with pytest.raises(LLMUpstreamError, match="网络请求失败"):
|
|
232
|
+
await provider.complete(_request())
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def test_error_message_is_surfaced():
|
|
236
|
+
"""上游的错误文案要透传,否则排查时只剩一个状态码。"""
|
|
237
|
+
|
|
238
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
239
|
+
return httpx.Response(400, json={"error": {"message": "model not found: gpt-x"}})
|
|
240
|
+
|
|
241
|
+
async with make_provider(handler) as provider:
|
|
242
|
+
with pytest.raises(LLMUpstreamError, match="model not found"):
|
|
243
|
+
await provider.complete(_request())
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# --- 流式输出 ---------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def test_stream_yields_content_chunks():
|
|
250
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
251
|
+
lines = [
|
|
252
|
+
'data: {"choices":[{"delta":{"content":"你"}}]}',
|
|
253
|
+
'data: {"choices":[{"delta":{"content":"好"}}]}',
|
|
254
|
+
"data: [DONE]",
|
|
255
|
+
]
|
|
256
|
+
return httpx.Response(200, text="\n".join(lines) + "\n")
|
|
257
|
+
|
|
258
|
+
async with make_provider(handler) as provider:
|
|
259
|
+
chunks = [chunk async for chunk in provider.stream(_request())]
|
|
260
|
+
|
|
261
|
+
assert chunks == ["你", "好"]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
async def test_stream_ignores_non_data_lines():
|
|
265
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
266
|
+
text = ': keep-alive\n\ndata: {"choices":[{"delta":{"content":"x"}}]}\ndata: [DONE]\n'
|
|
267
|
+
return httpx.Response(200, text=text)
|
|
268
|
+
|
|
269
|
+
async with make_provider(handler) as provider:
|
|
270
|
+
chunks = [chunk async for chunk in provider.stream(_request())]
|
|
271
|
+
assert chunks == ["x"]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# --- 结构化输出解析 ---------------------------------------------------
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_extract_json_from_clean_output():
|
|
278
|
+
assert extract_json('{"kind": "bugfix"}')["kind"] == "bugfix"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_extract_json_from_fenced_output():
|
|
282
|
+
"""模型常不顾"只输出 JSON"的指示,加上 ```json 围栏。"""
|
|
283
|
+
assert extract_json('```json\n{"kind": "cve"}\n```')["kind"] == "cve"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_extract_json_from_prose_wrapped_output():
|
|
287
|
+
text = '好的,我的判断如下:\n{"kind": "backport", "confidence": 0.9}\n希望有帮助。'
|
|
288
|
+
assert extract_json(text)["kind"] == "backport"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_extract_json_with_nested_braces():
|
|
292
|
+
text = '{"risks": [{"file": "net/x.c", "meta": {"line": 10}}]}'
|
|
293
|
+
assert extract_json(text)["risks"][0]["file"] == "net/x.c"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@pytest.mark.parametrize("text", ["", " ", "完全没有 JSON"])
|
|
297
|
+
def test_extract_json_fails_loudly_on_garbage(text):
|
|
298
|
+
"""解析失败必须显式报错 —— 把散文塞进结构化字段比失败更糟。"""
|
|
299
|
+
with pytest.raises(LLMResponseError):
|
|
300
|
+
extract_json(text)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_extract_json_rejects_non_object():
|
|
304
|
+
with pytest.raises(LLMResponseError, match="期望 JSON 对象"):
|
|
305
|
+
extract_json("[1, 2, 3]")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_stats_tracked():
|
|
309
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
310
|
+
return _ok("x")
|
|
311
|
+
|
|
312
|
+
import asyncio
|
|
313
|
+
|
|
314
|
+
async def run():
|
|
315
|
+
async with make_provider(handler) as provider:
|
|
316
|
+
await provider.complete(_request())
|
|
317
|
+
await provider.complete(_request())
|
|
318
|
+
return provider.requests
|
|
319
|
+
|
|
320
|
+
assert asyncio.run(run()) == 2
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""SIG 例会纪要解析。
|
|
2
|
+
|
|
3
|
+
样本取自 ``etherpad.openeuler.org/p/Kernel-meetings`` 的真实片段,包括
|
|
4
|
+
几处刻意保留的原样错误(截断的链接、重复的议题编号、模板占位行)——
|
|
5
|
+
解析器就是为容忍它们才这么写的。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import date
|
|
9
|
+
|
|
10
|
+
from app.domain.meeting import parse_pad, parse_progress_report
|
|
11
|
+
|
|
12
|
+
MEETING = """\
|
|
13
|
+
【2026/9/4 14:00 Kernel SIG 双周例会】
|
|
14
|
+
轮值主持:关文涛【轮值主持顺序:曾昭荣->桑力鹏->廖涛->吴腾达->陈玮->关文涛】
|
|
15
|
+
下次轮值主持:曾昭荣
|
|
16
|
+
会议链接:https://meeting.huaweicloud.com:36443/#/j/989418007
|
|
17
|
+
会议纪要:https://etherpad.openeuler.org/p/Kernel-meetings
|
|
18
|
+
|
|
19
|
+
一、上期遗留问题跟踪
|
|
20
|
+
二、议题列表
|
|
21
|
+
议题一:进展update(吴腾达)
|
|
22
|
+
【OLK-6.6】
|
|
23
|
+
OLK-6.6开发分支tag更新到6.6.0-170.0.0,期间合入补丁590个
|
|
24
|
+
git log 6.6.0-167.0.0..6.6.0-170.0.0 --oneline --no-merges | wc -l
|
|
25
|
+
ISO镜像和update rpm包获取链接:
|
|
26
|
+
https://repo.openeuler.org/openEuler-24.03-LTS-SP1
|
|
27
|
+
https://repo.openeuler.org/openEuler-24.03-LTS-SP3
|
|
28
|
+
CVE: (124)
|
|
29
|
+
CVE-2026-74739,CVE-2026-74544,CVE-2026-74439
|
|
30
|
+
Bugfix:
|
|
31
|
+
nfs: use nfsi->rwsem to protect traversal of the file lock list
|
|
32
|
+
https://atomgit.com/openeuler/kernel/pull/26235
|
|
33
|
+
USB: serial: io_ti: fix heap overflow in get_manuf_info()
|
|
34
|
+
https://atomgit.com/openeuler/kernel/pull/
|
|
35
|
+
Feature:
|
|
36
|
+
KVM: arm64:Add pvqspinlock
|
|
37
|
+
https://gitcode.com/openeuler/kernel/pull/26044
|
|
38
|
+
LTS:
|
|
39
|
+
[OLK-6.6][linux-6.6.y sync] Backport 6.6.151-6.6.152 LTS Patches
|
|
40
|
+
https://atomgit.com/openeuler/kernel/pull/26808
|
|
41
|
+
|
|
42
|
+
议题二:fs/exfat模块committer申报(池志凌)
|
|
43
|
+
上游fs/exfat的贡献者
|
|
44
|
+
atomgit id:chizl
|
|
45
|
+
|
|
46
|
+
议题X:其他议题在这里补充,格式:议题名称(汇报人)
|
|
47
|
+
|
|
48
|
+
三、遗留问题
|
|
49
|
+
某个待跟踪的问题
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
NESTED = """\
|
|
54
|
+
【2026/9/4 14:00 Kernel SIG 双周例会】
|
|
55
|
+
轮值主持:甲
|
|
56
|
+
下次轮值主持:乙
|
|
57
|
+
议题一:进展update(甲)
|
|
58
|
+
【OLK-5.10】
|
|
59
|
+
OLK-5.10开发分支tag更新到5.10.0-331.0.0,期间合入补丁123个
|
|
60
|
+
【OLK-6.6】
|
|
61
|
+
OLK-6.6开发分支tag更新到6.6.0-165.0.0,期间合入补丁1712个
|
|
62
|
+
【2026/8/21 Kernel SIG 双周例会】
|
|
63
|
+
轮值主持:丙
|
|
64
|
+
议题一:某议题(丁)
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_parses_header_fields():
|
|
69
|
+
(meeting,) = parse_pad(MEETING)
|
|
70
|
+
assert meeting.date == date(2026, 9, 4)
|
|
71
|
+
assert meeting.time == "14:00"
|
|
72
|
+
assert meeting.title == "Kernel SIG 双周例会"
|
|
73
|
+
assert meeting.host == "关文涛"
|
|
74
|
+
assert meeting.next_host == "曾昭荣"
|
|
75
|
+
assert meeting.meeting_url == "https://meeting.huaweicloud.com:36443/#/j/989418007"
|
|
76
|
+
assert meeting.minutes_url == "https://etherpad.openeuler.org/p/Kernel-meetings"
|
|
77
|
+
assert meeting.host_order[0] == "曾昭荣"
|
|
78
|
+
assert len(meeting.host_order) == 6
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_template_agenda_is_dropped():
|
|
82
|
+
"""「其他议题在这里补充」是给人填的模板,收录它会凭空造出一个议题。"""
|
|
83
|
+
(meeting,) = parse_pad(MEETING)
|
|
84
|
+
titles = [agenda.title for agenda in meeting.agendas]
|
|
85
|
+
assert titles == ["进展update", "fs/exfat模块committer申报"]
|
|
86
|
+
assert all("在这里补充" not in title for title in titles)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_agenda_owner_in_fullwidth_and_halfwidth_parens():
|
|
90
|
+
(meeting,) = parse_pad(MEETING)
|
|
91
|
+
assert meeting.agendas[0].owner == "吴腾达"
|
|
92
|
+
assert meeting.agendas[1].owner == "池志凌"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_legacy_section_captured():
|
|
96
|
+
(meeting,) = parse_pad(MEETING)
|
|
97
|
+
assert meeting.legacy == "某个待跟踪的问题"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_meeting_without_time_still_parses():
|
|
101
|
+
text = "【2026/7/10 Kernel SIG 双周例会】\n轮值主持:桑力鹏\n"
|
|
102
|
+
(meeting,) = parse_pad(text)
|
|
103
|
+
assert (meeting.date, meeting.time, meeting.host) == (date(2026, 7, 10), None, "桑力鹏")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_progress_block_fields():
|
|
107
|
+
(meeting,) = parse_pad(MEETING)
|
|
108
|
+
(progress,) = meeting.agendas[0].progress
|
|
109
|
+
assert progress.branch == "OLK-6.6"
|
|
110
|
+
assert progress.from_tag == "6.6.0-167.0.0"
|
|
111
|
+
assert progress.to_tag == "6.6.0-170.0.0"
|
|
112
|
+
assert progress.patch_count == 590
|
|
113
|
+
assert len(progress.iso_urls) == 2
|
|
114
|
+
assert progress.cve_ids == ["CVE-2026-74739", "CVE-2026-74544", "CVE-2026-74439"]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_progress_groups_and_items():
|
|
118
|
+
(meeting,) = parse_pad(MEETING)
|
|
119
|
+
(progress,) = meeting.agendas[0].progress
|
|
120
|
+
assert [group.name for group in progress.groups] == ["Bugfix", "Feature", "LTS"]
|
|
121
|
+
|
|
122
|
+
bugfix = progress.groups[0]
|
|
123
|
+
assert [item.pull_number for item in bugfix.items] == [26235, None]
|
|
124
|
+
assert bugfix.items[0].title.startswith("nfs: use nfsi->rwsem")
|
|
125
|
+
|
|
126
|
+
# gitcode 与 atomgit 两个域名都在用,都要认出编号
|
|
127
|
+
assert progress.groups[1].items[0].pull_number == 26044
|
|
128
|
+
assert progress.groups[2].items[0].pull_number == 26808
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_truncated_url_keeps_title_but_not_a_guessed_number():
|
|
132
|
+
"""实测存在 ".../pull/" 这种被截断的链接。丢掉整条比猜个编号诚实。"""
|
|
133
|
+
(meeting,) = parse_pad(MEETING)
|
|
134
|
+
item = meeting.agendas[0].progress[0].groups[0].items[1]
|
|
135
|
+
assert item.pull_number is None
|
|
136
|
+
assert "io_ti" in item.title
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_next_meeting_header_terminates_the_block():
|
|
140
|
+
"""新会议标题必须截断上一场,否则一场会议会把之后所有会议吞掉。"""
|
|
141
|
+
meetings = parse_pad(NESTED)
|
|
142
|
+
assert [m.date for m in meetings] == [date(2026, 9, 4), date(2026, 8, 21)]
|
|
143
|
+
assert [p.branch for p in meetings[0].agendas[0].progress] == ["OLK-5.10", "OLK-6.6"]
|
|
144
|
+
assert meetings[0].agendas[0].progress[1].patch_count == 1712
|
|
145
|
+
assert meetings[1].agendas[0].title == "某议题"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_bracketed_prefix_is_a_patch_title_not_a_progress_block():
|
|
149
|
+
"""「【OLK-6.6】drm: add ...」是带分支前缀的补丁标题,不是版本报告。"""
|
|
150
|
+
blocks = parse_progress_report("【OLK-6.6】drm: add display driver for EG210\n")
|
|
151
|
+
assert blocks == []
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_iso_label_is_not_taken_as_a_group():
|
|
155
|
+
(meeting,) = parse_pad(MEETING)
|
|
156
|
+
names = [group.name for group in meeting.agendas[0].progress[0].groups]
|
|
157
|
+
assert not any("ISO" in name or "获取链接" in name for name in names)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_empty_and_garbage_input_is_survivable():
|
|
161
|
+
"""上游是外部输入。解析失败只该少一条信息,不该让整页消失。"""
|
|
162
|
+
assert parse_pad("") == []
|
|
163
|
+
assert parse_pad("这里没有任何会议标题\n随便写点什么\n") == []
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_invalid_date_drops_only_that_meeting():
|
|
167
|
+
text = "【2026/2/30 坏日期】\n轮值主持:甲\n【2026/3/6 Kernel SIG 双周例会】\n轮值主持:乙\n"
|
|
168
|
+
meetings = parse_pad(text)
|
|
169
|
+
assert [m.date for m in meetings] == [date(2026, 3, 6)]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from app.core.permissions import (
|
|
4
|
+
ROLE_PERMISSIONS,
|
|
5
|
+
Permission,
|
|
6
|
+
Role,
|
|
7
|
+
role_has_permission,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_role_ordering_is_hierarchical():
|
|
12
|
+
assert Role.VIEWER < Role.REVIEWER < Role.COMMITTER < Role.MAINTAINER < Role.ADMIN
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_viewer_can_only_read():
|
|
16
|
+
assert role_has_permission(Role.VIEWER, Permission.VIEW)
|
|
17
|
+
assert not role_has_permission(Role.VIEWER, Permission.TRIGGER_ANALYSIS)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_reviewer_can_trigger_analysis_but_not_write_back():
|
|
21
|
+
assert role_has_permission(Role.REVIEWER, Permission.TRIGGER_ANALYSIS)
|
|
22
|
+
assert not role_has_permission(Role.REVIEWER, Permission.WRITE_BACK)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_committer_can_classify_and_write_back():
|
|
26
|
+
assert role_has_permission(Role.COMMITTER, Permission.CLASSIFY)
|
|
27
|
+
assert role_has_permission(Role.COMMITTER, Permission.WRITE_BACK)
|
|
28
|
+
assert not role_has_permission(Role.COMMITTER, Permission.MANAGE_USERS)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_maintainer_can_manage_repository_not_users():
|
|
32
|
+
assert role_has_permission(Role.MAINTAINER, Permission.MANAGE_REPOSITORY)
|
|
33
|
+
assert not role_has_permission(Role.MAINTAINER, Permission.MANAGE_USERS)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_admin_has_every_permission():
|
|
37
|
+
for permission in Permission:
|
|
38
|
+
assert role_has_permission(Role.ADMIN, permission)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_higher_role_inherits_lower_role_permissions():
|
|
42
|
+
"""层级继承:committer 拥有 reviewer 与 viewer 的全部权限。"""
|
|
43
|
+
assert ROLE_PERMISSIONS[Role.REVIEWER] <= ROLE_PERMISSIONS[Role.COMMITTER]
|
|
44
|
+
assert ROLE_PERMISSIONS[Role.COMMITTER] <= ROLE_PERMISSIONS[Role.MAINTAINER]
|
|
45
|
+
assert ROLE_PERMISSIONS[Role.MAINTAINER] <= ROLE_PERMISSIONS[Role.ADMIN]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_no_role_granted_permissions_outside_enum():
|
|
49
|
+
for role in Role:
|
|
50
|
+
assert ROLE_PERMISSIONS[role] <= set(Permission)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_role_accepts_string_name():
|
|
54
|
+
assert role_has_permission("ADMIN", Permission.MANAGE_USERS)
|
|
55
|
+
assert not role_has_permission("VIEWER", Permission.MANAGE_USERS)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_unknown_role_has_no_permission():
|
|
59
|
+
assert not role_has_permission("nonsense", Permission.VIEW)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_role_labels_are_localized():
|
|
63
|
+
assert Role.ADMIN.label == "管理员"
|
|
64
|
+
assert Role.MAINTAINER.label == "维护者"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.parametrize("role", list(Role))
|
|
68
|
+
def test_every_role_can_view(role):
|
|
69
|
+
assert role_has_permission(role, Permission.VIEW)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""列表与分面接口的参数装配。
|
|
2
|
+
|
|
3
|
+
这个测试的存在理由很具体:给 ``Selection`` 加了一个筛选维度后,只改了
|
|
4
|
+
``_selection`` 的签名与其中一处调用点,另一处漏传。类型检查发现不了
|
|
5
|
+
(缺的是运行时才绑定的关键字参数),既有的单元测试也没覆盖到处理函数,
|
|
6
|
+
最后是浏览器端 e2e 才抓出来 —— 表现是"整个列表页空掉",
|
|
7
|
+
而报错信息指向框架内部。这本可以在提交前几秒内发现。
|
|
8
|
+
|
|
9
|
+
所以这里用 ast 精确解析每个调用点传了哪些关键字,逐一比对。
|
|
10
|
+
不用正则:正则版本我写错过一次,测试比被测代码还脆是没有意义的。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import inspect
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from app.api.v1 import pulls
|
|
18
|
+
from app.services import pull_query
|
|
19
|
+
|
|
20
|
+
SOURCE = Path(inspect.getfile(pulls))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _call_sites() -> list[set[str]]:
|
|
24
|
+
"""源码里所有 ``_selection(...)`` 调用所传的关键字集合。"""
|
|
25
|
+
tree = ast.parse(SOURCE.read_text(encoding="utf-8"))
|
|
26
|
+
sites: list[set[str]] = []
|
|
27
|
+
for node in ast.walk(tree):
|
|
28
|
+
if not isinstance(node, ast.Call):
|
|
29
|
+
continue
|
|
30
|
+
func = node.func
|
|
31
|
+
name = func.id if isinstance(func, ast.Name) else getattr(func, "attr", None)
|
|
32
|
+
if name == "_selection":
|
|
33
|
+
sites.append({kw.arg for kw in node.keywords if kw.arg})
|
|
34
|
+
return sites
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_selection_covers_every_filter_dimension():
|
|
38
|
+
"""Selection 的每个筛选维度都要能经由 _selection 传入。
|
|
39
|
+
|
|
40
|
+
``q`` 与 ``search`` 是同一件事的两个名字(前者是查询参数惯例,
|
|
41
|
+
后者是领域模型的措辞),因此单独映射,不做强行改名。
|
|
42
|
+
"""
|
|
43
|
+
parameters = set(inspect.signature(pulls._selection).parameters) - {"return"}
|
|
44
|
+
alias = {"q": "search"}
|
|
45
|
+
|
|
46
|
+
uncovered = {
|
|
47
|
+
field
|
|
48
|
+
for field in pull_query.Selection.__dataclass_fields__
|
|
49
|
+
if field not in {alias.get(name, name) for name in parameters}
|
|
50
|
+
}
|
|
51
|
+
assert not uncovered, f"_selection 未覆盖这些筛选维度:{sorted(uncovered)}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_all_call_sites_pass_the_same_parameters():
|
|
55
|
+
"""两处调用(列表与分面)必须传同一组参数。
|
|
56
|
+
|
|
57
|
+
只要有一处漏传,FastAPI 就会把 TypeError 变成 500,
|
|
58
|
+
而使用者看到的是"列表空了"。
|
|
59
|
+
"""
|
|
60
|
+
sites = _call_sites()
|
|
61
|
+
assert len(sites) >= 2, "预期至少两处调用(列表与分面)"
|
|
62
|
+
|
|
63
|
+
reference = max(sites, key=len)
|
|
64
|
+
for index, site in enumerate(sites):
|
|
65
|
+
missing = reference - site
|
|
66
|
+
assert not missing, f"第 {index + 1} 处 _selection 调用漏传:{sorted(missing)}"
|