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)。
286 lines
9.6 KiB
Python
286 lines
9.6 KiB
Python
"""T1.4 端点测试:立项 CRUD + 写章 SSE + 自动保存(内存替身,无 DB/无网络)。
|
||
|
||
DoD:端点入 OpenAPI;POST draft 回 SSE 流(mock 网关验证);PUT draft 幂等。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
|
||
import httpx
|
||
import pytest
|
||
from cryptography.fernet import Fernet
|
||
from fakes_projects import FakeChapterRepo, FakeProjectRepo, FakeWriterGateway
|
||
from ww_core.domain.repositories import (
|
||
CharacterView,
|
||
DigestView,
|
||
ForeshadowView,
|
||
MemoryRepos,
|
||
OutlineView,
|
||
ProjectSpecView,
|
||
RuleView,
|
||
StyleView,
|
||
WorldEntityView,
|
||
)
|
||
from ww_shared import ErrorCode
|
||
|
||
|
||
class _EmptyOutlineRepo:
|
||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||
return None
|
||
|
||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||
return []
|
||
|
||
|
||
class _EmptyCharacterRepo:
|
||
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
|
||
return []
|
||
|
||
|
||
class _EmptyWorldEntityRepo:
|
||
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
|
||
return []
|
||
|
||
|
||
class _EmptyDigestRepo:
|
||
async def recent(self, project_id: uuid.UUID, k: int) -> list[DigestView]:
|
||
return []
|
||
|
||
|
||
class _EmptyForeshadowRepo:
|
||
async def list_for_codes(self, project_id: uuid.UUID, codes: list[str]) -> list[ForeshadowView]:
|
||
return []
|
||
|
||
|
||
class _EmptyStyleRepo:
|
||
async def latest(self, project_id: uuid.UUID) -> StyleView | None:
|
||
return None
|
||
|
||
|
||
class _EmptyRulesRepo:
|
||
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
|
||
return []
|
||
|
||
|
||
class _StubProjectSpecRepo:
|
||
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
|
||
return ProjectSpecView(title="测试作品", premise="测试前提")
|
||
|
||
|
||
class _NoInjectionRepo:
|
||
"""空注入覆盖 stub:draft 端点读它得 None(纯自动选择),不打真 DB。"""
|
||
|
||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> None:
|
||
return None
|
||
|
||
|
||
def _empty_memory_repos() -> MemoryRepos:
|
||
return MemoryRepos(
|
||
outline=_EmptyOutlineRepo(),
|
||
character=_EmptyCharacterRepo(),
|
||
world_entity=_EmptyWorldEntityRepo(),
|
||
digest=_EmptyDigestRepo(),
|
||
foreshadow=_EmptyForeshadowRepo(),
|
||
style=_EmptyStyleRepo(),
|
||
rules=_EmptyRulesRepo(),
|
||
project=_StubProjectSpecRepo(),
|
||
)
|
||
|
||
|
||
def _make_client(
|
||
*,
|
||
project_repo: FakeProjectRepo | None = None,
|
||
chapter_repo: FakeChapterRepo | None = None,
|
||
gateway: FakeWriterGateway | None = None,
|
||
) -> tuple[httpx.AsyncClient, FakeProjectRepo, FakeChapterRepo, FakeWriterGateway]:
|
||
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_chapter_repo,
|
||
get_injection_repo,
|
||
get_memory_repos,
|
||
get_project_repo,
|
||
get_writer_gateway,
|
||
)
|
||
|
||
project_repo = project_repo or FakeProjectRepo()
|
||
chapter_repo = chapter_repo or FakeChapterRepo()
|
||
gateway = gateway or FakeWriterGateway()
|
||
|
||
app = create_app()
|
||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||
app.dependency_overrides[get_chapter_repo] = lambda: chapter_repo
|
||
app.dependency_overrides[get_memory_repos] = _empty_memory_repos
|
||
app.dependency_overrides[get_writer_gateway] = lambda: gateway
|
||
# draft 端点现读注入覆盖:注空 stub,避免单测打真 DB(无覆盖 → 纯自动选择)。
|
||
app.dependency_overrides[get_injection_repo] = lambda: _NoInjectionRepo()
|
||
transport = httpx.ASGITransport(app=app)
|
||
client = httpx.AsyncClient(transport=transport, base_url="http://test")
|
||
return client, project_repo, chapter_repo, gateway
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_project_returns_201() -> None:
|
||
client, _, _, _ = _make_client()
|
||
async with client:
|
||
resp = await client.post(
|
||
"/projects",
|
||
json={"title": "我的网文", "genre": "玄幻", "selling_points": ["爽点密集"]},
|
||
)
|
||
assert resp.status_code == 201
|
||
body = resp.json()
|
||
assert body["title"] == "我的网文"
|
||
assert body["genre"] == "玄幻"
|
||
assert body["selling_points"] == ["爽点密集"]
|
||
assert uuid.UUID(body["id"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_projects() -> None:
|
||
client, repo, _, _ = _make_client()
|
||
async with client:
|
||
await client.post("/projects", json={"title": "甲"})
|
||
await client.post("/projects", json={"title": "乙"})
|
||
resp = await client.get("/projects")
|
||
assert resp.status_code == 200
|
||
titles = {p["title"] for p in resp.json()["projects"]}
|
||
assert titles == {"甲", "乙"}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_project_detail() -> None:
|
||
client, _, _, _ = _make_client()
|
||
async with client:
|
||
created = (await client.post("/projects", json={"title": "详情"})).json()
|
||
resp = await client.get(f"/projects/{created['id']}")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["title"] == "详情"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_unknown_project_404() -> None:
|
||
client, _, _, _ = _make_client()
|
||
async with client:
|
||
resp = await client.get(f"/projects/{uuid.uuid4()}")
|
||
assert resp.status_code == 404
|
||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_project_rejects_blank_title() -> None:
|
||
client, _, _, _ = _make_client()
|
||
async with client:
|
||
resp = await client.post("/projects", json={"title": ""})
|
||
assert resp.status_code == 422
|
||
|
||
|
||
@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()
|
||
async with client:
|
||
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
|
||
assert resp.status_code == 200
|
||
assert resp.headers["content-type"].startswith("text/event-stream")
|
||
text = resp.text
|
||
assert "event: token" in text
|
||
assert '"text": "阿福"' in text
|
||
assert "event: done" in text
|
||
# done 带累计长度(不回灌全文)
|
||
assert '"length": 6' in text
|
||
# 网关确实被调用,且只传 tier(不传具体 model)
|
||
assert len(gateway.requests) == 1
|
||
assert gateway.requests[0].tier == "writer"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_draft_stream_threads_directive_into_volatile() -> None:
|
||
gateway = FakeWriterGateway(chunks=["正文"])
|
||
client, _, _, _ = _make_client(gateway=gateway)
|
||
pid = uuid.uuid4()
|
||
async with client:
|
||
resp = await client.post(
|
||
f"/projects/{pid}/chapters/1/draft", json={"directive": "多写战斗"}
|
||
)
|
||
assert resp.status_code == 200
|
||
assert len(gateway.requests) == 1
|
||
# 指令直通 assemble→volatile(write 节点把 volatile 放进 LlmRequest.input)。
|
||
assert "多写战斗" in str(gateway.requests[0].input)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_draft_stream_backward_compatible_without_body() -> None:
|
||
gateway = FakeWriterGateway(chunks=["正文"])
|
||
client, _, _, _ = _make_client(gateway=gateway)
|
||
pid = uuid.uuid4()
|
||
async with client:
|
||
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
|
||
assert resp.status_code == 200
|
||
assert len(gateway.requests) == 1
|
||
assert "本章指令" not in str(gateway.requests[0].input)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
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()
|
||
async with client:
|
||
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
|
||
assert resp.status_code == 200
|
||
text = resp.text
|
||
assert "event: token" in text
|
||
assert "event: error" in text
|
||
assert ErrorCode.LLM_UNAVAILABLE in text
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_put_draft_is_idempotent() -> None:
|
||
chapter_repo = FakeChapterRepo()
|
||
client, _, _, _ = _make_client(chapter_repo=chapter_repo)
|
||
pid = uuid.uuid4()
|
||
async with client:
|
||
r1 = await client.put(f"/projects/{pid}/chapters/3/draft", json={"text": "初稿"})
|
||
r2 = await client.put(f"/projects/{pid}/chapters/3/draft", json={"text": "改稿更长"})
|
||
assert r1.status_code == 200
|
||
assert r2.status_code == 200
|
||
# 同章节只一条草稿(版次不爆炸)
|
||
assert len(chapter_repo.drafts) == 1
|
||
saved = chapter_repo.drafts[(pid, 3)]
|
||
assert saved.content == "改稿更长"
|
||
assert saved.version == 1
|
||
assert r2.json()["length"] == len("改稿更长")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_draft_returns_saved_content() -> None:
|
||
chapter_repo = FakeChapterRepo()
|
||
client, _, _, _ = _make_client(chapter_repo=chapter_repo)
|
||
pid = uuid.uuid4()
|
||
async with client:
|
||
await client.put(f"/projects/{pid}/chapters/5/draft", json={"text": "阿福重访工作台"})
|
||
resp = await client.get(f"/projects/{pid}/chapters/5/draft")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["content"] == "阿福重访工作台"
|
||
assert body["status"] == "draft"
|
||
assert body["version"] == 1
|
||
assert body["chapter_no"] == 5
|
||
assert body["length"] == len("阿福重访工作台")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_draft_404_when_no_draft() -> None:
|
||
client, _, _, _ = _make_client()
|
||
pid = uuid.uuid4()
|
||
async with client:
|
||
resp = await client.get(f"/projects/{pid}/chapters/9/draft")
|
||
assert resp.status_code == 404
|
||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|