"""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