#5 规则 DELETE + id:暴露 RuleView.id(PK);新增 DELETE /projects/{id}/rules/{rule_id} (项目/规则不存在→404,成功→204,按 (id,project_id) 限定);rule_repo 加 list_for_project/delete;RulesPage 每条加删除(乐观删+回滚+toast)。assemble 侧 RuleView(缓存前缀)不动,列表另立 RuleListItemView。 #7 codex 角色 relations:写侧本已持久化、读端点 _existing_characters 硬编码 []。 加 _relations_from_jsonb 解析 {name,kind,note},CodexPage 渲染关系 chip。 #8 角色入库幂等:SqlCharacterWriteRepo.create 改 (project_id,name) app 层 upsert—— 重复入库改更新而非插入;不加 UNIQUE/迁移(线上已有重复行会让约束迁移失败)。 #1 大纲卷过滤:GET /outline 支持可选 ?volume(无参=全部,向后兼容);OutlineEditor 加「查看:全部/卷N」筛选,与生成目标卷解耦。 H3/#9 文风回炉锚点:StyleDriftSegment 加 text(逐字命中段),style.md 指示审稿输出; 前端按内容锚点定位回炉目标(idx 仅排序),命中失败 → 提示「无法定位该段」而非 静默 no-op。style golden fixture 已重生成。 契约变更已 pnpm gen:api(RuleView.id / DELETE rules / outline ?volume)。无迁移 (alembic 无漂移)。门禁绿:ruff/mypy(210)/alembic/pytest 760 · 前端 tsc/lint/vitest 329。
175 lines
6.5 KiB
Python
175 lines
6.5 KiB
Python
"""T5.5 POST /projects/:id/rules 端点(内存替身,无 DB/无网络)。
|
||
|
||
覆盖:
|
||
- 201 + 回显 level/content + 端点 commit;
|
||
- 非法 level → 422(Pydantic Literal 校验,FastAPI 422);
|
||
- 空 content → 422;
|
||
- 项目不存在 → 404(QA H2 回归:此前 FK 违例逃逸成 500)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
|
||
import httpx
|
||
import pytest
|
||
from cryptography.fernet import Fernet
|
||
from fakes_projects import FakeProjectRepo, FakeSession
|
||
from ww_api.services.credentials import STUB_OWNER_ID
|
||
from ww_core.domain.project_repo import ProjectView
|
||
from ww_core.domain.rule_repo import RuleListItemView, 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(id=uuid.uuid4(), project_id=project_id, level=level, content=content)
|
||
self.rows.append(view)
|
||
return view
|
||
|
||
async def list_for_project(self, project_id: uuid.UUID) -> list[RuleListItemView]:
|
||
return [
|
||
RuleListItemView(id=r.id, level=r.level, content=r.content)
|
||
for r in self.rows
|
||
if r.project_id in (project_id, None)
|
||
]
|
||
|
||
async def delete(self, project_id: uuid.UUID, rule_id: uuid.UUID) -> bool:
|
||
before = len(self.rows)
|
||
self.rows = [r for r in self.rows if not (r.id == rule_id and r.project_id == project_id)]
|
||
return len(self.rows) < before
|
||
|
||
|
||
def _make_client() -> tuple[
|
||
httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession, FakeProjectRepo, uuid.UUID
|
||
]:
|
||
"""构建测试 client,并 seed 一个属于 STUB_OWNER_ID 的项目;返回其 pid。
|
||
|
||
create_rule 现在先校验项目存在(QA H2),故必须 override get_project_repo 并 seed,
|
||
否则正常用例会 404。返回的 pid 是已存在项目;未 seed 的随机 pid 即"不存在"。
|
||
"""
|
||
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_project_repo, get_rule_write_repo
|
||
from ww_db import get_session
|
||
|
||
repo = _FakeRuleWriteRepo()
|
||
session = FakeSession()
|
||
project_repo = FakeProjectRepo()
|
||
pid = uuid.uuid4()
|
||
project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="作品"))
|
||
|
||
app = create_app()
|
||
app.dependency_overrides[get_rule_write_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")
|
||
return client, repo, session, project_repo, pid
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_rule_returns_201_and_commits() -> None:
|
||
client, repo, session, _project_repo, pid = _make_client()
|
||
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"] == "主角不许中途复活"
|
||
# 创建回显带 id(前端删除 handle)。
|
||
assert body["id"] == str(repo.rows[0].id)
|
||
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, _project_repo, pid = _make_client()
|
||
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, _project_repo, pid = _make_client()
|
||
async with client:
|
||
resp = await client.post(
|
||
f"/projects/{pid}/rules",
|
||
json={"level": "global", "content": ""},
|
||
)
|
||
assert resp.status_code == 422
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_rule_whitespace_content_returns_422() -> None:
|
||
# QA MEDIUM 回归:纯空白 content(strip 后为空)应 422,不可入库成垃圾行。
|
||
client, repo, _session, _project_repo, pid = _make_client()
|
||
async with client:
|
||
resp = await client.post(
|
||
f"/projects/{pid}/rules",
|
||
json={"level": "project", "content": " "},
|
||
)
|
||
assert resp.status_code == 422
|
||
assert len(repo.rows) == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_rule_unknown_project_returns_404() -> None:
|
||
# QA H2 回归:给不存在的 project 加规则应 404(不是 500 的 FK 违例逃逸)。
|
||
client, repo, _session, _project_repo, _pid = _make_client()
|
||
async with client:
|
||
resp = await client.post(
|
||
f"/projects/{uuid.uuid4()}/rules",
|
||
json={"level": "project", "content": "x"},
|
||
)
|
||
assert resp.status_code == 404
|
||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||
assert len(repo.rows) == 0 # 未触达写库
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_delete_rule_returns_204_and_commits() -> None:
|
||
# 作者显式删一条本作品规则(不变量 #3):204 + 端点提交 + 行被移除。
|
||
client, repo, session, _project_repo, pid = _make_client()
|
||
seeded = await repo.create(pid, level="project", content="待删规则")
|
||
async with client:
|
||
resp = await client.delete(f"/projects/{pid}/rules/{seeded.id}")
|
||
assert resp.status_code == 204
|
||
assert session.commits == 1
|
||
assert len(repo.rows) == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_delete_rule_unknown_rule_returns_404() -> None:
|
||
# 未知 rule_id → 404;不提交(删不到行)。
|
||
client, repo, session, _project_repo, pid = _make_client()
|
||
async with client:
|
||
resp = await client.delete(f"/projects/{pid}/rules/{uuid.uuid4()}")
|
||
assert resp.status_code == 404
|
||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||
assert session.commits == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_delete_rule_unknown_project_returns_404() -> None:
|
||
# 项目不存在 → 404(先于 rule 查校验),不触达删除。
|
||
client, repo, session, _project_repo, _pid = _make_client()
|
||
async with client:
|
||
resp = await client.delete(f"/projects/{uuid.uuid4()}/rules/{uuid.uuid4()}")
|
||
assert resp.status_code == 404
|
||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||
assert session.commits == 0
|