feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复

M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。
M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变);
  网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则;
  前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。
K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径;
  coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。
本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400);
  GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化;
  字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。
门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-06-20 10:39:58 +02:00
parent 5fb7bfb1de
commit 765dbdfbd4
161 changed files with 17330 additions and 208 deletions

View File

@@ -12,7 +12,7 @@ from contextlib import asynccontextmanager
from typing import Any
from pydantic import BaseModel
from ww_core.domain import ForeshadowLedgerView, OutlineWriteView
from ww_core.domain import ForeshadowLedgerView, OutlineWriteView, StyleFingerprintView
from ww_core.domain.chapter_repo import ChapterDraftView, ChapterView
from ww_core.domain.foreshadow_state import (
ForeshadowStatus,
@@ -21,8 +21,16 @@ from ww_core.domain.foreshadow_state import (
from ww_core.domain.foreshadow_state import (
transition as apply_transition,
)
from ww_core.domain.job_repo import (
PROGRESS_COMPLETE,
STATUS_DONE,
STATUS_FAILED,
STATUS_QUEUED,
STATUS_RUNNING,
JobView,
)
from ww_core.domain.project_repo import ProjectCreate, ProjectView
from ww_core.domain.repositories import DigestView
from ww_core.domain.repositories import DigestView, OutlineView
from ww_core.domain.review_repo import ReviewView
from ww_llm_gateway.types import Delta, LlmRequest, LlmResponse, ServedBy, Usage
@@ -312,6 +320,40 @@ class FakeOutlineWriteRepo:
)
class FakeOutlineReadRepo:
"""实现读侧 `OutlineRepo` Protocol内存DB beats 形 `{"beats": [...]}`)。
`add_chapter` 以裸 `list[str]` 便利入种,内部包成 dict与 DB JSONB round-trip 一致)。
`list_for_project` 按 chapter_no 升序返回(仿 SqlOutlineRepo 的 order_by
"""
def __init__(self) -> None:
self.rows: dict[tuple[uuid.UUID, int], OutlineView] = {}
def add_chapter(
self,
project_id: uuid.UUID,
*,
volume: int,
chapter_no: int,
beats: list[str],
foreshadow_windows: list[dict[str, Any]] | None = None,
) -> None:
self.rows[(project_id, chapter_no)] = OutlineView(
volume=volume,
chapter_no=chapter_no,
beats={"beats": list(beats)},
foreshadow_windows=[dict(w) for w in (foreshadow_windows or [])],
)
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return self.rows.get((project_id, chapter_no))
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
views = [v for (p, _), v in self.rows.items() if p == project_id]
return sorted(views, key=lambda v: v.chapter_no)
class FakeSessionFactory:
"""到期扫描的独立 session 工厂替身:`()` → async-CM 产 `FakeSession`。
@@ -332,20 +374,106 @@ class FakeSessionFactory:
return _cm()
class FakeStyleWriteRepo:
"""实现 `StyleFingerprintWriteRepo` Protocol内存版本化 append + 最新读取)。
`append` 模拟 version=当前 max+1首次 1`latest` 取最大 version 行。
供 `GET /style` 读侧 + 学文风 workmode=update → 第二次 append version+1断言。
"""
def __init__(self) -> None:
# version -> (dimensions, evidence)
self.rows: dict[int, tuple[dict[str, Any], dict[str, Any]]] = {}
self.append_calls = 0
async def append(
self,
project_id: uuid.UUID,
*,
dimensions_json: dict[str, Any],
evidence_json: dict[str, Any],
) -> int:
self.append_calls += 1
next_version = (max(self.rows) if self.rows else 0) + 1
self.rows[next_version] = (dict(dimensions_json), dict(evidence_json))
return next_version
async def latest(self, project_id: uuid.UUID) -> StyleFingerprintView | None:
if not self.rows:
return None
version = max(self.rows)
dimensions, evidence = self.rows[version]
return StyleFingerprintView(dimensions=dimensions, evidence=evidence, version=version)
class FakeJobRepo:
"""实现 `JobRepo` Protocol内存create/get/状态流转,供 `POST /style` 断言。"""
def __init__(self) -> None:
self.rows: dict[uuid.UUID, JobView] = {}
async def create(self, project_id: uuid.UUID | None, kind: str) -> JobView:
view = JobView(id=uuid.uuid4(), kind=kind, status=STATUS_QUEUED, progress=0)
self.rows[view.id] = view
return view
async def set_running(self, job_id: uuid.UUID) -> JobView:
view = self.rows[job_id].model_copy(update={"status": STATUS_RUNNING})
self.rows[job_id] = view
return view
async def set_progress(self, job_id: uuid.UUID, pct: int) -> JobView:
view = self.rows[job_id].model_copy(update={"progress": max(0, min(100, pct))})
self.rows[job_id] = view
return view
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
view = self.rows[job_id].model_copy(
update={"status": STATUS_DONE, "progress": PROGRESS_COMPLETE, "result": dict(result)}
)
self.rows[job_id] = view
return view
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
view = self.rows[job_id].model_copy(update={"status": STATUS_FAILED, "error": error})
self.rows[job_id] = view
return view
async def get(self, job_id: uuid.UUID) -> JobView | None:
return self.rows.get(job_id)
async def reap_zombies(self) -> int:
return 0
class FakeReviewGateway:
"""实现审稿/digest 节点最小依赖(`run`):吐固定结构化 `parsed`,绝不联网。"""
def __init__(self, parsed: BaseModel | None = None, error: Exception | None = None) -> None:
def __init__(
self,
parsed: BaseModel | None = None,
error: Exception | None = None,
*,
text: str | None = None,
) -> None:
self._parsed = parsed
self._error = error
# `text` 覆盖纯文本产出refiner output_schema=None → 端点消费 resp.text
self._text = text
self.requests: list[LlmRequest] = []
async def run(self, req: LlmRequest) -> LlmResponse:
self.requests.append(req)
if self._error is not None:
raise self._error
if self._text is not None:
out_text = self._text
elif self._parsed is not None:
out_text = self._parsed.model_dump_json()
else:
out_text = "{}"
return LlmResponse(
text=self._parsed.model_dump_json() if self._parsed else "{}",
text=out_text,
parsed=self._parsed,
usage=Usage(
provider="fake",

View File

@@ -15,21 +15,34 @@ class FakeCredentialStore:
"""实现 `CredentialStore` Protocol 的内存版。"""
def __init__(self) -> None:
# key: (owner_id, provider) → 密文
# key: (owner_id, provider) → api_key 密文
self.creds: dict[tuple[uuid.UUID, str], bytes] = {}
# key: (owner_id, provider) → oauth 密文OAuth 凭据auth_type="oauth"
self.oauth: dict[tuple[uuid.UUID, str], bytes] = {}
self.routing: dict[str, StoredRouting] = {}
async def list_credentials(self, owner_id: uuid.UUID) -> list[StoredCredential]:
return [
rows = [
StoredCredential(provider=p, api_key_enc=blob)
for (o, p), blob in self.creds.items()
if o == owner_id
]
rows += [
StoredCredential(provider=p, api_key_enc=None, auth_type="oauth", oauth_enc=blob)
for (o, p), blob in self.oauth.items()
if o == owner_id
]
return rows
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:
oauth_blob = self.oauth.get((owner_id, provider))
if oauth_blob is not None:
return StoredCredential(
provider=provider, api_key_enc=None, auth_type="oauth", oauth_enc=oauth_blob
)
blob = self.creds.get((owner_id, provider))
if blob is None:
return None
@@ -39,6 +52,19 @@ class FakeCredentialStore:
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
) -> None:
self.creds[(owner_id, provider)] = api_key_enc
self.oauth.pop((owner_id, provider), None)
async def upsert_oauth_credential(
self, owner_id: uuid.UUID, provider: str, oauth_enc: bytes
) -> None:
self.oauth[(owner_id, provider)] = oauth_enc
self.creds.pop((owner_id, provider), None)
async def delete_credential(self, owner_id: uuid.UUID, provider: str) -> bool:
had = (owner_id, provider) in self.creds or (owner_id, provider) in self.oauth
self.creds.pop((owner_id, provider), None)
self.oauth.pop((owner_id, provider), None)
return had
async def upsert_routing(self, routing: StoredRouting) -> None:
self.routing[routing.tier] = routing

View File

@@ -0,0 +1,100 @@
"""T5.2/T5.4 接线:`build_gateway_for_tier` 据 DB tier_routing 装配多 provider 回退链。
不联网:只断言网关被装配了**多个适配器** + 注入了回退链解析器chain_resolver
故链长 > 1。真实回退/降级行为由 packages/llm_gateway 单测 + T5.7 E2E 覆盖。
"""
from __future__ import annotations
import os
import pytest
from fakes_projects import FakeSession
from fakes_providers import FakeCredentialStore
from ww_api.services.credentials import STUB_OWNER_ID, StoredRouting
# 真 Fernet key测试加解密凭据用仅本测试非生产密钥
_ENC_KEY = "imB6TEcV2AzlpXjR4NjTgK_9Zt-OoqaLFQc3XqBQ0CQ="
def _store_with_creds(*providers: str) -> FakeCredentialStore:
from ww_api.security.credentials import encrypt_api_key
store = FakeCredentialStore()
for p in providers:
store.creds[(STUB_OWNER_ID, p)] = encrypt_api_key("sk-test", key=_ENC_KEY)
return store
@pytest.mark.asyncio
async def test_build_gateway_wires_fallback_chain_with_multiple_adapters() -> None:
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
from ww_config import get_settings
get_settings.cache_clear()
from ww_api.services.project_deps import build_gateway_for_tier
store = _store_with_creds("deepseek", "kimi")
store.routing["writer"] = StoredRouting(
tier="writer", provider="deepseek", model="deepseek-chat", fallback=["kimi:moonshot-v1-8k"]
)
gateway = await build_gateway_for_tier(FakeSession(), store, "writer")
# 两个 provider 的适配器都装配进网关 → 回退链可绕过首个失败者。
assert set(gateway._adapters) == {"deepseek", "kimi"} # noqa: SLF001
# chain_resolver 注入 → 链长 2去重保序
chain = gateway._resolve_chain("writer") # noqa: SLF001
assert [r.provider for r in chain] == ["deepseek", "kimi"]
@pytest.mark.asyncio
async def test_build_gateway_single_provider_still_works() -> None:
"""无 fallback 的路由行单元素链M1 行为不变,向后兼容)。"""
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
from ww_config import get_settings
get_settings.cache_clear()
from ww_api.services.project_deps import build_gateway_for_tier
store = _store_with_creds("deepseek")
store.routing["analyst"] = StoredRouting(
tier="analyst", provider="deepseek", model="deepseek-chat", fallback=[]
)
gateway = await build_gateway_for_tier(FakeSession(), store, "analyst")
assert set(gateway._adapters) == {"deepseek"} # noqa: SLF001
chain = gateway._resolve_chain("analyst") # noqa: SLF001
assert len(chain) == 1
@pytest.mark.asyncio
async def test_build_gateway_no_routing_row_falls_back_to_global_default() -> None:
"""无 DB 路由行 → 退回全局 resolve_route单 provider"""
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
from ww_config import get_settings
get_settings.cache_clear()
from ww_api.services.project_deps import build_gateway_for_tier
store = _store_with_creds("deepseek") # 无 routing 行
gateway = await build_gateway_for_tier(FakeSession(), store, "writer")
assert "deepseek" in gateway._adapters # noqa: SLF001
@pytest.mark.asyncio
async def test_build_gateway_no_usable_credentials_raises_503() -> None:
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
from ww_config import get_settings
get_settings.cache_clear()
from ww_api.services.project_deps import build_gateway_for_tier
from ww_shared import AppError, ErrorCode
store = FakeCredentialStore() # 无任何凭据
store.routing["writer"] = StoredRouting(
tier="writer", provider="deepseek", model="deepseek-chat", fallback=["kimi:moonshot-v1-8k"]
)
with pytest.raises(AppError) as exc:
await build_gateway_for_tier(FakeSession(), store, "writer")
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE

View File

@@ -0,0 +1,506 @@
"""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 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", "x" * 44)
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"] == []

