diff --git a/apps/api/tests/test_generation.py b/apps/api/tests/test_generation.py index 2fd869c..67bd0bf 100644 --- a/apps/api/tests/test_generation.py +++ b/apps/api/tests/test_generation.py @@ -210,6 +210,16 @@ def _client(app: Any) -> httpx.AsyncClient: 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 + + # ---- 世界观生成 ---- diff --git a/apps/api/tests/test_rules.py b/apps/api/tests/test_rules.py index 6565087..f05f1a1 100644 --- a/apps/api/tests/test_rules.py +++ b/apps/api/tests/test_rules.py @@ -99,6 +99,19 @@ async def test_create_rule_empty_content_returns_422() -> None: 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 async def test_create_rule_unknown_project_returns_404() -> None: # QA H2 回归:给不存在的 project 加规则应 404(不是 500 的 FK 违例逃逸)。 diff --git a/apps/api/tests/test_settings_providers.py b/apps/api/tests/test_settings_providers.py index 1713416..04268c0 100644 --- a/apps/api/tests/test_settings_providers.py +++ b/apps/api/tests/test_settings_providers.py @@ -91,3 +91,16 @@ def test_put_validation_rejects_blank_provider(client: TestClient) -> None: json={"credentials": [{"provider": "", "api_key": "sk-x"}]}, ) 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 diff --git a/apps/api/ww_api/routers/generation.py b/apps/api/ww_api/routers/generation.py index 43b7281..7158683 100644 --- a/apps/api/ww_api/routers/generation.py +++ b/apps/api/ww_api/routers/generation.py @@ -378,8 +378,11 @@ async def list_world_entities( async def list_rules( project_id: uuid.UUID, repo: RulesReadRepoDep, + project_repo: ProjectRepoDep, ) -> 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) return RuleListResponse(rules=[RuleView(level=r.level, content=r.content) for r in rules]) diff --git a/apps/api/ww_api/schemas/providers.py b/apps/api/ww_api/schemas/providers.py index 3ea8366..b428d64 100644 --- a/apps/api/ww_api/schemas/providers.py +++ b/apps/api/ww_api/schemas/providers.py @@ -6,6 +6,7 @@ snake_case;响应一律 **掩码 Key**,绝不含明文。前端经 OpenAPI from __future__ import annotations from pydantic import BaseModel, Field +from ww_llm_gateway.types import Tier class ProviderView(BaseModel): @@ -41,7 +42,8 @@ class ProviderCredentialInput(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) model: str = Field(min_length=1) fallback: list[str] = Field(default_factory=list) diff --git a/apps/api/ww_api/schemas/rules.py b/apps/api/ww_api/schemas/rules.py index 1ce90b1..b45bdce 100644 --- a/apps/api/ww_api/schemas/rules.py +++ b/apps/api/ww_api/schemas/rules.py @@ -7,18 +7,21 @@ snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必 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"] +# 先 strip 再校验长度:纯空白内容(" ")strip 后为空 → 422(QA MEDIUM:此前被接受入库)。 +RuleContent = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] + class RuleCreateRequest(BaseModel): """POST /projects/:id/rules:新增一条规则。""" level: RuleLevel = Field(description="规则级别(global/genre/style/project,越具体越优先)") - content: str = Field(min_length=1, description="规则正文") + content: RuleContent = Field(description="规则正文(首尾空白会被裁剪,不可全空白)") class RuleView(BaseModel):