"""AC-2 POST/GET /projects/{id}/ai-messages 端点(内存替身,无 DB/无网络)。 docs/design/ai-chat-history-plan.md §3.3 / §6。覆盖: - POST 201 + 回显持久化视图(seq/id/created_at)+ 端点 commit 边界; - append→list 回环;项目级(chapter_no null) vs 本章 union; - 校验 → 422(非法 role、超上限 content、空 messages、超大 meta); - 未知项目 → 404;响应 key 集合不泄漏意外字段。 owner_id 全程 stub(单用户原型)。AI 一律不触达(纯 CRUD)。 """ from __future__ import annotations import os import uuid from datetime import UTC, datetime, timedelta import httpx import pytest from cryptography.fernet import Fernet from fakes_projects import FakeProjectRepo, FakeSession from ww_core.domain.ai_message_repo import AiMessageView, AiTurnRow from ww_core.domain.project_repo import ProjectView # 期望的响应 bubble 字段集合(防泄漏意外字段)。 _EXPECTED_KEYS = { "id", "project_id", "chapter_no", "thread_id", "seq", "kind", "tool_key", "role", "content", "meta", "created_at", } class _FakeAiMessageRepo: """镜像 `SqlAiMessageRepo` 语义的内存版(批内 seq、newest-first union)。""" def __init__(self) -> None: self.rows: list[AiMessageView] = [] self._clock = 0 async def append( self, project_id: uuid.UUID, *, thread_id: uuid.UUID, chapter_no: int | None, kind: str, tool_key: str | None, rows: list[AiTurnRow], ) -> list[AiMessageView]: self._clock += 1 created = datetime(2026, 7, 9, tzinfo=UTC) + timedelta(seconds=self._clock) out: list[AiMessageView] = [] for i, r in enumerate(rows): view = AiMessageView( id=uuid.uuid4(), project_id=project_id, chapter_no=chapter_no, thread_id=thread_id, seq=i, kind=kind, tool_key=tool_key, role=r.role, content=r.content, meta=dict(r.meta), created_at=created, ) self.rows.append(view) out.append(view) return out async def list_for_chapter( self, project_id: uuid.UUID, chapter_no: int | None, *, kind: str | None = None, limit: int, offset: int, ) -> list[AiMessageView]: matched = [r for r in self.rows if r.project_id == project_id] if chapter_no is not None: matched = [r for r in matched if r.chapter_no in (chapter_no, None)] else: matched = [r for r in matched if r.chapter_no is None] if kind is not None: matched = [r for r in matched if r.kind == kind] matched.sort(key=lambda r: (r.created_at, r.seq), reverse=True) return matched[offset : offset + limit] def _make_client() -> tuple[httpx.AsyncClient, _FakeAiMessageRepo, FakeProjectRepo, FakeSession]: os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode()) from ww_api.main import create_app from ww_api.services.credentials import STUB_OWNER_ID from ww_api.services.project_deps import get_ai_message_repo, get_project_repo from ww_db import get_session repo = _FakeAiMessageRepo() project_repo = FakeProjectRepo() session = FakeSession() # 一个已存在的项目(owner = stub),供 404 对照——直接种入 fake 的行表(避开事件循环)。 project_id = uuid.uuid4() project_repo.rows[project_id] = (STUB_OWNER_ID, ProjectView(id=project_id, title="AC-2")) app = create_app() app.dependency_overrides[get_ai_message_repo] = lambda: repo app.dependency_overrides[get_project_repo] = lambda: project_repo app.dependency_overrides[get_session] = lambda: session transport = httpx.ASGITransport(app=app) client = httpx.AsyncClient(transport=transport, base_url="http://test") client.project_id = project_id # type: ignore[attr-defined] return client, repo, project_repo, session def _refine_batch(chapter_no: int | None = 5) -> dict[str, object]: return { "thread_id": str(uuid.uuid4()), "chapter_no": chapter_no, "kind": "refine", "messages": [ {"role": "author", "content": "原选段"}, {"role": "ai", "content": "重写段", "meta": {"version_no": 1, "segment": "原选段"}}, ], } @pytest.mark.asyncio async def test_append_returns_201_persisted_views_and_commits() -> None: client, repo, _pr, session = _make_client() pid = client.project_id # type: ignore[attr-defined] async with client: resp = await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5)) assert resp.status_code == 201, resp.text body = resp.json() msgs = body["messages"] assert [m["seq"] for m in msgs] == [0, 1] assert [m["role"] for m in msgs] == ["author", "ai"] assert msgs[1]["meta"] == {"version_no": 1, "segment": "原选段"} assert all(set(m.keys()) == _EXPECTED_KEYS for m in msgs) assert all(m["id"] and m["created_at"] for m in msgs) assert session.commits == 1 assert len(repo.rows) == 2 @pytest.mark.asyncio async def test_append_then_list_roundtrip() -> None: client, _repo, _pr, _session = _make_client() pid = client.project_id # type: ignore[attr-defined] async with client: await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5)) resp = await client.get(f"/projects/{pid}/ai-messages", params={"chapter_no": 5}) assert resp.status_code == 200 msgs = resp.json()["messages"] # newest-first:批内 seq DESC → ai(seq1) 先于 author(seq0)。 assert [m["content"] for m in msgs] == ["重写段", "原选段"] @pytest.mark.asyncio async def test_list_chapter_union_includes_project_level() -> None: client, _repo, _pr, _session = _make_client() pid = client.project_id # type: ignore[attr-defined] async with client: await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5)) # 项目级工具箱生成(chapter_no 省略 → NULL)。 await client.post( f"/projects/{pid}/ai-messages", json={ "thread_id": str(uuid.uuid4()), "kind": "generator", "tool_key": "expand", "messages": [{"role": "ai", "content": "扩写预览"}], }, ) chapter_view = await client.get(f"/projects/{pid}/ai-messages", params={"chapter_no": 5}) toolbox_view = await client.get(f"/projects/{pid}/ai-messages") chapter_contents = {m["content"] for m in chapter_view.json()["messages"]} toolbox_contents = {m["content"] for m in toolbox_view.json()["messages"]} assert chapter_contents == {"原选段", "重写段", "扩写预览"} # 本章 ∪ 项目级 assert toolbox_contents == {"扩写预览"} # 仅项目级 @pytest.mark.asyncio async def test_list_kind_filter() -> None: client, _repo, _pr, _session = _make_client() pid = client.project_id # type: ignore[attr-defined] async with client: await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(2)) await client.post( f"/projects/{pid}/ai-messages", json={ "thread_id": str(uuid.uuid4()), "chapter_no": 2, "kind": "rewrite", "messages": [{"role": "ai", "content": "整章新版"}], }, ) resp = await client.get( f"/projects/{pid}/ai-messages", params={"chapter_no": 2, "kind": "rewrite"} ) msgs = resp.json()["messages"] assert [m["kind"] for m in msgs] == ["rewrite"] assert [m["content"] for m in msgs] == ["整章新版"] @pytest.mark.asyncio async def test_append_unknown_project_returns_404() -> None: client, repo, _pr, session = _make_client() async with client: resp = await client.post(f"/projects/{uuid.uuid4()}/ai-messages", json=_refine_batch(5)) assert resp.status_code == 404 assert resp.json()["error"]["code"] == "NOT_FOUND" assert session.commits == 0 assert repo.rows == [] @pytest.mark.asyncio async def test_list_unknown_project_returns_404() -> None: client, _repo, _pr, _session = _make_client() async with client: resp = await client.get(f"/projects/{uuid.uuid4()}/ai-messages", params={"chapter_no": 1}) assert resp.status_code == 404 assert resp.json()["error"]["code"] == "NOT_FOUND" @pytest.mark.asyncio async def test_append_invalid_role_returns_422() -> None: client, _repo, _pr, session = _make_client() pid = client.project_id # type: ignore[attr-defined] async with client: resp = await client.post( f"/projects/{pid}/ai-messages", json={ "thread_id": str(uuid.uuid4()), "chapter_no": 5, "kind": "refine", "messages": [{"role": "system", "content": "x"}], }, ) assert resp.status_code == 422 assert session.commits == 0 @pytest.mark.asyncio async def test_append_invalid_kind_returns_422() -> None: client, _repo, _pr, _session = _make_client() pid = client.project_id # type: ignore[attr-defined] async with client: resp = await client.post( f"/projects/{pid}/ai-messages", json={ "thread_id": str(uuid.uuid4()), "kind": "translate", # 非五类枚举 → 422 "messages": [{"role": "ai", "content": "x"}], }, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_append_empty_messages_returns_422() -> None: client, _repo, _pr, _session = _make_client() pid = client.project_id # type: ignore[attr-defined] async with client: resp = await client.post( f"/projects/{pid}/ai-messages", json={"thread_id": str(uuid.uuid4()), "kind": "refine", "messages": []}, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_append_empty_content_returns_422() -> None: client, _repo, _pr, _session = _make_client() pid = client.project_id # type: ignore[attr-defined] async with client: resp = await client.post( f"/projects/{pid}/ai-messages", json={ "thread_id": str(uuid.uuid4()), "kind": "refine", "messages": [{"role": "ai", "content": ""}], }, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_append_content_over_cap_returns_422() -> None: client, _repo, _pr, _session = _make_client() pid = client.project_id # type: ignore[attr-defined] async with client: resp = await client.post( f"/projects/{pid}/ai-messages", json={ "thread_id": str(uuid.uuid4()), "kind": "rewrite", "messages": [{"role": "ai", "content": "x" * 200_001}], }, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_append_oversize_meta_returns_422() -> None: client, _repo, _pr, _session = _make_client() pid = client.project_id # type: ignore[attr-defined] async with client: resp = await client.post( f"/projects/{pid}/ai-messages", json={ "thread_id": str(uuid.uuid4()), "kind": "refine", "messages": [{"role": "ai", "content": "x", "meta": {"blob": "y" * 60_000}}], }, ) assert resp.status_code == 422