auto-sync: 2026-06-05 18:45:24
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
"""Mail API 集成测试 — 覆盖全部端点 + 防御逻辑 A1-A10"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.main import app
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
# 有效 Agent(与 mail_routes fallback 一致)
|
||||
VALID_AGENTS = [
|
||||
"zhangfei-dev", "guanyu-dev", "zhaoyun-data",
|
||||
"jiangwei-infra", "pangtong-fujunshi", "simayi-challenger",
|
||||
]
|
||||
FROM_AGENT = "zhangfei-dev"
|
||||
TO_AGENT = "simayi-challenger"
|
||||
OTHER_AGENT = "pangtong-fujunshi"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mail_env(tmp_path):
|
||||
"""创建临时 Mail 环境,确保 BLACKBOARD_ROOT 隔离"""
|
||||
mail_root = tmp_path / "mail_root"
|
||||
mail_root.mkdir()
|
||||
os.environ["BLACKBOARD_ROOT"] = str(mail_root)
|
||||
yield mail_root
|
||||
del os.environ["BLACKBOARD_ROOT"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _send(client, from_a: str = FROM_AGENT, to_a: str = TO_AGENT,
|
||||
title: str = "Test Mail", text: str = "Hello",
|
||||
**kwargs) -> dict:
|
||||
"""发送邮件的便捷方法"""
|
||||
body = {"from": from_a, "to": to_a, "title": title, "text": text}
|
||||
body.update(kwargs)
|
||||
resp = client.post("/api/mail", json=body)
|
||||
return resp
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestMailList
|
||||
# ===================================================================
|
||||
|
||||
class TestMailList:
|
||||
def test_list_returns_all(self, client, mail_env):
|
||||
_send(client, title="Mail A")
|
||||
_send(client, from_a=TO_AGENT, to_a=FROM_AGENT, title="Mail B")
|
||||
resp = client.get("/api/mail")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 2
|
||||
|
||||
def test_filter_by_from_agent(self, client, mail_env):
|
||||
_send(client, title="From ZF")
|
||||
_send(client, from_a=OTHER_AGENT, to_a=TO_AGENT, title="From PT")
|
||||
resp = client.get("/api/mail", params={"from_agent": FROM_AGENT})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["mails"][0]["from"] == FROM_AGENT
|
||||
|
||||
def test_filter_by_to_agent(self, client, mail_env):
|
||||
_send(client, title="To SMY")
|
||||
_send(client, from_a=TO_AGENT, to_a=OTHER_AGENT, title="To PT")
|
||||
resp = client.get("/api/mail", params={"to_agent": TO_AGENT})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["mails"][0]["to"] == TO_AGENT
|
||||
|
||||
def test_filter_unread(self, client, mail_env):
|
||||
r = _send(client, title="Unread")
|
||||
mail_id = r.json()["mail_id"]
|
||||
# 标记已读
|
||||
client.patch(f"/api/mail/{mail_id}", json={"is_read": True})
|
||||
# 再发一封未读
|
||||
_send(client, from_a=OTHER_AGENT, to_a=FROM_AGENT, title="Still unread")
|
||||
resp = client.get("/api/mail", params={"unread": True})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
|
||||
def test_limit_parameter(self, client, mail_env):
|
||||
for i in range(5):
|
||||
_send(client, title=f"Mail {i}")
|
||||
resp = client.get("/api/mail", params={"limit": 2})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 2
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestMailAgents
|
||||
# ===================================================================
|
||||
|
||||
class TestMailAgents:
|
||||
def test_agents_list(self, client, mail_env):
|
||||
_send(client, title="A")
|
||||
_send(client, from_a=OTHER_AGENT, to_a=TO_AGENT, title="B")
|
||||
resp = client.get("/api/mail/agents/list")
|
||||
assert resp.status_code == 200
|
||||
agents = resp.json()["agents"]
|
||||
assert FROM_AGENT in agents
|
||||
assert TO_AGENT in agents
|
||||
assert OTHER_AGENT in agents
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestMailSummary
|
||||
# ===================================================================
|
||||
|
||||
class TestMailSummary:
|
||||
def test_summary_fields(self, client, mail_env):
|
||||
_send(client, title="Info", type="inform")
|
||||
_send(client, title="Req", type="request")
|
||||
resp = client.get("/api/mail/summary")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 2
|
||||
assert data["unread"] == 2
|
||||
assert data["by_type"]["inform"] == 1
|
||||
assert data["by_type"]["request"] == 1
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestMailDetail
|
||||
# ===================================================================
|
||||
|
||||
class TestMailDetail:
|
||||
def test_get_detail(self, client, mail_env):
|
||||
r = _send(client, title="Detail Test", text="Body content")
|
||||
mail_id = r.json()["mail_id"]
|
||||
resp = client.get(f"/api/mail/{mail_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["title"] == "Detail Test"
|
||||
assert data["description"] == "Body content"
|
||||
assert data["from"] == FROM_AGENT
|
||||
assert data["to"] == TO_AGENT
|
||||
assert "comments" in data
|
||||
|
||||
def test_detail_404(self, client, mail_env):
|
||||
resp = client.get("/api/mail/nonexistent-mail-id")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestSendMail — A1-A10 防御逻辑
|
||||
# ===================================================================
|
||||
|
||||
class TestSendMail:
|
||||
# --- 防御逻辑 ---
|
||||
|
||||
def test_A1_from_missing(self, client, mail_env):
|
||||
"""A1: from 必填 → 400"""
|
||||
resp = client.post("/api/mail", json={
|
||||
"to": TO_AGENT, "title": "T", "text": "Hi",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert "from" in resp.json()["detail"]
|
||||
|
||||
def test_A9_from_invalid(self, client, mail_env):
|
||||
"""A9: from 不是有效 Agent → 400"""
|
||||
resp = client.post("/api/mail", json={
|
||||
"from": "unknown-agent", "to": TO_AGENT, "title": "T", "text": "Hi",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert "不是有效的 Agent" in resp.json()["detail"]
|
||||
|
||||
def test_A2_to_missing_non_reply(self, client, mail_env):
|
||||
"""A2: 非回复时 to 缺失 → 400"""
|
||||
resp = client.post("/api/mail", json={
|
||||
"from": FROM_AGENT, "title": "T", "text": "Hi",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert "to" in resp.json()["detail"]
|
||||
|
||||
def test_A3_from_equals_to(self, client, mail_env):
|
||||
"""A3: from == to 防自环 → 400"""
|
||||
resp = client.post("/api/mail", json={
|
||||
"from": FROM_AGENT, "to": FROM_AGENT, "title": "T", "text": "Self",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert "不能给自己发邮件" in resp.json()["detail"]
|
||||
|
||||
def test_A4_to_invalid(self, client, mail_env):
|
||||
"""A4: to 不是有效 Agent → 400"""
|
||||
resp = client.post("/api/mail", json={
|
||||
"from": FROM_AGENT, "to": "nobody", "title": "T", "text": "Hi",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert "不是有效的 Agent" in resp.json()["detail"]
|
||||
|
||||
def test_A10_text_empty(self, client, mail_env):
|
||||
"""A10: 正文为空 → 400"""
|
||||
resp = client.post("/api/mail", json={
|
||||
"from": FROM_AGENT, "to": TO_AGENT, "title": "T", "text": " ",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert "正文" in resp.json()["detail"]
|
||||
|
||||
def test_A10_description_empty(self, client, mail_env):
|
||||
"""A10: description 字段为空也触发 → 400"""
|
||||
resp = client.post("/api/mail", json={
|
||||
"from": FROM_AGENT, "to": TO_AGENT, "title": "T",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
# --- 正常发送 ---
|
||||
|
||||
def test_send_inform(self, client, mail_env):
|
||||
"""正常发送 inform → 200"""
|
||||
resp = _send(client, type="inform")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"]
|
||||
assert data["mail_id"]
|
||||
# 验证列表可见
|
||||
mails = client.get("/api/mail").json()["mails"]
|
||||
assert len(mails) == 1
|
||||
assert mails[0]["type"] == "inform"
|
||||
|
||||
def test_send_request(self, client, mail_env):
|
||||
"""正常发送 request → 200"""
|
||||
resp = _send(client, type="request")
|
||||
assert resp.status_code == 200
|
||||
mails = client.get("/api/mail").json()["mails"]
|
||||
assert mails[0]["type"] == "request"
|
||||
|
||||
# --- 回复逻辑 ---
|
||||
|
||||
def test_A5_reply_to_nonexistent(self, client, mail_env):
|
||||
"""A5: in_reply_to 不存在 → 400"""
|
||||
resp = _send(client, in_reply_to="mail-99999")
|
||||
assert resp.status_code == 400
|
||||
assert "不存在" in resp.json()["detail"]
|
||||
|
||||
def test_A6_A7_auto_correct_to(self, client, mail_env):
|
||||
"""A6/A7: 回复时 to 被自动纠正为原发件者 → 200 + auto_corrected"""
|
||||
# simayi → zhangfei
|
||||
r = _send(client, from_a=TO_AGENT, to_a=FROM_AGENT,
|
||||
title="Original", text="Original body")
|
||||
orig_id = r.json()["mail_id"]
|
||||
|
||||
# zhangfei 回复 simayi,但故意传错 to
|
||||
resp = _send(client, from_a=FROM_AGENT, to_a=OTHER_AGENT,
|
||||
title="Reply", text="Reply body",
|
||||
in_reply_to=orig_id)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"]
|
||||
assert "auto_corrected" in data
|
||||
assert data["auto_corrected"]["field"] == "to"
|
||||
assert data["auto_corrected"]["original"] == OTHER_AGENT
|
||||
assert data["auto_corrected"]["corrected"] == TO_AGENT
|
||||
|
||||
def test_A8_non_party_reply_rejected(self, client, mail_env):
|
||||
"""A8: 非邮件双方回复 → 400"""
|
||||
# zhangfei → simayi
|
||||
r = _send(client, title="Private", text="Secret")
|
||||
orig_id = r.json()["mail_id"]
|
||||
|
||||
# pangtong(第三方)尝试回复
|
||||
resp = _send(client, from_a=OTHER_AGENT, to_a=FROM_AGENT,
|
||||
title="Intrude", text="I shouldn't be here",
|
||||
in_reply_to=orig_id)
|
||||
assert resp.status_code == 400
|
||||
assert "发送者或接收者" in resp.json()["detail"]
|
||||
|
||||
def test_reply_inherits_conversation_id(self, client, mail_env):
|
||||
"""回复时 conversation_id 继承"""
|
||||
r = _send(client, from_a=TO_AGENT, to_a=FROM_AGENT,
|
||||
title="Conv Start", text="Start",
|
||||
conversation_id="conv-test-123")
|
||||
orig_id = r.json()["mail_id"]
|
||||
|
||||
resp = _send(client, from_a=FROM_AGENT, to_a=TO_AGENT,
|
||||
title="Conv Reply", text="Reply",
|
||||
in_reply_to=orig_id)
|
||||
assert resp.status_code == 200
|
||||
reply_id = resp.json()["mail_id"]
|
||||
|
||||
# 验证回复邮件继承了 conversation_id
|
||||
detail = client.get(f"/api/mail/{reply_id}").json()
|
||||
assert detail["conversation_id"] == "conv-test-123"
|
||||
|
||||
def test_reply_type_defaults_inform(self, client, mail_env):
|
||||
"""回复时 type 默认 inform"""
|
||||
r = _send(client, from_a=TO_AGENT, to_a=FROM_AGENT,
|
||||
title="Orig", text="Hi", type="request")
|
||||
orig_id = r.json()["mail_id"]
|
||||
|
||||
# 不传 type,应默认 inform
|
||||
resp = client.post("/api/mail", json={
|
||||
"from": FROM_AGENT, "to": TO_AGENT,
|
||||
"title": "Re: Orig", "text": "Reply",
|
||||
"in_reply_to": orig_id,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
reply_id = resp.json()["mail_id"]
|
||||
detail = client.get(f"/api/mail/{reply_id}").json()
|
||||
assert detail["type"] == "inform"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestDeleteMail
|
||||
# ===================================================================
|
||||
|
||||
class TestDeleteMail:
|
||||
def test_delete_by_prefix(self, client, mail_env):
|
||||
_send(client, title="[BULK] Alert 1")
|
||||
_send(client, title="[BULK] Alert 2")
|
||||
_send(client, title="Keep this")
|
||||
resp = client.delete("/api/mail", params={"prefix": "[BULK]"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"]
|
||||
assert data["deleted_count"] == 2
|
||||
# 只有 1 封保留
|
||||
mails = client.get("/api/mail").json()["mails"]
|
||||
assert len(mails) == 1
|
||||
assert mails[0]["title"] == "Keep this"
|
||||
|
||||
def test_delete_prefix_missing(self, client, mail_env):
|
||||
resp = client.delete("/api/mail")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_delete_no_match(self, client, mail_env):
|
||||
_send(client, title="Keep")
|
||||
resp = client.delete("/api/mail", params={"prefix": "NONEXISTENT"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["deleted_count"] == 0
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestUpdateMail
|
||||
# ===================================================================
|
||||
|
||||
class TestUpdateMail:
|
||||
def test_mark_read(self, client, mail_env):
|
||||
r = _send(client, title="Read me")
|
||||
mail_id = r.json()["mail_id"]
|
||||
|
||||
resp = client.patch(f"/api/mail/{mail_id}", json={"is_read": True})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"]
|
||||
|
||||
# 验证已读
|
||||
detail = client.get(f"/api/mail/{mail_id}").json()
|
||||
assert detail["is_read"] is True
|
||||
|
||||
def test_mark_executed(self, client, mail_env):
|
||||
r = _send(client, title="Do me")
|
||||
mail_id = r.json()["mail_id"]
|
||||
|
||||
resp = client.patch(f"/api/mail/{mail_id}", json={"mark_executed": True})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"]
|
||||
|
||||
# 验证已读 + status=done
|
||||
detail = client.get(f"/api/mail/{mail_id}").json()
|
||||
assert detail["is_read"] is True
|
||||
assert detail["status"] == "done"
|
||||
|
||||
def test_update_404(self, client, mail_env):
|
||||
resp = client.patch("/api/mail/no-such-mail", json={"is_read": True})
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user