fix(qa): 修 QA C1/H1/H2——写章/规则缺项目校验 + 立项向导字段覆盖

C1 (CRITICAL) stream_draft:对不存在 project 流式写章先 fail-fast 404,
  否则非法 id 静默烧一次付费/限流 LLM 调用并返 200。在触网关前查 project_repo.get。
H2 (HIGH) create_rule:给不存在 project 加规则原 FK 违例逃逸成 500 → 改为入库前
  校验项目存在返 404(仿 chain/_require_project)。
H1 (HIGH) ProjectWizard:第3步「立意」与第4步「主角/金手指」原共用 form.premise
  互相覆盖丢数据 → 新增独立 form.protagonist,toCreateRequest 合并两段进 premise
  (M1 projects 表仍只有 premise,不编造 API)。

回归测试:
- test_projects.py:stream_draft 不存在 project → 404 且网关零调用;已有 draft
  用例改 seed 真项目。
- test_rules.py:create_rule 不存在 project → 404 不写库;已有用例 seed 真项目。
- wizard.test.ts:premise+protagonist 合并不互相覆盖(2 例)。
门禁绿:ruff/format clean · mypy 210 · pytest 749 · 前端 tsc/lint/vitest 干净。
This commit is contained in:
Yaojia Wang
2026-06-24 17:17:35 +02:00
parent a4ef250fc9
commit 2fe3bedfba
13 changed files with 688 additions and 27 deletions

View File

@@ -9,7 +9,6 @@ bugruntime checkpointer 工厂曾把 `postgresql+psycopg://…`settings
from __future__ import annotations
import pytest
from ww_api.services import chain_deps

View File

