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)。
262 lines
9.1 KiB
Python
262 lines
9.1 KiB
Python
"""B0 注入透明读端点测试:GET /projects/:id/chapters/:no/injection。
|
||
|
||
读取 assemble 的确定性 SelectionTrace(无 LLM、无 commit):
|
||
- 命中实体 + 入选理由徽标;项目不存在 → 404;无大纲 → selected: [](不报错)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
|
||
import httpx
|
||
import pytest
|
||
from cryptography.fernet import Fernet
|
||
from fakes_projects import FakeProjectRepo
|
||
from ww_api.services.credentials import STUB_OWNER_ID
|
||
from ww_core.domain.injection_repo import InjectionOverride
|
||
from ww_core.domain.project_repo import ProjectCreate
|
||
from ww_core.domain.repositories import (
|
||
CharacterView,
|
||
DigestView,
|
||
ForeshadowView,
|
||
MemoryRepos,
|
||
OutlineView,
|
||
ProjectSpecView,
|
||
RuleView,
|
||
StyleView,
|
||
WorldEntityView,
|
||
)
|
||
from ww_shared import ErrorCode
|
||
|
||
# ---- 内存 fake memory repos(无 DB;只够断言 SelectionTrace 形状)----
|
||
|
||
|
||
class _OutlineRepo:
|
||
def __init__(self, rows: dict[int, OutlineView]) -> None:
|
||
self._rows = rows
|
||
|
||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||
return self._rows.get(chapter_no)
|
||
|
||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||
return [self._rows[k] for k in sorted(self._rows)]
|
||
|
||
|
||
class _CharRepo:
|
||
def __init__(self, rows: list[CharacterView]) -> None:
|
||
self._rows = rows
|
||
|
||
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
|
||
return list(self._rows)
|
||
|
||
|
||
class _WorldRepo:
|
||
def __init__(self, rows: list[WorldEntityView]) -> None:
|
||
self._rows = rows
|
||
|
||
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
|
||
return list(self._rows)
|
||
|
||
|
||
class _DigestRepo:
|
||
async def recent(self, project_id: uuid.UUID, k: int) -> list[DigestView]:
|
||
return []
|
||
|
||
|
||
class _ForeshadowRepo:
|
||
async def list_for_codes(self, project_id: uuid.UUID, codes: list[str]) -> list[ForeshadowView]:
|
||
return []
|
||
|
||
|
||
class _StyleRepo:
|
||
async def latest(self, project_id: uuid.UUID) -> StyleView | None:
|
||
return None
|
||
|
||
|
||
class _RulesRepo:
|
||
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
|
||
return []
|
||
|
||
|
||
class _SpecRepo:
|
||
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
|
||
return ProjectSpecView(title="测试作品", premise="测试前提")
|
||
|
||
|
||
class _FakeInjectionRepo:
|
||
"""内存注入覆盖 repo(按 (project_id, chapter_no) 存最后一次 upsert)。"""
|
||
|
||
def __init__(self) -> None:
|
||
self._store: dict[tuple[str, int], InjectionOverride] = {}
|
||
|
||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> InjectionOverride | None:
|
||
return self._store.get((str(project_id), chapter_no))
|
||
|
||
async def upsert(
|
||
self, project_id: uuid.UUID, chapter_no: int, override: InjectionOverride
|
||
) -> InjectionOverride:
|
||
self._store[(str(project_id), chapter_no)] = override
|
||
return override
|
||
|
||
|
||
def _memory_repos(
|
||
*,
|
||
outline: dict[int, OutlineView] | None = None,
|
||
characters: list[CharacterView] | None = None,
|
||
world: list[WorldEntityView] | None = None,
|
||
) -> MemoryRepos:
|
||
return MemoryRepos(
|
||
outline=_OutlineRepo(outline or {}),
|
||
character=_CharRepo(characters or []),
|
||
world_entity=_WorldRepo(world or []),
|
||
digest=_DigestRepo(),
|
||
foreshadow=_ForeshadowRepo(),
|
||
style=_StyleRepo(),
|
||
rules=_RulesRepo(),
|
||
project=_SpecRepo(),
|
||
)
|
||
|
||
|
||
def _make_client(
|
||
*,
|
||
project_repo: FakeProjectRepo,
|
||
memory_repos: MemoryRepos,
|
||
injection_repo: _FakeInjectionRepo | None = None,
|
||
) -> httpx.AsyncClient:
|
||
import os
|
||
|
||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||
from fakes_projects import FakeSession
|
||
from ww_api.main import create_app
|
||
from ww_api.services.project_deps import (
|
||
get_injection_repo,
|
||
get_memory_repos,
|
||
get_project_repo,
|
||
)
|
||
from ww_db import get_session
|
||
|
||
app = create_app()
|
||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||
app.dependency_overrides[get_memory_repos] = lambda: memory_repos
|
||
app.dependency_overrides[get_injection_repo] = lambda: injection_repo or _FakeInjectionRepo()
|
||
app.dependency_overrides[get_session] = lambda: FakeSession()
|
||
transport = httpx.ASGITransport(app=app)
|
||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||
|
||
|
||
async def _create_project(repo: FakeProjectRepo, title: str = "注入") -> uuid.UUID:
|
||
view = await repo.create(STUB_OWNER_ID, ProjectCreate(title=title))
|
||
return uuid.UUID(str(view.id))
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_injection_lists_selected_entities_with_reasons() -> None:
|
||
repo = FakeProjectRepo()
|
||
pid = await _create_project(repo)
|
||
memory = _memory_repos(
|
||
outline={3: OutlineView(volume=1, chapter_no=3, beats={"entities": ["青檀"]})},
|
||
characters=[
|
||
CharacterView(name="林动", role="主角"),
|
||
CharacterView(name="青檀", role="配角"),
|
||
CharacterView(name="路人甲", role="龙套"),
|
||
],
|
||
)
|
||
client = _make_client(project_repo=repo, memory_repos=memory)
|
||
async with client:
|
||
resp = await client.get(f"/projects/{pid}/chapters/3/injection")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["project_id"] == str(pid)
|
||
assert body["chapter_no"] == 3
|
||
by_name = {e["name"]: e for e in body["selected"]}
|
||
assert "林动" in by_name and "main_character" in by_name["林动"]["reasons"]
|
||
assert "青檀" in by_name and "explicit_beat" in by_name["青檀"]["reasons"]
|
||
assert "路人甲" not in by_name # 未命中任何理由 → 不注入
|
||
assert body["recent_n"] >= 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_injection_unknown_project_404() -> None:
|
||
repo = FakeProjectRepo()
|
||
client = _make_client(project_repo=repo, memory_repos=_memory_repos())
|
||
async with client:
|
||
resp = await client.get(f"/projects/{uuid.uuid4()}/chapters/1/injection")
|
||
assert resp.status_code == 404
|
||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_injection_no_outline_returns_empty() -> None:
|
||
repo = FakeProjectRepo()
|
||
pid = await _create_project(repo)
|
||
client = _make_client(project_repo=repo, memory_repos=_memory_repos())
|
||
async with client:
|
||
resp = await client.get(f"/projects/{pid}/chapters/9/injection")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["selected"] == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_injection_put_pin_excludes_and_reflects() -> None:
|
||
repo = FakeProjectRepo()
|
||
pid = await _create_project(repo)
|
||
memory = _memory_repos(
|
||
outline={3: OutlineView(volume=1, chapter_no=3, beats={})},
|
||
characters=[
|
||
CharacterView(name="林动", role="主角"),
|
||
CharacterView(name="路人乙", role="龙套"),
|
||
],
|
||
)
|
||
injection = _FakeInjectionRepo()
|
||
client = _make_client(project_repo=repo, memory_repos=memory, injection_repo=injection)
|
||
async with client:
|
||
# pin 龙套路人乙(本无理由)、排除主角林动、近况回看设为 2。
|
||
put = await client.put(
|
||
f"/projects/{pid}/chapters/3/injection",
|
||
json={
|
||
"pinned": [{"kind": "character", "name": "路人乙"}],
|
||
"excluded": [{"kind": "character", "name": "林动"}],
|
||
"recent_n": 2,
|
||
},
|
||
)
|
||
assert put.status_code == 200
|
||
body = put.json()
|
||
by_name = {e["name"]: e for e in body["selected"]}
|
||
assert "路人乙" in by_name and "author_pin" in by_name["路人乙"]["reasons"]
|
||
assert "林动" not in by_name # 排除生效
|
||
assert body["recent_n"] == 2
|
||
assert body["pinned"] == [{"kind": "character", "name": "路人乙"}]
|
||
assert body["excluded"] == [{"kind": "character", "name": "林动"}]
|
||
|
||
# 覆盖已落 fake repo → GET 回放同一结果(看到的=写章用的)。
|
||
got = await client.get(f"/projects/{pid}/chapters/3/injection")
|
||
assert got.status_code == 200
|
||
got_names = {e["name"] for e in got.json()["selected"]}
|
||
assert "路人乙" in got_names and "林动" not in got_names
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_injection_put_unknown_project_404() -> None:
|
||
repo = FakeProjectRepo()
|
||
client = _make_client(project_repo=repo, memory_repos=_memory_repos())
|
||
async with client:
|
||
resp = await client.put(
|
||
f"/projects/{uuid.uuid4()}/chapters/1/injection",
|
||
json={"pinned": [], "excluded": [], "recent_n": None},
|
||
)
|
||
assert resp.status_code == 404
|
||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_injection_put_rejects_out_of_range_recent_n() -> None:
|
||
repo = FakeProjectRepo()
|
||
pid = await _create_project(repo)
|
||
client = _make_client(project_repo=repo, memory_repos=_memory_repos())
|
||
async with client:
|
||
resp = await client.put(
|
||
f"/projects/{pid}/chapters/1/injection",
|
||
json={"pinned": [], "excluded": [], "recent_n": 999},
|
||
)
|
||
assert resp.status_code == 422
|