Files
codex-app-server/cloud/tests/test_push.py
T
host fb3a311da8 推送真发送:webhook/slack 内置真实发送端 + worker 终态自动通知
此前订阅与分发框架都在,但分发端点传 sender=None,
webhook/slack 永远报无 sender、从未真发过。本轮:
- push:send_webhook/send_slack(标准库 urllib POST,
  单次 10 秒超时,不重试,失败记 errors);sender=None
  时用内置端,显式 sender 优先;webpush/apns/fcm 仍报
  缺凭证(原因写明:webpush 需浏览器端三字段订阅与标准库
  没有的 P-256 ECDH + AES-GCM;apns 需 HTTP/2;fcm 需
  Firebase 服务账号,三者均无现成接收方,不造假发送)
- worker:run_one 终态(completed/failed,缺 key 与仓库
  失败两条早退路径也在内)后 best-effort 调分发,通知
  失败只记日志、绝不影响任务状态;取消(interrupted)
  不在事件集合里,不通知
- 测试:真实发送端 6 个(本地回环服务器)、worker 终态
  通知 2 个,旧无 sender 断言按新语义改写
2026-10-26 17:31:30 +08:00

449 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""push.py 单测:订阅增删查、偏好过滤、分发错误不静默丢。
不连外网、不向真实推送服务发任何请求:注入 sender 的测试
用假 sender;真实发送端的测试用本地回环 HTTP 服务器
(127.0.0.1 临时端口)收 POST,断言方法、头与 body。
本文件不含任何真实密钥、token、证书或私钥。
"""
import http.server
import json
import os
import sqlite3
import sys
import threading
import unittest
from pathlib import Path
try:
from cloud.push import (
ALLOWED_PUSH_EVENTS,
CHANNELS,
FAILURE_EVENTS,
PREFERENCES,
create_subscription,
delete_subscription,
dispatch_event,
list_subscriptions,
send_webhook,
)
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.push import (
ALLOWED_PUSH_EVENTS,
CHANNELS,
FAILURE_EVENTS,
PREFERENCES,
create_subscription,
delete_subscription,
dispatch_event,
list_subscriptions,
send_webhook,
)
class PushTestCase(unittest.TestCase):
def setUp(self):
# 内存库 + 单一连接:init 与查询共用同一连接,避免 :memory: 换连接丢表。
self.conn = sqlite3.connect(":memory:")
def tearDown(self):
self.conn.close()
def _sub(self, project="proj-a", user="user-1", channel="webhook",
target="https://example.invalid/hook", preference="instant"):
return create_subscription(
self.conn, project, user, channel, target, preference)
def _calls(self):
calls = []
def sender(message):
calls.append(message)
return calls, sender
class TestSubscriptionCrud(PushTestCase):
def test_create_and_list(self):
sub = self._sub()
self.assertEqual(sub["project_id"], "proj-a")
self.assertEqual(sub["user_id"], "user-1")
self.assertEqual(sub["channel"], "webhook")
self.assertEqual(sub["preference"], "instant")
listed = list_subscriptions(self.conn, "proj-a", "user-1")
self.assertEqual(len(listed), 1)
self.assertEqual(listed[0]["id"], sub["id"])
def test_create_duplicate_returns_existing_not_duplicate_row(self):
first = self._sub()
second = self._sub()
self.assertEqual(first["id"], second["id"])
self.assertEqual(
len(list_subscriptions(self.conn, "proj-a", "user-1")), 1)
def test_create_invalid_channel_or_preference_rejected(self):
with self.assertRaises(ValueError):
self._sub(channel="sms")
with self.assertRaises(ValueError):
self._sub(preference="weekly")
self.assertEqual(
len(list_subscriptions(self.conn, "proj-a", "user-1")), 0)
def test_delete_by_id_then_no_longer_listed(self):
sub = self._sub()
self.assertTrue(delete_subscription(self.conn, sub["id"]))
self.assertEqual(
list_subscriptions(self.conn, "proj-a", "user-1"), [])
# 再次删除无匹配,返回 False,不报错。
self.assertFalse(delete_subscription(self.conn, sub["id"]))
def test_constants_cover_task_contract(self):
self.assertEqual(
set(CHANNELS), {"webhook", "slack", "webpush", "apns", "fcm"})
self.assertEqual(set(PREFERENCES), {"instant", "digest", "silent"})
self.assertEqual(
ALLOWED_PUSH_EVENTS,
{"completed", "failed", "goal-unmet", "merge-failed",
"review-requested"},
)
self.assertEqual(FAILURE_EVENTS, {"failed", "goal-unmet", "merge-failed"})
class TestDispatchPreferences(PushTestCase):
def test_silent_preference_never_sends(self):
self._sub(preference="silent")
calls, sender = self._calls()
result = dispatch_event(
self.conn, "failed", "proj-a", {"task_id": "t-1"}, sender=sender)
self.assertEqual(calls, [])
self.assertEqual(result["sent"], [])
self.assertEqual(len(result["skipped_silent"]), 1)
self.assertEqual(result["skipped_silent"][0]["preference"], "silent")
def test_failure_event_sends_immediately_even_with_digest(self):
# 失败类:digest 也即时发,不进 skipped_for_digest。
for event_type in ("failed", "goal-unmet", "merge-failed"):
with self.subTest(event_type=event_type):
conn = sqlite3.connect(":memory:")
try:
create_subscription(
conn, "proj-a", "user-1", "webhook",
"https://example.invalid/hook", "digest")
calls, sender = self._calls()
result = dispatch_event(
conn, event_type, "proj-a", {"task_id": "t-1"},
sender=sender)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0]["event_type"], event_type)
self.assertEqual(len(result["sent"]), 1)
self.assertEqual(result["skipped_for_digest"], [])
finally:
conn.close()
def test_completed_event_filtered_by_digest_returns_pending_list(self):
self._sub(preference="digest")
calls, sender = self._calls()
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"},
sender=sender)
# 不立即发,但不是静默丢弃:明确列出待摘要。
self.assertEqual(calls, [])
self.assertEqual(result["sent"], [])
self.assertEqual(len(result["skipped_for_digest"]), 1)
self.assertEqual(
result["skipped_for_digest"][0]["preference"], "digest")
self.assertEqual(result["errors"], [])
def test_review_requested_digest_also_deferred(self):
self._sub(preference="digest")
calls, sender = self._calls()
result = dispatch_event(
self.conn, "review-requested", "proj-a", {"task_id": "t-1"},
sender=sender)
self.assertEqual(calls, [])
self.assertEqual(len(result["skipped_for_digest"]), 1)
def test_instant_completed_sends(self):
self._sub(preference="instant")
calls, sender = self._calls()
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"},
sender=sender)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0]["channel"], "webhook")
self.assertEqual(calls[0]["payload"], {"task_id": "t-1"})
self.assertEqual(len(result["sent"]), 1)
self.assertEqual(result["errors"], [])
def test_invalid_event_rejected_and_sender_not_called(self):
self._sub(preference="instant")
calls, sender = self._calls()
result = dispatch_event(
self.conn, "running", "proj-a", {"task_id": "t-1"}, sender=sender)
self.assertEqual(calls, [])
self.assertEqual(result["sent"], [])
self.assertEqual(result["reason"], "event not allowed")
self.assertTrue(
any("event not allowed" in e["reason"] for e in result["errors"]))
class TestDispatchSendersAndCredentials(PushTestCase):
def test_explicit_sender_missing_channel_returns_no_sender_error_not_silent_ok(self):
# sender=None 时内置真实发送端会真 POST(见 TestRealSenders),
# “无 sender”只发生在显式 sender 里没有该通道时,仍不静默成功。
self._sub(channel="webhook")
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"},
sender={})
self.assertEqual(result["sent"], [])
self.assertEqual(len(result["errors"]), 1)
self.assertIn("no sender", result["errors"][0]["reason"])
def test_slack_via_channel_dict_sender(self):
create_subscription(
self.conn, "proj-a", "user-1", "slack", "C123", "instant")
slack_calls = []
def slack_sender(message):
slack_calls.append(message)
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"},
sender={"slack": slack_sender})
self.assertEqual(len(slack_calls), 1)
self.assertEqual(slack_calls[0]["channel"], "slack")
self.assertEqual(len(result["sent"]), 1)
self.assertEqual(result["errors"], [])
def test_webpush_apns_fcm_missing_credential_error_not_silent_drop(self):
cases = {
"webpush": "VAPID",
"apns": "APNs",
"fcm": "FCM",
}
for channel, keyword in cases.items():
with self.subTest(channel=channel):
conn = sqlite3.connect(":memory:")
try:
create_subscription(
conn, "proj-a", "user-1", channel,
"device-or-endpoint", "instant")
calls, sender = self._calls()
result = dispatch_event(
conn, "failed", "proj-a", {"task_id": "t-1"},
sender=sender)
# 绝不走假 sender、绝不假装成功:明确报错。
self.assertEqual(calls, [])
self.assertEqual(result["sent"], [])
self.assertEqual(len(result["errors"]), 1)
self.assertIn("missing credential", result["errors"][0]["reason"])
self.assertIn(keyword, result["errors"][0]["reason"])
finally:
conn.close()
def test_sender_exception_recorded_not_raised_and_others_continue(self):
self._sub(user="user-1", target="https://example.invalid/hook-1")
def flaky(message):
if message["target"] == "https://example.invalid/hook-1":
raise RuntimeError("fake send failure")
calls.append(message)
calls = []
# 第二个订阅用另一个 target,同一 sender 只对第一个抛错。
create_subscription(
self.conn, "proj-a", "user-2", "webhook",
"https://example.invalid/hook-2", "instant")
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"},
sender=flaky)
self.assertEqual(len(result["sent"]), 1)
self.assertEqual(len(result["errors"]), 1)
self.assertIn("sender failed", result["errors"][0]["reason"])
self.assertEqual(len(calls), 1)
class TestDispatchIsolation(PushTestCase):
def test_deleted_subscription_no_longer_receives(self):
sub = self._sub()
self.assertTrue(delete_subscription(self.conn, sub["id"]))
calls, sender = self._calls()
result = dispatch_event(
self.conn, "failed", "proj-a", {"task_id": "t-1"}, sender=sender)
self.assertEqual(calls, [])
self.assertEqual(result["sent"], [])
self.assertEqual(result["errors"], [])
def test_cross_project_subscriptions_do_not_mix(self):
self._sub(project="proj-a", user="user-1")
self._sub(project="proj-b", user="user-1",
target="https://example.invalid/hook-b")
calls, sender = self._calls()
# B 项目事件只发给 B 的订阅,A 的订阅收不到。
result_b = dispatch_event(
self.conn, "failed", "proj-b", {"task_id": "t-1"}, sender=sender)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0]["target"], "https://example.invalid/hook-b")
self.assertEqual(len(result_b["sent"]), 1)
# A 项目事件只发给 A 的订阅。
calls.clear()
result_a = dispatch_event(
self.conn, "failed", "proj-a", {"task_id": "t-1"}, sender=sender)
self.assertEqual(len(calls), 1)
self.assertEqual(
calls[0]["target"], "https://example.invalid/hook")
self.assertEqual(len(result_a["sent"]), 1)
def test_list_subscriptions_scoped_by_project_and_user(self):
self._sub(project="proj-a", user="user-1")
self._sub(project="proj-a", user="user-2",
target="https://example.invalid/hook-2")
self._sub(project="proj-b", user="user-1",
target="https://example.invalid/hook-b")
self.assertEqual(len(list_subscriptions(self.conn, "proj-a")), 2)
self.assertEqual(
len(list_subscriptions(self.conn, "proj-a", "user-1")), 1)
self.assertEqual(
len(list_subscriptions(self.conn, "proj-b", "user-1")), 1)
class _RecordingHandler(http.server.BaseHTTPRequestHandler):
"""本地回环接收器:记录每次 POST 的头与 body,按类属性回状态。"""
requests = []
status = 200
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else b""
_RecordingHandler.requests.append({
"path": self.path,
"content_type": self.headers.get("Content-Type"),
"body": body,
})
self.send_response(_RecordingHandler.status)
self.send_header("Content-Length", "2")
self.end_headers()
self.wfile.write(b"{}")
def log_message(self, *args):
pass
class TestRealSenders(PushTestCase):
"""内置真实发送端:对本地回环服务器真 POST,断言方法/头/body;
不连外网。"""
def setUp(self):
super().setUp()
_RecordingHandler.requests = []
_RecordingHandler.status = 200
self.server = http.server.ThreadingHTTPServer(
("127.0.0.1", 0), _RecordingHandler)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
super().tearDown()
def _url(self, path="/hook"):
return "http://127.0.0.1:%d%s" % (self.port, path)
def test_webhook_posts_json_via_default_sender(self):
create_subscription(
self.conn, "proj-a", "user-1", "webhook", self._url(),
"instant")
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"},
sender=None)
self.assertEqual(len(result["sent"]), 1)
self.assertEqual(result["errors"], [])
self.assertEqual(len(_RecordingHandler.requests), 1)
req = _RecordingHandler.requests[0]
self.assertEqual(req["content_type"], "application/json")
body = json.loads(req["body"].decode("utf-8"))
self.assertEqual(body["event_type"], "completed")
self.assertEqual(body["project_id"], "proj-a")
self.assertEqual(body["task_id"], "t-1")
self.assertEqual(body["payload"], {"task_id": "t-1"})
def test_webhook_non_2xx_is_error_not_sent(self):
_RecordingHandler.status = 500
create_subscription(
self.conn, "proj-a", "user-1", "webhook", self._url(),
"instant")
result = dispatch_event(
self.conn, "failed", "proj-a", {"task_id": "t-1"},
sender=None)
self.assertEqual(result["sent"], [])
self.assertEqual(len(result["errors"]), 1)
self.assertIn("delivery failed", result["errors"][0]["reason"])
self.assertEqual(len(_RecordingHandler.requests), 1)
def test_webhook_non_http_target_is_error(self):
create_subscription(
self.conn, "proj-a", "user-1", "webhook",
"ftp://example.invalid/hook", "instant")
with self.assertRaises(ValueError):
send_webhook({
"event_type": "completed", "project_id": "proj-a",
"payload": {"task_id": "t-1"},
"channel": "webhook",
"target": "ftp://example.invalid/hook",
"user_id": "user-1", "subscription_id": "s-1"})
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"},
sender=None)
self.assertEqual(result["sent"], [])
self.assertIn("http(s) URL", result["errors"][0]["reason"])
def test_slack_posts_text_with_task_and_error(self):
create_subscription(
self.conn, "proj-a", "user-1", "slack", self._url(),
"instant")
result = dispatch_event(
self.conn, "failed", "proj-a",
{"task_id": "t-9", "error": "boom happened"},
sender=None)
self.assertEqual(len(result["sent"]), 1)
body = json.loads(_RecordingHandler.requests[0]["body"].decode())
self.assertIn("t-9", body["text"])
self.assertIn("failed", body["text"])
self.assertIn("boom happened", body["text"])
def test_webpush_still_missing_credential_with_default_sender(self):
create_subscription(
self.conn, "proj-a", "user-1", "webpush",
"https://example.invalid/endpoint", "instant")
result = dispatch_event(
self.conn, "failed", "proj-a", {"task_id": "t-1"},
sender=None)
self.assertEqual(result["sent"], [])
self.assertIn("missing credential", result["errors"][0]["reason"])
self.assertEqual(_RecordingHandler.requests, [])
def test_explicit_sender_wins_over_default(self):
create_subscription(
self.conn, "proj-a", "user-1", "webhook", self._url(),
"instant")
calls = []
def fake(message):
calls.append(message)
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"},
sender={"webhook": fake})
self.assertEqual(len(calls), 1)
self.assertEqual(len(result["sent"]), 1)
self.assertEqual(_RecordingHandler.requests, [])
if __name__ == "__main__":
unittest.main()