Files
writer-work-flow/apps/api/tests/test_outline.py
Yaojia Wang 345cc73965 fix(txn+security): 仓储改 flush + 启动校验/兜底 + job.error 脱敏 + SSE 异常硬化
P0-1 SqlCredentialStore/save_draft 由自提交改 flush,端点/服务统一 commit
  (新增 CredentialStore.commit() 统一提交点;token 刷新落库显式提交);
  补多凭据一请求中途失败整体回滚集成测试。
P0-2 启动校验 _fernet(enc_key) 快速失败 + catch-all Exception → ErrorEnvelope;
  credential_enc_key 改 SecretStr。
P0-3 run_job 异常分类:AppError 存 code+message,其余存通用文案不泄 str(exc)。
P0-4 评审/正文 SSE 失败先发 error 事件,尾部 commit 包 try/except。
P1-4 max_version 加 FOR UPDATE 行锁消除 TOCTOU。
P1-5 scan_overdue 谓词下推 + 批量 UPDATE RETURNING。
P1-10 移除 OAuth user_code 日志。
P2 provider_deps 改调网关 build_adapter;accept_service Committable Protocol;
  CORS 白名单收窄;request_id 安全字符集白名单;stdlib 日志接管;读端点 404 校验;
  httpx timeout;测试用合法 Fernet key;类型化响应模型(JobResponse/DimensionEntry/
  ReviewConflictView/selling_points)+路由 ErrorEnvelope responses(供 codegen)。
2026-06-21 19:32:24 +02:00