View File

@@ -0,0 +1,153 @@
"""T4.1 通用长任务 runner 单测ARCH §7.4)。
直接 `await run_job(...)`(不起后台线程、不连真 DB注入 fake session 工厂 + fake job
repo + fake work断言
- 成功路set_running → work → complete(result) → commit
- 失败路work 抛 → 回滚 → 新 session 里 fail(error) → commit且异常不冒泡。
独立 session 纪律 = `run_job` 自建 session经 session_factory与 `run_overdue_scan`
先例一致。这里的 fake session 工厂记录开了几次 session + 各次 commit。
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from ww_api.services.job_runner import run_job
from ww_core.domain.job_repo import (
PROGRESS_COMPLETE,
STATUS_DONE,
STATUS_FAILED,
STATUS_RUNNING,
JobView,
)
JOB_ID = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
class _FakeSession:
"""最小 fake记录 commit 次数(提交边界断言)。"""
def __init__(self) -> None:
self.commits = 0
async def commit(self) -> None:
self.commits += 1
class _FakeSessionFactory:
"""独立 session 工厂替身:`()` → async-CM 产新 `_FakeSession`,记录开了几次。"""
def __init__(self) -> None:
self.sessions: list[_FakeSession] = []
def __call__(self) -> Any:
session = _FakeSession()
self.sessions.append(session)
@asynccontextmanager
async def _cm() -> AsyncIterator[_FakeSession]:
yield session
return _cm()
@dataclass
class _FakeJobRepo:
"""内存 job repo记录状态流转 + result/error不触 DB"""
status: str = "queued"
progress: int = 0
result: dict[str, Any] | None = None
error: str | None = None
calls: list[str] = field(default_factory=list)
def _view(self, job_id: uuid.UUID) -> JobView:
return JobView(
id=job_id,
kind="style_learn",
status=self.status,
progress=self.progress,
result=self.result,
error=self.error,
)
async def set_running(self, job_id: uuid.UUID) -> JobView:
self.calls.append("set_running")
self.status = STATUS_RUNNING
return self._view(job_id)
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
self.calls.append("complete")
self.status = STATUS_DONE
self.progress = PROGRESS_COMPLETE
self.result = dict(result)
return self._view(job_id)
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
self.calls.append("fail")
self.status = STATUS_FAILED
self.error = error
return self._view(job_id)
# ---- success path ----
async def test_run_job_success_sets_done_and_commits() -> None:
factory = _FakeSessionFactory()
repo = _FakeJobRepo()
received: list[AsyncSession] = []
async def work(session: AsyncSession) -> dict[str, Any]:
received.append(session)
return {"version": 1, "dims_count": 16}
await run_job(
factory,
JOB_ID,
work,
repo_factory=lambda _s: repo,
)
assert repo.calls == ["set_running", "complete"]
assert repo.status == STATUS_DONE
assert repo.progress == PROGRESS_COMPLETE
assert repo.result == {"version": 1, "dims_count": 16}
# 自建了一个独立 session 且提交了一次
assert len(factory.sessions) == 1
assert factory.sessions[0].commits == 1
# work 拿到的就是 run_job 自建的 session
assert len(received) == 1
# ---- failure path ----
async def test_run_job_failure_sets_failed_and_does_not_raise() -> None:
factory = _FakeSessionFactory()
repo = _FakeJobRepo()
async def work(_session: AsyncSession) -> dict[str, Any]:
raise RuntimeError("extraction blew up")
# 异常被吞(后台任务边界),不冒泡
await run_job(
factory,
JOB_ID,
work,
repo_factory=lambda _s: repo,
)
assert "complete" not in repo.calls
assert "fail" in repo.calls
assert repo.status == STATUS_FAILED
assert repo.error == "extraction blew up"
# 失败置态在一个**全新** session 里完成并 commit原事务作废
assert len(factory.sessions) == 2
assert factory.sessions[1].commits == 1

View File

@@ -0,0 +1,77 @@
"""接线测试:`kimi-code-key` 作为**普通 api_key** provider 经既有路径建干净 coding 适配器。
不联网。与 OAuth 的 `kimi-code` 对照——`kimi-code-key`
- 走 api_key 凭据路径Fernet `api_key_enc`**不**触发 OAuth 刷新分支;
- `build_gateway_for_tier` 据 DB `tier_routing` 路由 → `_build_provider_adapter` →
`build_adapter("kimi-code-key", ...)` → `KimiCodeKeyAdapter`coding base + 纯 bearer
- 适配器**不带**伪造 UA / X-Msh-* 头,结构化走 JSON 模式。
"""
from __future__ import annotations
import os
import pytest
from fakes_projects import FakeSession
from fakes_providers import FakeCredentialStore
from ww_api.services.credentials import STUB_OWNER_ID, StoredRouting
from ww_api.services.project_deps import _build_provider_adapter, build_gateway_for_tier
from ww_llm_gateway.adapters.kimi_code_key import KIMI_CODE_KEY_PROVIDER, KimiCodeKeyAdapter
_ENC_KEY = "imB6TEcV2AzlpXjR4NjTgK_9Zt-OoqaLFQc3XqBQ0CQ="
_API_KEY = "kimi-console-static-key-not-real"
def _set_env() -> None:
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
from ww_config import get_settings
get_settings.cache_clear()
def _store_with_key() -> FakeCredentialStore:
from ww_api.security.credentials import encrypt_api_key
store = FakeCredentialStore()
store.creds[(STUB_OWNER_ID, KIMI_CODE_KEY_PROVIDER)] = encrypt_api_key(_API_KEY, key=_ENC_KEY)
return store
@pytest.mark.asyncio
async def test_build_provider_adapter_for_kimi_code_key_sends_spoofed_headers() -> None:
_set_env()
store = _store_with_key()
adapter = await _build_provider_adapter(store, KIMI_CODE_KEY_PROVIDER)
assert isinstance(adapter, KimiCodeKeyAdapter)
assert adapter.provider == "kimi-code-key"
client = adapter._client # noqa: SLF001
assert str(client.base_url).rstrip("/") == "https://api.kimi.com/coding/v1"
assert client.api_key == _API_KEY
# 实测coding 端点据 UA 做 allow-list 门禁缺伪造头→403故静态 key 也必须发伪造头。
headers = client.default_headers
assert any(str(k).lower().startswith("x-msh-") for k in headers)
assert "KimiCLI" in str(headers.get("User-Agent", ""))
@pytest.mark.asyncio
async def test_build_gateway_for_tier_routes_kimi_code_key_for_coding_model() -> None:
_set_env()
store = _store_with_key()
# 把 writer 档位路由到 kimi-code-key:kimi-for-coding无回退
store.routing["writer"] = StoredRouting(
tier="writer",
provider=KIMI_CODE_KEY_PROVIDER,
model="kimi-for-coding",
fallback=[],
)
gateway = await build_gateway_for_tier(FakeSession(), store, "writer")
assert set(gateway._adapters) == {KIMI_CODE_KEY_PROVIDER} # noqa: SLF001
adapter = gateway._adapters[KIMI_CODE_KEY_PROVIDER] # noqa: SLF001
assert isinstance(adapter, KimiCodeKeyAdapter)
chain = gateway._resolve_chain("writer") # noqa: SLF001
assert [r.provider for r in chain] == [KIMI_CODE_KEY_PROVIDER]
assert chain[0].model == "kimi-for-coding"

View File

@@ -0,0 +1,125 @@
"""K1.3 接线测试:`_build_provider_adapter` 为 kimi-code 走 OAuth 路径 + 按需刷新。
不联网refresh 路径 monkeypatch httpx + kimi_oauth.refresh。断言
- 凭据未临近过期 → 直接用现有 access token 建 KimiCodeAdapter不刷新
- 临近过期 → 调 refresh + 持久化新包 + 用新 access token 建适配器;
- 无 oauth 凭据 → LLM_UNAVAILABLE。
"""
from __future__ import annotations
import os
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
from fakes_providers import FakeCredentialStore
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.kimi_oauth import TokenSet, decrypt_oauth_bundle, encrypt_oauth_bundle
from ww_llm_gateway.adapters.kimi_code import KIMI_CODE_PROVIDER, KimiCodeAdapter
from ww_shared import AppError, ErrorCode
_ENC_KEY = "imB6TEcV2AzlpXjR4NjTgK_9Zt-OoqaLFQc3XqBQ0CQ="
def _store_with_token(token: TokenSet) -> FakeCredentialStore:
store = FakeCredentialStore()
store.oauth[(STUB_OWNER_ID, KIMI_CODE_PROVIDER)] = encrypt_oauth_bundle(token, key=_ENC_KEY)
return store
def _set_env() -> None:
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
from ww_config import get_settings
get_settings.cache_clear()
@pytest.mark.asyncio
async def test_build_adapter_uses_existing_token_when_fresh() -> None:
_set_env()
from ww_api.services.project_deps import _build_provider_adapter
fresh = TokenSet(
access_token="fresh-acc",
refresh_token="ref",
expires_at=datetime.now(UTC) + timedelta(seconds=900),
)
store = _store_with_token(fresh)
adapter = await _build_provider_adapter(store, KIMI_CODE_PROVIDER)
assert isinstance(adapter, KimiCodeAdapter)
# 未刷新oauth 凭据未变。
cred = store.oauth[(STUB_OWNER_ID, KIMI_CODE_PROVIDER)]
assert decrypt_oauth_bundle(cred, key=_ENC_KEY).access_token == "fresh-acc"
@pytest.mark.asyncio
async def test_build_adapter_refreshes_when_near_expiry(monkeypatch: pytest.MonkeyPatch) -> None:
_set_env()
from ww_api.services import project_deps
from ww_api.services.project_deps import _build_provider_adapter
near = TokenSet(
access_token="old-acc",
refresh_token="old-ref",
expires_at=datetime.now(UTC) + timedelta(seconds=30), # 临近过期
)
store = _store_with_token(near)
refreshed = TokenSet(
access_token="new-acc",
refresh_token="new-ref",
expires_at=datetime.now(UTC) + timedelta(seconds=900),
)
async def _fake_refresh(_http: Any, _refresh_token: str) -> TokenSet:
assert _refresh_token == "old-ref"
return refreshed
# 替换 refresh + httpx.AsyncClient避免真联网
monkeypatch.setattr(project_deps, "kimi_refresh", _fake_refresh)
class _FakeAsyncClient:
async def __aenter__(self) -> _FakeAsyncClient:
return self
async def __aexit__(self, *exc: object) -> None:
return None
monkeypatch.setattr(
"ww_api.services.project_deps.httpx.AsyncClient", lambda *a, **k: _FakeAsyncClient()
)
adapter = await _build_provider_adapter(store, KIMI_CODE_PROVIDER)
assert isinstance(adapter, KimiCodeAdapter)
# 刷新后新包持久化(下次复用刷新结果)。
cred = store.oauth[(STUB_OWNER_ID, KIMI_CODE_PROVIDER)]
assert decrypt_oauth_bundle(cred, key=_ENC_KEY).access_token == "new-acc"
@pytest.mark.asyncio
async def test_build_adapter_no_credential_returns_none() -> None:
_set_env()
from ww_api.services.project_deps import _build_provider_adapter
store = FakeCredentialStore() # 无任何凭据
adapter = await _build_provider_adapter(store, KIMI_CODE_PROVIDER)
assert adapter is None
@pytest.mark.asyncio
async def test_build_adapter_raises_when_oauth_missing_blob() -> None:
"""auth_type=oauth 但 oauth_enc=None数据不一致→ LLM_UNAVAILABLE。"""
_set_env()
from ww_api.services.credentials import StoredCredential
from ww_api.services.project_deps import _resolve_kimi_code_token
bad = StoredCredential(
provider=KIMI_CODE_PROVIDER, api_key_enc=None, auth_type="oauth", oauth_enc=None
)
with pytest.raises(AppError) as exc:
await _resolve_kimi_code_token(FakeCredentialStore(), bad, _ENC_KEY)
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE

View File

@@ -0,0 +1,213 @@
"""K1.3 端点测试Kimi Code OAuth start/disconnect/status内存替身无 DB/无网络)。
覆盖:
- POST .../start → 202 + user_code/verification_uri + 创建 job后台轮询 poll_token 成功
→ 存加密 oauth_enc + job done**token 不进 job 结果/响应**
- 后台轮询 authorization_pending → 继续轮询直到成功;
- POST .../disconnect → 清除凭据行;
- GET .../status → connected 真值(无 token 本体)。
后台 work 用 `run_job` 自建独立 session请求 session 已关闭)——测试经依赖 override
注 fake http + fake store共享实例断后台落库+ FakeSessionFactorymonkeypatch
`kimi_oauth.asyncio.sleep` 为 no-op避免真等 interval 秒)。
"""
from __future__ import annotations
import os
from typing import Any
import pytest
from cryptography.fernet import Fernet
from fakes_providers import FakeCredentialStore
from fastapi.testclient import TestClient
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.kimi_oauth import decrypt_oauth_bundle
from ww_db import get_session
class _FakeResponse:
def __init__(self, status_code: int, body: dict[str, Any]) -> None:
self.status_code = status_code
self._body = body
def json(self) -> Any:
return self._body
class _ScriptedHttp:
"""按调用序号返回预设响应device auth → poll attempts。aclose 无操作。"""
def __init__(self, responses: list[_FakeResponse]) -> None:
self._responses = responses
self.calls: list[tuple[str, dict[str, str]]] = []
async def post(self, url: str, *, data: dict[str, str]) -> _FakeResponse:
idx = len(self.calls)
self.calls.append((url, data))
return self._responses[idx]
async def aclose(self) -> None:
return None
def _make_client(
*,
store: FakeCredentialStore,
http: _ScriptedHttp,
enc_key: str,
monkeypatch: pytest.MonkeyPatch,
) -> TestClient:
os.environ["CREDENTIAL_ENC_KEY"] = enc_key
from ww_config import get_settings
get_settings.cache_clear()
from fakes_projects import FakeJobRepo, FakeSession, FakeSessionFactory
from ww_api import routers
from ww_api.main import create_app
from ww_api.services import job_runner
from ww_api.services.project_deps import (
get_job_repo,
get_session_factory,
)
from ww_api.services.provider_deps import get_credential_store
# 后台轮询不真等 interval 秒。
async def _no_sleep(_seconds: float) -> None:
return None
monkeypatch.setattr("ww_api.routers.kimi_oauth.asyncio.sleep", _no_sleep)
# 后台 work 自建 SqlCredentialStore(session)——指到共享 fake store断后台落库
monkeypatch.setattr(routers.kimi_oauth, "SqlCredentialStore", lambda _session: store)
# 后台 work 内调模块级 `_default_http_client()` 自建 http非 dep——monkeypatch 它指到
# scripted fake否则后台轮询会真联网见 device-flow 注入纪律)。
monkeypatch.setattr(routers.kimi_oauth, "_default_http_client", lambda: http)
# `run_job` 默认 repo_factory 构 `SqlJobRepo(session)`——FakeSession 无 .execute
# 故 monkeypatch 模块级 `job_runner.SqlJobRepo` 指到共享 fake见 memory/gotchas
job_repo = FakeJobRepo()
monkeypatch.setattr(job_runner, "SqlJobRepo", lambda _session: job_repo)
app = create_app()
app.dependency_overrides[get_credential_store] = lambda: store
app.dependency_overrides[get_job_repo] = lambda: job_repo
app.dependency_overrides[get_session] = lambda: FakeSession()
app.dependency_overrides[get_session_factory] = lambda: FakeSessionFactory()
app.dependency_overrides[routers.kimi_oauth._default_http_client] = lambda: http
return TestClient(app)
def _device_auth_response() -> _FakeResponse:
return _FakeResponse(
200,
{
"device_code": "dev-1",
"user_code": "WXYZ-9999",
"verification_uri": "https://kimi.com/device",
"verification_uri_complete": "https://kimi.com/device?code=WXYZ-9999",
"expires_in": 600,
"interval": 5,
},
)
def _token_response() -> _FakeResponse:
return _FakeResponse(
200, {"access_token": "secret-acc", "refresh_token": "secret-ref", "expires_in": 900}
)
def test_start_returns_202_and_polls_to_success(monkeypatch: pytest.MonkeyPatch) -> None:
enc_key = Fernet.generate_key().decode()
store = FakeCredentialStore()
# device auth → poll #1 successTestClient 同步跑完 background task
http = _ScriptedHttp([_device_auth_response(), _token_response()])
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
resp = client.post("/settings/providers/kimi-code/oauth/start")
assert resp.status_code == 202
body = resp.json()
assert body["user_code"] == "WXYZ-9999"
assert body["verification_uri"] == "https://kimi.com/device"
assert body["interval"] == 5
assert "job_id" in body
# 响应绝不含 token。
assert "secret-acc" not in resp.text
assert "secret-ref" not in resp.text
# 后台轮询成功 → 加密 oauth 凭据落库(共享 store
cred = store.oauth.get((STUB_OWNER_ID, "kimi-code"))
assert cred is not None
token = decrypt_oauth_bundle(cred, key=enc_key)
assert token.access_token == "secret-acc"
assert token.refresh_token == "secret-ref"
def test_start_polls_through_authorization_pending(monkeypatch: pytest.MonkeyPatch) -> None:
enc_key = Fernet.generate_key().decode()
store = FakeCredentialStore()
http = _ScriptedHttp(
[
_device_auth_response(),
_FakeResponse(400, {"error": "authorization_pending"}),
_token_response(),
]
)
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
resp = client.post("/settings/providers/kimi-code/oauth/start")
assert resp.status_code == 202
# pending 后继续轮询 → 第二次成功落库。
assert (STUB_OWNER_ID, "kimi-code") in store.oauth
def test_disconnect_clears_credential(monkeypatch: pytest.MonkeyPatch) -> None:
enc_key = Fernet.generate_key().decode()
store = FakeCredentialStore()
store.oauth[(STUB_OWNER_ID, "kimi-code")] = b"blob"
http = _ScriptedHttp([])
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
resp = client.post("/settings/providers/kimi-code/oauth/disconnect")
assert resp.status_code == 200
assert resp.json()["disconnected"] is True
assert (STUB_OWNER_ID, "kimi-code") not in store.oauth
def test_status_reflects_connection(monkeypatch: pytest.MonkeyPatch) -> None:
from datetime import UTC, datetime, timedelta
from ww_api.services.kimi_oauth import TokenSet, encrypt_oauth_bundle
enc_key = Fernet.generate_key().decode()
store = FakeCredentialStore()
expires = datetime.now(UTC) + timedelta(seconds=900)
token = TokenSet(access_token="a", refresh_token="r", expires_at=expires)
store.oauth[(STUB_OWNER_ID, "kimi-code")] = encrypt_oauth_bundle(token, key=enc_key)
http = _ScriptedHttp([])
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
resp = client.get("/settings/providers/kimi-code/oauth/status")
assert resp.status_code == 200
body = resp.json()
assert body["connected"] is True
assert body["expires_at"] is not None
# 状态绝不回 token 本体。
assert "access_token" not in body
assert "refresh_token" not in body
def test_status_not_connected_when_no_credential(monkeypatch: pytest.MonkeyPatch) -> None:
enc_key = Fernet.generate_key().decode()
store = FakeCredentialStore()
http = _ScriptedHttp([])
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
resp = client.get("/settings/providers/kimi-code/oauth/status")
assert resp.status_code == 200
assert resp.json()["connected"] is False

View File

@@ -0,0 +1,184 @@
"""K1.3 单测Kimi Code OAuth device-flow 服务fake httpx绝不联网
覆盖start_device_authorization、poll_token成功/pending/slow_down/失败、refresh、
encrypt/decrypt round-trip、needs_refresh 启发式。
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
from cryptography.fernet import Fernet
from ww_api.services.kimi_oauth import (
DEVICE_AUTHORIZATION_URL,
TOKEN_URL,
AuthorizationPending,
SlowDown,
TokenSet,
decrypt_oauth_bundle,
encrypt_oauth_bundle,
needs_refresh,
poll_token,
refresh,
start_device_authorization,
)
from ww_shared import AppError, ErrorCode
class _FakeResponse:
def __init__(self, status_code: int, body: dict[str, Any]) -> None:
self.status_code = status_code
self._body = body
def json(self) -> Any:
return self._body
class _FakeHttp:
"""记录每次 post 的 url + data按 url 返回预设响应。"""
def __init__(self, responses: list[_FakeResponse]) -> None:
self._responses = responses
self.calls: list[tuple[str, dict[str, str]]] = []
async def post(self, url: str, *, data: dict[str, str]) -> _FakeResponse:
self.calls.append((url, data))
return self._responses[len(self.calls) - 1]
@pytest.mark.asyncio
async def test_start_device_authorization_parses_response_and_sends_scope() -> None:
http = _FakeHttp(
[
_FakeResponse(
200,
{
"device_code": "dev-123",
"user_code": "ABCD-1234",
"verification_uri": "https://kimi.com/device",
"verification_uri_complete": "https://kimi.com/device?code=ABCD-1234",
"expires_in": 600,
"interval": 5,
},
)
]
)
device = await start_device_authorization(http)
assert device.device_code == "dev-123"
assert device.user_code == "ABCD-1234"
assert device.interval == 5
assert device.expires_in == 600
# 请求打到 device authorization 端点,且**带 scope=kimi-code**coding entitlement
url, data = http.calls[0]
assert url == DEVICE_AUTHORIZATION_URL
assert "client_id" in data
assert data["scope"] == "kimi-code"
@pytest.mark.asyncio
async def test_poll_token_success_returns_token_set() -> None:
http = _FakeHttp(
[
_FakeResponse(
200,
{
"access_token": "acc-xyz",
"refresh_token": "ref-xyz",
"expires_in": 900,
},
)
]
)
token = await poll_token(http, "dev-123")
assert token.access_token == "acc-xyz"
assert token.refresh_token == "ref-xyz"
assert token.expires_at > datetime.now(UTC)
# 打到 token 端点 + device_code grant。
url, data = http.calls[0]
assert url == TOKEN_URL
assert data["device_code"] == "dev-123"
assert data["grant_type"].endswith("device_code")
@pytest.mark.asyncio
async def test_poll_token_authorization_pending_raises_pending() -> None:
http = _FakeHttp([_FakeResponse(400, {"error": "authorization_pending"})])
with pytest.raises(AuthorizationPending):
await poll_token(http, "dev-123")
@pytest.mark.asyncio
async def test_poll_token_slow_down_raises_slow_down() -> None:
http = _FakeHttp([_FakeResponse(400, {"error": "slow_down"})])
with pytest.raises(SlowDown):
await poll_token(http, "dev-123")
@pytest.mark.asyncio
async def test_poll_token_expired_raises_app_error() -> None:
http = _FakeHttp([_FakeResponse(400, {"error": "expired_token"})])
with pytest.raises(AppError) as exc:
await poll_token(http, "dev-123")
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE
@pytest.mark.asyncio
async def test_refresh_returns_new_token_set() -> None:
http = _FakeHttp(
[
_FakeResponse(
200, {"access_token": "new-acc", "refresh_token": "new-ref", "expires_in": 900}
)
]
)
token = await refresh(http, "ref-old")
assert token.access_token == "new-acc"
assert token.refresh_token == "new-ref"
url, data = http.calls[0]
assert url == TOKEN_URL
assert data["grant_type"] == "refresh_token"
assert data["refresh_token"] == "ref-old"
@pytest.mark.asyncio
async def test_refresh_failure_raises_app_error() -> None:
http = _FakeHttp([_FakeResponse(400, {"error": "invalid_grant"})])
with pytest.raises(AppError) as exc:
await refresh(http, "ref-bad")
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE
def test_encrypt_decrypt_oauth_bundle_round_trip() -> None:
key = Fernet.generate_key().decode()
expires = datetime.now(UTC) + timedelta(seconds=900)
token = TokenSet(access_token="acc", refresh_token="ref", expires_at=expires)
blob = encrypt_oauth_bundle(token, key=key)
restored = decrypt_oauth_bundle(blob, key=key)
assert restored.access_token == "acc"
assert restored.refresh_token == "ref"
assert restored.expires_at == expires
# 密文不含明文 token加密真生效
assert b"acc" not in blob
assert b"ref" not in blob
def test_needs_refresh_true_when_near_expiry() -> None:
now = datetime.now(UTC)
near = TokenSet(access_token="a", refresh_token="r", expires_at=now + timedelta(seconds=60))
assert needs_refresh(near, now=now) is True
def test_needs_refresh_false_when_fresh() -> None:
now = datetime.now(UTC)
fresh = TokenSet(access_token="a", refresh_token="r", expires_at=now + timedelta(seconds=900))
assert needs_refresh(fresh, now=now) is False

