Files
writer-work-flow/apps/api/tests/test_chapters_list.py
Yaojia Wang 9aa0cddeaf feat(review): 审稿选章可用——列章端点 + 选章下拉全章可见 + 默认最近章
- 后端新增 GET /projects/{id}/chapters(列真实存在的章 + has_draft/accepted/reviewed_at),TDD 10 测
- gen:api 重生成 TS 客户端(ChapterListItem)
- 选章下拉去掉 hidden(手机/窄屏也可切章);选项以真实章为准 + 大纲结构,
  无草稿章置灰(仅当前章例外),覆盖『写过但不在大纲』的章
- 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章
2026-07-12 17:52:40 +02:00

214 lines
7.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""GET /projects/:id/chapters 列章端点 + 纯聚合逻辑测试(审稿选章枚举)。
分两层,与本仓「真 PG 行为交 tests/ E2E、单测保持确定性」纪律一致
- **纯聚合** `build_chapter_list`:喂造好的 chapter/review 行,断言 has_draft/accepted/
reviewed_at、去重、按章号升序、空正文草稿不计这是端点真正的业务逻辑脱库直测
- **端点集成**FastAPI client + 覆盖 `get_chapter_lister` 为 Fake同 test_projects.py 风格),
断言 404、空项目 []、字段透传、升序;网关无关(本端点不触 LLM
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeProjectRepo
from ww_api.services.chapter_list import (
ChapterRow,
ReviewRow,
build_chapter_list,
)
from ww_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.project_repo import ProjectView
from ww_shared import ErrorCode
# ---- 纯聚合逻辑build_chapter_list----
def _dt(day: int) -> datetime:
return datetime(2026, 7, day, tzinfo=UTC)
def test_build_chapter_list_marks_draft_accepted_and_reviewed() -> None:
# 三态各出现:草稿章(1) / 已验收章(2) / 已审章(3, 有 review 时间)。
chapter_rows = [
ChapterRow(chapter_no=1, status="draft", content="草稿正文"),
ChapterRow(chapter_no=2, status="draft", content="待验收"),
ChapterRow(chapter_no=2, status="accepted", content="终稿"),
ChapterRow(chapter_no=3, status="draft", content="被审的草稿"),
]
review_rows = [ReviewRow(chapter_no=3, created_at=_dt(5))]
items = build_chapter_list(chapter_rows, review_rows)
by_no = {it.chapter_no: it for it in items}
assert by_no[1].has_draft is True
assert by_no[1].accepted is False
assert by_no[1].reviewed_at is None
assert by_no[2].accepted is True
assert by_no[2].has_draft is True
assert by_no[3].reviewed_at == _dt(5)
def test_build_chapter_list_empty_returns_empty() -> None:
assert build_chapter_list([], []) == []
def test_build_chapter_list_sorted_ascending_and_deduped() -> None:
# 乱序 + 同章多版本行 → 去重 + 章号升序。
chapter_rows = [
ChapterRow(chapter_no=3, status="draft", content="c3"),
ChapterRow(chapter_no=1, status="draft", content="c1"),
ChapterRow(chapter_no=1, status="accepted", content="c1a"),
ChapterRow(chapter_no=2, status="accepted", content="c2"),
]
items = build_chapter_list(chapter_rows, [])
assert [it.chapter_no for it in items] == [1, 2, 3]
def test_build_chapter_list_reviewed_at_is_latest() -> None:
# 同章多次审稿 → reviewed_at 取最近一次。
chapter_rows = [ChapterRow(chapter_no=1, status="draft", content="x")]
review_rows = [
ReviewRow(chapter_no=1, created_at=_dt(3)),
ReviewRow(chapter_no=1, created_at=_dt(9)),
ReviewRow(chapter_no=1, created_at=_dt(6)),
]
items = build_chapter_list(chapter_rows, review_rows)
assert items[0].reviewed_at == _dt(9)
def test_build_chapter_list_empty_content_draft_not_marked() -> None:
# 空/纯空白正文的草稿行不算「有草稿正文」(与 GET .../draft 语义一致)。
chapter_rows = [
ChapterRow(chapter_no=1, status="draft", content=" "),
ChapterRow(chapter_no=1, status="draft", content=None),
]
items = build_chapter_list(chapter_rows, [])
assert items[0].chapter_no == 1
assert items[0].has_draft is False
def test_build_chapter_list_review_only_chapter_appears() -> None:
# 有审稿但无 chapters 行(作者以 body.draft 送审未存草稿)→ 该章仍枚举、标已审。
items = build_chapter_list([], [ReviewRow(chapter_no=7, created_at=_dt(2))])
assert len(items) == 1
assert items[0].chapter_no == 7
assert items[0].has_draft is False
assert items[0].accepted is False
assert items[0].reviewed_at == _dt(2)
# ---- 端点集成FastAPI client + Fake lister----
class _FakeChapterLister:
"""实现 ChapterLister Protocol 的内存版:回放预置的列章结果。"""
def __init__(self, items_by_project: dict[uuid.UUID, list[object]] | None = None) -> None:
self.items_by_project = items_by_project or {}
async def list_chapters(self, project_id: uuid.UUID) -> list[object]:
return self.items_by_project.get(project_id, [])
def _make_client(
*,
project_repo: FakeProjectRepo,
lister: _FakeChapterLister,
) -> httpx.AsyncClient:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import get_chapter_lister, get_project_repo
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_chapter_lister] = lambda: lister
transport = httpx.ASGITransport(app=app)
return httpx.AsyncClient(transport=transport, base_url="http://test")
def _seed_project(project_repo: FakeProjectRepo) -> uuid.UUID:
pid = uuid.uuid4()
project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="测试"))
return pid
@pytest.mark.asyncio
async def test_list_chapters_returns_items_with_fields() -> None:
from ww_api.schemas.projects import ChapterListItem
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
lister = _FakeChapterLister(
{
pid: [
ChapterListItem(chapter_no=1, has_draft=True, accepted=False, reviewed_at=None),
ChapterListItem(chapter_no=2, has_draft=True, accepted=True, reviewed_at=_dt(4)),
]
}
)
client = _make_client(project_repo=project_repo, lister=lister)
async with client:
resp = await client.get(f"/projects/{pid}/chapters")
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, list)
assert body[0] == {
"chapter_no": 1,
"has_draft": True,
"accepted": False,
"reviewed_at": None,
}
assert body[1]["chapter_no"] == 2
assert body[1]["accepted"] is True
assert body[1]["reviewed_at"].startswith("2026-07-04")
@pytest.mark.asyncio
async def test_list_chapters_empty_project_returns_empty_list() -> None:
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
client = _make_client(project_repo=project_repo, lister=_FakeChapterLister())
async with client:
resp = await client.get(f"/projects/{pid}/chapters")
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_list_chapters_unknown_project_404() -> None:
client = _make_client(project_repo=FakeProjectRepo(), lister=_FakeChapterLister())
async with client:
resp = await client.get(f"/projects/{uuid.uuid4()}/chapters")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_list_chapters_ascending_order() -> None:
from ww_api.schemas.projects import ChapterListItem
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
lister = _FakeChapterLister(
{
pid: [
ChapterListItem(chapter_no=1, has_draft=True, accepted=False, reviewed_at=None),
ChapterListItem(chapter_no=2, has_draft=False, accepted=True, reviewed_at=None),
ChapterListItem(chapter_no=5, has_draft=True, accepted=False, reviewed_at=None),
]
}
)
client = _make_client(project_repo=project_repo, lister=lister)
async with client:
resp = await client.get(f"/projects/{pid}/chapters")
assert resp.status_code == 200
assert [it["chapter_no"] for it in resp.json()] == [1, 2, 5]