# -*- 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)