"""AI 立项方案生成端点测试(灵感⑤;内存替身,无 DB/无网络)。 覆盖 `POST /skills/project-plan/generate`(不带 project 前缀): - 无既有 project 也能生成(向导阶段 project 未建)→ 200 结构化预览 + 落 ledger。 - 种子门控:缺 genre / logline → 422,不触达网关、不 commit。 - 无凭据 → 503。 - 向导草稿序列化进网关 context(种子 + 已填字段透传)。 LLM 一律 mock,绝不联网。 """ from __future__ import annotations import os from typing import Any import httpx import pytest from cryptography.fernet import Fernet from fakes_projects import FakeSession from ww_agents import ProjectPlanResult from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage from ww_shared import AppError, ErrorCode class _CaptureGateway: """记录最近一次 req.input(验证向导草稿进了 context),返固定 ProjectPlanResult。""" def __init__(self, parsed: ProjectPlanResult) -> None: self._parsed = parsed self.last_input: str | None = None self.calls = 0 async def run(self, req: LlmRequest) -> LlmResponse: self.calls += 1 self.last_input = req.input if isinstance(req.input, str) else str(req.input) return LlmResponse( text=self._parsed.model_dump_json(), parsed=self._parsed, usage=Usage( provider="fake", model="fake", input_tokens=1, output_tokens=1, cost_minor=0, currency="USD", ), served_by=ServedBy(provider="fake", model="fake"), ) def _plan() -> ProjectPlanResult: return ProjectPlanResult( title_candidates=["逐光而行", "剑试九霄"], setting="上古仙门世界", narrative_structure="黄金三章立钩 → 升级", story_core="废柴逆袭封神的代价", ending_design="归隐收束(正剧)", tone="热血", ) def _make_app( *, gateway: Any, session: FakeSession | None = None, no_creds: bool = False, ) -> tuple[Any, FakeSession]: 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_project_plan_gateway from ww_db import get_session session = session or FakeSession() async def _ok() -> Any: return gateway async def _no_creds() -> Any: raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"}) app = create_app() app.dependency_overrides[get_project_plan_gateway] = _no_creds if no_creds else _ok app.dependency_overrides[get_session] = lambda: session return app, session def _client(app: Any) -> httpx.AsyncClient: transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) return httpx.AsyncClient(transport=transport, base_url="http://test") @pytest.mark.asyncio async def test_generate_without_existing_project() -> None: # 向导阶段 project 未建:无 project 前缀端点直接生成,返结构化方案 + 落 ledger。 gateway = _CaptureGateway(_plan()) app, session = _make_app(gateway=gateway) async with _client(app) as client: resp = await client.post( "/skills/project-plan/generate", json={"genre": "仙侠", "logline": "废柴少年觉醒禁忌血脉,逆势封神。"}, ) assert resp.status_code == 200 body = resp.json() assert body["title_candidates"] == ["逐光而行", "剑试九霄"] assert body["story_core"] == "废柴逆袭封神的代价" assert body["ending_design"].endswith("(正剧)") assert body["tone"] == "热血" assert session.commits == 1 # 预览不写业务表,但落 ledger @pytest.mark.asyncio async def test_generate_serializes_wizard_draft_into_context() -> None: # 种子 + 已填字段序列化进网关 context(有则贴合)。 gateway = _CaptureGateway(_plan()) app, _ = _make_app(gateway=gateway) async with _client(app) as client: resp = await client.post( "/skills/project-plan/generate", json={ "genre": "都市", "logline": "外卖骑手觉醒读心术。", "title": "听风者", "tone": "轻松", "selling_points": ["逆袭", "系统流"], "brief": "偏悬疑向", }, ) assert resp.status_code == 200 assert gateway.last_input is not None assert "题材:都市" in gateway.last_input assert "一句话故事:外卖骑手觉醒读心术。" in gateway.last_input assert "暂定书名:听风者" in gateway.last_input assert "核心卖点:逆袭、系统流" in gateway.last_input assert "偏悬疑向" in gateway.last_input # brief 进 context @pytest.mark.asyncio async def test_requires_seed_missing_logline_422() -> None: # 种子门控:缺 logline → 422,不触达网关、不 commit。 gateway = _CaptureGateway(_plan()) app, session = _make_app(gateway=gateway) async with _client(app) as client: resp = await client.post("/skills/project-plan/generate", json={"genre": "仙侠"}) assert resp.status_code == 422 assert resp.json()["error"]["code"] == ErrorCode.VALIDATION assert gateway.calls == 0 assert session.commits == 0 @pytest.mark.asyncio async def test_requires_seed_missing_genre_422() -> None: gateway = _CaptureGateway(_plan()) app, session = _make_app(gateway=gateway) async with _client(app) as client: resp = await client.post("/skills/project-plan/generate", json={"logline": "一句话故事。"}) assert resp.status_code == 422 assert gateway.calls == 0 assert session.commits == 0 @pytest.mark.asyncio async def test_requires_seed_blank_strings_422() -> None: # 纯空白种子也拒(strip 后为空)。 gateway = _CaptureGateway(_plan()) app, session = _make_app(gateway=gateway) async with _client(app) as client: resp = await client.post( "/skills/project-plan/generate", json={"genre": " ", "logline": " "} ) assert resp.status_code == 422 assert gateway.calls == 0 assert session.commits == 0 @pytest.mark.asyncio async def test_generate_no_credentials_503() -> None: app, session = _make_app(gateway=object(), no_creds=True) async with _client(app) as client: resp = await client.post( "/skills/project-plan/generate", json={"genre": "仙侠", "logline": "一句话故事。"}, ) assert resp.status_code == 503 assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE assert session.commits == 0