Files
writer-work-flow/apps/api/tests/test_generation.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

508 lines
17 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.

"""T5.2 生成/入库 + 读端点测试(内存替身,无 DB/无网络)。
覆盖:
- POST /world/generatemock 网关产 WorldGenResult → 预览 + commit落 ledger项目不存在 404。
- POST /characters/generatemock 网关产 CharacterGenResult → 预览(含已有角色注入防雷同)。
- POST /characters入库precheck 无冲突 → 写库 + 201有冲突且未确认 → 409确认后放行写库。
- 无凭据 → 503override 网关 dep 抛 LLM_UNAVAILABLE
- GET /rules规则列表GET /skills技能库列表。
"""
from __future__ import annotations
import os
import uuid
from typing import Any
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeProjectRepo, FakeSession
from test_projects import _empty_memory_repos
from ww_agents import (
AgentSpec,
CharacterCard,
CharacterGenResult,
Conflict,
ContinuityReview,
WorldEntityCard,
WorldGenResult,
)
from ww_core.domain.character_repo import CharacterWriteView
from ww_core.domain.project_repo import ProjectCreate
from ww_core.domain.repositories import RuleView
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
from ww_shared import AppError, ErrorCode
from ww_skills import SkillRegistry
STUB_OWNER = uuid.UUID(int=1)
class _SchemaRoutingGateway:
"""按 `req.output_schema` 返对应 parsedworld/character/precheck 各拿自己的产物)。"""
def __init__(self, by_schema: dict[type[Any] | None, Any]) -> None:
self._by_schema = by_schema
self.calls: list[type[Any] | None] = []
async def run(self, req: LlmRequest) -> LlmResponse:
schema = req.output_schema
self.calls.append(schema)
parsed = self._by_schema.get(schema)
return LlmResponse(
text=parsed.model_dump_json() if parsed is not None else "{}",
parsed=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"),
)
class _FakeCharacterWriteRepo:
def __init__(self) -> None:
self.rows: list[dict[str, Any]] = []
async def create(
self,
project_id: uuid.UUID,
*,
name: str,
role: str,
traits: list[str],
backstory: str,
arc: str,
speech_tics: list[str],
tags: list[Any],
relations: list[dict[str, Any]],
) -> CharacterWriteView:
self.rows.append(
{
"project_id": project_id,
"name": name,
"role": role,
"traits": list(traits),
"arc": arc,
"speech_tics": list(speech_tics),
"tags": list(tags),
"relations": [dict(r) for r in relations],
}
)
return CharacterWriteView(id=uuid.uuid4(), name=name, role=role)
class _FakeRulesReadRepo:
def __init__(self, rules: list[RuleView] | None = None) -> None:
self._rules = rules or []
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
return list(self._rules)
def _world_result() -> WorldGenResult:
return WorldGenResult(
entities=[WorldEntityCard(type="力量体系", name="灵脉", rules=["灵力守恒", "禁止跨界"])]
)
def _character_result() -> CharacterGenResult:
return CharacterGenResult(
cards=[
CharacterCard(
name="叶寒",
role="主角",
traits=["腹黑", "护短"],
backstory="孤儿出身",
arc="从孤儿到帝王",
speech_tics=[""],
tags=["天才"],
relations=[],
)
]
)
def _no_conflicts() -> ContinuityReview:
return ContinuityReview(conflicts=[])
def _with_conflicts() -> ContinuityReview:
return ContinuityReview(
conflicts=[Conflict(type="设定违例", where="叶寒卡", refs=["灵脉"], suggestion="改设定")]
)
def _skill_registry() -> SkillRegistry:
spec = AgentSpec(
name="custom-namer",
tier="light",
system_prompt="取名",
input_schema=None,
output_schema=None,
reads=["characters"],
writes=["characters"],
scope="custom",
)
return SkillRegistry({spec.name: spec})
def _make_app(
*,
project_repo: FakeProjectRepo,
gateway: Any,
char_repo: _FakeCharacterWriteRepo | None = None,
session: FakeSession | None = None,
rules_repo: _FakeRulesReadRepo | None = None,
registry: SkillRegistry | None = None,
memory: Any = 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_character_gen_gateway,
get_character_write_repo,
get_memory_repos,
get_precheck_gateway,
get_project_repo,
get_rules_read_repo,
get_skill_registry,
get_worldbuilder_gateway,
)
from ww_db import get_session
session = session or FakeSession()
char_repo = char_repo or _FakeCharacterWriteRepo()
rules_repo = rules_repo or _FakeRulesReadRepo()
registry = registry or _skill_registry()
async def _raise_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_memory_repos] = (
(lambda: memory) if memory is not None else _empty_memory_repos
)
app.dependency_overrides[get_character_write_repo] = lambda: char_repo
app.dependency_overrides[get_rules_read_repo] = lambda: rules_repo
app.dependency_overrides[get_skill_registry] = lambda: registry
app.dependency_overrides[get_session] = lambda: session
gw = _raise_no_creds if no_creds else (lambda: gateway)
app.dependency_overrides[get_worldbuilder_gateway] = gw
app.dependency_overrides[get_character_gen_gateway] = gw
app.dependency_overrides[get_precheck_gateway] = gw
return app, session
async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID:
view = await repo.create(STUB_OWNER, ProjectCreate(title="测试作品", genre="玄幻"))
return uuid.UUID(str(view.id))
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_world_returns_preview_and_commits_ledger() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({WorldGenResult: _world_result()})
app, session = _make_app(project_repo=repo, gateway=gateway)
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/world/generate", json={"brief": "东方玄幻"})
assert resp.status_code == 200
body = resp.json()
assert body["entities"][0]["name"] == "灵脉"
assert body["entities"][0]["rules"] == ["灵力守恒", "禁止跨界"]
assert session.commits == 1 # 预览不写业务表,但落 ledger
@pytest.mark.asyncio
async def test_generate_world_unknown_project_404() -> None:
repo = FakeProjectRepo()
gateway = _SchemaRoutingGateway({WorldGenResult: _world_result()})
app, _ = _make_app(project_repo=repo, gateway=gateway)
async with _client(app) as client:
resp = await client.post(f"/projects/{uuid.uuid4()}/world/generate", json={"brief": "x"})
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_generate_world_no_credentials_503() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
app, session = _make_app(project_repo=repo, gateway=object(), no_creds=True)
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/world/generate", json={"brief": "x"})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
assert session.commits == 0
# ---- 角色生成 ----
@pytest.mark.asyncio
async def test_generate_characters_returns_preview() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({CharacterGenResult: _character_result()})
app, session = _make_app(project_repo=repo, gateway=gateway)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/characters/generate", json={"brief": "天才主角", "count": 1}
)
assert resp.status_code == 200
card = resp.json()["cards"][0]
assert card["name"] == "叶寒"
assert card["traits"] == ["腹黑", "护短"]
assert card["arc"] == "从孤儿到帝王"
assert session.commits == 1
# ---- 角色入库gate----
@pytest.mark.asyncio
async def test_ingest_characters_no_conflict_writes_and_201() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
char_repo = _FakeCharacterWriteRepo()
app, session = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/characters",
json={
"cards": [
{
"name": "叶寒",
"role": "主角",
"traits": ["腹黑"],
"backstory": "孤儿",
"arc": "成长",
"speech_tics": [""],
"tags": ["天才"],
"relations": [],
}
]
},
)
assert resp.status_code == 201
body = resp.json()
assert body["created"] == ["叶寒"]
assert body["rejected_tables"] == []
assert len(char_repo.rows) == 1
assert char_repo.rows[0]["traits"] == ["腹黑"]
assert session.commits == 1
@pytest.mark.asyncio
async def test_ingest_characters_with_conflict_blocks_409() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _with_conflicts()})
char_repo = _FakeCharacterWriteRepo()
app, session = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/characters",
json={"cards": [{"name": "叶寒", "role": "主角", "backstory": "孤儿", "arc": "成长"}]},
)
assert resp.status_code == 409
err = resp.json()["error"]
assert err["code"] == ErrorCode.CONFLICT_UNRESOLVED
assert err["details"]["conflict_count"] == 1
assert err["details"]["conflicts"][0]["type"] == "设定违例"
# 未写库gate 拦下),但落 precheck ledger。
assert len(char_repo.rows) == 0
assert session.commits == 1
@pytest.mark.asyncio
async def test_ingest_characters_acknowledged_conflict_writes() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _with_conflicts()})
char_repo = _FakeCharacterWriteRepo()
app, _ = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/characters",
json={
"cards": [{"name": "叶寒", "role": "主角", "backstory": "孤儿", "arc": "成长"}],
"acknowledge_conflicts": True,
},
)
assert resp.status_code == 201
assert len(char_repo.rows) == 1
# ---- 读端点 ----
@pytest.mark.asyncio
async def test_list_rules_returns_rules() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
rules_repo = _FakeRulesReadRepo(
[
RuleView(level="project", content="主角不复活"),
RuleView(level="global", content="无脏话"),
]
)
gateway = _SchemaRoutingGateway({})
app, _ = _make_app(project_repo=repo, gateway=gateway, rules_repo=rules_repo)
async with _client(app) as client:
resp = await client.get(f"/projects/{pid}/rules")
assert resp.status_code == 200
rules = resp.json()["rules"]
assert [r["content"] for r in rules] == ["主角不复活", "无脏话"]
@pytest.mark.asyncio
async def test_list_skills_returns_registry() -> None:
repo = FakeProjectRepo()
gateway = _SchemaRoutingGateway({})
app, _ = _make_app(project_repo=repo, gateway=gateway)
async with _client(app) as client:
resp = await client.get("/skills")
assert resp.status_code == 200
skills = resp.json()["skills"]
assert skills[0]["name"] == "custom-namer"
assert skills[0]["scope"] == "custom"
assert skills[0]["writes"] == ["characters"]
# ---- 设定库 Codex 读端点GET characters / world_entities----
def _codex_memory() -> Any:
"""MemoryRepos角色/世界观各一行DB JSONB dict 形,验反向解包到 API list/str"""
from test_projects import (
_EmptyDigestRepo,
_EmptyForeshadowRepo,
_EmptyOutlineRepo,
_EmptyRulesRepo,
_EmptyStyleRepo,
_StubProjectSpecRepo,
)
from ww_core.domain.repositories import (
CharacterView,
MemoryRepos,
WorldEntityView,
)
class _CharRepo:
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
return [
CharacterView(
name="叶寒",
role="主角",
traits={"items": ["腹黑", "护短"]},
backstory="孤儿出身",
arc={"text": "从孤儿到帝王"},
speech_tics={"items": [""]},
tags=["天才"],
relations=[],
)
]
class _WorldRepo:
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
return [
WorldEntityView(
type="力量体系",
name="灵脉",
rules={"rules": ["灵力守恒", "禁止跨界"]},
)
]
return MemoryRepos(
outline=_EmptyOutlineRepo(),
character=_CharRepo(),
world_entity=_WorldRepo(),
digest=_EmptyDigestRepo(),
foreshadow=_EmptyForeshadowRepo(),
style=_EmptyStyleRepo(),
rules=_EmptyRulesRepo(),
project=_StubProjectSpecRepo(),
)
@pytest.mark.asyncio
async def test_list_characters_unpacks_jsonb_to_api_shape() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
app, _ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}), memory=_codex_memory())
async with _client(app) as client:
resp = await client.get(f"/projects/{pid}/characters")
assert resp.status_code == 200
chars = resp.json()["characters"]
assert len(chars) == 1
card = chars[0]
# DB JSONB dict → API list/str反向解包入库形变的逆向
assert card["name"] == "叶寒"
assert card["traits"] == ["腹黑", "护短"]
assert card["speech_tics"] == [""]
assert card["arc"] == "从孤儿到帝王"
assert card["tags"] == ["天才"]
@pytest.mark.asyncio
async def test_list_world_entities_unpacks_rules_to_list() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
app, _ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}), memory=_codex_memory())
async with _client(app) as client:
resp = await client.get(f"/projects/{pid}/world_entities")
assert resp.status_code == 200
entities = resp.json()["world_entities"]
assert len(entities) == 1
assert entities[0]["type"] == "力量体系"
assert entities[0]["name"] == "灵脉"
assert entities[0]["rules"] == ["灵力守恒", "禁止跨界"]
@pytest.mark.asyncio
async def test_list_characters_empty_when_none_ingested() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
app, _ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}))
async with _client(app) as client:
resp = await client.get(f"/projects/{pid}/characters")
assert resp.status_code == 200
assert resp.json()["characters"] == []