Files
shirobana-bot/tests/test_push_mirror.py
T
RRRosmontis 34089d0042 Initial import of Shirobana bot backend
Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
2026-11-11 00:25:30 +08:00

1527 lines
74 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)