View File

@@ -16,6 +16,7 @@ import httpx
import pytest
from fakes_projects import (
FakeForeshadowRepo,
FakeOutlineReadRepo,
FakeOutlineWriteRepo,
FakeProjectRepo,
FakeReviewGateway,
@@ -186,6 +187,84 @@ async def test_generate_outline_upper_error_propagates() -> None:
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", "x" * 44)
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
# ---- 伏笔看板 ----

View File

@@ -16,6 +16,7 @@ from ww_core.domain.repositories import (
ForeshadowView,
MemoryRepos,
OutlineView,
ProjectSpecView,
RuleView,
StyleView,
WorldEntityView,
@@ -27,6 +28,9 @@ 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]:
@@ -58,6 +62,11 @@ class _EmptyRulesRepo:
return []
class _StubProjectSpecRepo:
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
return ProjectSpecView(title="测试作品", premise="测试前提")
def _empty_memory_repos() -> MemoryRepos:
return MemoryRepos(
outline=_EmptyOutlineRepo(),
@@ -67,6 +76,7 @@ def _empty_memory_repos() -> MemoryRepos:
foreshadow=_EmptyForeshadowRepo(),
style=_EmptyStyleRepo(),
rules=_EmptyRulesRepo(),
project=_StubProjectSpecRepo(),
)
@@ -208,3 +218,30 @@ async def test_put_draft_is_idempotent() -> None:
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

View File

@@ -0,0 +1,87 @@
"""T5.5 POST /projects/:id/rules 端点(内存替身,无 DB/无网络)。
覆盖:
- 201 + 回显 level/content + 端点 commit
- 非法 level → 422Pydantic Literal 校验FastAPI 422
- 空 content → 422。
"""
from __future__ import annotations
import uuid
import httpx
import pytest
from fakes_projects import FakeSession
from ww_core.domain.rule_repo import RuleWriteView
class _FakeRuleWriteRepo:
def __init__(self) -> None:
self.rows: list[RuleWriteView] = []
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView:
view = RuleWriteView(project_id=project_id, level=level, content=content)
self.rows.append(view)
return view
def _make_client() -> tuple[httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession]:
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_rule_write_repo
from ww_db import get_session
repo = _FakeRuleWriteRepo()
session = FakeSession()
app = create_app()
app.dependency_overrides[get_rule_write_repo] = lambda: repo
app.dependency_overrides[get_session] = lambda: session
transport = httpx.ASGITransport(app=app)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
return client, repo, session
@pytest.mark.asyncio
async def test_create_rule_returns_201_and_commits() -> None:
client, repo, session = _make_client()
pid = uuid.uuid4()
async with client:
resp = await client.post(
f"/projects/{pid}/rules",
json={"level": "project", "content": "主角不许中途复活"},
)
assert resp.status_code == 201
body = resp.json()
assert body["level"] == "project"
assert body["content"] == "主角不许中途复活"
assert session.commits == 1
assert len(repo.rows) == 1
assert repo.rows[0].project_id == pid
@pytest.mark.asyncio
async def test_create_rule_invalid_level_returns_422() -> None:
client, _repo, _session = _make_client()
pid = uuid.uuid4()
async with client:
resp = await client.post(
f"/projects/{pid}/rules",
json={"level": "nonsense", "content": "x"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_create_rule_empty_content_returns_422() -> None:
client, _repo, _session = _make_client()
pid = uuid.uuid4()
async with client:
resp = await client.post(
f"/projects/{pid}/rules",
json={"level": "global", "content": ""},
)
assert resp.status_code == 422

View File

@@ -0,0 +1,394 @@
"""T4.3 端点测试学文风jobs 异步)+ 回炉 + 最新指纹(内存替身,无 DB/无网络)。
覆盖:
- POST /style写一行 job 返 202 `{job_id}`BackgroundTask 跑完写 style_fingerprint
+ 置 job doneASGITransport 下 background task 在请求返回前跑完,见 gotcha
- mode="update" → 第二次 append version+1
- 项目不存在 → 404无凭据 → 503dep 解析阶段拦下,未写 job
- GET /style返最新指纹dimensions/evidence/version无指纹 → 404
- POST /refine{original, refined}refiner 纯文本)+ 末尾 commit记账落库无凭据 → 503。
学文风 work 在 `run_job` 自建独立 session 上自造网关 + repo请求 session 已关闭)——
测试 monkeypatch `build_gateway_for_tier`(产 StyleFingerprintResult+ 写侧 repo
(指到共享 fake repo并 override `get_session_factory` 为 FakeSessionFactory断言后台落库。
"""
from __future__ import annotations
import os
import uuid
import httpx
import pytest
from fakes_projects import (
FakeJobRepo,
FakeProjectRepo,
FakeReviewGateway,
FakeSession,
FakeSessionFactory,
FakeStyleWriteRepo,
)
from fastapi import FastAPI
from ww_agents import StyleDimension, StyleFingerprintResult
from ww_core.domain.job_repo import STATUS_DONE
from ww_core.domain.project_repo import ProjectCreate
from ww_shared import AppError, ErrorCode
def _fingerprint() -> StyleFingerprintResult:
return StyleFingerprintResult(
dimensions=[
StyleDimension(name="句长节奏", value="短句为主", evidence=["他来了。", "她走了。"]),
StyleDimension(name="叙事人称", value="第三人称", evidence=["他看着远方。"]),
]
)
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))
def _app_with_overrides(
*,
project_repo: FakeProjectRepo,
job_repo: FakeJobRepo | None = None,
style_repo: FakeStyleWriteRepo | None = None,
session: FakeSession | None = None,
session_factory: FakeSessionFactory | None = None,
extract_gateway: object | None = None,
refine_gateway: object | None = None,
) -> FastAPI:
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_job_repo,
get_project_repo,
get_refine_gateway,
get_session_factory,
get_style_extract_gateway,
get_style_write_repo,
)
from ww_db import get_session
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_session] = lambda: session or FakeSession()
if job_repo is not None:
app.dependency_overrides[get_job_repo] = lambda: job_repo
if style_repo is not None:
app.dependency_overrides[get_style_write_repo] = lambda: style_repo
if session_factory is not None:
app.dependency_overrides[get_session_factory] = lambda: session_factory
if extract_gateway is not None:
app.dependency_overrides[get_style_extract_gateway] = lambda: extract_gateway
if refine_gateway is not None:
app.dependency_overrides[get_refine_gateway] = lambda: refine_gateway
return app
def _client(app: FastAPI) -> httpx.AsyncClient:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
return httpx.AsyncClient(transport=transport, base_url="http://test")
# ---- POST /style学文风 jobs 异步)----
@pytest.mark.asyncio
async def test_learn_style_returns_202_and_writes_job_row(
monkeypatch: pytest.MonkeyPatch,
) -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
job_repo = FakeJobRepo()
session = FakeSession()
app = _app_with_overrides(
project_repo=project_repo,
job_repo=job_repo,
session=session,
session_factory=FakeSessionFactory(),
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
)
# background task work 触网关/repo → monkeypatch 成 no-op fake本用例不验落库
# run_job 默认 repo_factory 用 SqlJobRepo(FakeSession) 会触 .execute → 改用 fake job repo。
import ww_api.routers.style as style_mod
import ww_api.services.job_runner as runner_mod
async def _noop_work(_session: object) -> dict[str, int]:
return {"version": 1, "dims_count": 2}
monkeypatch.setattr(style_mod, "_make_style_learn_work", lambda *_a, **_k: _noop_work)
monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo)
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/style", json={"samples": ["样本正文一"]})
assert resp.status_code == 202
body = resp.json()
job_id = uuid.UUID(body["job_id"])
# job 行已创建 + 请求阶段 commit 一次(供前端立即轮询)。
assert job_id in job_repo.rows
assert job_repo.rows[job_id].kind == "style_learn"
assert session.commits == 1
@pytest.mark.asyncio
async def test_learn_style_background_task_writes_fingerprint_and_completes_job(
monkeypatch: pytest.MonkeyPatch,
) -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
job_repo = FakeJobRepo()
style_repo = FakeStyleWriteRepo()
session_factory = FakeSessionFactory()
import ww_api.routers.style as style_mod
# work 内 build_gateway_for_tier → fake 网关产指纹;写侧 repo → 共享 fake repo。
async def _fake_build_gateway(_session, _store, _tier): # type: ignore[no-untyped-def]
return FakeReviewGateway(parsed=_fingerprint())
monkeypatch.setattr(style_mod, "build_gateway_for_tier", _fake_build_gateway)
monkeypatch.setattr(style_mod, "SqlStyleFingerprintWriteRepo", lambda _s: style_repo)
# `run_job` 用 SqlJobRepo默认 repo_factory会触 FakeSession.execute → 改用 fake job repo。
import ww_api.services.job_runner as runner_mod
monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo)
app = _app_with_overrides(
project_repo=project_repo,
job_repo=job_repo,
session=FakeSession(),
session_factory=session_factory,
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/style", json={"samples": ["样本一", "样本二"], "mode": "create"}
)
assert resp.status_code == 202
job_id = uuid.UUID(resp.json()["job_id"])
# BackgroundTask 已跑完ASGITransport 等其完成style_fingerprint 落库 + job done。
assert style_repo.append_calls == 1
assert style_repo.rows[1][0] == {"句长节奏": "短句为主", "叙事人称": "第三人称"}
assert style_repo.rows[1][1]["句长节奏"] == ["他来了。", "她走了。"]
assert job_repo.rows[job_id].status == STATUS_DONE
assert job_repo.rows[job_id].result == {"version": 1, "dims_count": 2}
@pytest.mark.asyncio
async def test_learn_style_update_mode_bumps_version(monkeypatch: pytest.MonkeyPatch) -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
job_repo = FakeJobRepo()
style_repo = FakeStyleWriteRepo()
# 预置 version 1首学已存在
await style_repo.append(pid, dimensions_json={"x": "y"}, evidence_json={"x": []})
import ww_api.routers.style as style_mod
import ww_api.services.job_runner as runner_mod
async def _fake_build_gateway(_session, _store, _tier): # type: ignore[no-untyped-def]
return FakeReviewGateway(parsed=_fingerprint())
monkeypatch.setattr(style_mod, "build_gateway_for_tier", _fake_build_gateway)
monkeypatch.setattr(style_mod, "SqlStyleFingerprintWriteRepo", lambda _s: style_repo)
monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo)
app = _app_with_overrides(
project_repo=project_repo,
job_repo=job_repo,
session=FakeSession(),
session_factory=FakeSessionFactory(),
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/style", json={"samples": ["新样本"], "mode": "update"}
)
assert resp.status_code == 202
job_id = uuid.UUID(resp.json()["job_id"])
# 第二次 append → version 2version+1
assert max(style_repo.rows) == 2
assert job_repo.rows[job_id].result == {"version": 2, "dims_count": 2}
@pytest.mark.asyncio
async def test_learn_style_unknown_project_404() -> None:
project_repo = FakeProjectRepo()
app = _app_with_overrides(
project_repo=project_repo,
job_repo=FakeJobRepo(),
session_factory=FakeSessionFactory(),
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
)
async with _client(app) as client:
resp = await client.post(f"/projects/{uuid.uuid4()}/style", json={"samples": ["x"]})
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_learn_style_without_credentials_503_and_no_job() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
job_repo = FakeJobRepo()
session = FakeSession()
async def _no_creds() -> object:
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
app = _app_with_overrides(
project_repo=project_repo,
job_repo=job_repo,
session=session,
session_factory=FakeSessionFactory(),
)
from ww_api.services.project_deps import get_style_extract_gateway
app.dependency_overrides[get_style_extract_gateway] = _no_creds
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/style", json={"samples": ["x"]})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
# 凭据探测在调度 job 之前 → 未写 job、未 commit。
assert job_repo.rows == {}
assert session.commits == 0
@pytest.mark.asyncio
async def test_learn_style_empty_samples_422() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
app = _app_with_overrides(
project_repo=project_repo,
job_repo=FakeJobRepo(),
session_factory=FakeSessionFactory(),
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
)
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/style", json={"samples": []})
assert resp.status_code == 422
# ---- GET /style最新指纹----
@pytest.mark.asyncio
async def test_get_style_returns_latest_fingerprint() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
style_repo = FakeStyleWriteRepo()
await style_repo.append(
pid, dimensions_json={"句长": ""}, evidence_json={"句长": ["他来了。"]}
)
app = _app_with_overrides(project_repo=project_repo, style_repo=style_repo)
async with _client(app) as client:
resp = await client.get(f"/projects/{pid}/style")
assert resp.status_code == 200
body = resp.json()
assert body["version"] == 1
assert body["dimensions"] == {"句长": ""}
assert body["evidence"] == {"句长": ["他来了。"]}
@pytest.mark.asyncio
async def test_get_style_no_fingerprint_404() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
app = _app_with_overrides(project_repo=project_repo, style_repo=FakeStyleWriteRepo())
async with _client(app) as client:
resp = await client.get(f"/projects/{pid}/style")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_get_style_unknown_project_404() -> None:
app = _app_with_overrides(project_repo=FakeProjectRepo(), style_repo=FakeStyleWriteRepo())
async with _client(app) as client:
resp = await client.get(f"/projects/{uuid.uuid4()}/style")
assert resp.status_code == 404
# ---- POST /refine同步回炉----
@pytest.mark.asyncio
async def test_refine_returns_original_and_refined_and_commits() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
session = FakeSession()
gateway = FakeReviewGateway(text="重写后的段落。")
app = _app_with_overrides(project_repo=project_repo, session=session, refine_gateway=gateway)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/1/refine",
json={"segment": "原始段落。", "instruction": "去掉机翻腔"},
)
assert resp.status_code == 200
body = resp.json()
assert body["original"] == "原始段落。"
assert body["refined"] == "重写后的段落。"
# writer 档 + 末尾 commit记账落库
assert gateway.requests[0].tier == "writer"
assert session.commits == 1
# 指令进入了输入文本。
assert "去掉机翻腔" in str(gateway.requests[0].input)
@pytest.mark.asyncio
async def test_refine_unknown_project_404() -> None:
app = _app_with_overrides(
project_repo=FakeProjectRepo(), refine_gateway=FakeReviewGateway(text="x")
)
async with _client(app) as client:
resp = await client.post(
f"/projects/{uuid.uuid4()}/chapters/1/refine", json={"segment": "原段"}
)
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_refine_empty_segment_422() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
app = _app_with_overrides(project_repo=project_repo, refine_gateway=FakeReviewGateway(text="x"))
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/chapters/1/refine", json={"segment": ""})
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_refine_without_credentials_503() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
session = FakeSession()
async def _no_creds() -> object:
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
app = _app_with_overrides(project_repo=project_repo, session=session)
from ww_api.services.project_deps import get_refine_gateway
app.dependency_overrides[get_refine_gateway] = _no_creds
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/chapters/1/refine", json={"segment": "原段"})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
assert session.commits == 0