diff --git a/tests/integration/test_api_mail.py b/tests/integration/test_api_mail.py new file mode 100644 index 0000000..4b1db9b --- /dev/null +++ b/tests/integration/test_api_mail.py @@ -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