fix(qa): 修 3 个 QA MEDIUM——规则/档位路由校验收紧
- 规则 GET 不存在 project → 404(原返误导性空 200,掩盖坏 id):list_rules 加 项目存在校验(仿 C1/H2)。 - 规则 content 纯空白 → 422(原 strip 前校验 min_length,空白行入库成垃圾): 改 StringConstraints(strip_whitespace=True, min_length=1)。 - 档位路由 tier 限定 writer/analyst/light(Tier Literal)→ 未知档位 422 (原接受任意字符串)。 回归测试:test_generation(GET rules 404)/ test_rules(空白 content 422)/ test_settings_providers(未知 tier 422)。门禁绿:ruff/format/mypy(210)/pytest。
This commit is contained in:
@@ -210,6 +210,16 @@ def _client(app: Any) -> httpx.AsyncClient:
|
|||||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_rules_unknown_project_returns_404() -> None:
|
||||||
|
# QA MEDIUM 回归:GET 规则列表对不存在 project 返 404(原返误导性空 200,掩盖坏 id)。
|
||||||
|
app, _ = _make_app(project_repo=FakeProjectRepo(), gateway=object())
|
||||||
|
async with _client(app) as client:
|
||||||
|
resp = await client.get(f"/projects/{uuid.uuid4()}/rules")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||||
|
|
||||||
|
|
||||||
# ---- 世界观生成 ----
|
# ---- 世界观生成 ----
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,19 @@ async def test_create_rule_empty_content_returns_422() -> None:
|
|||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_rule_whitespace_content_returns_422() -> None:
|
||||||
|
# QA MEDIUM 回归:纯空白 content(strip 后为空)应 422,不可入库成垃圾行。
|
||||||
|
client, repo, _session, _project_repo, pid = _make_client()
|
||||||
|
async with client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"/projects/{pid}/rules",
|
||||||
|
json={"level": "project", "content": " "},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert len(repo.rows) == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_rule_unknown_project_returns_404() -> None:
|
async def test_create_rule_unknown_project_returns_404() -> None:
|
||||||
# QA H2 回归:给不存在的 project 加规则应 404(不是 500 的 FK 违例逃逸)。
|
# QA H2 回归:给不存在的 project 加规则应 404(不是 500 的 FK 违例逃逸)。
|
||||||
|
|||||||
@@ -91,3 +91,16 @@ def test_put_validation_rejects_blank_provider(client: TestClient) -> None:
|
|||||||
json={"credentials": [{"provider": "", "api_key": "sk-x"}]},
|
json={"credentials": [{"provider": "", "api_key": "sk-x"}]},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_validation_rejects_unknown_tier(client: TestClient) -> None:
|
||||||
|
# QA MEDIUM 回归:tier 限定 writer/analyst/light;未知档位 → 422(原接受任意字符串)。
|
||||||
|
resp = client.put(
|
||||||
|
"/settings/providers",
|
||||||
|
json={
|
||||||
|
"tier_routing": [
|
||||||
|
{"tier": "bogus", "provider": "deepseek", "model": "x", "fallback": []}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|||||||
@@ -378,8 +378,11 @@ async def list_world_entities(
|
|||||||
async def list_rules(
|
async def list_rules(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo: RulesReadRepoDep,
|
repo: RulesReadRepoDep,
|
||||||
|
project_repo: ProjectRepoDep,
|
||||||
) -> RuleListResponse:
|
) -> RuleListResponse:
|
||||||
"""规则列表(按读侧顺序)。规则页用。"""
|
"""规则列表(按读侧顺序)。规则页用。项目不存在 → 404(QA MEDIUM:此前返误导性空 200)。"""
|
||||||
|
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||||
|
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||||
rules = await repo.all_for_project(project_id)
|
rules = await repo.all_for_project(project_id)
|
||||||
return RuleListResponse(rules=[RuleView(level=r.level, content=r.content) for r in rules])
|
return RuleListResponse(rules=[RuleView(level=r.level, content=r.content) for r in rules])
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ snake_case;响应一律 **掩码 Key**,绝不含明文。前端经 OpenAPI
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from ww_llm_gateway.types import Tier
|
||||||
|
|
||||||
|
|
||||||
class ProviderView(BaseModel):
|
class ProviderView(BaseModel):
|
||||||
@@ -41,7 +42,8 @@ class ProviderCredentialInput(BaseModel):
|
|||||||
class TierRoutingInput(BaseModel):
|
class TierRoutingInput(BaseModel):
|
||||||
"""单条档位路由写入。"""
|
"""单条档位路由写入。"""
|
||||||
|
|
||||||
tier: str = Field(min_length=1)
|
# tier 限定已知档位 writer/analyst/light;未知档位 → 422(QA MEDIUM:原接受任意字符串)。
|
||||||
|
tier: Tier
|
||||||
provider: str = Field(min_length=1)
|
provider: str = Field(min_length=1)
|
||||||
model: str = Field(min_length=1)
|
model: str = Field(min_length=1)
|
||||||
fallback: list[str] = Field(default_factory=list)
|
fallback: list[str] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -7,18 +7,21 @@ snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, StringConstraints
|
||||||
|
|
||||||
RuleLevel = Literal["global", "genre", "style", "project"]
|
RuleLevel = Literal["global", "genre", "style", "project"]
|
||||||
|
|
||||||
|
# 先 strip 再校验长度:纯空白内容(" ")strip 后为空 → 422(QA MEDIUM:此前被接受入库)。
|
||||||
|
RuleContent = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||||
|
|
||||||
|
|
||||||
class RuleCreateRequest(BaseModel):
|
class RuleCreateRequest(BaseModel):
|
||||||
"""POST /projects/:id/rules:新增一条规则。"""
|
"""POST /projects/:id/rules:新增一条规则。"""
|
||||||
|
|
||||||
level: RuleLevel = Field(description="规则级别(global/genre/style/project,越具体越优先)")
|
level: RuleLevel = Field(description="规则级别(global/genre/style/project,越具体越优先)")
|
||||||
content: str = Field(min_length=1, description="规则正文")
|
content: RuleContent = Field(description="规则正文(首尾空白会被裁剪,不可全空白)")
|
||||||
|
|
||||||
|
|
||||||
class RuleView(BaseModel):
|
class RuleView(BaseModel):
|
||||||
|
|||||||
Reference in New Issue
Block a user