Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
1527 lines
74 KiB
Python
1527 lines
74 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""推送镜像回归测试:所有异步个人推送 = TG 私聊 + QQ 私聊镜像。
|
||
|
||
运行(在 C:/call/bot 目录下):
|
||
python -m unittest discover -s tests -t . -v
|
||
|
||
覆盖:
|
||
* identity.push_targets / push_target / admin_uids
|
||
* channel.send_message 返回值语义(None/True=成功、False=失败)向后兼容
|
||
* channel.push_to_user 扇出(0 / 1 / 2 个 QQ、通道缺失、通道抛错、通道返回 False)
|
||
* qq_channel.NapcatChannel.send 未连接返回 False / 已连接返回 True
|
||
* ginkgostu 持久化 outbox 逐目标重试(绝不重复推送已成功目标)
|
||
* ginkgo_delivery 部分失败保留 pending + 退避;次数用尽 → 终态 failed
|
||
* mail / komari / deepseek / live_monitor 的扇出与旧记录兼容
|
||
"""
|
||
import asyncio
|
||
import json
|
||
import os
|
||
import shutil
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
import unittest
|
||
from unittest import mock
|
||
|
||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # C:/call/bot
|
||
UTIL_DIR = os.path.join(BASE_DIR, "bot-utility")
|
||
for _p in (BASE_DIR, UTIL_DIR):
|
||
if _p not in sys.path:
|
||
sys.path.insert(0, _p)
|
||
|
||
import channel # noqa: E402
|
||
import identity # noqa: E402
|
||
from channel import (Channel, register_channel, unregister_channel,
|
||
send_message, push_to_user) # noqa: E402
|
||
from qq_channel import NapcatChannel # noqa: E402
|
||
import ginkgostu as delivery # noqa: E402
|
||
import ginkgostu # noqa: E402
|
||
|
||
TG_OFFSET = 10 ** 10
|
||
UID_BASE = 9000000001
|
||
|
||
|
||
class FakeChannel(Channel):
|
||
"""记录型假通道。mode: ok(None 旧语义) / true / down(False) / error(抛错)"""
|
||
|
||
def __init__(self, name, mode="ok"):
|
||
self.name = name
|
||
self.mode = mode
|
||
self.sent = []
|
||
|
||
async def send(self, msg_type, target_id, content):
|
||
if self.mode == "down":
|
||
return False
|
||
if self.mode == "error":
|
||
raise RuntimeError("boom")
|
||
self.sent.append((msg_type, target_id, content))
|
||
if self.mode == "true":
|
||
return True
|
||
return None # 旧通道语义:None = 成功
|
||
|
||
|
||
class FakeWS:
|
||
"""假 NapCat 反向 WS 连接:记录发出的 OneBot action"""
|
||
|
||
def __init__(self):
|
||
self.sent = []
|
||
|
||
async def send(self, payload):
|
||
action = json.loads(payload)
|
||
self.sent.append(action)
|
||
self.channel.resolve_receipt({"echo": action["echo"], "status": "ok", "retcode": 0})
|
||
|
||
|
||
class PushTestBase(unittest.TestCase):
|
||
def setUp(self):
|
||
self.tmp = tempfile.mkdtemp(prefix="pushmirror-")
|
||
self.accounts = os.path.join(self.tmp, "accounts")
|
||
os.makedirs(self.accounts, exist_ok=True)
|
||
identity.configure(self.accounts, admin_tg_ids=[])
|
||
self._registered = []
|
||
|
||
def tearDown(self):
|
||
for name in self._registered:
|
||
unregister_channel(name)
|
||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||
|
||
def add_channel(self, name, mode="ok"):
|
||
ch = FakeChannel(name, mode)
|
||
register_channel(ch)
|
||
self._registered.append(name)
|
||
return ch
|
||
|
||
def make_user(self, tg_raw=2001, qqs=(), admin_tg=None):
|
||
if admin_tg is not None:
|
||
identity.configure(self.accounts, admin_tg_ids=[tg_raw])
|
||
uid = identity.ensure_tg(TG_OFFSET + tg_raw)
|
||
for qq in qqs:
|
||
ok, msg = identity.force_bind_qq(qq, uid)
|
||
self.assertTrue(ok, msg)
|
||
return uid
|
||
|
||
|
||
# ==================== identity ====================
|
||
class IdentityTargetsTest(PushTestBase):
|
||
def test_no_qq_returns_only_tg(self):
|
||
uid = self.make_user(2001)
|
||
self.assertEqual(identity.push_targets(uid), [("tg", TG_OFFSET + 2001)])
|
||
self.assertEqual(identity.push_target(uid), ("tg", TG_OFFSET + 2001))
|
||
|
||
def test_one_qq_tg_first_then_qq(self):
|
||
uid = self.make_user(2002, qqs=[3280589370])
|
||
self.assertEqual(identity.push_targets(uid),
|
||
[("tg", TG_OFFSET + 2002), ("qq", 3280589370)])
|
||
self.assertEqual(identity.push_target(uid), ("tg", TG_OFFSET + 2002))
|
||
|
||
def test_two_qq_both_listed_and_dedup(self):
|
||
uid = self.make_user(2003, qqs=[3280589370, 2952584477])
|
||
ok, _ = identity.force_bind_qq(3280589370, uid) # 重复绑定
|
||
self.assertTrue(ok)
|
||
self.assertEqual(identity.push_targets(uid),
|
||
[("tg", TG_OFFSET + 2003),
|
||
("qq", 3280589370), ("qq", 2952584477)])
|
||
|
||
def test_unknown_uid_returns_empty(self):
|
||
self.assertEqual(identity.push_targets(999999999), [])
|
||
self.assertIsNone(identity.push_target(999999999))
|
||
self.assertEqual(identity.push_targets(None), [])
|
||
self.assertEqual(identity.push_targets("abc"), [])
|
||
|
||
def test_user_without_tg_still_returns_qq(self):
|
||
rec = {"uid": UID_BASE + 8, "created_at": "", "admin": False,
|
||
"tg": {}, "qq": [{"id": 3334445556}], "note": ""}
|
||
with open(os.path.join(self.accounts, "users", "%d.json" % (UID_BASE + 8)),
|
||
"w", encoding="utf-8") as fh:
|
||
json.dump(rec, fh, ensure_ascii=False)
|
||
identity.configure(self.accounts)
|
||
self.assertEqual(identity.push_targets(UID_BASE + 8), [("qq", 3334445556)])
|
||
self.assertIsNone(identity.push_target(UID_BASE + 8))
|
||
|
||
def test_dirty_qq_ids_are_filtered(self):
|
||
rec = {"uid": UID_BASE + 9, "created_at": "", "admin": False,
|
||
"tg": {"raw": 3009, "ns": TG_OFFSET + 3009},
|
||
"qq": [{"id": "12"}, {"id": 3280589370}, {"id": None},
|
||
{"id": "1234567890123"}, "garbage"], "note": ""}
|
||
with open(os.path.join(self.accounts, "users", "%d.json" % (UID_BASE + 9)),
|
||
"w", encoding="utf-8") as fh:
|
||
json.dump(rec, fh, ensure_ascii=False)
|
||
identity.configure(self.accounts)
|
||
self.assertEqual(identity.push_targets(UID_BASE + 9),
|
||
[("tg", TG_OFFSET + 3009), ("qq", 3280589370)])
|
||
|
||
def test_admin_uids_uses_identity_not_admin_list(self):
|
||
uid = self.make_user(7856415768, admin_tg=True)
|
||
self.assertEqual(identity.admin_uids(), [uid])
|
||
self.assertEqual(identity.admin_list("qq") if hasattr(identity, "admin_list") else [],
|
||
[])
|
||
|
||
|
||
# ==================== channel ====================
|
||
class SendMessageCompatTest(PushTestBase):
|
||
def test_none_return_is_success(self):
|
||
ch = self.add_channel("tg", "ok")
|
||
self.assertTrue(asyncio.run(send_message(ch, "private", 1, "hi")))
|
||
self.assertEqual(ch.sent, [("private", 1, "hi")])
|
||
|
||
def test_true_return_is_success(self):
|
||
ch = self.add_channel("tg", "true")
|
||
self.assertTrue(asyncio.run(send_message(ch, "private", 1, "hi")))
|
||
|
||
def test_false_return_is_failure(self):
|
||
ch = self.add_channel("tg", "down")
|
||
self.assertFalse(asyncio.run(send_message(ch, "private", 1, "hi")))
|
||
|
||
def test_exception_is_failure(self):
|
||
ch = self.add_channel("tg", "error")
|
||
self.assertFalse(asyncio.run(send_message(ch, "private", 1, "hi")))
|
||
|
||
def test_missing_channel_is_failure(self):
|
||
self.assertFalse(asyncio.run(send_message(None, "private", 1, "hi")))
|
||
|
||
|
||
class PushToUserTest(PushTestBase):
|
||
def test_no_targets_returns_empty_dict_without_raising(self):
|
||
self.assertEqual(asyncio.run(push_to_user(999999999, "x")), {})
|
||
|
||
def test_tg_only_when_no_qq(self):
|
||
uid = self.make_user(2011)
|
||
tg = self.add_channel("tg", "ok")
|
||
live_qq = NapcatChannel()
|
||
register_channel(live_qq)
|
||
self._registered.append("qq")
|
||
results = asyncio.run(push_to_user(uid, "hello", tag="测试"))
|
||
self.assertEqual(results, {("tg", TG_OFFSET + 2011): True})
|
||
self.assertEqual(len(tg.sent), 1)
|
||
|
||
def test_tg_and_one_qq_each_once(self):
|
||
uid = self.make_user(2012, qqs=[3280589370])
|
||
tg = self.add_channel("tg", "ok")
|
||
qq = self.add_channel("qq", "ok")
|
||
results = asyncio.run(push_to_user(uid, "hello"))
|
||
self.assertEqual(results, {("tg", TG_OFFSET + 2012): True,
|
||
("qq", 3280589370): True})
|
||
self.assertEqual(len(tg.sent), 1)
|
||
self.assertEqual(qq.sent, [("private", 3280589370, "hello")])
|
||
|
||
def test_tg_and_two_qq_all_receive(self):
|
||
uid = self.make_user(2013, qqs=[3280589370, 2952584477])
|
||
tg = self.add_channel("tg", "ok")
|
||
qq = self.add_channel("qq", "ok")
|
||
results = asyncio.run(push_to_user(uid, "hello"))
|
||
self.assertEqual(sorted(results), sorted([("tg", TG_OFFSET + 2013),
|
||
("qq", 3280589370),
|
||
("qq", 2952584477)]))
|
||
self.assertTrue(all(results.values()))
|
||
self.assertEqual(len(tg.sent), 1)
|
||
self.assertEqual(sorted(t[1] for t in qq.sent), [2952584477, 3280589370])
|
||
|
||
def test_qq_unregistered_records_false_tg_still_succeeds(self):
|
||
uid = self.make_user(2014, qqs=[3280589370])
|
||
tg = self.add_channel("tg", "ok")
|
||
results = asyncio.run(push_to_user(uid, "hello"))
|
||
self.assertEqual(results, {("tg", TG_OFFSET + 2014): True,
|
||
("qq", 3280589370): False})
|
||
self.assertEqual(len(tg.sent), 1)
|
||
|
||
def test_qq_raises_tg_still_succeeds(self):
|
||
uid = self.make_user(2015, qqs=[3280589370])
|
||
tg = self.add_channel("tg", "ok")
|
||
self.add_channel("qq", "error")
|
||
results = asyncio.run(push_to_user(uid, "hello"))
|
||
self.assertEqual(results, {("tg", TG_OFFSET + 2015): True,
|
||
("qq", 3280589370): False})
|
||
|
||
def test_targets_subset_is_respected(self):
|
||
uid = self.make_user(2016, qqs=[3280589370])
|
||
tg = self.add_channel("tg", "ok")
|
||
qq = self.add_channel("qq", "ok")
|
||
results = asyncio.run(push_to_user(uid, "hello", targets=[("qq", 3280589370)]))
|
||
self.assertEqual(results, {("qq", 3280589370): True})
|
||
self.assertEqual(tg.sent, [])
|
||
|
||
|
||
class NapcatChannelTest(PushTestBase):
|
||
def test_send_returns_false_when_not_connected(self):
|
||
ch = NapcatChannel()
|
||
self.assertFalse(asyncio.run(ch.send("private", 123, "hi")))
|
||
self.assertFalse(asyncio.run(send_message(ch, "private", 123, "hi")))
|
||
|
||
def test_send_returns_true_when_connected(self):
|
||
sent = []
|
||
|
||
class FakeWS:
|
||
async def send(self, payload):
|
||
action = json.loads(payload)
|
||
sent.append(action)
|
||
self.channel.resolve_receipt({"echo": action["echo"], "status": "ok", "retcode": 0})
|
||
|
||
ch = NapcatChannel()
|
||
ws = FakeWS()
|
||
ws.channel = ch
|
||
ch.attach(ws)
|
||
self.assertTrue(asyncio.run(ch.send("private", 123, "hi")))
|
||
self.assertEqual(sent[0]["params"]["user_id"], 123)
|
||
|
||
def test_detach_makes_send_fail_again(self):
|
||
class FakeWS:
|
||
async def send(self, payload):
|
||
action = json.loads(payload)
|
||
self.channel.resolve_receipt({"echo": action["echo"], "status": "ok", "retcode": 0})
|
||
|
||
ch = NapcatChannel()
|
||
ws = FakeWS()
|
||
ws.channel = ch
|
||
ch.attach(ws)
|
||
self.assertTrue(asyncio.run(ch.send("private", 123, "hi")))
|
||
ch.detach()
|
||
self.assertFalse(asyncio.run(ch.send("private", 123, "hi")))
|
||
|
||
|
||
# ==================== ginkgo_delivery ====================
|
||
class AckOutboxTest(unittest.TestCase):
|
||
def _row(self, attempts=1):
|
||
return {"outbox": {"k": {"key": "k", "kind": "notice", "text": "t",
|
||
"status": "sending", "claim_id": "c",
|
||
"attempts": attempts, "sent_targets": [],
|
||
"created_at": 0, "next_attempt_at": 0}}}
|
||
|
||
def test_ack_true_marks_sent(self):
|
||
data = self._row()
|
||
delivery.ack_outbox(data, "k", "c", True, "", 100)
|
||
self.assertEqual(data["outbox"]["k"]["status"], "sent")
|
||
self.assertIsNone(data["outbox"]["k"]["next_attempt_at"])
|
||
|
||
def test_partial_failure_keeps_pending_and_records_progress(self):
|
||
data = self._row()
|
||
delivery.ack_outbox(data, "k", "c", False, "部分失败", 100,
|
||
succeeded=[("tg", 17856415768)])
|
||
row = data["outbox"]["k"]
|
||
self.assertEqual(row["status"], "pending")
|
||
self.assertEqual(row["sent_targets"], ["tg:17856415768"])
|
||
self.assertEqual(row["next_attempt_at"], 100 + delivery.backoff(1))
|
||
self.assertFalse(delivery.has_due(data, now=100))
|
||
|
||
def test_attempts_exhausted_is_terminal_failed(self):
|
||
data = self._row(attempts=delivery.MAX_DELIVERY_ATTEMPTS)
|
||
delivery.ack_outbox(data, "k", "c", False, "全部失败", 100)
|
||
row = data["outbox"]["k"]
|
||
self.assertEqual(row["status"], "failed")
|
||
self.assertIn("failed", delivery.OUTBOX_TERMINAL)
|
||
self.assertFalse(delivery.has_due(data, now=10 ** 9))
|
||
self.assertFalse(delivery.claim_outbox(data, "k", "c2", 10 ** 9))
|
||
self.assertEqual(len(data["outbox"]), 1) # 只有这一条回执行
|
||
|
||
def test_ack_requires_exact_true(self):
|
||
data = self._row()
|
||
delivery.ack_outbox(data, "k", "c", "yes", "", 100)
|
||
self.assertEqual(data["outbox"]["k"]["status"], "pending")
|
||
|
||
|
||
# ==================== ginkgostu outbox 逐目标重试 ====================
|
||
class GinkgoOutboxFanoutTest(unittest.TestCase):
|
||
ACCOUNT = "2025000001"
|
||
|
||
def setUp(self):
|
||
self.tmp = tempfile.mkdtemp(prefix="pushmirror-gk-")
|
||
self._saved = (ginkgostu.STATE_DIR, ginkgostu.PUSH_LOG)
|
||
ginkgostu.STATE_DIR = self.tmp
|
||
ginkgostu.PUSH_LOG = os.path.join(self.tmp, "push.log")
|
||
self.accounts = os.path.join(self.tmp, "accounts")
|
||
os.makedirs(self.accounts, exist_ok=True)
|
||
identity.configure(self.accounts, admin_tg_ids=[])
|
||
self.uid = identity.ensure_tg(TG_OFFSET + 3001)
|
||
identity.force_bind_qq(3280589370, self.uid)
|
||
self._registered = []
|
||
|
||
def tearDown(self):
|
||
for name in self._registered:
|
||
unregister_channel(name)
|
||
ginkgostu.STATE_DIR, ginkgostu.PUSH_LOG = self._saved
|
||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||
|
||
def add_channel(self, name, mode="ok"):
|
||
ch = FakeChannel(name, mode)
|
||
register_channel(ch)
|
||
self._registered.append(name)
|
||
return ch
|
||
|
||
def _write_account(self, attempts=0, sent_targets=None, next_attempt_at=0):
|
||
state = {"account": self.ACCOUNT, "user_id": self.uid,
|
||
"notice_push": True, "auto_sign_in": False}
|
||
row = delivery.enqueue(state, "notice:k1", "notice", "新通知正文")
|
||
row["attempts"] = attempts
|
||
row["sent_targets"] = list(sent_targets or [])
|
||
row["next_attempt_at"] = next_attempt_at
|
||
path = os.path.join(self.tmp, self.ACCOUNT + ".json")
|
||
with open(path, "w", encoding="utf-8") as fh:
|
||
json.dump(state, fh, ensure_ascii=False)
|
||
return path
|
||
|
||
def _load(self):
|
||
return delivery.AccountStore(self.tmp).load(self.ACCOUNT)
|
||
|
||
def _push_log_text(self):
|
||
try:
|
||
with open(os.path.join(self.tmp, "push.log"), "r", encoding="utf-8") as fh:
|
||
return fh.read()
|
||
except OSError:
|
||
return ""
|
||
|
||
def test_partial_failure_retries_only_qq_and_never_repeats_tg(self):
|
||
self._write_account()
|
||
tg = self.add_channel("tg", "ok")
|
||
qq = NapcatChannel() # 未连接 -> send() 返回 False
|
||
register_channel(qq)
|
||
self._registered.append("qq")
|
||
|
||
asyncio.run(ginkgostu._deliver_outbox(self.ACCOUNT))
|
||
self.assertEqual(len(tg.sent), 1)
|
||
row = self._load()["outbox"]["notice:k1"]
|
||
self.assertEqual(row["status"], "pending")
|
||
self.assertEqual(row["sent_targets"], ["tg:%d" % (TG_OFFSET + 3001)])
|
||
self.assertEqual(row["attempts"], 1)
|
||
self.assertIn("失败", self._push_log_text())
|
||
self.assertIn("成功", self._push_log_text())
|
||
|
||
# 第二次尝试:TG 绝不能重复收到
|
||
state = self._load()
|
||
state["outbox"]["notice:k1"]["next_attempt_at"] = 0
|
||
with open(os.path.join(self.tmp, self.ACCOUNT + ".json"), "w", encoding="utf-8") as fh:
|
||
json.dump(state, fh, ensure_ascii=False)
|
||
asyncio.run(ginkgostu._deliver_outbox(self.ACCOUNT))
|
||
self.assertEqual(len(tg.sent), 1, "已成功的 TG 目标被重复推送")
|
||
|
||
# QQ 恢复连接后:只补投 QQ,随后整行转 sent
|
||
ws = FakeWS()
|
||
ws.channel = qq
|
||
qq.attach(ws)
|
||
state = self._load()
|
||
state["outbox"]["notice:k1"]["next_attempt_at"] = 0
|
||
with open(os.path.join(self.tmp, self.ACCOUNT + ".json"), "w", encoding="utf-8") as fh:
|
||
json.dump(state, fh, ensure_ascii=False)
|
||
asyncio.run(ginkgostu._deliver_outbox(self.ACCOUNT))
|
||
self.assertEqual(len(tg.sent), 1)
|
||
self.assertEqual(len(ws.sent), 1, "QQ 目标未被补投")
|
||
self.assertEqual(ws.sent[0]["params"]["user_id"], 3280589370)
|
||
row = self._load()["outbox"]["notice:k1"]
|
||
self.assertEqual(row["status"], "sent")
|
||
self.assertEqual(sorted(row["sent_targets"]),
|
||
sorted(["tg:%d" % (TG_OFFSET + 3001), "qq:3280589370"]))
|
||
|
||
def test_all_targets_fail_keeps_pending_with_backoff(self):
|
||
self._write_account()
|
||
self.add_channel("tg", "down")
|
||
register_channel(NapcatChannel())
|
||
self._registered.append("qq")
|
||
|
||
before = time.time()
|
||
asyncio.run(ginkgostu._deliver_outbox(self.ACCOUNT))
|
||
row = self._load()["outbox"]["notice:k1"]
|
||
self.assertEqual(row["status"], "pending")
|
||
self.assertEqual(row["sent_targets"], [])
|
||
self.assertGreater(row["next_attempt_at"], before)
|
||
self.assertEqual(row["attempts"], 1)
|
||
|
||
def test_attempts_exhausted_goes_terminal_with_single_receipt(self):
|
||
self._write_account(attempts=delivery.MAX_DELIVERY_ATTEMPTS - 1)
|
||
self.add_channel("tg", "down")
|
||
register_channel(NapcatChannel())
|
||
self._registered.append("qq")
|
||
|
||
asyncio.run(ginkgostu._deliver_outbox(self.ACCOUNT))
|
||
state = self._load()
|
||
row = state["outbox"]["notice:k1"]
|
||
self.assertEqual(row["attempts"], delivery.MAX_DELIVERY_ATTEMPTS)
|
||
self.assertEqual(row["status"], "failed")
|
||
self.assertEqual(len(state["outbox"]), 1)
|
||
self.assertFalse(delivery.has_due(state))
|
||
|
||
asyncio.run(ginkgostu._deliver_outbox(self.ACCOUNT)) # 终态不再重试
|
||
self.assertEqual(self._load()["outbox"]["notice:k1"]["attempts"],
|
||
delivery.MAX_DELIVERY_ATTEMPTS)
|
||
|
||
def test_no_available_targets_keeps_row_pending(self):
|
||
self._write_account()
|
||
# 身份记录被删除:push_targets -> []
|
||
os.unlink(os.path.join(self.accounts, "users", "%d.json" % self.uid))
|
||
identity.configure(self.accounts)
|
||
self.add_channel("tg", "ok")
|
||
self.add_channel("qq", "ok")
|
||
asyncio.run(ginkgostu._deliver_outbox(self.ACCOUNT))
|
||
row = self._load()["outbox"]["notice:k1"]
|
||
self.assertEqual(row["status"], "pending")
|
||
self.assertIn("无可用的推送目标", row["last_error"])
|
||
self.assertEqual(len(self._push_log_text().splitlines()), 1)
|
||
|
||
def test_notify_returns_per_target_results(self):
|
||
tg = self.add_channel("tg", "ok")
|
||
qq = self.add_channel("qq", "down")
|
||
results = asyncio.run(ginkgostu._notify({"user_id": self.uid}, "正文", tag="测试"))
|
||
self.assertEqual(results, {("tg", TG_OFFSET + 3001): True,
|
||
("qq", 3280589370): False})
|
||
self.assertEqual(len(tg.sent), 1)
|
||
self.assertEqual(len(self._push_log_text().splitlines()), 2)
|
||
|
||
|
||
# ==================== live_monitor ====================
|
||
class LiveMonitorFollowTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.tmp = tempfile.mkdtemp(prefix="pushmirror-live-")
|
||
import live_monitor
|
||
self.lm = live_monitor
|
||
self._saved = (live_monitor.RECORD_DIR, live_monitor.RECORD_FILE)
|
||
live_monitor.RECORD_DIR = self.tmp
|
||
live_monitor.RECORD_FILE = os.path.join(self.tmp, "record.json")
|
||
self.accounts = os.path.join(self.tmp, "accounts")
|
||
os.makedirs(self.accounts, exist_ok=True)
|
||
identity.configure(self.accounts, admin_tg_ids=[])
|
||
self.uid = identity.ensure_tg(TG_OFFSET + 4001)
|
||
while not live_monitor.notification_queue.empty():
|
||
live_monitor.notification_queue.get_nowait()
|
||
|
||
def tearDown(self):
|
||
self.lm.RECORD_DIR, self.lm.RECORD_FILE = self._saved
|
||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||
|
||
def test_private_follow_stored_as_uid_and_fans_out(self):
|
||
self.lm.add_follow("99", "private", self.uid, "", "tg")
|
||
rec = self.lm._load_records()
|
||
self.assertEqual(rec["follows"]["99"][0], {"chat_type": "private",
|
||
"uid": self.uid, "site": ""})
|
||
client = self.lm.app.test_client()
|
||
client.post("/internal/notify", json={"room_id": "99", "site": "1"})
|
||
notif = self.lm.notification_queue.get_nowait()
|
||
self.assertEqual(notif["chat_type"], "private")
|
||
self.assertEqual(notif["uid"], self.uid)
|
||
self.assertNotIn("chat_id", notif)
|
||
|
||
def test_legacy_private_record_with_uid_chat_id_is_normalised(self):
|
||
data = {"follows": {"77": [{"channel": "tg", "chat_type": "private",
|
||
"chat_id": self.uid, "site": ""}]}}
|
||
with open(self.lm.RECORD_FILE, "w", encoding="utf-8") as fh:
|
||
json.dump(data, fh, ensure_ascii=False)
|
||
self.assertEqual(self.lm.get_follows("private", self.uid, "tg"), [("77", "")])
|
||
self.lm.remove_follow("77", "private", self.uid, "", "tg")
|
||
self.assertEqual(self.lm._load_records()["follows"], {})
|
||
|
||
def test_legacy_private_record_with_tg_ns_resolves_to_uid(self):
|
||
data = {"follows": {"78": [{"channel": "tg", "chat_type": "private",
|
||
"chat_id": TG_OFFSET + 4001, "site": "1"}]}}
|
||
with open(self.lm.RECORD_FILE, "w", encoding="utf-8") as fh:
|
||
json.dump(data, fh, ensure_ascii=False)
|
||
self.assertEqual(self.lm.get_follows("private", self.uid, "tg"), [("78", "1")])
|
||
notif = self.lm._notification("78", "1", data["follows"]["78"][0])
|
||
self.assertEqual(notif["uid"], self.uid)
|
||
|
||
def test_group_follow_stays_group_push(self):
|
||
self.lm.add_follow("17", "group", 1054435390, "", "qq")
|
||
client = self.lm.app.test_client()
|
||
client.post("/internal/notify", json={"room_id": "17", "site": "1"})
|
||
notif = self.lm.notification_queue.get_nowait()
|
||
self.assertEqual(notif["chat_type"], "group")
|
||
self.assertEqual(notif["channel"], "qq")
|
||
self.assertEqual(notif["chat_id"], 1054435390)
|
||
|
||
def test_unresolvable_private_record_falls_back(self):
|
||
notif = self.lm._notification("1", "1", {"chat_type": "private",
|
||
"chat_id": 424242,
|
||
"channel": "qq"})
|
||
self.assertIsNone(notif.get("uid"))
|
||
self.assertEqual(notif["channel"], "qq")
|
||
|
||
# ---- 原子写(故障注入) ----
|
||
def test_save_records_byte_identical_to_legacy_writer(self):
|
||
"""正常路径:输出与旧实现(直接 open(w) + json.dump)逐字节一致。
|
||
|
||
注意用真实旧写法写一份对照文件再比字节,而不是比 json.dumps 的字符串——
|
||
文本模式在 Windows 会把 \\n 翻成 \\r\\n,旧实现同样如此,必须同类比较。
|
||
"""
|
||
data = {"follows": {"10": [{"chat_type": "private", "uid": self.uid, "site": ""}],
|
||
"0": [{"channel": "qq", "chat_type": "group",
|
||
"chat_id": 1054435390, "site": ""}]}}
|
||
self.lm._save_records(data)
|
||
with open(self.lm.RECORD_FILE, "rb") as fh:
|
||
got = fh.read()
|
||
legacy_path = os.path.join(self.tmp, "legacy-record.json")
|
||
with open(legacy_path, "w", encoding="utf-8") as fh: # 旧实现
|
||
json.dump(data, fh, ensure_ascii=False, indent=2)
|
||
with open(legacy_path, "rb") as fh:
|
||
expect = fh.read()
|
||
self.assertEqual(got, expect)
|
||
self.assertTrue(got.startswith(b"{"))
|
||
self.assertTrue(got.endswith(b"}"))
|
||
|
||
def test_save_records_failure_keeps_original_and_leaves_no_tmp(self):
|
||
"""故障注入:os.replace 抛错 -> 原文件不变、目录内无 .tmp 残留。"""
|
||
self.lm.add_follow("10", "private", self.uid, "", "tg")
|
||
before = open(self.lm.RECORD_FILE, "rb").read()
|
||
tmp = self.lm.RECORD_FILE + ".tmp"
|
||
with mock.patch.object(self.lm.os, "replace", side_effect=OSError("disk full")):
|
||
with self.assertRaises(OSError):
|
||
self.lm._save_records({"follows": {"999": []}})
|
||
with open(self.lm.RECORD_FILE, "rb") as fh:
|
||
self.assertEqual(fh.read(), before, "原子写失败后原文件被破坏")
|
||
self.assertFalse(os.path.exists(tmp), "原子写失败后遗留 .tmp 残留文件")
|
||
self.assertEqual([n for n in os.listdir(self.tmp) if n.endswith(".tmp")], [])
|
||
|
||
def test_save_records_failure_leaves_no_tmp_when_serialisation_fails(self):
|
||
"""故障注入:序列化阶段抛错 -> 同样不留 .tmp,原文件不变。"""
|
||
self.lm.add_follow("10", "private", self.uid, "", "tg")
|
||
before = open(self.lm.RECORD_FILE, "rb").read()
|
||
with mock.patch.object(self.lm.json, "dump", side_effect=TypeError("boom")):
|
||
with self.assertRaises(TypeError):
|
||
self.lm._save_records({"follows": {"999": []}})
|
||
with open(self.lm.RECORD_FILE, "rb") as fh:
|
||
self.assertEqual(fh.read(), before)
|
||
self.assertFalse(os.path.exists(self.lm.RECORD_FILE + ".tmp"))
|
||
|
||
|
||
# ==================== 告警出口扇出 ====================
|
||
class AlertFanoutTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.tmp = tempfile.mkdtemp(prefix="pushmirror-alert-")
|
||
self.accounts = os.path.join(self.tmp, "accounts")
|
||
os.makedirs(self.accounts, exist_ok=True)
|
||
identity.configure(self.accounts, admin_tg_ids=[7856415768])
|
||
self.uid = identity.ensure_tg(TG_OFFSET + 7856415768)
|
||
identity.force_bind_qq(3280589370, self.uid)
|
||
self._registered = []
|
||
|
||
def tearDown(self):
|
||
for name in self._registered:
|
||
unregister_channel(name)
|
||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||
|
||
def add_channel(self, name, mode="ok"):
|
||
ch = FakeChannel(name, mode)
|
||
register_channel(ch)
|
||
self._registered.append(name)
|
||
return ch
|
||
|
||
def test_komari_alerts_hit_tg_and_qq(self):
|
||
import for_admin_komari_agent
|
||
import notifications
|
||
tg = self.add_channel("tg", "ok")
|
||
qq = self.add_channel("qq", "ok")
|
||
center = notifications.NotificationCenter(os.path.join(self.tmp, "komari-events.db"))
|
||
|
||
async def run():
|
||
with mock.patch.object(notifications, "center", center):
|
||
accepted = await for_admin_komari_agent._notify_admins("[Komari] 节点离线")
|
||
with mock.patch.object(center, "_write_history_once"):
|
||
delivered = await center.deliver_due()
|
||
return accepted, delivered
|
||
|
||
accepted, delivered = asyncio.run(run())
|
||
self.assertEqual(delivered, 2)
|
||
self.assertEqual(len(tg.sent), 1)
|
||
self.assertEqual(len(qq.sent), 1)
|
||
self.assertIn("Komari", qq.sent[0][2])
|
||
self.assertTrue(accepted, "有效目标未能写入持久通知队列")
|
||
|
||
def test_komari_notify_reports_failure_when_no_targets_exist(self):
|
||
import for_admin_komari_agent
|
||
with mock.patch("identity.push_targets", return_value=[]):
|
||
delivered = asyncio.run(for_admin_komari_agent._notify_admins("[Komari] 节点离线"))
|
||
self.assertFalse(delivered)
|
||
|
||
def test_deepseek_balance_alert_hits_tg_and_qq(self):
|
||
from deepseek import deepseek_balance as dsb
|
||
tg = self.add_channel("tg", "ok")
|
||
qq = self.add_channel("qq", "ok")
|
||
|
||
async def fake_balance():
|
||
return 0.0
|
||
|
||
saved = (dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted, dsb.get_balance,
|
||
set(dsb._alert_delivered))
|
||
dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted = 1, True, False
|
||
dsb._alert_delivered = set()
|
||
dsb.get_balance = fake_balance
|
||
try:
|
||
asyncio.run(dsb.check_balance_once())
|
||
finally:
|
||
(dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted, dsb.get_balance,
|
||
dsb._alert_delivered) = saved
|
||
self.assertEqual(len(tg.sent), 1)
|
||
self.assertEqual(len(qq.sent), 1)
|
||
self.assertIn("阈值", tg.sent[0][2])
|
||
|
||
def test_deepseek_alert_q_side_not_lost_when_qq_admin_list_empty(self):
|
||
from deepseek import deepseek_balance as dsb
|
||
settings_patch = mock.patch.object(channel, "load_settings", return_value={"admin": {}})
|
||
settings_patch.start()
|
||
self.addCleanup(settings_patch.stop)
|
||
# settings.json 里 admin.qq 缺失 -> channel.admin_list("qq") == []
|
||
self.assertEqual(channel.admin_list("qq"), [])
|
||
tg = self.add_channel("tg", "ok")
|
||
qq = self.add_channel("qq", "ok")
|
||
|
||
async def fake_balance():
|
||
return 0.0
|
||
|
||
saved = (dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted, dsb.get_balance,
|
||
set(dsb._alert_delivered))
|
||
dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted = 1, True, False
|
||
dsb._alert_delivered = set()
|
||
dsb.get_balance = fake_balance
|
||
try:
|
||
asyncio.run(dsb.check_balance_once())
|
||
finally:
|
||
(dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted, dsb.get_balance,
|
||
dsb._alert_delivered) = saved
|
||
self.assertEqual(len(qq.sent), 1)
|
||
|
||
def test_deepseek_alert_latches_only_after_a_successful_delivery(self):
|
||
"""启动竞态(通道未注册 / NapCat 未连接)不得把真实告警永久吞掉。"""
|
||
from deepseek import deepseek_balance as dsb
|
||
|
||
async def fake_balance():
|
||
return 0.0
|
||
|
||
saved = (dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted, dsb.get_balance,
|
||
set(dsb._alert_delivered))
|
||
dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted = 1, True, False
|
||
dsb._alert_delivered = set()
|
||
dsb.get_balance = fake_balance
|
||
try:
|
||
# 第一轮:通道全部缺失 -> 0 个目标成功 -> 不允许上闩
|
||
asyncio.run(dsb.check_balance_once())
|
||
self.assertFalse(dsb._alerted, "没有目标投递成功却上了闩,告警会被永久吞掉")
|
||
|
||
# 第二轮:通道就绪 -> TG + QQ 各 1 条,此时才上闩
|
||
tg = self.add_channel("tg", "ok")
|
||
qq = self.add_channel("qq", "ok")
|
||
asyncio.run(dsb.check_balance_once())
|
||
self.assertEqual(len(tg.sent), 1)
|
||
self.assertEqual(len(qq.sent), 1)
|
||
self.assertTrue(dsb._alerted)
|
||
|
||
# 第三轮:已上闩,不再重复轰炸
|
||
asyncio.run(dsb.check_balance_once())
|
||
self.assertEqual(len(tg.sent), 1)
|
||
self.assertEqual(len(qq.sent), 1)
|
||
finally:
|
||
(dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted, dsb.get_balance,
|
||
dsb._alert_delivered) = saved
|
||
|
||
def test_deepseek_alert_retries_only_unsent_targets_until_all_succeed(self):
|
||
"""逐目标补投:已成功目标绝不重发;全部当前目标成功才结案。"""
|
||
from deepseek import deepseek_balance as dsb
|
||
|
||
async def fake_balance():
|
||
return 0.0
|
||
|
||
saved = (dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted, dsb.get_balance,
|
||
set(dsb._alert_delivered))
|
||
dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted = 1, True, False
|
||
dsb._alert_delivered = set()
|
||
dsb.get_balance = fake_balance
|
||
try:
|
||
tg = self.add_channel("tg", "ok")
|
||
qq = self.add_channel("qq", "down") # QQ 侧暂时投不出去
|
||
|
||
# 第 1 轮:TG 成功、QQ 失败 -> 未结案
|
||
asyncio.run(dsb.check_balance_once())
|
||
self.assertEqual(len(tg.sent), 1)
|
||
self.assertFalse(dsb._alerted, "还有目标没成功就结案了")
|
||
self.assertEqual(dsb._alert_delivered, {"tg:%d" % (TG_OFFSET + 7856415768)})
|
||
|
||
# 第 2 轮:QQ 仍失败 -> TG 不得重复推送
|
||
asyncio.run(dsb.check_balance_once())
|
||
self.assertEqual(len(tg.sent), 1, "已成功的 TG 目标被重复推送")
|
||
self.assertFalse(dsb._alerted)
|
||
|
||
# 第 3 轮:QQ 恢复 -> 只补投 QQ,随后结案
|
||
qq.mode = "ok"
|
||
asyncio.run(dsb.check_balance_once())
|
||
self.assertEqual(len(tg.sent), 1, "已成功的 TG 目标被重复推送")
|
||
self.assertEqual(len(qq.sent), 1, "QQ 目标未被补投")
|
||
self.assertTrue(dsb._alerted)
|
||
self.assertEqual(
|
||
dsb._alert_delivered,
|
||
{"tg:%d" % (TG_OFFSET + 7856415768), "qq:3280589370"})
|
||
|
||
# 第 4 轮:已结案,不再重复轰炸
|
||
asyncio.run(dsb.check_balance_once())
|
||
self.assertEqual(len(tg.sent), 1)
|
||
self.assertEqual(len(qq.sent), 1)
|
||
finally:
|
||
(dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted, dsb.get_balance,
|
||
dsb._alert_delivered) = saved
|
||
|
||
def test_deepseek_unbound_target_does_not_block_closure(self):
|
||
"""某目标被解绑后视为消失,不得因此永远无法结案。"""
|
||
from deepseek import deepseek_balance as dsb
|
||
|
||
async def fake_balance():
|
||
return 0.0
|
||
|
||
saved = (dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted, dsb.get_balance,
|
||
set(dsb._alert_delivered))
|
||
dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted = 1, True, False
|
||
dsb._alert_delivered = set()
|
||
dsb.get_balance = fake_balance
|
||
try:
|
||
tg = self.add_channel("tg", "ok")
|
||
self.add_channel("qq", "down")
|
||
asyncio.run(dsb.check_balance_once())
|
||
self.assertFalse(dsb._alerted)
|
||
self.assertEqual(len(tg.sent), 1)
|
||
|
||
# 解绑 QQ:该目标消失,剩余目标(TG)已成功 -> 应结案
|
||
ok, msg = identity.unbind_qq(self.uid, 3280589370)
|
||
self.assertTrue(ok, msg)
|
||
asyncio.run(dsb.check_balance_once())
|
||
self.assertTrue(dsb._alerted, "目标解绑后仍无法结案")
|
||
self.assertEqual(len(tg.sent), 1)
|
||
finally:
|
||
(dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted, dsb.get_balance,
|
||
dsb._alert_delivered) = saved
|
||
|
||
def test_deepseek_zero_success_never_closes(self):
|
||
"""0 目标成功(通道未注册/未连接/无管理员)绝不结案。"""
|
||
from deepseek import deepseek_balance as dsb
|
||
|
||
async def fake_balance():
|
||
return 0.0
|
||
|
||
saved = (dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted, dsb.get_balance,
|
||
set(dsb._alert_delivered))
|
||
dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted = 1, True, False
|
||
dsb._alert_delivered = set()
|
||
dsb.get_balance = fake_balance
|
||
try:
|
||
asyncio.run(dsb.check_balance_once())
|
||
asyncio.run(dsb.check_balance_once())
|
||
self.assertFalse(dsb._alerted)
|
||
self.assertEqual(dsb._alert_delivered, set())
|
||
finally:
|
||
(dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted, dsb.get_balance,
|
||
dsb._alert_delivered) = saved
|
||
|
||
def test_deepseek_alert_resets_on_balance_recovery(self):
|
||
"""余额恢复到阈值以上时复位告警与逐目标投递记录。"""
|
||
from deepseek import deepseek_balance as dsb
|
||
|
||
holder = {"balance": 0.0}
|
||
|
||
async def fake_balance():
|
||
return holder["balance"]
|
||
|
||
saved = (dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted, dsb.get_balance,
|
||
set(dsb._alert_delivered))
|
||
dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted = 1, True, False
|
||
dsb._alert_delivered = set()
|
||
dsb.get_balance = fake_balance
|
||
try:
|
||
self.add_channel("tg", "ok")
|
||
self.add_channel("qq", "ok")
|
||
asyncio.run(dsb.check_balance_once())
|
||
self.assertTrue(dsb._alerted)
|
||
|
||
holder["balance"] = 99.0
|
||
asyncio.run(dsb.check_balance_once())
|
||
self.assertFalse(dsb._alerted)
|
||
self.assertEqual(dsb._alert_delivered, set())
|
||
finally:
|
||
(dsb.THRESHOLD, dsb.ALERT_ENABLED, dsb._alerted, dsb.get_balance,
|
||
dsb._alert_delivered) = saved
|
||
|
||
|
||
# ==================== Komari 状态推进与通知入队绑定 ====================
|
||
class KomariStateAdvanceTest(unittest.TestCase):
|
||
"""没有任何有效目标入队的告警不得推进 _last_state(否则该告警永久丢失)。"""
|
||
|
||
def setUp(self):
|
||
self.tmp = tempfile.mkdtemp(prefix="pushmirror-komari-")
|
||
self.accounts = os.path.join(self.tmp, "accounts")
|
||
os.makedirs(self.accounts, exist_ok=True)
|
||
identity.configure(self.accounts, admin_tg_ids=[7856415768])
|
||
self.uid = identity.ensure_tg(TG_OFFSET + 7856415768)
|
||
identity.force_bind_qq(3280589370, self.uid)
|
||
|
||
import for_admin_komari_agent as komari
|
||
self.komari = komari
|
||
self.notices = []
|
||
self.deliver_ok = True
|
||
self.node_offline = {"value": True}
|
||
|
||
async def fake_login():
|
||
return "token"
|
||
|
||
async def fake_get_json(_session, path, _token):
|
||
if path == "/api/nodes":
|
||
return {"status": "success",
|
||
"data": [{"uuid": "u1", "name": "node-1"}]}
|
||
return {"data": [{"offline": self.node_offline["value"]}]}
|
||
|
||
async def fake_notify(content, **_event_info):
|
||
self.notices.append(content)
|
||
return self.deliver_ok
|
||
|
||
self._saved = (komari.POLLING_ENABLED, komari._login, komari._get_json,
|
||
komari._pick_latest, komari._is_offline, komari._notify_admins,
|
||
dict(komari._last_state))
|
||
komari.POLLING_ENABLED = True
|
||
komari._login = fake_login
|
||
komari._get_json = fake_get_json
|
||
komari._pick_latest = lambda rows: (rows or [None])[0]
|
||
komari._is_offline = lambda latest: bool(latest and latest.get("offline"))
|
||
komari._notify_admins = fake_notify
|
||
komari._last_state.clear()
|
||
|
||
def tearDown(self):
|
||
(self.komari.POLLING_ENABLED, self.komari._login, self.komari._get_json,
|
||
self.komari._pick_latest, self.komari._is_offline, self.komari._notify_admins,
|
||
saved_state) = self._saved
|
||
self.komari._last_state.clear()
|
||
self.komari._last_state.update(saved_state)
|
||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||
|
||
def _run(self):
|
||
asyncio.run(self.komari._check_servers_once())
|
||
|
||
def test_first_offline_alert_not_delivered_does_not_advance_state(self):
|
||
self.deliver_ok = False
|
||
self._run()
|
||
self.assertNotIn("u1", self.komari._last_state,
|
||
"投递全失败却推进了状态,该离线告警将永久丢失")
|
||
self.assertEqual(len(self.notices), 1)
|
||
|
||
# 下一轮仍判定为「首次发现离线」并重发;这次投递成功 -> 才推进
|
||
self.deliver_ok = True
|
||
self._run()
|
||
self.assertEqual(len(self.notices), 2, "未送达的告警没有重发")
|
||
self.assertIs(self.komari._last_state["u1"]["offline"], True)
|
||
|
||
def test_recovery_alert_never_reversed_when_undelivered(self):
|
||
self.node_offline["value"] = True
|
||
self.deliver_ok = True
|
||
self._run()
|
||
self.assertIs(self.komari._last_state["u1"]["offline"], True)
|
||
|
||
# 节点已恢复,但恢复告警投递失败 -> 不推进状态
|
||
self.node_offline["value"] = False
|
||
self.deliver_ok = False
|
||
self._run()
|
||
self.assertIs(self.komari._last_state["u1"]["offline"], True,
|
||
"投递全失败却推进了状态")
|
||
self.assertIn("已恢复上线", self.notices[-1])
|
||
self.assertNotIn("已离线", self.notices[-1], "发出了与现状相反的陈旧告警")
|
||
|
||
# 下一轮仍按当前真实状态(已恢复)重发,成功后推进
|
||
self.deliver_ok = True
|
||
self._run()
|
||
self.assertIn("已恢复上线", self.notices[-1])
|
||
self.assertIs(self.komari._last_state["u1"]["offline"], False)
|
||
|
||
def test_node_without_notice_advances_even_when_delivery_is_down(self):
|
||
self.node_offline["value"] = False # 首次检查即在线 -> 本轮无需告警
|
||
self.deliver_ok = False
|
||
self._run()
|
||
self.assertEqual(self.notices, [])
|
||
self.assertIs(self.komari._last_state["u1"]["offline"], False,
|
||
"无需告警的节点应直接用当前真实状态推进")
|
||
|
||
|
||
# ==================== 启动竞态回归 ====================
|
||
class StartupDelayTest(unittest.TestCase):
|
||
"""重启后的首次告警不得落在通道注册 / NapCat 反连之前(启动竞态回归)。"""
|
||
|
||
def setUp(self):
|
||
self.tmp = tempfile.mkdtemp(prefix="pushmirror-delay-")
|
||
self.accounts = os.path.join(self.tmp, "accounts")
|
||
os.makedirs(self.accounts, exist_ok=True)
|
||
identity.configure(self.accounts, admin_tg_ids=[7856415768])
|
||
|
||
def tearDown(self):
|
||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||
|
||
@staticmethod
|
||
def _first_call_delay(module, loop_name, once_name, delay_attr):
|
||
"""跑 monitor_loop,返回首次调用 check 函数相对启动的延迟(秒)。"""
|
||
calls = []
|
||
|
||
async def fake_once(*_args, **_kwargs):
|
||
calls.append(time.monotonic())
|
||
|
||
saved = (getattr(module, once_name), getattr(module, delay_attr),
|
||
getattr(module, "POLLING_MINUTES"))
|
||
setattr(module, once_name, fake_once)
|
||
setattr(module, delay_attr, 0.4)
|
||
setattr(module, "POLLING_MINUTES", 60)
|
||
|
||
async def scenario():
|
||
start = time.monotonic()
|
||
task = asyncio.ensure_future(getattr(module, loop_name)())
|
||
while not calls:
|
||
await asyncio.sleep(0.02)
|
||
elapsed = calls[0] - start
|
||
task.cancel()
|
||
try:
|
||
await task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
return elapsed
|
||
|
||
try:
|
||
return asyncio.run(scenario())
|
||
finally:
|
||
setattr(module, once_name, saved[0])
|
||
setattr(module, delay_attr, saved[1])
|
||
setattr(module, "POLLING_MINUTES", saved[2])
|
||
|
||
def test_deepseek_monitor_loop_delays_first_poll(self):
|
||
from deepseek import deepseek_balance as dsb
|
||
elapsed = self._first_call_delay(dsb, "monitor_loop", "check_balance_once",
|
||
"MONITOR_STARTUP_DELAY")
|
||
self.assertGreaterEqual(
|
||
elapsed, 0.35, "deepseek 首次余额查询没有启动宽限,重启后首次告警会投递到空通道")
|
||
self.assertGreaterEqual(dsb.MONITOR_STARTUP_DELAY, 10, "启动宽限过短(应 >= 10s)")
|
||
|
||
def test_komari_monitor_loop_delays_first_check(self):
|
||
import for_admin_komari_agent as komari
|
||
elapsed = self._first_call_delay(komari, "monitor_loop", "_check_servers_once",
|
||
"MONITOR_STARTUP_DELAY")
|
||
self.assertGreaterEqual(
|
||
elapsed, 0.35, "komari 首次检查没有启动宽限,首次离线告警会投递到空通道")
|
||
self.assertGreaterEqual(komari.MONITOR_STARTUP_DELAY, 10, "启动宽限过短(应 >= 10s)")
|
||
|
||
|
||
# ==================== 静态检查 ====================
|
||
def _iter_production_modules():
|
||
"""遍历线上生产模块(跳过 chat/、缓存目录、以下划线开头的暂存/工具目录)"""
|
||
for root, dirs, files in os.walk(BASE_DIR):
|
||
dirs[:] = [d for d in dirs
|
||
if d != "__pycache__" and not d.startswith(("_", "."))]
|
||
parts = os.path.relpath(root, BASE_DIR).split(os.sep)
|
||
if "chat" in parts:
|
||
continue
|
||
for name in files:
|
||
if name.endswith(".py"):
|
||
yield os.path.join(root, name)
|
||
|
||
|
||
class NoLegacyPushTargetCallersTest(unittest.TestCase):
|
||
def test_no_module_still_calls_push_target_for_async_push(self):
|
||
offenders = []
|
||
for path in _iter_production_modules():
|
||
if os.path.basename(path) in ("identity.py", "test_push_mirror.py"):
|
||
continue
|
||
with open(path, "r", encoding="utf-8") as fh:
|
||
for lineno, line in enumerate(fh, 1):
|
||
if "identity.push_target(" in line or "push_target(uid)" in line:
|
||
offenders.append("%s:%d" % (path, lineno))
|
||
self.assertEqual(offenders, [])
|
||
|
||
def test_no_module_still_fans_out_via_channel_admin_list(self):
|
||
offenders = []
|
||
for path in _iter_production_modules():
|
||
if os.path.basename(path) in ("tg_watchdog.py", "test_push_mirror.py"):
|
||
continue
|
||
with open(path, "r", encoding="utf-8") as fh:
|
||
src = fh.read()
|
||
if "all_channels()" in src and "admin_list(ch.name)" in src:
|
||
offenders.append(path)
|
||
self.assertEqual(offenders, [])
|
||
|
||
|
||
# ==================== .ginkgo sign / durable sign-in jobs ====================
|
||
class GinkgoManualSigninTest(unittest.TestCase):
|
||
ACCOUNT = "2025000099"
|
||
URL1 = "https://ginkgostu.dufe.edu.cn/course/detail/1/batch-a/2/signin/101/1"
|
||
URL2 = "https://ginkgostu.dufe.edu.cn/course/detail/1/batch-a/2/signin/202/1?x=redacted"
|
||
|
||
@staticmethod
|
||
def item(key, ts, link, text="你收到来自课程的签到", kind="busi"):
|
||
return {"kind": kind, "key": key, "title": text, "text": text,
|
||
"ts": ts, "time": "2026-09-15 12:00:00", "link": link}
|
||
|
||
def test_latest_valid_activity_excludes_notice_homework_and_bad_link(self):
|
||
rows = [
|
||
self.item("N1", 999, self.URL1, kind="notice"),
|
||
self.item("Bhomework", 998, "https://ginkgostu.dufe.edu.cn/homework", "作业待提交"),
|
||
self.item("Bbad", 997, "https://example.invalid/signin", "签到通知"),
|
||
self.item("Bold", 10, self.URL1),
|
||
self.item("Bnew", 20, self.URL2),
|
||
]
|
||
chosen = ginkgostu._latest_signin_activity(rows)
|
||
self.assertEqual(chosen["key"], "Bnew")
|
||
self.assertEqual(chosen["signin_activity_key"], "S202")
|
||
self.assertNotIn("?", chosen["link"])
|
||
|
||
def test_no_valid_activity_and_http_fallback_parser(self):
|
||
self.assertIsNone(ginkgostu._latest_signin_activity([
|
||
self.item("B1", 1, "https://example.invalid/homework", "签到作业")]))
|
||
self.assertIsNone(ginkgostu._latest_signin_activity([
|
||
self.item("Bevil", 3, "https://evil.invalid/course/detail/1/b/2/signin/101/1")]))
|
||
self.assertIsNone(ginkgostu._latest_signin_activity([
|
||
self.item("Bsuffix", 4, self.URL1 + "/garbage")]))
|
||
self.assertIsNone(ginkgostu._latest_signin_activity([
|
||
self.item("Buserinfo", 5, self.URL1.replace("https://", "https://user:secret@"))]))
|
||
self.assertIsNone(ginkgostu._latest_signin_activity([
|
||
self.item("Bport", 6, self.URL1.replace(".cn", ".cn:8443"))]))
|
||
chosen = ginkgostu._latest_signin_activity([self.item("B2", 2, self.URL1)])
|
||
self.assertEqual(chosen["signin_activity_key"], "S101")
|
||
|
||
def test_same_signin_id_different_message_uuid_is_one_job(self):
|
||
first = ginkgostu._validated_signin_item(self.item("Bfirst", 1, self.URL1))
|
||
second = ginkgostu._validated_signin_item(self.item("Bsecond", 2, self.URL1))
|
||
data = {"auto_sign_in": True}
|
||
delivery.discover(data, [first, second], lambda item: "n", now=1)
|
||
self.assertEqual(list(data["signin_jobs"]), ["S101"])
|
||
|
||
@unittest.skip("upstream HTTP fallback removed in merged ginkgostu.py")
|
||
def test_http_fallback_does_not_persist_upstream_secret_message(self):
|
||
class Response:
|
||
status_code = 200
|
||
headers = {}
|
||
text = ""
|
||
def json(self):
|
||
return {"message": "token=SECRET https://host/path?password=SECRET",
|
||
"data": {"commit": {"success": False}}}
|
||
class Session:
|
||
def post(self, *_args, **_kwargs):
|
||
return Response()
|
||
with mock.patch.object(ginkgostu, "new_session", return_value=Session()):
|
||
ok, message, _payload, limited = ginkgostu._call_sign_in_http(
|
||
"2025000000", "password", self.URL1)
|
||
self.assertFalse(ok)
|
||
self.assertFalse(limited)
|
||
self.assertNotIn("SECRET", message)
|
||
self.assertNotIn("http", message)
|
||
|
||
@unittest.skip("upstream HTTP fallback removed in merged ginkgostu.py")
|
||
def test_http_fallback_nested_commit_rate_limit_is_terminal_control(self):
|
||
class Response:
|
||
status_code = 200
|
||
headers = {}
|
||
text = ""
|
||
def json(self):
|
||
return {"data": {"commit": {"success": False,
|
||
"message": "请求过于频繁"}}}
|
||
class Session:
|
||
def post(self, *_args, **_kwargs):
|
||
return Response()
|
||
with mock.patch.object(ginkgostu, "new_session", return_value=Session()):
|
||
ok, _message, _payload, limited = ginkgostu._call_sign_in_http(
|
||
"2025000000", "password", self.URL1)
|
||
self.assertFalse(ok)
|
||
self.assertTrue(limited)
|
||
|
||
|
||
class SigninJobStateMachineTest(unittest.TestCase):
|
||
URL = GinkgoManualSigninTest.URL1
|
||
|
||
@staticmethod
|
||
def item():
|
||
return ginkgostu._validated_signin_item(
|
||
GinkgoManualSigninTest.item("B1", 1, GinkgoManualSigninTest.URL1))
|
||
|
||
def test_manual_claims_when_auto_off_but_auto_does_not(self):
|
||
manual = {"auto_sign_in": False}
|
||
_, manual_key, _ = delivery.enqueue_signin(manual, self.item(), "manual", now=1,
|
||
run_id="manual-run")
|
||
self.assertTrue(delivery.has_due(manual, now=1))
|
||
self.assertIsNone(delivery.claim_signin(manual, manual_key, "c", 1))
|
||
self.assertEqual(manual["signin_jobs"][manual_key]["status"], "running")
|
||
|
||
auto = {"auto_sign_in": False}
|
||
_, auto_key, _ = delivery.enqueue_signin(auto, self.item(), "auto", now=1,
|
||
run_id="auto-run")
|
||
self.assertFalse(delivery.has_due(auto, now=1))
|
||
self.assertFalse(delivery.claim_signin(auto, auto_key, "c", 1))
|
||
|
||
def test_auto_off_cancels_only_auto_and_manual_survives(self):
|
||
data = {"auto_sign_in": False, "signin_jobs": {}}
|
||
auto_item = self.item()
|
||
manual_item = dict(self.item(), signin_activity_key="S202", link=GinkgoManualSigninTest.URL2)
|
||
_, auto_key, _ = delivery.enqueue_signin(data, auto_item, "auto", now=1)
|
||
_, manual_key, _ = delivery.enqueue_signin(data, manual_item, "manual", now=1)
|
||
delivery.cancel_signins(data, "off", source="auto")
|
||
self.assertEqual(data["signin_jobs"][auto_key]["status"], "cancelled")
|
||
self.assertEqual(data["signin_jobs"][manual_key]["status"], "pending")
|
||
|
||
def test_manual_request_promotes_active_auto_job_before_auto_off(self):
|
||
data = {"auto_sign_in": True}
|
||
_, key, _ = delivery.enqueue_signin(data, self.item(), "auto", now=1,
|
||
run_id="auto-run")
|
||
action, same_key, job = delivery.enqueue_signin(data, self.item(), "manual", now=2)
|
||
self.assertEqual((action, same_key), ("active", key))
|
||
self.assertEqual(job["source"], "manual")
|
||
delivery.cancel_signins(data, "off", source="auto")
|
||
self.assertEqual(data["signin_jobs"][key]["status"], "pending")
|
||
self.assertTrue(any(row["event"] == "manual_signin_promoted"
|
||
for row in data["delivery_audit"]))
|
||
|
||
def test_duplicate_terminal_reset_and_audit_with_unique_receipts(self):
|
||
data = {"signin_jobs": {}}
|
||
action, key, first = delivery.enqueue_signin(data, self.item(), "manual", now=1,
|
||
run_id="run-one")
|
||
self.assertEqual(action, "created")
|
||
self.assertEqual(delivery.enqueue_signin(data, self.item(), "manual", now=2)[0], "active")
|
||
delivery.claim_signin(data, key, "c1", 1)
|
||
delivery.complete_signin(data, key, "c1", "no", {"commit": {"success": False}}, now=2)
|
||
data["signin_jobs"][key]["attempts"] = delivery.MAX_SIGNIN_ATTEMPTS
|
||
data["signin_jobs"][key]["status"] = "running"
|
||
data["signin_jobs"][key]["claim_id"] = "c2"
|
||
delivery.complete_signin(data, key, "c2", "failed", None, now=3)
|
||
self.assertIn("signin:%s:run-one" % key, data["outbox"])
|
||
action, key2, second = delivery.enqueue_signin(data, self.item(), "manual", now=4,
|
||
run_id="run-two")
|
||
self.assertEqual((action, key2, second["attempts"]), ("reset", key, 0))
|
||
self.assertTrue(any(row["event"] == "manual_signin_reset"
|
||
and row["old_run_id"] == "run-one"
|
||
for row in data["delivery_audit"]))
|
||
delivery.claim_signin(data, key, "c3", 4)
|
||
delivery.complete_signin(data, key, "c3", "ok", {"commit": {"success": True}}, now=5)
|
||
self.assertIn("signin:%s:run-two" % key, data["outbox"])
|
||
self.assertEqual(delivery.enqueue_signin(data, self.item(), "manual", now=6)[0], "succeeded")
|
||
|
||
def test_only_exact_commit_success_true_stops_other_shapes_reach_six(self):
|
||
bad_payloads = [None, [], {}, {"commit": None}, {"commit": {}},
|
||
{"commit": {"success": 1}}, {"commit": {"success": "true"}},
|
||
{"data": {"commit": {"success": True}}}]
|
||
for index, payload in enumerate(bad_payloads):
|
||
data = {"auto_sign_in": False}
|
||
_, key, _ = delivery.enqueue_signin(data, self.item(), "manual", now=0,
|
||
run_id="r%d" % index)
|
||
for attempt in range(1, delivery.MAX_SIGNIN_ATTEMPTS + 1):
|
||
self.assertIsNone(delivery.claim_signin(data, key, "c%d" % attempt, 10 ** attempt))
|
||
delivery.complete_signin(data, key, "c%d" % attempt, "bad", payload,
|
||
now=10 ** attempt)
|
||
self.assertEqual(data["signin_jobs"][key]["status"], "failed")
|
||
self.assertEqual(data["signin_jobs"][key]["attempts"], 6)
|
||
good = {"auto_sign_in": False}
|
||
_, key, _ = delivery.enqueue_signin(good, self.item(), "manual", now=0)
|
||
delivery.claim_signin(good, key, "ok", 0)
|
||
delivery.complete_signin(good, key, "ok", "accepted",
|
||
{"commit": {"success": True}}, now=0)
|
||
self.assertEqual(good["signin_jobs"][key]["status"], "succeeded")
|
||
|
||
def test_attempts_and_next_attempt_survive_json_reload(self):
|
||
with tempfile.TemporaryDirectory(prefix="ginkgo-sign-json-") as tmp:
|
||
store = delivery.AccountStore(tmp)
|
||
store.mutate("2025000088", lambda data: delivery.enqueue_signin(
|
||
data, self.item(), "manual", now=1, run_id="persisted"), create=True)
|
||
state = store.load("2025000088")
|
||
key = next(iter(state["signin_jobs"]))
|
||
store.mutate("2025000088", lambda data: delivery.claim_signin(data, key, "c", 1),
|
||
generation=state["_generation"])
|
||
state = store.load("2025000088")
|
||
store.mutate("2025000088", lambda data: delivery.complete_signin(
|
||
data, key, "c", "retry", {}, now=2), generation=state["_generation"])
|
||
reloaded = delivery.AccountStore(tmp).load("2025000088")
|
||
self.assertEqual(reloaded["signin_jobs"][key]["attempts"], 1)
|
||
self.assertEqual(reloaded["signin_jobs"][key]["next_attempt_at"],
|
||
2 + delivery.SIGNIN_BACKOFF[0])
|
||
|
||
def test_rate_limit_cancels_auto_and_pauses_manual(self):
|
||
data = {"account": "2025000088", "notice_push": True, "auto_sign_in": True}
|
||
delivery.enqueue_signin(data, self.item(), "auto", now=1)
|
||
manual_item = dict(self.item(), signin_activity_key="S202", link=GinkgoManualSigninTest.URL2)
|
||
delivery.enqueue_signin(data, manual_item, "manual", now=1)
|
||
ginkgostu._pause_state(data, "limited")
|
||
self.assertFalse(data["notice_push"])
|
||
self.assertFalse(data["auto_sign_in"])
|
||
self.assertEqual(data["signin_jobs"]["S101"]["status"], "cancelled")
|
||
self.assertEqual(data["signin_jobs"]["S202"]["status"], "pending")
|
||
self.assertGreater(data["rate_limited_until"], time.time())
|
||
self.assertFalse(delivery.claim_signin(data, "S202", "next", time.time()))
|
||
self.assertTrue(any(row.get("kind") == "control" for row in data["outbox"].values()))
|
||
|
||
|
||
class GinkgoSignCommandTest(unittest.TestCase):
|
||
ACCOUNT = "2025000077"
|
||
|
||
def setUp(self):
|
||
self.tmp = tempfile.mkdtemp(prefix="ginkgo-sign-command-")
|
||
self.saved = (ginkgostu.STATE_DIR, ginkgostu.PUSH_LOG)
|
||
ginkgostu.STATE_DIR = self.tmp
|
||
ginkgostu.PUSH_LOG = os.path.join(self.tmp, "push.log")
|
||
with open(os.path.join(self.tmp, self.ACCOUNT + ".json"), "w", encoding="utf-8") as fh:
|
||
json.dump({"account": self.ACCOUNT, "password": "secret", "user_id": 98765,
|
||
"notice_push": False, "auto_sign_in": False}, fh)
|
||
|
||
def tearDown(self):
|
||
ginkgostu.STATE_DIR, ginkgostu.PUSH_LOG = self.saved
|
||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||
|
||
def test_command_only_fetches_messages_and_enqueues_without_real_signin(self):
|
||
ch = FakeChannel("tg")
|
||
valid = GinkgoManualSigninTest.item("Bcmd", 20, GinkgoManualSigninTest.URL1)
|
||
with mock.patch.object(ginkgostu, "get_session", return_value=object()), \
|
||
mock.patch.object(ginkgostu, "fetch_all", return_value=([], [valid])) as fetched, \
|
||
mock.patch.object(ginkgostu, "call_sign_in_api") as signer:
|
||
asyncio.run(ginkgostu._cmd_sign(ch, "private", 123, 98765))
|
||
self.assertEqual(fetched.call_count, 1)
|
||
signer.assert_not_called()
|
||
state = delivery.AccountStore(self.tmp).load(self.ACCOUNT)
|
||
job = state["signin_jobs"]["S101"]
|
||
self.assertEqual(job["source"], "manual")
|
||
self.assertEqual(job["attempts"], 0)
|
||
self.assertFalse(state["auto_sign_in"])
|
||
self.assertIn("标题:", ch.sent[-1][2])
|
||
self.assertIn("签到类型:数字码签到", ch.sent[-1][2])
|
||
self.assertIn(GinkgoManualSigninTest.URL1, ch.sent[-1][2])
|
||
|
||
def test_command_reports_no_activity(self):
|
||
ch = FakeChannel("tg")
|
||
with mock.patch.object(ginkgostu, "get_session", return_value=object()), \
|
||
mock.patch.object(ginkgostu, "fetch_all", return_value=([], [])), \
|
||
mock.patch.object(ginkgostu, "call_sign_in_api") as signer:
|
||
asyncio.run(ginkgostu._cmd_sign(ch, "private", 123, 98765))
|
||
signer.assert_not_called()
|
||
self.assertEqual(ch.sent[-1][2], "没有找到可签到活动")
|
||
|
||
def test_manual_job_runs_with_auto_off(self):
|
||
state = delivery.AccountStore(self.tmp).load(self.ACCOUNT)
|
||
item = ginkgostu._validated_signin_item(
|
||
GinkgoManualSigninTest.item("Brun", 1, GinkgoManualSigninTest.URL1))
|
||
delivery.AccountStore(self.tmp).mutate(
|
||
self.ACCOUNT, lambda data: delivery.enqueue_signin(data, item, "manual", now=0),
|
||
generation=state["_generation"])
|
||
with mock.patch.object(ginkgostu, "call_sign_in_api",
|
||
return_value=(True, "ok", {"commit": {"success": True}}, False)) as signer:
|
||
asyncio.run(ginkgostu._run_signin_jobs(self.ACCOUNT))
|
||
self.assertEqual(signer.call_count, 1)
|
||
reloaded = delivery.AccountStore(self.tmp).load(self.ACCOUNT)
|
||
self.assertEqual(reloaded["signin_jobs"]["S101"]["status"], "succeeded")
|
||
self.assertFalse(reloaded["auto_sign_in"])
|
||
|
||
|
||
class SigninThreeSecondProgressTest(unittest.TestCase):
|
||
ACCOUNT = "2025000066"
|
||
|
||
@staticmethod
|
||
def item():
|
||
return ginkgostu._validated_signin_item(
|
||
GinkgoManualSigninTest.item("Bretry", 1, GinkgoManualSigninTest.URL1))
|
||
|
||
def test_all_five_signin_intervals_are_three_seconds(self):
|
||
self.assertEqual(delivery.SIGNIN_BACKOFF, (3, 3, 3, 3, 3))
|
||
|
||
def test_attempts_one_to_five_each_create_unique_durable_progress(self):
|
||
with tempfile.TemporaryDirectory(prefix="signin-progress-json-") as tmp:
|
||
store = delivery.AccountStore(tmp)
|
||
state = store.mutate(self.ACCOUNT, lambda data: delivery.enqueue_signin(
|
||
data, self.item(), "manual", now=0, run_id="progress-run"), create=True)
|
||
key = "S101"
|
||
for attempt in range(1, 6):
|
||
now = attempt * 10
|
||
state = store.mutate(self.ACCOUNT, lambda data, n=attempt, t=now:
|
||
delivery.claim_signin(data, key, "c%d" % n, t),
|
||
generation=state["_generation"])
|
||
state = store.mutate(self.ACCOUNT, lambda data, n=attempt, t=now:
|
||
delivery.complete_signin(data, key, "c%d" % n, "脱敏失败原因", {}, t),
|
||
generation=state["_generation"])
|
||
state = delivery.AccountStore(tmp).load(self.ACCOUNT)
|
||
row = state["signin_jobs"][key]
|
||
self.assertEqual(row["attempts"], attempt)
|
||
self.assertEqual(row["next_attempt_at"], now + 3)
|
||
progress_key = "signin-retry:S101:progress-run:%d" % attempt
|
||
self.assertIn(progress_key, state["outbox"])
|
||
text = state["outbox"][progress_key]["text"]
|
||
self.assertIn("手动签到进度", text)
|
||
self.assertIn("第 %d/6 次尝试失败" % attempt, text)
|
||
self.assertIn("3秒后进行第 %d/6 次" % (attempt + 1), text)
|
||
self.assertEqual(len([k for k in state["outbox"] if k.startswith("signin-retry:")]), 5)
|
||
|
||
def test_sixth_attempt_creates_only_final_receipt(self):
|
||
data = {"auto_sign_in": False}
|
||
_, key, job = delivery.enqueue_signin(data, self.item(), "manual", now=0,
|
||
run_id="six-run")
|
||
job.update(attempts=5, next_attempt_at=0)
|
||
delivery.claim_signin(data, key, "six", 100)
|
||
delivery.complete_signin(data, key, "six", "最终失败", {}, 100)
|
||
self.assertEqual(data["signin_jobs"][key]["status"], "failed")
|
||
self.assertFalse(any(k.endswith(":6") for k in data["outbox"]
|
||
if k.startswith("signin-retry:")))
|
||
self.assertIn("signin:S101:six-run", data["outbox"])
|
||
|
||
def test_rate_limit_pause_has_no_retry_progress(self):
|
||
data = {"account": self.ACCOUNT, "notice_push": True, "auto_sign_in": True}
|
||
_, key, job = delivery.enqueue_signin(data, self.item(), "manual", now=0,
|
||
run_id="limited-run")
|
||
job.update(status="running", claim_id="c", attempts=1)
|
||
ginkgostu._pause_state(data, "上游请求过于频繁,请稍后重试")
|
||
self.assertEqual(data["signin_jobs"][key]["status"], "running")
|
||
self.assertGreater(data["rate_limited_until"], time.time())
|
||
self.assertFalse(delivery.claim_signin(data, key, "next", time.time()))
|
||
self.assertFalse(any(k.startswith("signin-retry:") for k in data["outbox"]))
|
||
self.assertTrue(any(row.get("kind") == "control" for row in data["outbox"].values()))
|
||
|
||
@unittest.skip("upstream retry migration helper removed from merged module")
|
||
def test_migration_is_idempotent_and_preserves_attempts_run_id(self):
|
||
data = {"auto_sign_in": False}
|
||
_, key, job = delivery.enqueue_signin(data, self.item(), "manual", now=0,
|
||
run_id="held-run")
|
||
job.update(status="pending", attempts=5, reason="第五次失败", next_attempt_at=999,
|
||
claim_id="stale-claim")
|
||
self.assertTrue(delivery.migrate_signin_retry_policy(data, key, now=100))
|
||
self.assertEqual(job["attempts"], 5)
|
||
self.assertEqual(job["run_id"], "held-run")
|
||
self.assertEqual(job["next_attempt_at"], 103)
|
||
self.assertNotIn("claim_id", job)
|
||
progress = "signin-retry:S101:held-run:5"
|
||
self.assertIn(progress, data["outbox"])
|
||
audit_count = len([row for row in data["delivery_audit"]
|
||
if row["event"] == "signin_retry_policy_migration_release"])
|
||
self.assertFalse(delivery.migrate_signin_retry_policy(data, key, now=200))
|
||
self.assertEqual(job["next_attempt_at"], 103)
|
||
self.assertEqual(len([k for k in data["outbox"] if k == progress]), 1)
|
||
self.assertEqual(len([row for row in data["delivery_audit"]
|
||
if row["event"] == "signin_retry_policy_migration_release"]), audit_count)
|
||
self.assertEqual([k for k in data["outbox"] if k.startswith("signin-retry:")], [progress])
|
||
|
||
@unittest.skip("upstream retry migration helper removed from merged module")
|
||
def test_migration_rejects_attempts_one_to_four(self):
|
||
for attempts in range(1, 5):
|
||
data = {"auto_sign_in": False}
|
||
_, key, job = delivery.enqueue_signin(data, self.item(), "manual", now=0,
|
||
run_id="negative-%d" % attempts)
|
||
job.update(status="pending", attempts=attempts, reason="old", next_attempt_at=999)
|
||
self.assertFalse(delivery.migrate_signin_retry_policy(data, key, now=100))
|
||
self.assertEqual(job["next_attempt_at"], 999)
|
||
self.assertFalse(any(k.startswith("signin-retry:") for k in data.get("outbox", {})))
|
||
|
||
def test_progress_receipt_uses_existing_tg_qq_mirror_outbox(self):
|
||
tmp = tempfile.mkdtemp(prefix="signin-progress-mirror-")
|
||
accounts = os.path.join(tmp, "accounts")
|
||
os.makedirs(accounts, exist_ok=True)
|
||
saved = (ginkgostu.STATE_DIR, ginkgostu.PUSH_LOG)
|
||
registered = []
|
||
try:
|
||
ginkgostu.STATE_DIR = tmp
|
||
ginkgostu.PUSH_LOG = os.path.join(tmp, "push.log")
|
||
identity.configure(accounts, admin_tg_ids=[])
|
||
uid = identity.ensure_tg(TG_OFFSET + 6066)
|
||
identity.force_bind_qq(3280589370, uid)
|
||
tg, qq = FakeChannel("tg"), FakeChannel("qq")
|
||
register_channel(tg); registered.append("tg")
|
||
register_channel(qq); registered.append("qq")
|
||
store = delivery.AccountStore(tmp)
|
||
state = store.mutate(self.ACCOUNT, lambda data: data.update(
|
||
user_id=uid, notice_push=False, auto_sign_in=False), create=True)
|
||
def add_progress(data):
|
||
_, key, job = delivery.enqueue_signin(data, self.item(), "manual", now=0,
|
||
run_id="mirror-run")
|
||
delivery.enqueue_retry_receipt(data, key, job, "脱敏失败原因", 1, now=0)
|
||
store.mutate(self.ACCOUNT, add_progress, generation=state["_generation"])
|
||
asyncio.run(ginkgostu._deliver_outbox(self.ACCOUNT))
|
||
self.assertEqual(len(tg.sent), 1)
|
||
self.assertEqual(len(qq.sent), 1)
|
||
row = store.load(self.ACCOUNT)["outbox"]["signin-retry:S101:mirror-run:1"]
|
||
self.assertEqual(row["status"], "sent")
|
||
self.assertEqual(sorted(row["sent_targets"]),
|
||
sorted(["tg:%d" % (TG_OFFSET + 6066), "qq:3280589370"]))
|
||
finally:
|
||
for name in registered:
|
||
unregister_channel(name)
|
||
ginkgostu.STATE_DIR, ginkgostu.PUSH_LOG = saved
|
||
shutil.rmtree(tmp, ignore_errors=True)
|
||
|
||
|
||
class SigninTypeSemanticsTest(unittest.TestCase):
|
||
BASE = "https://ginkgostu.dufe.edu.cn/course/detail/1/batch/2/signin/101/%d"
|
||
|
||
def _fetch(self, signin_type, code, model_type_marker=True):
|
||
import ginkgostu as signin
|
||
submit = mock.Mock(headers={"Location": "https://ginkgostu.dufe.edu.cn/login?code=oauth"},
|
||
status_code=302)
|
||
response = mock.Mock()
|
||
model = {"code": code, "title": "类型测试"}
|
||
if model_type_marker:
|
||
model["type"] = signin_type
|
||
response.json.return_value = {"data": {"signInModel": model, "signStatus": 0}}
|
||
with mock.patch.object(signin, "login", return_value=(object(), "test-session", "Test")), \
|
||
mock.patch.object(signin, "new_session", return_value=object()), \
|
||
mock.patch.object(signin, "get_unify_config", return_value={}), \
|
||
mock.patch.object(signin, "open_authorize", return_value=("csrf", "page")), \
|
||
mock.patch.object(signin, "exploit_slider", return_value={}), \
|
||
mock.patch.object(signin, "submit_login", return_value=submit), \
|
||
mock.patch.object(signin, "check_response", side_effect=lambda r, *_: r), \
|
||
mock.patch.object(signin, "exchange_token", return_value="token"), \
|
||
mock.patch.object(signin, "call_api", return_value=response), \
|
||
mock.patch.object(signin, "commit_sign_in",
|
||
return_value=(True, "accepted", {})) as commit:
|
||
result = signin.fetch_sign_in_info(
|
||
"account", "password", self.BASE % signin_type, do_commit=True)
|
||
return signin, result, commit
|
||
|
||
def test_normal_type_two_commits_exact_empty_code(self):
|
||
_signin, result, commit = self._fetch(2, None)
|
||
self.assertEqual(result["type"], 2)
|
||
self.assertEqual(result["typeName"], "普通签到")
|
||
self.assertEqual(result["signInCode"], "")
|
||
self.assertIs(result["commit"]["success"], True)
|
||
self.assertEqual(commit.call_count, 1)
|
||
self.assertEqual(commit.call_args.args[-1], "")
|
||
|
||
def test_numeric_type_one_requires_and_uses_code(self):
|
||
_signin, result, commit = self._fetch(1, "2468")
|
||
self.assertEqual(result["typeName"], "数字码签到")
|
||
self.assertEqual(commit.call_args.args[-1], "2468")
|
||
import ginkgostu as signin
|
||
with self.assertRaises(signin.NoSignInCodeError):
|
||
self._fetch(1, None)
|
||
|
||
def test_model_type_missing_falls_back_to_route_type(self):
|
||
_signin, result, commit = self._fetch(2, None, model_type_marker=False)
|
||
self.assertEqual(result["type"], 2)
|
||
self.assertEqual(result["typeName"], "普通签到")
|
||
self.assertEqual(commit.call_args.args[-1], "")
|
||
|
||
def test_qrcode_and_live_are_explicitly_unsupported(self):
|
||
for signin_type, name in ((0, "二维码签到"), (3, "直播签到")):
|
||
_signin, result, commit = self._fetch(signin_type, None)
|
||
commit.assert_not_called()
|
||
self.assertEqual(result["typeName"], name)
|
||
self.assertIs(result["commit"]["success"], False)
|
||
self.assertIn(name, result["commit"]["message"])
|
||
self.assertIn("暂不支持", result["commit"]["message"])
|
||
|
||
@unittest.skip("upstream HTTP fallback removed in merged ginkgostu.py")
|
||
def test_http_fallback_numeric_no_code_reason_is_clear(self):
|
||
class Response:
|
||
status_code = 404
|
||
headers = {}
|
||
text = ""
|
||
def json(self):
|
||
return {"code": 404, "msg": "untrusted", "data": None}
|
||
class Session:
|
||
kwargs = None
|
||
def post(self, *_args, **kwargs):
|
||
self.kwargs = kwargs
|
||
return Response()
|
||
session = Session()
|
||
with mock.patch.object(ginkgostu, "new_session", return_value=session):
|
||
ok, reason, payload, limited = ginkgostu._call_sign_in_http(
|
||
"account", "password", self.BASE % 1)
|
||
self.assertFalse(ok)
|
||
self.assertEqual(reason, "数字码签到未配置签到码")
|
||
self.assertIsNone(payload)
|
||
self.assertFalse(limited)
|
||
self.assertIn("data", session.kwargs)
|
||
self.assertNotIn("params", session.kwargs)
|
||
|
||
def test_validated_item_and_all_receipts_name_type(self):
|
||
raw = GinkgoManualSigninTest.item("Btype", 1, self.BASE % 2)
|
||
item = ginkgostu._validated_signin_item(raw)
|
||
self.assertEqual(item["signin_type"], 2)
|
||
self.assertEqual(item["signin_type_name"], "普通签到")
|
||
job = {"source": "manual", "item": item}
|
||
self.assertIn("签到类型:普通签到",
|
||
delivery.retry_receipt_text(job, "失败", 1))
|
||
self.assertIn("签到类型:普通签到",
|
||
delivery.receipt_text(item, False, "失败", None, 6, "manual"))
|
||
self.assertIn("签到类型:普通签到",
|
||
delivery.receipt_text(item, True, "成功",
|
||
{"type": 2, "typeName": "普通签到"}, 1, "auto"))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main(verbosity=2)
|