test_step6_pap_idempotency.py
5.05 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# Step 6 (PAP 审计) 验证:跨渠道同一意图幂等 + 审批动作对称性
# 知识来源:personal-agent-protocol-pap(learn_queue #1307/#1361)
# - PAP 授权层 ③「账号授权 ≠ 操作授权」:同一意图重复触发不得产生第二笔外部动作
# - PAP 跨渠道统一会话:任一入口不得绕过统一守卫(单条 send 与批量 send 守卫必须一致)
# - 4C 对称性:batch-send 逐条返回 gate reason_code ⇒ batch-cancel 同样要逐条返回
# 安全策略:monkeypatch app._send_wechat_text,绝不真正外发微信。
import sys
sys.path.insert(0, "/data/data/com.termux/files/home/wechat-viewer")
import local_db
import app as app_module
from app import app
SENT_CALLS = []
def _fake_send(to_wxid, text):
"""记录调用,绝不真发。任何进入发送层的调用都会被断言计数。"""
SENT_CALLS.append((to_wxid, text))
return {"status": "sent", "to": to_wxid}
app_module._send_wechat_text = _fake_send
client = app.test_client()
TAG = "pap_step6_test"
failures = []
def check(name, cond, detail=""):
if cond:
print(f"[{name} PASS] {detail}")
else:
print(f"[{name} FAIL] {detail}")
failures.append(name)
# ── 准备:一条 pending + 一条已 sent
rid_pending = local_db.reply_add(
msg_id=0, talker=f"{TAG}_wxid", sender=f"{TAG}_wxid",
sender_name=f"{TAG}_pending", kind="private", incoming="测试消息",
draft="幂等测试草稿", action="reply", reason="pap-step6", status="pending",
)
rid_sent = local_db.reply_add(
msg_id=0, talker=f"{TAG}_wxid", sender=f"{TAG}_wxid",
sender_name=f"{TAG}_sent", kind="private", incoming="测试消息2",
draft="已发送草稿", action="reply", reason="pap-step6", status="sent",
)
print(f"[setup] pending id={rid_pending} sent id={rid_sent}")
# ── T1: 单条 send 对「已发送」条目必须拒绝(幂等守卫)
# BEFORE FIX: 无 status 检查 ⇒ 直接进入 _send_wechat_text ⇒ 重复外发
before = len(SENT_CALLS)
r = client.post(f"/api/reply/{rid_sent}/send", json={})
j = r.get_json()
check("T1", j.get("ok") is False and j.get("gate") == "REPLY_NOT_PENDING",
f"status={r.status_code} gate={j.get('gate')} error={j.get('error')}")
check("T1b", len(SENT_CALLS) == before,
f"未触发外发 (send 调用数 {before}->{len(SENT_CALLS)})")
# ── T2: 单条 send 重复调用同一 pending 条目:第一次成功、第二次必须被拦(幂等实测)
before = len(SENT_CALLS)
r1 = client.post(f"/api/reply/{rid_pending}/send", json={})
j1 = r1.get_json()
r2 = client.post(f"/api/reply/{rid_pending}/send", json={})
j2 = r2.get_json()
check("T2", j1.get("ok") is True, f"首次发送 ok={j1.get('ok')}")
check("T2b", j2.get("ok") is False, f"二次发送被拦 ok={j2.get('ok')} gate={j2.get('gate')}")
check("T2c", len(SENT_CALLS) == before + 1,
f"同一意图只产生一次外发动作 (send 调用 {len(SENT_CALLS) - before} 次)")
# ── T3: batch-send 与单条 send 守卫一致(跨渠道/跨入口权限一致)
r = client.post("/api/replies/batch-send", json={"ids": [rid_sent]})
j = r.get_json()
check("T3", j["results"][0]["gate"] == "REPLY_NOT_PENDING",
f"batch-send 同一守卫: gate={j['results'][0]['gate']}")
# ── T4: batch-cancel 逐条返回 gate reason_code(4C 对称性)
r = client.post("/api/replies/batch-cancel", json={"ids": [rid_sent, 999999]})
j = r.get_json()
results = j.get("results") or []
check("T4", len(results) == 2, f"逐条返回 results: {j}")
check("T4b", results and results[0].get("gate") == "REPLY_ALREADY_SENT",
f"已发送条目不可静默取消: {results[0] if results else None}")
check("T4c", len(results) > 1 and results[1].get("gate") == "REPLY_NOT_FOUND",
f"不存在条目 gate: {results[1] if len(results) > 1 else None}")
# ── T5: reply_add 按 msg_id 幂等(msg_id>0 时同一消息不重复入队)
mid = 987654321
a = local_db.reply_add(mid, f"{TAG}_wxid", f"{TAG}_wxid", f"{TAG}_dup",
"private", "dup", "draft", "reply", "pap-step6")
b = local_db.reply_add(mid, f"{TAG}_wxid", f"{TAG}_wxid", f"{TAG}_dup",
"private", "dup", "draft", "reply", "pap-step6")
check("T5", a == b, f"同一 msg_id 复用同一条目 (a={a} b={b})")
# ── T6: 回归 —— msg_id=0 是「无消息来源」哨兵,不得被去重(保 test_step6_approvals 语义)
c = local_db.reply_add(0, f"{TAG}_wxid", f"{TAG}_wxid", f"{TAG}_z1",
"private", "z", "draft", "reply", "pap-step6")
d = local_db.reply_add(0, f"{TAG}_wxid", f"{TAG}_wxid", f"{TAG}_z2",
"private", "z", "draft", "reply", "pap-step6")
check("T6", c != d, f"msg_id=0 不去重 (c={c} d={d})")
# ── 清理
try:
conn = local_db.get_conn()
conn.execute("DELETE FROM reply_queue WHERE sender_name LIKE ?", (f"{TAG}%",))
conn.commit()
conn.close()
print("[cleanup] 测试数据已清理")
except Exception as e:
print(f"[cleanup] 清理失败(不阻塞): {e}")
if failures:
print(f"\nFAILED: {failures}")
sys.exit(1)
print("\nALL_PAP_STEP6_TESTS_PASS")