G10:POST /tasks/{id}/goal-resume,仅 interrupted 的 goal 任务,
走 host.py goal-resume(无 --goal-text)从 goal_rounds_done 下一轮
断点续跑,保留原线程与 worktree,状态回 queued 并置
goal_resume_pending(worker 认领后清掉),写 task_goal_resumed
审计;goal-met/unmet、failed、非 goal 一律 409;/continue 开新
循环语义不动。Local/Docker 两路新增 goal_resume_breakpoint_task,
不支持断点的执行器明确失败,绝不回落成 goal 或开新循环。
G11:digest 非失败事件持久化进 push_digest_events,worker 循环
每小时按(项目、订阅)分组汇总发一条,失败类仍即时发。
G12:webhook/slack 投递失败写 push_outbox,worker 循环后台重试
(最多 3 次、默认 60 秒退避),3 次仍失败删行并记日志,不丢
任务状态;缺凭证与无 sender 不进 outbox;文档写明 at-least-once
下 webhook/slack 可能重复,接收方须去重。
测试:cloud 769(4 跳过)、host 68、deploy 19 全过。
574 lines
23 KiB
Python
574 lines
23 KiB
Python
"""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_outbox,
|
||
list_pending_digest,
|
||
list_subscriptions,
|
||
process_outbox_due,
|
||
send_digest_due,
|
||
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_outbox,
|
||
list_pending_digest,
|
||
list_subscriptions,
|
||
process_outbox_due,
|
||
send_digest_due,
|
||
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()
|
||
# P1 SSRF 门默认挡 loopback;本地录制服务器测试显式放行,
|
||
# 生产默认值仍关闭(不要把本开关当成生产默认)。
|
||
self._old_push_allow = os.environ.get(
|
||
"CODEX_PUSH_ALLOW_PRIVATE_TARGETS")
|
||
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = "1"
|
||
_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)
|
||
if getattr(self, "_old_push_allow", None) is None:
|
||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||
else:
|
||
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = (
|
||
self._old_push_allow)
|
||
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, [])
|
||
|
||
|
||
class TestDigestPersistence(PushTestCase):
|
||
"""G11:digest 非失败事件必须持久化、按组汇总发一次。"""
|
||
|
||
def test_digest_events_persisted_and_sent_once_per_group(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(len(result["skipped_for_digest"]), 1)
|
||
dispatch_event(
|
||
self.conn, "completed", "proj-a", {"task_id": "t-2"},
|
||
sender=sender)
|
||
pending = list_pending_digest(self.conn, "proj-a")
|
||
self.assertEqual(len(pending), 2)
|
||
digest = send_digest_due(self.conn, sender=sender)
|
||
self.assertEqual(len(digest["sent"]), 1)
|
||
self.assertEqual(digest["sent"][0]["count"], 2)
|
||
self.assertEqual(len(calls), 1)
|
||
message = calls[0]
|
||
self.assertEqual(message["event_type"], "digest")
|
||
self.assertTrue(message["payload"]["digest"])
|
||
self.assertEqual(message["payload"]["count"], 2)
|
||
self.assertEqual(list_pending_digest(self.conn, "proj-a"), [])
|
||
# 第二次无待发事件,不再重复发。
|
||
again = send_digest_due(self.conn, sender=sender)
|
||
self.assertEqual(again["sent"], [])
|
||
self.assertEqual(len(calls), 1)
|
||
|
||
def test_failure_events_skip_digest_and_send_immediately(self):
|
||
self._sub(preference="digest")
|
||
calls, sender = self._calls()
|
||
result = dispatch_event(
|
||
self.conn, "failed", "proj-a", {"task_id": "t-1"},
|
||
sender=sender)
|
||
self.assertEqual(len(calls), 1)
|
||
self.assertEqual(result["skipped_for_digest"], [])
|
||
self.assertEqual(list_pending_digest(self.conn, "proj-a"), [])
|
||
|
||
|
||
class TestOutboxRetry(PushTestCase):
|
||
"""G12:推送失败进 outbox、到期重试、3 次后丢弃。"""
|
||
|
||
def _failing(self):
|
||
def sender(message):
|
||
raise RuntimeError("downstream down")
|
||
return sender
|
||
|
||
def test_failed_send_enqueued_then_retried_successfully(self):
|
||
self._sub(preference="instant")
|
||
result = dispatch_event(
|
||
self.conn, "failed", "proj-a", {"task_id": "t-1"},
|
||
sender=self._failing())
|
||
self.assertEqual(len(result["errors"]), 1)
|
||
self.assertEqual(len(result["outbox"]), 1)
|
||
rows = list_outbox(self.conn, "proj-a")
|
||
self.assertEqual(len(rows), 1)
|
||
self.assertEqual(rows[0]["attempts"], 0)
|
||
# 未到期不重试。
|
||
early = process_outbox_due(self.conn, sender=self._failing(),
|
||
now=rows[0]["next_attempt_at"] - 1)
|
||
self.assertEqual(early["sent"], [])
|
||
self.assertEqual(len(list_outbox(self.conn, "proj-a")), 1)
|
||
# 到期后成功发送,行删掉。
|
||
calls, sender = self._calls()
|
||
done = process_outbox_due(
|
||
self.conn, sender=sender,
|
||
now=rows[0]["next_attempt_at"] + 1)
|
||
self.assertEqual(len(done["sent"]), 1)
|
||
self.assertEqual(len(calls), 1)
|
||
self.assertEqual(calls[0]["event_type"], "failed")
|
||
self.assertEqual(list_outbox(self.conn, "proj-a"), [])
|
||
|
||
def test_three_failures_drop_the_row(self):
|
||
self._sub(preference="instant")
|
||
dispatch_event(
|
||
self.conn, "failed", "proj-a", {"task_id": "t-1"},
|
||
sender=self._failing())
|
||
self.assertEqual(len(list_outbox(self.conn, "proj-a")), 1)
|
||
# now 取远未来 + backoff=0,让三次重试都在同一 now 发生
|
||
# (单测确定性;真实退避由上一例的 next_attempt_at 覆盖)。
|
||
for i in range(2):
|
||
out = process_outbox_due(
|
||
self.conn, sender=self._failing(), now=10 ** 10,
|
||
backoff_seconds=0)
|
||
self.assertEqual(out["failed"], [])
|
||
self.assertEqual(len(list_outbox(self.conn, "proj-a")), 1)
|
||
out = process_outbox_due(
|
||
self.conn, sender=self._failing(), now=10 ** 10,
|
||
backoff_seconds=0)
|
||
self.assertEqual(len(out["failed"]), 1)
|
||
self.assertEqual(out["failed"][0]["attempts"], 3)
|
||
self.assertEqual(list_outbox(self.conn, "proj-a"), [])
|
||
|
||
def test_missing_credential_does_not_enter_outbox(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=self._calls()[1])
|
||
self.assertEqual(len(result["errors"]), 1)
|
||
self.assertEqual(result["outbox"], [])
|
||
self.assertEqual(list_outbox(self.conn, "proj-a"), [])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|