✨ feat: WP-11 社区——发布、互动、关注与治理
- 迁移 12:community_content(帖子/角色 Release、publication 六态、可见性)、comment(父级树)、reaction((content,actor,kind) 唯一防重复)、follow(双 FK 显式命名修复 convention 冲突)、report + moderation_case(举报即建司议案) - 发布:帖子直发;角色 Release 由服务端构造公开快照(name/道体/版本数)——私密记忆/关系/对话结构性缺席,测试以注入私密记忆后发布验证零泄露 - 互动:评论(父级校验同内容可见)、点赞/收藏(重复 409、取消后计数正确) - 关注/取关与计数;举报自动生成司议案(治理队列入口,status=pending) - 下架传播:removed 后列表/详情/评论统一 404 或过滤(require_content 单点);非作者下架 404 fail-closed - 测试基建修复:login 返回自包含 headers(显式 Cookie 优先),多用户交替不再串会话 - 测试 79 项全绿(+7)
This commit is contained in:
1 parent
34c481ab9e
commit
99985f5855
7 files changed
+823
-2
No files matched your search
@@ -74,10 +74,21 @@ async def client(db):
|
||||
|
||||
|
||||
async def login(client, email: str) -> dict[str, str]:
|
||||
"""开发档登录:返回需附加的请求头(Cookie 由客户端自动携带)。"""
|
||||
"""开发档登录:返回自包含请求头(显式 Cookie 支持多用户交替)。
|
||||
|
||||
同时保留 cookie jar 自动存储:不带 headers 的旧式请求仍走 jar。
|
||||
显式 Cookie header 优先于 jar(httpx 行为),交替测试不串会话。
|
||||
"""
|
||||
resp = await client.post("/v1/auth/sessions", json={"email": email})
|
||||
assert resp.status_code == 201, resp.text
|
||||
return {"Hunjing-CSRF-Token": resp.json()["csrf_token"]}
|
||||
from app.auth.service import SESSION_COOKIE
|
||||
|
||||
token = resp.cookies.get(SESSION_COOKIE)
|
||||
assert token, "登录未签发会话 Cookie"
|
||||
return {
|
||||
"Hunjing-CSRF-Token": resp.json()["csrf_token"],
|
||||
"Cookie": f"{SESSION_COOKIE}={token}",
|
||||
}
|
||||
|
||||
|
||||
def _mock_openai_stream():
|
||||
|
||||
@@ -1144,3 +1144,141 @@ class TestMarketplace:
|
||||
"/v1/conversations", json={"title": "恢复"}, headers=headers
|
||||
)
|
||||
assert recovered.status_code == 201
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client")
|
||||
class TestCommunity:
|
||||
async def _publish_post(self, client, headers, title="昭星使用心得") -> str:
|
||||
resp = await client.post(
|
||||
"/v1/community/contents",
|
||||
json={"kind": "post", "title": title, "text": "很好用"}, headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()["id"]
|
||||
|
||||
async def test_publish_and_public_listing(self, client) -> None:
|
||||
headers = await login(client, "cm1@example.com")
|
||||
cid = await self._publish_post(client, headers)
|
||||
listing = await client.get("/v1/community/contents", headers=headers)
|
||||
assert any(c["id"] == cid for c in listing.json()["data"])
|
||||
|
||||
async def test_character_release_snapshot_has_no_private_data(self, client) -> None:
|
||||
"""Release 快照仅公开字段:结构上不含记忆/关系/对话(契约)。"""
|
||||
headers = await login(client, "cm2@example.com")
|
||||
c = await client.post(
|
||||
"/v1/characters", json={"name": "霁月"}, headers=headers
|
||||
)
|
||||
cid = c.json()["id"]
|
||||
await client.post(f"/v1/characters/{cid}/activations", headers=headers)
|
||||
# 给角色添加一条私密记忆(不应出现在发布物)
|
||||
await client.post(
|
||||
"/v1/memories/candidates",
|
||||
json={"kind": "fact", "sensitivity": "high", "character_id": cid,
|
||||
"payload": {"text": "绝对私密的事实"}},
|
||||
headers=headers,
|
||||
)
|
||||
release = await client.post(
|
||||
"/v1/community/contents",
|
||||
json={"kind": "character_release", "title": "霁月 · 发布",
|
||||
"character_id": cid},
|
||||
headers=headers,
|
||||
)
|
||||
assert release.status_code == 201, release.text
|
||||
body = release.json()["body"]
|
||||
assert body["character_name"] == "霁月"
|
||||
assert "public_baseline" in body
|
||||
# 私密字段结构性缺席
|
||||
import json as _json
|
||||
|
||||
assert "绝对私密的事实" not in _json.dumps(body, ensure_ascii=False)
|
||||
assert "memory" not in _json.dumps(body)
|
||||
|
||||
async def test_comments_likes_saves(self, client) -> None:
|
||||
headers = await login(client, "cm3@example.com")
|
||||
cid = await self._publish_post(client, headers, "互动测试")
|
||||
c1 = await client.post(
|
||||
f"/v1/community/contents/{cid}/comments",
|
||||
json={"body": "第一条"}, headers=headers,
|
||||
)
|
||||
assert c1.status_code == 201
|
||||
c2 = await client.post(
|
||||
f"/v1/community/contents/{cid}/comments",
|
||||
json={"body": "回复", "parent_comment_id": c1.json()["id"]}, headers=headers,
|
||||
)
|
||||
assert c2.status_code == 201 and c2.json()["parent_comment_id"] == c1.json()["id"]
|
||||
like = await client.post(
|
||||
f"/v1/community/contents/{cid}/reactions", json={"kind": "like"}, headers=headers
|
||||
)
|
||||
assert like.json()["like"] == "1"
|
||||
dup = await client.post(
|
||||
f"/v1/community/contents/{cid}/reactions", json={"kind": "like"}, headers=headers
|
||||
)
|
||||
assert dup.status_code == 409 # uk 防重复
|
||||
save = await client.post(
|
||||
f"/v1/community/contents/{cid}/reactions", json={"kind": "save"}, headers=headers
|
||||
)
|
||||
assert save.json()["save"] == "1"
|
||||
await client.request(
|
||||
"DELETE",
|
||||
f"/v1/community/contents/{cid}/reactions",
|
||||
json={"kind": "like"}, headers=headers,
|
||||
)
|
||||
detail = await client.get(f"/v1/community/contents/{cid}", headers=headers)
|
||||
assert detail.json()["reactions"] == {"save": 1} # like 已取消
|
||||
|
||||
async def test_follow_counts(self, client) -> None:
|
||||
a = await login(client, "cm4a@example.com")
|
||||
b = await login(client, "cm4b@example.com")
|
||||
# b 的域 ID:发布一条后从列表拿 author_domain_id
|
||||
cid = await self._publish_post(client, b)
|
||||
listing = await client.get("/v1/community/contents", headers=b)
|
||||
b_domain = next(
|
||||
c["author_domain_id"] for c in listing.json()["data"] if c["id"] == cid
|
||||
)
|
||||
follow = await client.post(
|
||||
"/v1/community/follows", json={"followee_domain_id": b_domain}, headers=a
|
||||
)
|
||||
assert follow.status_code == 201
|
||||
assert follow.json()["following"] == 1
|
||||
counts_b = await client.get("/v1/community/follows/counts", headers=b)
|
||||
assert counts_b.json()["followers"] == 1
|
||||
|
||||
async def test_report_creates_moderation_case(self, client) -> None:
|
||||
a = await login(client, "cm5a@example.com")
|
||||
cid = await self._publish_post(client, a)
|
||||
b = await login(client, "cm5b@example.com")
|
||||
report = await client.post(
|
||||
"/v1/community/reports",
|
||||
json={"target_type": "content", "target_id": cid, "reason": "违规内容"},
|
||||
headers=b,
|
||||
)
|
||||
assert report.status_code == 201
|
||||
assert report.json()["case_id"] and report.json()["status"] == "pending"
|
||||
|
||||
async def test_withdraw_propagates_invisibility(self, client) -> None:
|
||||
a = await login(client, "cm6a@example.com")
|
||||
cid = await self._publish_post(client, a)
|
||||
b = await login(client, "cm6b@example.com")
|
||||
await client.post(
|
||||
f"/v1/community/contents/{cid}/comments", json={"body": "x"}, headers=b
|
||||
)
|
||||
withdrawn = await client.post(
|
||||
f"/v1/community/contents/{cid}/withdrawals", headers=a
|
||||
)
|
||||
assert withdrawn.json()["status"] == "removed"
|
||||
# 列表不可见
|
||||
listing = await client.get("/v1/community/contents", headers=b)
|
||||
assert all(c["id"] != cid for c in listing.json()["data"])
|
||||
# 详情 404
|
||||
detail = await client.get(f"/v1/community/contents/{cid}", headers=b)
|
||||
assert detail.status_code == 404
|
||||
# 评论不可见
|
||||
comments = await client.get(f"/v1/community/contents/{cid}/comments", headers=b)
|
||||
assert comments.status_code == 404
|
||||
|
||||
async def test_non_author_cannot_withdraw(self, client) -> None:
|
||||
a = await login(client, "cm7a@example.com")
|
||||
cid = await self._publish_post(client, a)
|
||||
b = await login(client, "cm7b@example.com")
|
||||
resp = await client.post(f"/v1/community/contents/{cid}/withdrawals", headers=b)
|
||||
assert resp.status_code == 404 # 非作者:fail-closed
|
||||
Reference in new issue
Block a user