@@ -11,6 +11,8 @@ import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeChapterRepo, FakeProjectRepo, FakeWriterGateway
from ww_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.project_repo import ProjectView
from ww_core.domain.repositories import (
CharacterView,
DigestView,
@@ -122,6 +124,16 @@ def _make_client(
return client, project_repo, chapter_repo, gateway
def _seed_project(project_repo: FakeProjectRepo) -> uuid.UUID:
"""Seed 一个属于 STUB_OWNER_ID 的项目,返回其 pid。
stream_draft 现先校验项目存在QA C1流式用例须用已存在项目否则 404。
"""
pid = uuid.uuid4()
project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="测试"))
return pid
@pytest.mark.asyncio
async def test_create_project_returns_201() -> None:
client, _, _, _ = _make_client()
@@ -180,8 +192,8 @@ async def test_create_project_rejects_blank_title() -> None:
@pytest.mark.asyncio
async def test_draft_stream_yields_sse_tokens_and_done() -> None:
gateway = FakeWriterGateway(chunks=["阿福", "走进门。"])
client, _, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4()
client, project_repo, _, _ = _make_client(gateway=gateway)
pid = _seed_project(project_repo)
async with client:
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
assert resp.status_code == 200
@@ -200,8 +212,8 @@ async def test_draft_stream_yields_sse_tokens_and_done() -> None:
@pytest.mark.asyncio
async def test_draft_stream_threads_directive_into_volatile() -> None:
gateway = FakeWriterGateway(chunks=["正文"])
client, _, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4()
client, project_repo, _, _ = _make_client(gateway=gateway)
pid = _seed_project(project_repo)
async with client:
resp = await client.post(
f"/projects/{pid}/chapters/1/draft", json={"directive": "多写战斗"}
@@ -215,8 +227,8 @@ async def test_draft_stream_threads_directive_into_volatile() -> None:
@pytest.mark.asyncio
async def test_draft_stream_backward_compatible_without_body() -> None:
gateway = FakeWriterGateway(chunks=["正文"])
client, _, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4()
client, project_repo, _, _ = _make_client(gateway=gateway)
pid = _seed_project(project_repo)
async with client:
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
assert resp.status_code == 200
@@ -229,8 +241,8 @@ async def test_draft_stream_maps_error_to_sse_error_event() -> None:
from ww_shared import AppError
gateway = FakeWriterGateway(chunks=["半段"], error=AppError(ErrorCode.LLM_UNAVAILABLE, "boom"))
client, _, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4()
client, project_repo, _, _ = _make_client(gateway=gateway)
pid = _seed_project(project_repo)
async with client:
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
assert resp.status_code == 200
@@ -240,6 +252,18 @@ async def test_draft_stream_maps_error_to_sse_error_event() -> None:
assert ErrorCode.LLM_UNAVAILABLE in text
@pytest.mark.asyncio
async def test_draft_stream_unknown_project_returns_404_without_calling_gateway() -> None:
# QA C1 回归:对不存在的 project 流式写章必须 404且绝不触网关不烧 LLM 调用)。
gateway = FakeWriterGateway(chunks=["不该被生成"])
client, _project_repo, _, _ = _make_client(gateway=gateway)
async with client:
resp = await client.post(f"/projects/{uuid.uuid4()}/chapters/1/draft")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
assert len(gateway.requests) == 0 # 未触达网关
@pytest.mark.asyncio
async def test_put_draft_is_idempotent() -> None:
chapter_repo = FakeChapterRepo()

View File

@@ -3,7 +3,8 @@
覆盖:
- 201 + 回显 level/content + 端点 commit
- 非法 level → 422Pydantic Literal 校验FastAPI 422
- 空 content → 422
- 空 content → 422
- 项目不存在 → 404QA H2 回归:此前 FK 违例逃逸成 500
"""
from __future__ import annotations
@@ -13,7 +14,9 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeSession
from fakes_projects import FakeProjectRepo, FakeSession
from ww_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.project_repo import ProjectView
from ww_core.domain.rule_repo import RuleWriteView
@@ -27,29 +30,39 @@ class _FakeRuleWriteRepo:
return view
def _make_client() -> tuple[httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession]:
def _make_client() -> tuple[
httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession, FakeProjectRepo, uuid.UUID
]:
"""构建测试 client并 seed 一个属于 STUB_OWNER_ID 的项目;返回其 pid。
create_rule 现在先校验项目存在QA H2故必须 override get_project_repo 并 seed
否则正常用例会 404。返回的 pid 是已存在项目;未 seed 的随机 pid 即"不存在"
"""
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import get_rule_write_repo
from ww_api.services.project_deps import get_project_repo, get_rule_write_repo
from ww_db import get_session
repo = _FakeRuleWriteRepo()
session = FakeSession()
project_repo = FakeProjectRepo()
pid = uuid.uuid4()
project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="作品"))
app = create_app()
app.dependency_overrides[get_rule_write_repo] = lambda: repo
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_session] = lambda: session
transport = httpx.ASGITransport(app=app)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
return client, repo, session
return client, repo, session, project_repo, pid
@pytest.mark.asyncio
async def test_create_rule_returns_201_and_commits() -> None:
client, repo, session = _make_client()
pid = uuid.uuid4()
client, repo, session, _project_repo, pid = _make_client()
async with client:
resp = await client.post(
f"/projects/{pid}/rules",
@@ -66,8 +79,7 @@ async def test_create_rule_returns_201_and_commits() -> None:
@pytest.mark.asyncio
async def test_create_rule_invalid_level_returns_422() -> None:
client, _repo, _session = _make_client()
pid = uuid.uuid4()
client, _repo, _session, _project_repo, pid = _make_client()
async with client:
resp = await client.post(
f"/projects/{pid}/rules",
@@ -78,11 +90,24 @@ async def test_create_rule_invalid_level_returns_422() -> None:
@pytest.mark.asyncio
async def test_create_rule_empty_content_returns_422() -> None:
client, _repo, _session = _make_client()
pid = uuid.uuid4()
client, _repo, _session, _project_repo, pid = _make_client()
async with client:
resp = await client.post(
f"/projects/{pid}/rules",
json={"level": "global", "content": ""},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_create_rule_unknown_project_returns_404() -> None:
# QA H2 回归:给不存在的 project 加规则应 404不是 500 的 FK 违例逃逸)。
client, repo, _session, _project_repo, _pid = _make_client()
async with client:
resp = await client.post(
f"/projects/{uuid.uuid4()}/rules",
json={"level": "project", "content": "x"},
)
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "NOT_FOUND"
assert len(repo.rows) == 0 # 未触达写库