推送真发送: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 断言按新语义改写
This commit is contained in:
host committed 2026-10-26 17:31:30 +08:00
1 parent b7dace55b9
commit fb3a311da8
7 files changed
+429 -29

No files matched your search

+149 -5
View File
@@ -1,14 +1,17 @@
"""push.py 单测:订阅增删查、偏好过滤、分发错误不静默丢。
只用标准库,不联网、不向真实推送服务发任何请求:所有发送都用
假 sender(可调用对象或按通道分的 dict)注入;webpush/apns/fcm
只断言缺凭证报错,不调用任何 sender。
不连外网、不向真实推送服务发任何请求:注入 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
@@ -22,6 +25,7 @@ try:
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]))
@@ -34,6 +38,7 @@ except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.p
delete_subscription,
dispatch_event,
list_subscriptions,
send_webhook,
)
@@ -184,10 +189,13 @@ class TestDispatchPreferences(PushTestCase):
class TestDispatchSendersAndCredentials(PushTestCase):
def test_webhook_without_sender_returns_no_sender_error_not_silent_ok(self):
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=None)
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"])
@@ -300,5 +308,141 @@ class TestDispatchIsolation(PushTestCase):
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()