diff --git a/apps/api/tests/test_chain_deps.py b/apps/api/tests/test_chain_deps.py index 5f0c2ec..40afc40 100644 --- a/apps/api/tests/test_chain_deps.py +++ b/apps/api/tests/test_chain_deps.py @@ -9,7 +9,6 @@ bug:runtime checkpointer 工厂曾把 `postgresql+psycopg://…`(settings from __future__ import annotations import pytest - from ww_api.services import chain_deps diff --git a/apps/api/tests/test_projects.py b/apps/api/tests/test_projects.py index df62961..1937314 100644 --- a/apps/api/tests/test_projects.py +++ b/apps/api/tests/test_projects.py @@ -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() diff --git a/apps/api/tests/test_rules.py b/apps/api/tests/test_rules.py index 99c2a07..6565087 100644 --- a/apps/api/tests/test_rules.py +++ b/apps/api/tests/test_rules.py @@ -3,7 +3,8 @@ 覆盖: - 201 + 回显 level/content + 端点 commit; - 非法 level → 422(Pydantic Literal 校验,FastAPI 422); -- 空 content → 422。 +- 空 content → 422; +- 项目不存在 → 404(QA 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 # 未触达写库 diff --git a/apps/api/ww_api/routers/projects.py b/apps/api/ww_api/routers/projects.py index fa7a075..a1603fc 100644 --- a/apps/api/ww_api/routers/projects.py +++ b/apps/api/ww_api/routers/projects.py @@ -252,6 +252,7 @@ async def stream_draft( repos: MemoryReposDep, gateway: GatewayDep, injection_repo: InjectionRepoDep, + project_repo: ProjectRepoDep, session: Annotated[AsyncSession, Depends(get_session)], body: DraftStreamRequest | None = None, ) -> StreamingResponse: @@ -259,7 +260,12 @@ async def stream_draft( `body.directive`(可选,T4-b)是临时本章指令,直通 assemble→volatile(不持久化); 无 body 的旧调用方仍可用(向后兼容)。 + + 项目不存在 → 404(在触网关前 fail-fast):否则非法 project_id 会静默烧一次付费/限流的 + LLM 调用并返回 200(QA C1)。 """ + if await project_repo.get(STUB_OWNER_ID, project_id) is None: + raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") request_id = getattr(request.state, "request_id", None) directive = body.directive if body else None override = await injection_repo.get(project_id, chapter_no) diff --git a/apps/api/ww_api/routers/rules.py b/apps/api/ww_api/routers/rules.py index c03d079..9b427b8 100644 --- a/apps/api/ww_api/routers/rules.py +++ b/apps/api/ww_api/routers/rules.py @@ -18,17 +18,21 @@ from typing import Annotated from fastapi import APIRouter, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from ww_core.domain import RuleWriteRepo +from ww_core.domain.project_repo import ProjectRepo from ww_db import get_session +from ww_shared import AppError, ErrorCode from ww_api.logging_config import get_logger from ww_api.schemas.rules import RuleCreateRequest, RuleView -from ww_api.services.project_deps import get_rule_write_repo +from ww_api.services.credentials import STUB_OWNER_ID +from ww_api.services.project_deps import get_project_repo, get_rule_write_repo log = get_logger("ww.api.rules") router = APIRouter(prefix="/projects", tags=["rules"]) RuleWriteRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_repo)] +ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] SessionDep = Annotated[AsyncSession, Depends(get_session)] @@ -38,10 +42,16 @@ async def create_rule( body: RuleCreateRequest, request: Request, repo: RuleWriteRepoDep, + project_repo: ProjectRepoDep, session: SessionDep, ) -> RuleView: - """新增一条规则(201)。非法 level / 空 content → FastAPI 422。""" + """新增一条规则(201)。非法 level / 空 content → FastAPI 422;项目不存在 → 404。 + + 项目存在性须在 insert 前校验:否则 FK 违例会逃逸成 500(QA H2),而非干净的 404。 + """ request_id = getattr(request.state, "request_id", None) + if await project_repo.get(STUB_OWNER_ID, project_id) is None: + raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") view = await repo.create(project_id, level=body.level, content=body.content) await session.commit() log.info( diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 76257c9..1d18341 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -2,3 +2,4 @@ /.next /lib/api/openapi.json next-env.d.ts +.gstack/ diff --git a/apps/web/components/ProjectWizard.tsx b/apps/web/components/ProjectWizard.tsx index ed3ee62..b3155df 100644 --- a/apps/web/components/ProjectWizard.tsx +++ b/apps/web/components/ProjectWizard.tsx @@ -274,7 +274,8 @@ function StepPremise({ form, update }: StepProps) { } function StepProtagonist({ form, update }: StepProps) { - // M1 projects 表无独立主角/金手指字段;先并入立意/总纲文本,避免编造 API。 + // M1 projects 表无独立主角/金手指字段;提交时由 toCreateRequest 合并进 premise(与立意各占一段)。 + // 必须绑定独立的 form.protagonist,不能复用 form.premise,否则与第 3 步立意互相覆盖(QA H1)。 return (

@@ -283,8 +284,8 @@ function StepProtagonist({ form, update }: StepProps) {