feat: M1 — 立项→写章草稿(SSE)→自动保存;连一家 provider
- 薄自建 LLM 网关:OpenAI 兼容适配器(DeepSeek) + instructor 结构化输出 + usage_ledger 记账 + 档位路由 - 记忆服务 assemble:确定性选择(显式+主角+近况) + 渲染卡 + 缓存断点(中性文本) - LangGraph 写章节点 + Postgres checkpointer + SSE 归一(token/done/error) - API:立项 + 写章 draft(SSE) + PUT 自动保存 + 提供商凭据(Fernet 加密/测试连接) - 前端:AppShell + 作品库 + 5 步立项向导 + 写作工作台(流式打字机+自动保存) + 设置页 - M1 E2E:真实 DB + mock 网关零 token 走通闭环
This commit is contained in:
227
apps/api/tests/fakes_projects.py
Normal file
227
apps/api/tests/fakes_projects.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""内存替身:项目/章节 repo + 写章网关(端点测试用,无 DB/无网络)。
|
||||
|
||||
绝对导入 `from fakes_projects import ...`(包目录无 __init__.py,见 memory/gotchas)。
|
||||
全局唯一名(避免跨包同名碰撞)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from ww_core.domain.chapter_repo import ChapterDraftView, ChapterView
|
||||
from ww_core.domain.project_repo import ProjectCreate, ProjectView
|
||||
from ww_core.domain.repositories import DigestView
|
||||
from ww_core.domain.review_repo import ReviewView
|
||||
from ww_llm_gateway.types import Delta, LlmRequest, LlmResponse, ServedBy, Usage
|
||||
|
||||
|
||||
class FakeProjectRepo:
|
||||
"""实现 `ProjectRepo` Protocol 的内存版(按 owner_id 隔离)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.rows: dict[uuid.UUID, tuple[uuid.UUID, ProjectView]] = {}
|
||||
|
||||
async def create(self, owner_id: uuid.UUID, data: ProjectCreate) -> ProjectView:
|
||||
pid = uuid.uuid4()
|
||||
view = ProjectView(
|
||||
id=pid,
|
||||
title=data.title,
|
||||
genre=data.genre,
|
||||
logline=data.logline,
|
||||
premise=data.premise,
|
||||
theme=data.theme,
|
||||
selling_points=list(data.selling_points),
|
||||
structure=data.structure,
|
||||
)
|
||||
self.rows[pid] = (owner_id, view)
|
||||
return view
|
||||
|
||||
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]:
|
||||
return [v for (o, v) in self.rows.values() if o == owner_id]
|
||||
|
||||
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None:
|
||||
entry = self.rows.get(project_id)
|
||||
if entry is None or entry[0] != owner_id:
|
||||
return None
|
||||
return entry[1]
|
||||
|
||||
|
||||
class FakeChapterRepo:
|
||||
"""实现 `ChapterRepo` Protocol:幂等覆盖同 (project_id, chapter_no) 草稿。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.drafts: dict[tuple[uuid.UUID, int], ChapterDraftView] = {}
|
||||
self.accepted_versions: dict[tuple[uuid.UUID, int], int] = {}
|
||||
self.accepted_content: dict[tuple[uuid.UUID, int, int], str] = {}
|
||||
|
||||
async def save_draft(
|
||||
self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1
|
||||
) -> ChapterDraftView:
|
||||
view = ChapterDraftView(
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
volume=volume,
|
||||
content=text,
|
||||
status="draft",
|
||||
version=1,
|
||||
)
|
||||
self.drafts[(project_id, chapter_no)] = view
|
||||
return view
|
||||
|
||||
async def get_draft(self, project_id: uuid.UUID, chapter_no: int) -> ChapterDraftView | None:
|
||||
return self.drafts.get((project_id, chapter_no))
|
||||
|
||||
async def max_version(self, project_id: uuid.UUID, chapter_no: int) -> int:
|
||||
versions = [
|
||||
v for (p, c), v in self.accepted_versions.items() if p == project_id and c == chapter_no
|
||||
]
|
||||
draft = 1 if (project_id, chapter_no) in self.drafts else 0
|
||||
return max([*versions, draft], default=0)
|
||||
|
||||
async def promote_to_accepted(
|
||||
self, project_id: uuid.UUID, chapter_no: int, *, content: str, volume: int = 1
|
||||
) -> ChapterView:
|
||||
next_version = (await self.max_version(project_id, chapter_no)) + 1
|
||||
self.accepted_versions[(project_id, chapter_no)] = next_version
|
||||
self.accepted_content[(project_id, chapter_no, next_version)] = content
|
||||
return ChapterView(
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
volume=volume,
|
||||
content=content,
|
||||
status="accepted",
|
||||
version=next_version,
|
||||
)
|
||||
|
||||
async def latest_accepted(self, project_id: uuid.UUID, chapter_no: int) -> ChapterView | None:
|
||||
ver = self.accepted_versions.get((project_id, chapter_no))
|
||||
if ver is None:
|
||||
return None
|
||||
return ChapterView(
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
volume=1,
|
||||
content=self.accepted_content[(project_id, chapter_no, ver)],
|
||||
status="accepted",
|
||||
version=ver,
|
||||
)
|
||||
|
||||
|
||||
class FakeReviewRepo:
|
||||
"""实现 `ReviewRepo` Protocol(内存留痕 + 历史 + 裁决;只 flush 语义无 DB)。"""
|
||||
|
||||
def __init__(self, *, fail_set_decisions: bool = False) -> None:
|
||||
self.rows: list[ReviewView] = []
|
||||
self._fail_set_decisions = fail_set_decisions
|
||||
|
||||
async def record(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
*,
|
||||
chapter_version: int | None = None,
|
||||
conflicts: list[dict[str, Any]],
|
||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||
style: dict[str, Any] | None = None,
|
||||
pace: dict[str, Any] | None = None,
|
||||
health_score: int | None = None,
|
||||
) -> ReviewView:
|
||||
view = ReviewView(
|
||||
id=uuid.uuid4(),
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
chapter_version=chapter_version,
|
||||
conflicts=[dict(c) for c in conflicts],
|
||||
foreshadow_sug=[dict(s) for s in (foreshadow_sug or [])],
|
||||
style=style,
|
||||
pace=pace,
|
||||
health_score=health_score,
|
||||
)
|
||||
# 新→旧:插到列表头部。
|
||||
self.rows.insert(0, view)
|
||||
return view
|
||||
|
||||
async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[ReviewView]:
|
||||
return [r for r in self.rows if r.project_id == project_id and r.chapter_no == chapter_no]
|
||||
|
||||
async def set_decisions(self, review_id: uuid.UUID, *, decisions: dict[str, Any]) -> ReviewView:
|
||||
if self._fail_set_decisions:
|
||||
raise RuntimeError("boom: set_decisions failed (transaction rollback test)")
|
||||
for i, r in enumerate(self.rows):
|
||||
if r.id == review_id:
|
||||
updated = r.model_copy(update={"decisions": dict(decisions)})
|
||||
self.rows[i] = updated
|
||||
return updated
|
||||
raise LookupError(f"review not found: {review_id}")
|
||||
|
||||
|
||||
class FakeDigestAppendRepo:
|
||||
"""实现 `DigestAppendRepo` Protocol(内存 append-only)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.rows: list[tuple[uuid.UUID, int, dict[str, Any]]] = []
|
||||
|
||||
async def append(
|
||||
self, project_id: uuid.UUID, chapter_no: int, *, facts: dict[str, Any]
|
||||
) -> DigestView:
|
||||
self.rows.append((project_id, chapter_no, dict(facts)))
|
||||
return DigestView(chapter_no=chapter_no, facts=dict(facts))
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""最小 fake:记录 commit 次数(验收事务边界断言)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.commits = 0
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
|
||||
class FakeReviewGateway:
|
||||
"""实现审稿/digest 节点最小依赖(`run`):吐固定结构化 `parsed`,绝不联网。"""
|
||||
|
||||
def __init__(self, parsed: BaseModel | None = None, error: Exception | None = None) -> None:
|
||||
self._parsed = parsed
|
||||
self._error = error
|
||||
self.requests: list[LlmRequest] = []
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||||
self.requests.append(req)
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return LlmResponse(
|
||||
text=self._parsed.model_dump_json() if self._parsed else "{}",
|
||||
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"),
|
||||
)
|
||||
|
||||
|
||||
class FakeWriterGateway:
|
||||
"""实现 write 节点最小依赖(`stream`):吐固定 `Delta` 序列,绝不联网。
|
||||
|
||||
可注入 `error` 以验证 SSE 归一层把异常归一为 error 事件。
|
||||
"""
|
||||
|
||||
def __init__(self, chunks: list[str] | None = None, error: Exception | None = None) -> None:
|
||||
self._chunks = chunks if chunks is not None else ["第一段。", "第二段。"]
|
||||
self._error = error
|
||||
self.requests: list[LlmRequest] = []
|
||||
|
||||
async def stream(self, req: LlmRequest) -> AsyncIterator[Delta]:
|
||||
self.requests.append(req)
|
||||
for c in self._chunks:
|
||||
yield Delta(text=c)
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
56
apps/api/tests/fakes_providers.py
Normal file
56
apps/api/tests/fakes_providers.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""内存替身:凭据存储 + 提供商探测(端点测试用,无 DB/无网络)。
|
||||
|
||||
绝对导入 `from fakes import ...`(包目录无 __init__.py,见 memory/gotchas)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from ww_api.services.credentials import StoredCredential, StoredRouting
|
||||
from ww_llm_gateway.adapters.base import Capabilities
|
||||
|
||||
|
||||
class FakeCredentialStore:
|
||||
"""实现 `CredentialStore` Protocol 的内存版。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# key: (owner_id, provider) → 密文
|
||||
self.creds: dict[tuple[uuid.UUID, str], bytes] = {}
|
||||
self.routing: dict[str, StoredRouting] = {}
|
||||
|
||||
async def list_credentials(self, owner_id: uuid.UUID) -> list[StoredCredential]:
|
||||
return [
|
||||
StoredCredential(provider=p, api_key_enc=blob)
|
||||
for (o, p), blob in self.creds.items()
|
||||
if o == owner_id
|
||||
]
|
||||
|
||||
async def list_routing(self) -> list[StoredRouting]:
|
||||
return list(self.routing.values())
|
||||
|
||||
async def get_credential(self, owner_id: uuid.UUID, provider: str) -> StoredCredential | None:
|
||||
blob = self.creds.get((owner_id, provider))
|
||||
if blob is None:
|
||||
return None
|
||||
return StoredCredential(provider=provider, api_key_enc=blob)
|
||||
|
||||
async def upsert_credential(
|
||||
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
|
||||
) -> None:
|
||||
self.creds[(owner_id, provider)] = api_key_enc
|
||||
|
||||
async def upsert_routing(self, routing: StoredRouting) -> None:
|
||||
self.routing[routing.tier] = routing
|
||||
|
||||
|
||||
class FakeProviderProbe:
|
||||
"""实现 `ProviderProbe` Protocol:返回固定能力矩阵,绝不联网。"""
|
||||
|
||||
def __init__(self, caps: Capabilities | None = None) -> None:
|
||||
self.caps = caps or Capabilities(structured_output=True, prefix_cache=True, thinking=False)
|
||||
self.calls: list[tuple[uuid.UUID, str]] = []
|
||||
|
||||
async def probe(self, owner_id: uuid.UUID, provider: str) -> Capabilities:
|
||||
self.calls.append((owner_id, provider))
|
||||
return self.caps
|
||||
59
apps/api/tests/test_credentials_crypto.py
Normal file
59
apps/api/tests/test_credentials_crypto.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""T1.7 凭据加密工具单测——Fernet 往返 + 掩码(ARCH §4.7)。
|
||||
|
||||
绝不在响应/日志回显明文 Key。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from ww_api.security.credentials import (
|
||||
CredentialKeyError,
|
||||
decrypt_api_key,
|
||||
encrypt_api_key,
|
||||
mask_api_key,
|
||||
)
|
||||
|
||||
KEY = Fernet.generate_key().decode()
|
||||
|
||||
|
||||
def test_encrypt_decrypt_round_trip() -> None:
|
||||
# Arrange
|
||||
plaintext = "sk-abc123def456"
|
||||
# Act
|
||||
blob = encrypt_api_key(plaintext, key=KEY)
|
||||
# Assert
|
||||
assert isinstance(blob, bytes)
|
||||
assert plaintext.encode() not in blob # 密文不含明文
|
||||
assert decrypt_api_key(blob, key=KEY) == plaintext
|
||||
|
||||
|
||||
def test_encrypt_is_non_deterministic() -> None:
|
||||
# Fernet 含随机 IV——两次加密产物不同,但都能解回
|
||||
a = encrypt_api_key("sk-secret", key=KEY)
|
||||
b = encrypt_api_key("sk-secret", key=KEY)
|
||||
assert a != b
|
||||
assert decrypt_api_key(a, key=KEY) == decrypt_api_key(b, key=KEY) == "sk-secret"
|
||||
|
||||
|
||||
def test_missing_key_fails_fast() -> None:
|
||||
with pytest.raises(CredentialKeyError):
|
||||
encrypt_api_key("sk-x", key="")
|
||||
|
||||
|
||||
def test_invalid_key_fails_fast() -> None:
|
||||
with pytest.raises(CredentialKeyError):
|
||||
encrypt_api_key("sk-x", key="not-a-valid-fernet-key")
|
||||
|
||||
|
||||
def test_mask_shows_only_last_four() -> None:
|
||||
assert mask_api_key("sk-abcdefgh1234") == "sk-…1234"
|
||||
|
||||
|
||||
def test_mask_short_key_fully_hidden() -> None:
|
||||
# 极短 key 不泄露任何字符
|
||||
assert mask_api_key("ab") == "sk-…••••"
|
||||
|
||||
|
||||
def test_mask_empty_key() -> None:
|
||||
assert mask_api_key("") == "sk-…••••"
|
||||
210
apps/api/tests/test_projects.py
Normal file
210
apps/api/tests/test_projects.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""T1.4 端点测试:立项 CRUD + 写章 SSE + 自动保存(内存替身,无 DB/无网络)。
|
||||
|
||||
DoD:端点入 OpenAPI;POST draft 回 SSE 流(mock 网关验证);PUT draft 幂等。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fakes_projects import FakeChapterRepo, FakeProjectRepo, FakeWriterGateway
|
||||
from ww_core.domain.repositories import (
|
||||
CharacterView,
|
||||
DigestView,
|
||||
ForeshadowView,
|
||||
MemoryRepos,
|
||||
OutlineView,
|
||||
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
|
||||
|
||||
|
||||
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 []
|
||||
|
||||
|
||||
def _empty_memory_repos() -> MemoryRepos:
|
||||
return MemoryRepos(
|
||||
outline=_EmptyOutlineRepo(),
|
||||
character=_EmptyCharacterRepo(),
|
||||
world_entity=_EmptyWorldEntityRepo(),
|
||||
digest=_EmptyDigestRepo(),
|
||||
foreshadow=_EmptyForeshadowRepo(),
|
||||
style=_EmptyStyleRepo(),
|
||||
rules=_EmptyRulesRepo(),
|
||||
)
|
||||
|
||||
|
||||
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", "x" * 44)
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import (
|
||||
get_chapter_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
|
||||
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_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("改稿更长")
|
||||
93
apps/api/tests/test_settings_providers.py
Normal file
93
apps/api/tests/test_settings_providers.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""T1.7 端点测试:凭据 upsert/列表/探测(内存替身,无 DB/无网络)。
|
||||
|
||||
DoD:掩码往返、test-connection 经可注入探测回能力矩阵、明文绝不出响应。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fakes_providers import FakeCredentialStore, FakeProviderProbe
|
||||
from fastapi.testclient import TestClient
|
||||
from ww_api.security.credentials import decrypt_api_key
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
|
||||
|
||||
def test_get_empty_providers(client: TestClient) -> None:
|
||||
resp = client.get("/settings/providers")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body == {"providers": [], "tier_routing": []}
|
||||
|
||||
|
||||
def test_put_credential_encrypts_and_masks(
|
||||
client: TestClient, store: FakeCredentialStore, enc_key: str
|
||||
) -> None:
|
||||
# Act
|
||||
resp = client.put(
|
||||
"/settings/providers",
|
||||
json={"credentials": [{"provider": "deepseek", "api_key": "sk-secret-9999"}]},
|
||||
)
|
||||
# Assert: 响应含掩码,绝不含明文
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "sk-secret-9999" not in resp.text
|
||||
assert body["providers"][0]["provider"] == "deepseek"
|
||||
# 存的是密文且能解回明文(往返)
|
||||
blob = store.creds[(STUB_OWNER_ID, "deepseek")]
|
||||
assert b"sk-secret-9999" not in blob
|
||||
assert decrypt_api_key(blob, key=enc_key) == "sk-secret-9999"
|
||||
|
||||
|
||||
def test_put_is_idempotent(client: TestClient, store: FakeCredentialStore) -> None:
|
||||
payload = {"credentials": [{"provider": "deepseek", "api_key": "sk-a"}]}
|
||||
client.put("/settings/providers", json=payload)
|
||||
client.put(
|
||||
"/settings/providers", json={"credentials": [{"provider": "deepseek", "api_key": "sk-b"}]}
|
||||
)
|
||||
# 同 (owner, provider) 只一条
|
||||
assert len([k for k in store.creds if k[1] == "deepseek"]) == 1
|
||||
|
||||
|
||||
def test_put_tier_routing(client: TestClient, store: FakeCredentialStore) -> None:
|
||||
resp = client.put(
|
||||
"/settings/providers",
|
||||
json={
|
||||
"tier_routing": [
|
||||
{
|
||||
"tier": "writer",
|
||||
"provider": "deepseek",
|
||||
"model": "deepseek-chat",
|
||||
"fallback": ["kimi"],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
routing = resp.json()["tier_routing"]
|
||||
assert routing[0] == {
|
||||
"tier": "writer",
|
||||
"provider": "deepseek",
|
||||
"model": "deepseek-chat",
|
||||
"fallback": ["kimi"],
|
||||
}
|
||||
|
||||
|
||||
def test_test_connection_returns_capabilities(client: TestClient, probe: FakeProviderProbe) -> None:
|
||||
resp = client.post("/settings/providers/test", json={"provider": "deepseek"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["provider"] == "deepseek"
|
||||
assert body["ok"] is True
|
||||
assert body["capabilities"] == {
|
||||
"structured_output": True,
|
||||
"prefix_cache": True,
|
||||
"thinking": False,
|
||||
}
|
||||
assert probe.calls == [(STUB_OWNER_ID, "deepseek")]
|
||||
|
||||
|
||||
def test_put_validation_rejects_blank_provider(client: TestClient) -> None:
|
||||
resp = client.put(
|
||||
"/settings/providers",
|
||||
json={"credentials": [{"provider": "", "api_key": "sk-x"}]},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
Reference in New Issue
Block a user