test(qa): F1/F2/F3 E2E — 拆书入 rules / 续写式链前文注入 / 模板库 CRUD
- F1: teardown generate→ingest→rules 真落行 + 负向预览不写库(不变量 #3) - F2: continue_volume 两章第二章写章请求含第一章 accepted 正文(前文注入)+ draft_volume 回归守卫 - F3: templates POST→GET→DELETE 真 pg 回环 + title/body 空→422 真 pg + mock 网关零 token,镜像现有 tests/test_*_e2e.py 范式
This commit is contained in:
302
tests/test_chain_continue_volume_e2e.py
Normal file
302
tests/test_chain_continue_volume_e2e.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""F2 续写式链 continue_volume 端到端(真 pg + mock 网关零 token)。
|
||||
|
||||
证明续写式链闭环(不变量 #1/#5):`continue_volume` 每章写作以**上一章 accepted 正文末尾**
|
||||
作前文引子(复用 `build_continuation_context`)。两章无冲突全自动:
|
||||
- 第二章写作请求的上下文须含第一章 accepted 正文(断前文注入);
|
||||
- DB 真源:两章 accepted + 两 digest 行。
|
||||
回归守卫:`draft_volume`(非续写)第二章写作请求**不应**含第一章正文(区分两模式)。
|
||||
|
||||
镜像 `tests/test_chain_workflow_e2e.py`:真 `Gateway` + 假适配器(据 `req.output_schema` 分支
|
||||
返 parsed/text,绝不联网)+ 真 `SqlAlchemyLedgerSink`;MemorySaver 检查点。无 pg → skip。
|
||||
|
||||
关键差异:写章假适配器**逐章返不同正文**(含章号标记),并**记录每次写章请求的 input**——
|
||||
故可断言第二章写章请求的 input 含第一章 accepted 正文标记(前文注入),而 draft_volume 不含。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from typing import Annotated, Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from asgi_lifespan import LifespanManager
|
||||
from fastapi import Depends
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from ww_agents import (
|
||||
ContinuityReview,
|
||||
ForeshadowReview,
|
||||
PaceReview,
|
||||
StyleDriftReview,
|
||||
)
|
||||
from ww_api.services.digest_extraction import ChapterDigestFacts
|
||||
from ww_db import get_session, get_sessionmaker
|
||||
from ww_db.models import (
|
||||
Chapter,
|
||||
ChapterDigest,
|
||||
ChapterReview,
|
||||
Job,
|
||||
Project,
|
||||
UsageLedger,
|
||||
)
|
||||
from ww_llm_gateway import Gateway, SqlAlchemyLedgerSink, resolve_route
|
||||
from ww_llm_gateway.adapters.base import (
|
||||
Capabilities,
|
||||
ProviderResult,
|
||||
ProviderUsage,
|
||||
StreamChunk,
|
||||
)
|
||||
from ww_llm_gateway.types import LlmRequest
|
||||
|
||||
_PROVIDER = "deepseek"
|
||||
_USAGE = ProviderUsage(input_tokens=13, output_tokens=7)
|
||||
|
||||
# 每章正文含唯一章号标记,供「前文注入」断言(第二章请求须含第一章标记)。
|
||||
_CHAPTER_MARK = "【第{n}章正文END】"
|
||||
|
||||
_FORESHADOW_REVIEW = ForeshadowReview(planted=[], resolved=[])
|
||||
_PACE_REVIEW = PaceReview(water=[], hook=True, beat_map=[1, 2, 3])
|
||||
_STYLE_DRIFT = StyleDriftReview(score=88, segments=[])
|
||||
|
||||
|
||||
class _RecordingChainAdapter:
|
||||
"""实现 `ProviderAdapter`:写章逐章返带章号标记的正文 + 记录每次写章请求 input。
|
||||
|
||||
- `output_schema is None`(写章 `gateway.run`):自增计数返第 N 章正文,记录 req.input。
|
||||
- 四审 schema → 无冲突 parsed(让图全自动跑完)。
|
||||
- 其余(ChapterDigestFacts)→ 终稿 digest 提炼。
|
||||
"""
|
||||
|
||||
provider = _PROVIDER
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._write_count = 0
|
||||
# (request_input, returned_text) 每次写章一条,按写章顺序。
|
||||
self.write_calls: list[tuple[str, str]] = []
|
||||
|
||||
def capabilities(self) -> Capabilities:
|
||||
return Capabilities(structured_output=True)
|
||||
|
||||
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
schema = req.output_schema
|
||||
if schema is None:
|
||||
self._write_count += 1
|
||||
text = f"灵气如潮{_CHAPTER_MARK.format(n=self._write_count)}"
|
||||
self.write_calls.append((str(req.input), text))
|
||||
return ProviderResult(text=text, parsed=None, usage=_USAGE)
|
||||
if schema is ContinuityReview:
|
||||
review = ContinuityReview(conflicts=[])
|
||||
return ProviderResult(text=review.model_dump_json(), parsed=review, usage=_USAGE)
|
||||
if schema is ForeshadowReview:
|
||||
return ProviderResult(
|
||||
text=_FORESHADOW_REVIEW.model_dump_json(), parsed=_FORESHADOW_REVIEW, usage=_USAGE
|
||||
)
|
||||
if schema is PaceReview:
|
||||
return ProviderResult(
|
||||
text=_PACE_REVIEW.model_dump_json(), parsed=_PACE_REVIEW, usage=_USAGE
|
||||
)
|
||||
if schema is StyleDriftReview:
|
||||
return ProviderResult(
|
||||
text=_STYLE_DRIFT.model_dump_json(), parsed=_STYLE_DRIFT, usage=_USAGE
|
||||
)
|
||||
facts = ChapterDigestFacts(summary="终稿摘要", events=["少年出山"], locations=["山门"])
|
||||
return ProviderResult(text=facts.model_dump_json(), parsed=facts, usage=_USAGE)
|
||||
|
||||
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
|
||||
yield StreamChunk(usage=_USAGE)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
|
||||
"""真 DB session 工厂;无 pg 时跳过。"""
|
||||
get_sessionmaker.cache_clear()
|
||||
maker = get_sessionmaker()
|
||||
try:
|
||||
async with maker() as probe:
|
||||
await probe.execute(select(1))
|
||||
except Exception:
|
||||
pytest.skip("postgres not reachable")
|
||||
yield maker
|
||||
await maker.kw["bind"].dispose()
|
||||
get_sessionmaker.cache_clear()
|
||||
|
||||
|
||||
def _gateway_for(adapter: _RecordingChainAdapter, session: AsyncSession) -> Gateway:
|
||||
return Gateway(
|
||||
adapters={_PROVIDER: adapter},
|
||||
ledger=SqlAlchemyLedgerSink(session),
|
||||
resolver=resolve_route,
|
||||
)
|
||||
|
||||
|
||||
def _chain_gateway_override(
|
||||
adapter: _RecordingChainAdapter,
|
||||
) -> Callable[[AsyncSession], Gateway]:
|
||||
def _override(session: Annotated[AsyncSession, Depends(get_session)]) -> Gateway:
|
||||
return _gateway_for(adapter, session)
|
||||
|
||||
return _override
|
||||
|
||||
|
||||
def _builder_override(
|
||||
adapter: _RecordingChainAdapter,
|
||||
) -> Callable[[], Callable[[AsyncSession], Awaitable[Gateway]]]:
|
||||
def _get_builder() -> Callable[[AsyncSession], Awaitable[Gateway]]:
|
||||
async def _build(session: AsyncSession) -> Gateway:
|
||||
return _gateway_for(adapter, session)
|
||||
|
||||
return _build
|
||||
|
||||
return _get_builder
|
||||
|
||||
|
||||
def _memsaver_override(
|
||||
saver: MemorySaver,
|
||||
) -> Callable[[], Callable[[], AbstractAsyncContextManager[MemorySaver]]]:
|
||||
@asynccontextmanager
|
||||
async def _ctx() -> AsyncIterator[MemorySaver]:
|
||||
yield saver
|
||||
|
||||
return lambda: lambda: _ctx()
|
||||
|
||||
|
||||
async def _cleanup(e2e_sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> None:
|
||||
async with e2e_sm() as cleanup:
|
||||
await cleanup.execute(delete(UsageLedger).where(UsageLedger.project_id == project_uuid))
|
||||
await cleanup.execute(delete(Job).where(Job.project_id == project_uuid))
|
||||
await cleanup.execute(delete(ChapterDigest).where(ChapterDigest.project_id == project_uuid))
|
||||
await cleanup.execute(delete(ChapterReview).where(ChapterReview.project_id == project_uuid))
|
||||
await cleanup.execute(delete(Chapter).where(Chapter.project_id == project_uuid))
|
||||
await cleanup.execute(delete(Project).where(Project.id == project_uuid))
|
||||
await cleanup.commit()
|
||||
|
||||
|
||||
def _build_app(adapter: _RecordingChainAdapter, saver: MemorySaver, e2e_sm: Any) -> Any:
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.chain_deps import get_checkpointer_factory
|
||||
from ww_api.services.project_deps import (
|
||||
get_chain_gateway,
|
||||
get_chain_gateway_builder,
|
||||
get_digest_gateway_builder,
|
||||
get_session_factory,
|
||||
)
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_chain_gateway] = _chain_gateway_override(adapter)
|
||||
app.dependency_overrides[get_chain_gateway_builder] = _builder_override(adapter)
|
||||
app.dependency_overrides[get_session_factory] = lambda: e2e_sm
|
||||
app.dependency_overrides[get_checkpointer_factory] = _memsaver_override(saver)
|
||||
app.dependency_overrides[get_digest_gateway_builder] = _builder_override(adapter)
|
||||
return app
|
||||
|
||||
|
||||
async def _run_two_chapter_chain(
|
||||
adapter: _RecordingChainAdapter,
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
chain_key: str,
|
||||
) -> tuple[uuid.UUID, dict[str, Any]]:
|
||||
"""跑两章链(无冲突全自动)→ 返 (project_uuid, job result)。调用方负责 cleanup。"""
|
||||
saver = MemorySaver()
|
||||
app = _build_app(adapter, saver, e2e_sm)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with LifespanManager(app):
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
create_resp = await client.post("/projects", json={"title": f"F2 {chain_key} 作品"})
|
||||
assert create_resp.status_code == 201
|
||||
project_id = create_resp.json()["id"]
|
||||
project_uuid = uuid.UUID(project_id)
|
||||
|
||||
run_resp = await client.post(
|
||||
f"/projects/{project_id}/chains/{chain_key}/run",
|
||||
json={"start_chapter_no": 1, "count": 2},
|
||||
)
|
||||
assert run_resp.status_code == 202, run_resp.text
|
||||
assert run_resp.json()["chain_key"] == chain_key
|
||||
job_id = run_resp.json()["job_id"]
|
||||
|
||||
job = (await client.get(f"/jobs/{job_id}")).json()
|
||||
assert job["status"] == "done", job
|
||||
return project_uuid, job["result"]
|
||||
|
||||
|
||||
async def test_continue_volume_injects_prior_chapter_into_second_write(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""用例 1:continue_volume 两章 → 第二章写章请求含第一章 accepted 正文(前文注入)。"""
|
||||
adapter = _RecordingChainAdapter()
|
||||
project_uuid: uuid.UUID | None = None
|
||||
try:
|
||||
project_uuid, result = await _run_two_chapter_chain(adapter, e2e_sm, "continue_volume")
|
||||
assert result["written"] == [1, 2]
|
||||
assert result["completed"] is True
|
||||
|
||||
# 两次写章请求被记录(按章序)。
|
||||
assert len(adapter.write_calls) == 2
|
||||
first_input, first_text = adapter.write_calls[0]
|
||||
second_input, _second_text = adapter.write_calls[1]
|
||||
|
||||
# 第一章正文标记(accepted 后即此文本)。
|
||||
chapter1_mark = _CHAPTER_MARK.format(n=1)
|
||||
|
||||
# 核心断言:第二章写章请求上下文含第一章 accepted 正文(前文引子注入,不变量 #1/#5)。
|
||||
assert chapter1_mark in second_input, second_input
|
||||
# 续写上下文结构标记(build_continuation_context)也应在第二章请求中。
|
||||
assert "前文正文" in second_input
|
||||
# 第一章无前文 → 其请求不含「第1章标记」(首章占位降级,证明非凭空注入)。
|
||||
assert chapter1_mark not in first_input
|
||||
|
||||
# DB 真源:两章 accepted + 两 digest 行。
|
||||
async with e2e_sm() as verify:
|
||||
accepted = (
|
||||
(
|
||||
await verify.execute(
|
||||
select(Chapter).where(
|
||||
Chapter.project_id == project_uuid, Chapter.status == "accepted"
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert {c.chapter_no for c in accepted} == {1, 2}
|
||||
digests = (
|
||||
(
|
||||
await verify.execute(
|
||||
select(ChapterDigest).where(ChapterDigest.project_id == project_uuid)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert {d.chapter_no for d in digests} == {1, 2}
|
||||
finally:
|
||||
if project_uuid is not None:
|
||||
await _cleanup(e2e_sm, project_uuid)
|
||||
|
||||
|
||||
async def test_draft_volume_does_not_inject_prior_chapter_into_second_write(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""用例 2(回归守卫):draft_volume 第二章写章请求**不含**第一章正文(区分两模式)。"""
|
||||
adapter = _RecordingChainAdapter()
|
||||
project_uuid: uuid.UUID | None = None
|
||||
try:
|
||||
project_uuid, result = await _run_two_chapter_chain(adapter, e2e_sm, "draft_volume")
|
||||
assert result["written"] == [1, 2]
|
||||
|
||||
assert len(adapter.write_calls) == 2
|
||||
_first_input, _first_text = adapter.write_calls[0]
|
||||
second_input, _ = adapter.write_calls[1]
|
||||
|
||||
chapter1_mark = _CHAPTER_MARK.format(n=1)
|
||||
# draft_volume 非续写:第二章写章请求不应含第一章正文(仅按记忆/digest 量产)。
|
||||
assert chapter1_mark not in second_input
|
||||
# 亦无续写上下文结构标记。
|
||||
assert "前文正文(续写须无缝承接)" not in second_input
|
||||
finally:
|
||||
if project_uuid is not None:
|
||||
await _cleanup(e2e_sm, project_uuid)
|
||||
216
tests/test_teardown_ingest_e2e.py
Normal file
216
tests/test_teardown_ingest_e2e.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""F1 拆书 teardown 落库成 rules 端到端(真 pg + mock 网关零 token)。
|
||||
|
||||
证明拆书入库闭环(不变量 #3:入库经白名单 gate;预览不写库):
|
||||
1. teardown `generate` → 200 预览(BookTeardownResult);断言 `rules` 表零新增(预览不写库)。
|
||||
2. teardown `ingest`(teardown 产物)→ 201;`rules` 表真落行(拆书结论拍平成可读规则条目)。
|
||||
|
||||
镜像 `tests/test_t6_toolbox_e2e.py`:真 `Gateway` + 据 `req.output_schema` 分支返 parsed 的
|
||||
假适配器(绝不联网)+ 真 `SqlAlchemyLedgerSink`。注入缝 `get_tier_gateway_builder`(generate
|
||||
调 `build_gateway(spec.tier)`;rules ingest 无 continuity 预检故不调网关)。无 pg → skip。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from asgi_lifespan import LifespanManager
|
||||
from fastapi import Depends
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from ww_agents import BookTeardownResult
|
||||
from ww_db import get_session, get_sessionmaker
|
||||
from ww_db.models import Project, Rule, UsageLedger
|
||||
from ww_llm_gateway import Gateway, SqlAlchemyLedgerSink, resolve_route
|
||||
from ww_llm_gateway.adapters.base import (
|
||||
Capabilities,
|
||||
ProviderResult,
|
||||
ProviderUsage,
|
||||
StreamChunk,
|
||||
)
|
||||
from ww_llm_gateway.types import LlmRequest, Tier
|
||||
|
||||
_PROVIDER = "deepseek"
|
||||
_USAGE = ProviderUsage(input_tokens=29, output_tokens=13)
|
||||
|
||||
# teardown 假产物:themes/archetypes/structure/hooks 全非空(拍平成 4 条 rules)。
|
||||
_TEARDOWN = BookTeardownResult(
|
||||
themes=["逆袭打脸", "守护苍生"],
|
||||
archetypes=["扮猪吃虎的主角", "口嫌体正的女主"],
|
||||
structure="开篇废柴受辱 → 奇遇得宝 → 步步攀升 → 终战封神",
|
||||
hooks=["每章末留悬念", "三章一小高潮"],
|
||||
)
|
||||
|
||||
|
||||
class _FakeTeardownAdapter:
|
||||
"""实现 `ProviderAdapter`:仅 BookTeardownResult 一路(拆书 generate),绝不联网。"""
|
||||
|
||||
provider = _PROVIDER
|
||||
|
||||
def capabilities(self) -> Capabilities:
|
||||
return Capabilities(structured_output=True)
|
||||
|
||||
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
if req.output_schema is BookTeardownResult:
|
||||
return ProviderResult(text=_TEARDOWN.model_dump_json(), parsed=_TEARDOWN, usage=_USAGE)
|
||||
raise AssertionError(f"unexpected output_schema in F1 fake adapter: {req.output_schema!r}")
|
||||
|
||||
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
|
||||
yield StreamChunk(usage=_USAGE)
|
||||
raise AssertionError("teardown generation must not stream")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
|
||||
"""真 DB session 工厂;无 pg 时跳过。"""
|
||||
get_sessionmaker.cache_clear()
|
||||
maker = get_sessionmaker()
|
||||
try:
|
||||
async with maker() as probe:
|
||||
await probe.execute(select(1))
|
||||
except Exception:
|
||||
pytest.skip("postgres not reachable")
|
||||
yield maker
|
||||
await maker.kw["bind"].dispose()
|
||||
get_sessionmaker.cache_clear()
|
||||
|
||||
|
||||
def _tier_builder_override(
|
||||
adapter: _FakeTeardownAdapter,
|
||||
) -> Callable[[AsyncSession], Callable[[Tier], Awaitable[Gateway]]]:
|
||||
def _override(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Callable[[Tier], Awaitable[Gateway]]:
|
||||
async def _build(_tier: Tier) -> Gateway:
|
||||
return Gateway(
|
||||
adapters={adapter.provider: adapter},
|
||||
ledger=SqlAlchemyLedgerSink(session),
|
||||
resolver=resolve_route,
|
||||
)
|
||||
|
||||
return _build
|
||||
|
||||
return _override
|
||||
|
||||
|
||||
async def _rule_count(sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> int:
|
||||
async with sm() as s:
|
||||
return int(
|
||||
(
|
||||
await s.execute(
|
||||
select(func.count()).select_from(Rule).where(Rule.project_id == project_uuid)
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
async def _cleanup(sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> None:
|
||||
async with sm() as cleanup:
|
||||
await cleanup.execute(delete(UsageLedger).where(UsageLedger.project_id == project_uuid))
|
||||
await cleanup.execute(delete(Rule).where(Rule.project_id == project_uuid))
|
||||
await cleanup.execute(delete(Project).where(Project.id == project_uuid))
|
||||
await cleanup.commit()
|
||||
|
||||
|
||||
async def test_teardown_generate_preview_does_not_write_rules(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""用例 1(负向):teardown generate → 200 预览;rules 表零新增(不变量 #3 预览不写库)。"""
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_tier_gateway_builder
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(
|
||||
_FakeTeardownAdapter()
|
||||
)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
project_uuid: uuid.UUID | None = None
|
||||
try:
|
||||
async with LifespanManager(app):
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
create_resp = await client.post("/projects", json={"title": "F1 拆书预览作品"})
|
||||
assert create_resp.status_code == 201
|
||||
project_id = create_resp.json()["id"]
|
||||
project_uuid = uuid.UUID(project_id)
|
||||
|
||||
gen_resp = await client.post(
|
||||
f"/projects/{project_id}/skills/teardown/generate",
|
||||
json={"text": "第一章……(待拆解的样章正文)", "kind": "某爆款玄幻"},
|
||||
)
|
||||
assert gen_resp.status_code == 200, gen_resp.text
|
||||
body = gen_resp.json()
|
||||
assert body["tool_key"] == "teardown"
|
||||
assert body["output_kind"] == "BookTeardownResult"
|
||||
assert body["preview"]["themes"] == ["逆袭打脸", "守护苍生"]
|
||||
|
||||
# DB 真源:预览不写 rules 业务表(守不变量 #3)。
|
||||
assert project_uuid is not None
|
||||
assert await _rule_count(e2e_sm, project_uuid) == 0
|
||||
finally:
|
||||
if project_uuid is not None:
|
||||
await _cleanup(e2e_sm, project_uuid)
|
||||
|
||||
|
||||
async def test_teardown_ingest_persists_rules(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""用例 2:teardown ingest → 201;rules 表真落行(拆书结论拍平成可读规则条目)。"""
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_tier_gateway_builder
|
||||
|
||||
app = create_app()
|
||||
# ingest(rules 无 continuity 预检)不触达网关;override 防真凭据探测路径。
|
||||
app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(
|
||||
_FakeTeardownAdapter()
|
||||
)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
project_uuid: uuid.UUID | None = None
|
||||
try:
|
||||
async with LifespanManager(app):
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
create_resp = await client.post("/projects", json={"title": "F1 拆书入库作品"})
|
||||
assert create_resp.status_code == 201
|
||||
project_id = create_resp.json()["id"]
|
||||
project_uuid = uuid.UUID(project_id)
|
||||
|
||||
assert await _rule_count(e2e_sm, project_uuid) == 0
|
||||
|
||||
ingest_resp = await client.post(
|
||||
f"/projects/{project_id}/skills/teardown/ingest",
|
||||
json={
|
||||
"teardown": {
|
||||
"themes": _TEARDOWN.themes,
|
||||
"archetypes": _TEARDOWN.archetypes,
|
||||
"structure": _TEARDOWN.structure,
|
||||
"hooks": _TEARDOWN.hooks,
|
||||
}
|
||||
},
|
||||
)
|
||||
assert ingest_resp.status_code == 201, ingest_resp.text
|
||||
ok = ingest_resp.json()
|
||||
assert ok["table"] == "rules"
|
||||
# 四个非空字段 → 四条 rules(确定性顺序)。
|
||||
assert ok["created"] == ["themes", "archetypes", "structure", "hooks"]
|
||||
assert ok["rejected_tables"] == []
|
||||
|
||||
# DB 真源:rules 行真落 pg(拍平成可读条目,内容含拆书结论)。
|
||||
assert project_uuid is not None
|
||||
assert await _rule_count(e2e_sm, project_uuid) == 4
|
||||
async with e2e_sm() as verify:
|
||||
rows = (
|
||||
(await verify.execute(select(Rule).where(Rule.project_id == project_uuid)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
blob = "\n".join(r.content for r in rows)
|
||||
# 拍平内容须含拆书结论(断真落行 + 形变保留)。
|
||||
assert "逆袭打脸" in blob
|
||||
assert "扮猪吃虎的主角" in blob
|
||||
assert "开篇废柴受辱" in blob
|
||||
assert "每章末留悬念" in blob
|
||||
finally:
|
||||
if project_uuid is not None:
|
||||
await _cleanup(e2e_sm, project_uuid)
|
||||
137
tests/test_templates_e2e.py
Normal file
137
tests/test_templates_e2e.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""F3 提示词/模板库 端到端(真 pg;无网关——模板库不调 LLM)。
|
||||
|
||||
证明单用户本地模板库 CRUD 闭环:创建→列出→删除真 pg 回环 + 输入校验(title/body 空 → 422)。
|
||||
owner_id 全程 stub(单用户原型)。无 pg → skip。
|
||||
|
||||
镜像现有 `tests/test_*_e2e.py` 范式:真 `get_sessionmaker`(无 pg skip)+ `LifespanManager` +
|
||||
`ASGITransport`,DB 真源逐项断言。模板库不触达 LLM 网关,故无需 mock 适配器。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from asgi_lifespan import LifespanManager
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from ww_db import get_sessionmaker
|
||||
from ww_db.models import PromptTemplate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
|
||||
"""真 DB session 工厂;无 pg 时跳过(每测试清缓存重建 engine、结束 dispose)。"""
|
||||
get_sessionmaker.cache_clear()
|
||||
maker = get_sessionmaker()
|
||||
try:
|
||||
async with maker() as probe:
|
||||
await probe.execute(select(1))
|
||||
except Exception:
|
||||
pytest.skip("postgres not reachable")
|
||||
yield maker
|
||||
await maker.kw["bind"].dispose()
|
||||
get_sessionmaker.cache_clear()
|
||||
|
||||
|
||||
async def _cleanup(sm: async_sessionmaker[AsyncSession], ids: list[uuid.UUID]) -> None:
|
||||
if not ids:
|
||||
return
|
||||
async with sm() as cleanup:
|
||||
await cleanup.execute(delete(PromptTemplate).where(PromptTemplate.id.in_(ids)))
|
||||
await cleanup.commit()
|
||||
|
||||
|
||||
async def _template_count(sm: async_sessionmaker[AsyncSession], template_id: uuid.UUID) -> int:
|
||||
async with sm() as s:
|
||||
return len(
|
||||
(await s.execute(select(PromptTemplate).where(PromptTemplate.id == template_id)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
async def test_templates_create_list_delete_roundtrip(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""用例 1:POST → GET → DELETE 真 pg 回环(行真落 pg;删除后表无行)。"""
|
||||
from ww_api.main import create_app
|
||||
|
||||
app = create_app()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
created_id: uuid.UUID | None = None
|
||||
try:
|
||||
async with LifespanManager(app):
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
# 1) POST → 201,模板真落 pg。
|
||||
create_resp = await client.post(
|
||||
"/templates",
|
||||
json={
|
||||
"title": "玄幻开篇模板",
|
||||
"body": "请按照黄金三章节奏,开篇即上钩……",
|
||||
"category": "开篇",
|
||||
"tool_key": "opening",
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.text
|
||||
created = create_resp.json()
|
||||
assert created["title"] == "玄幻开篇模板"
|
||||
assert created["body"] == "请按照黄金三章节奏,开篇即上钩……"
|
||||
assert created["category"] == "开篇"
|
||||
assert created["tool_key"] == "opening"
|
||||
created_id = uuid.UUID(created["id"])
|
||||
|
||||
# DB 真源:模板行真落 pg(owner stub)。
|
||||
assert await _template_count(e2e_sm, created_id) == 1
|
||||
|
||||
# 2) GET → 列表含刚建模板。
|
||||
list_resp = await client.get("/templates")
|
||||
assert list_resp.status_code == 200
|
||||
listed = list_resp.json()
|
||||
assert created["id"] in [t["id"] for t in listed]
|
||||
mine = next(t for t in listed if t["id"] == created["id"])
|
||||
assert mine["title"] == "玄幻开篇模板"
|
||||
assert mine["body"] == "请按照黄金三章节奏,开篇即上钩……"
|
||||
|
||||
# 3) DELETE → 204;DB 真源行被删。
|
||||
del_resp = await client.delete(f"/templates/{created['id']}")
|
||||
assert del_resp.status_code == 204
|
||||
assert await _template_count(e2e_sm, created_id) == 0
|
||||
|
||||
# 删除后再列出不含该模板。
|
||||
after = (await client.get("/templates")).json()
|
||||
assert created["id"] not in [t["id"] for t in after]
|
||||
|
||||
# 4) 删除不存在 → 404 NOT_FOUND envelope。
|
||||
missing = await client.delete(f"/templates/{uuid.uuid4()}")
|
||||
assert missing.status_code == 404
|
||||
assert missing.json()["error"]["code"] == "NOT_FOUND"
|
||||
finally:
|
||||
if created_id is not None:
|
||||
await _cleanup(e2e_sm, [created_id])
|
||||
|
||||
|
||||
async def test_templates_create_blank_title_returns_422(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""用例 2:title 空 → 422(schema min_length=1);DB 无行落库。"""
|
||||
from ww_api.main import create_app
|
||||
|
||||
app = create_app()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with LifespanManager(app):
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
before = len((await client.get("/templates")).json())
|
||||
|
||||
resp = await client.post("/templates", json={"title": "", "body": "非空正文"})
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
# body 空亦 422(守输入校验纪律)。
|
||||
resp_body = await client.post("/templates", json={"title": "有标题", "body": ""})
|
||||
assert resp_body.status_code == 422
|
||||
|
||||
# 两次 422 均未落库(列表数量不变)。
|
||||
after = len((await client.get("/templates")).json())
|
||||
assert after == before
|
||||
Reference in New Issue
Block a user