"""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 # deleted_count=2 表示成功;被删除的邮件 status=cancelled # 列表默认不按 status 过滤,所以验证非 cancelled 的只有 1 封 mails = client.get("/api/mail").json()["mails"] active = [m for m in mails if m["status"] != "cancelled"] assert len(active) == 1 assert active[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"] # mark_executed 设 is_read=True 并尝试 status→done # pending→done 不是合法转换,但 is_read 应被设为 True detail = client.get(f"/api/mail/{mail_id}").json() assert detail["is_read"] is True 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