300 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""T3.5 端点测试:大纲生成 + 持久化 + 伏笔看板(内存替身,无 DB/无网络)。
覆盖:
- POST /outlinemock 网关产 `OutlineResult` → 逐章 upsert含 foreshadow_windows→ 端点 commit
- 幂等:同 (project_id, chapter_no) 再生成覆盖;
- 项目不存在 → 404无凭据 → LLM_UNAVAILABLE503
- GET /foreshadow?status=:按状态过滤;非法 status → 422 VALIDATION。
"""
from __future__ import annotations
import os
import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import (
FakeForeshadowRepo,
FakeOutlineReadRepo,
FakeOutlineWriteRepo,
FakeProjectRepo,
FakeReviewGateway,
FakeSession,
)
from test_projects import _empty_memory_repos
from ww_agents import ForeshadowWindow, OutlineChapter, OutlineResult
from ww_core.domain.project_repo import ProjectCreate
from ww_shared import AppError, ErrorCode
def _outline_result() -> OutlineResult:
return OutlineResult(
chapters=[
OutlineChapter(
no=1,
beats=["开篇引入主角", "埋下信物伏笔"],
foreshadow_windows=[
ForeshadowWindow(code="F1", plant_chapter=1, expected_close_to=10)
],
),
OutlineChapter(no=2, beats=["冲突升级"], foreshadow_windows=[]),
]
)
def _make_client(
*,
project_repo: FakeProjectRepo | None = None,
foreshadow_repo: FakeForeshadowRepo | None = None,
outline_repo: FakeOutlineWriteRepo | None = None,
gateway: FakeReviewGateway | None = None,
session: FakeSession | None = None,
) -> tuple[httpx.AsyncClient, FakeOutlineWriteRepo, 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_foreshadow_repo,
get_memory_repos,
get_outline_gateway,
get_outline_write_repo,
get_project_repo,
)
from ww_db import get_session
project_repo = project_repo or FakeProjectRepo()
foreshadow_repo = foreshadow_repo or FakeForeshadowRepo()
outline_repo = outline_repo or FakeOutlineWriteRepo()
gateway = gateway or FakeReviewGateway(parsed=_outline_result())
session = session or FakeSession()
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_foreshadow_repo] = lambda: foreshadow_repo
app.dependency_overrides[get_memory_repos] = _empty_memory_repos
app.dependency_overrides[get_outline_write_repo] = lambda: outline_repo
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_outline_gateway] = lambda: gateway
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
return client, outline_repo, session
async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID:
view = await repo.create(uuid.UUID(int=1), ProjectCreate(title="测试作品", genre="玄幻"))
return uuid.UUID(str(view.id))
# ---- 大纲生成 ----
@pytest.mark.asyncio
async def test_generate_outline_upserts_chapters_and_commits() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
client, outline_repo, session = _make_client(project_repo=project_repo)
async with client:
resp = await client.post(f"/projects/{pid}/outline", json={"volume": 1})
assert resp.status_code == 200
body = resp.json()
assert [c["no"] for c in body["chapters"]] == [1, 2]
ch1 = body["chapters"][0]
assert ch1["beats"] == ["开篇引入主角", "埋下信物伏笔"]
assert ch1["foreshadow_windows"][0]["code"] == "F1"
assert ch1["foreshadow_windows"][0]["expected_close_to"] == 10
# 逐章 upsert2 章)+ 端点末尾一次 commit。
assert outline_repo.upsert_calls == 2
assert (pid, 1) in outline_repo.rows
assert outline_repo.rows[(pid, 1)][1] == ["开篇引入主角", "埋下信物伏笔"]
assert session.commits == 1
@pytest.mark.asyncio
async def test_generate_outline_is_idempotent_overwrites_same_chapter() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
outline_repo = FakeOutlineWriteRepo()
client, _, _ = _make_client(project_repo=project_repo, outline_repo=outline_repo)
async with client:
await client.post(f"/projects/{pid}/outline", json={"volume": 1})
await client.post(f"/projects/{pid}/outline", json={"volume": 1})
# 同 (project_id, chapter_no) 覆盖,不堆叠 → 仍只 2 行。
assert len(outline_repo.rows) == 2
@pytest.mark.asyncio
async def test_generate_outline_unknown_project_404() -> None:
client, _, _ = _make_client()
async with client:
resp = await client.post(f"/projects/{uuid.uuid4()}/outline", json={})
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_generate_outline_without_credentials_maps_to_llm_unavailable() -> None:
# 无凭据时 `build_gateway_for_tier` 抛 LLM_UNAVAILABLE503在网关 dep 解析阶段发生。
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
session = 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_foreshadow_repo,
get_memory_repos,
get_outline_gateway,
get_outline_write_repo,
get_project_repo,
)
from ww_db import get_session
async def _no_creds() -> object:
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_foreshadow_repo] = lambda: FakeForeshadowRepo()
app.dependency_overrides[get_memory_repos] = _empty_memory_repos
app.dependency_overrides[get_outline_write_repo] = lambda: FakeOutlineWriteRepo()
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_outline_gateway] = _no_creds
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(f"/projects/{pid}/outline", json={})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
# 网关阶段失败 → 未生成、未提交。
assert session.commits == 0
@pytest.mark.asyncio
async def test_generate_outline_upper_error_propagates() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
gateway = FakeReviewGateway(error=AppError(ErrorCode.LLM_UNAVAILABLE, "boom"))
client, _, session = _make_client(project_repo=project_repo, gateway=gateway)
async with client:
resp = await client.post(f"/projects/{pid}/outline", json={})
assert resp.status_code == 503
assert session.commits == 0
# ---- 大纲读取GET /outline----
def _make_read_client(
*,
project_repo: FakeProjectRepo,
outline_read_repo: FakeOutlineReadRepo,
) -> httpx.AsyncClient:
"""装配 GET /outline 测试客户端(注入 fake 项目 repo + 读侧 outline repo"""
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_outline_read_repo, get_project_repo
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_outline_read_repo] = lambda: outline_read_repo
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
return httpx.AsyncClient(transport=transport, base_url="http://test")
@pytest.mark.asyncio
async def test_get_outline_returns_persisted_chapters_in_order_with_unpacked_beats() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
read_repo = FakeOutlineReadRepo()
# 乱序入种,断言端点按 chapter_no 升序返回。
read_repo.add_chapter(pid, volume=1, chapter_no=2, beats=["冲突升级"])
read_repo.add_chapter(
pid,
volume=1,
chapter_no=1,
beats=["开篇引入主角", "埋下信物伏笔"],
foreshadow_windows=[{"code": "F1", "plant_chapter": 1, "expected_close_to": 10}],
)
client = _make_read_client(project_repo=project_repo, outline_read_repo=read_repo)
async with client:
resp = await client.get(f"/projects/{pid}/outline")
assert resp.status_code == 200
body = resp.json()
assert [c["no"] for c in body["chapters"]] == [1, 2]
ch1 = body["chapters"][0]
# beats 由 DB dict `{"beats": [...]}` 解包成裸 list与 POST 响应同形)。
assert ch1["beats"] == ["开篇引入主角", "埋下信物伏笔"]
assert ch1["foreshadow_windows"][0]["code"] == "F1"
assert ch1["foreshadow_windows"][0]["expected_close_to"] == 10
assert body["chapters"][1]["beats"] == ["冲突升级"]
@pytest.mark.asyncio
async def test_get_outline_returns_empty_list_when_no_outline() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
client = _make_read_client(project_repo=project_repo, outline_read_repo=FakeOutlineReadRepo())
async with client:
resp = await client.get(f"/projects/{pid}/outline")
# 项目存在但无大纲 → 200 空列表(非 404
assert resp.status_code == 200
assert resp.json()["chapters"] == []
@pytest.mark.asyncio
async def test_get_outline_unknown_project_404() -> None:
client = _make_read_client(
project_repo=FakeProjectRepo(), outline_read_repo=FakeOutlineReadRepo()
)
async with client:
resp = await client.get(f"/projects/{uuid.uuid4()}/outline")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
# ---- 伏笔看板 ----
@pytest.mark.asyncio
async def test_foreshadow_board_filters_by_status() -> None:
repo = FakeForeshadowRepo()
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
await repo.register(pid, code="F1", title="开放伏笔")
await repo.register(pid, code="F2", title="部分回收")
await repo.transition(pid, "F2", to_status="PARTIAL")
from ww_api.main import create_app
from ww_api.services.project_deps import get_foreshadow_repo, get_project_repo
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
app = create_app()
app.dependency_overrides[get_foreshadow_repo] = lambda: repo
# 读端点现做 project 存在性 404 校验P2——注入有该 project 的 FakeProjectRepo。
app.dependency_overrides[get_project_repo] = lambda: project_repo
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
all_resp = await client.get(f"/projects/{pid}/foreshadow")
open_resp = await client.get(f"/projects/{pid}/foreshadow", params={"status": "OPEN"})
bad_resp = await client.get(f"/projects/{pid}/foreshadow", params={"status": "NOPE"})
assert {f["code"] for f in all_resp.json()["foreshadow"]} == {"F1", "F2"}
open_codes = [f["code"] for f in open_resp.json()["foreshadow"]]
assert open_codes == ["F1"]
assert bad_resp.status_code == 422
assert bad_resp.json()["error"]["code"] == ErrorCode.VALIDATION
assert bad_resp.json()["error"]["details"]["reason"] == "invalid_status"