Files
AuthBot/tests/test_state.py
T
snowy-yang 755013b9db 初始提交:QQ 群入群验证机器人
基于 qqbotsdk-py 的入群验证:进群发按钮验证码、完成前禁言、
超时踢出(11253 时兜底长期禁言)、乱点他人按钮处罚、状态持久化
与重启恢复。38 个单元/集成测试。
2026-10-04 18:44:08 +08:00

230 lines
6.8 KiB
Python

"""验证状态持久化与超时定时器测试。"""
import asyncio
import json
import tempfile
import time
from pathlib import Path
from authbot.state import (
ForeignClickTracker,
PendingRecord,
RecentSet,
VerifyState,
pending_key,
)
def make_record(group="G1", member="M1", deadline=None, token="tok1", answer=7):
return PendingRecord(
group_openid=group,
member_openid=member,
deadline=deadline if deadline is not None else time.time() + 600,
token=token,
answer=answer,
created_at=time.time(),
)
def test_put_get_pop_roundtrip(tmp_path):
async def scenario():
async def noop(record):
pass
state = VerifyState(tmp_path, on_timeout=noop)
rec = make_record()
state.put(rec)
assert state.get(rec.key) is not None
# 新实例从磁盘恢复(模拟重启)
state2 = VerifyState(tmp_path, on_timeout=noop)
restored = state2.get(rec.key)
assert restored is not None
assert restored.token == rec.token
assert restored.answer == rec.answer
assert restored.deadline == rec.deadline
assert state2.pop(rec.key).token == rec.token
assert state2.get(rec.key) is None
assert state2.pop(rec.key) is None # 重复 pop 安全
asyncio.run(scenario())
def test_pop_persists_deletion(tmp_path):
async def scenario():
async def noop(record):
pass
state = VerifyState(tmp_path, on_timeout=noop)
rec = make_record()
state.put(rec)
state.pop(rec.key)
assert VerifyState(tmp_path, on_timeout=noop).get(rec.key) is None
asyncio.run(scenario())
def test_verified_trust_window(tmp_path):
async def scenario():
async def noop(record):
pass
state = VerifyState(tmp_path, on_timeout=noop)
key = pending_key("G1", "M1")
state.mark_verified(key)
# trust_days=0:即使刚通过也不免验
assert state.is_verified_recently(key, 0) is False
assert state.is_verified_recently(key, 1) is True
# 手动把通过时间改到 3 天前
verified = json.loads((tmp_path / "verified.json").read_text("utf-8"))
verified[key] = time.time() - 3 * 86400
(tmp_path / "verified.json").write_text(json.dumps(verified), "utf-8")
state2 = VerifyState(tmp_path, on_timeout=noop)
assert state2.is_verified_recently(key, 1) is False
assert state2.is_verified_recently(key, 7) is True
asyncio.run(scenario())
def test_summary_remaining_seconds(tmp_path):
async def scenario():
async def noop(record):
pass
state = VerifyState(tmp_path, on_timeout=noop)
rec = make_record(deadline=time.time() + 120)
state.put(rec)
[(got, remain)] = state.summary()
assert got.key == rec.key
assert 110 < remain <= 120
asyncio.run(scenario())
def test_timeout_timer_fires_and_pop_cancels(tmp_path):
async def scenario():
fired = []
async def on_timeout(record):
fired.append(record.key)
state = VerifyState(tmp_path, on_timeout=on_timeout)
# 已到点的:立即触发
state.put(make_record(member="EXPIRED", deadline=time.time() - 1))
# 未到点的随后被 pop:不应触发
state.put(make_record(member="CANCELLED", deadline=time.time() + 5))
state.pop(pending_key("G1", "CANCELLED"))
await asyncio.sleep(0.3)
assert fired == [pending_key("G1", "EXPIRED")]
asyncio.run(scenario())
def test_restore_rearms_from_disk(tmp_path):
async def scenario():
async def noop(record):
pass
# 先写入一条已过期与一条未过期的记录
VerifyState(tmp_path, on_timeout=noop).put(
make_record(member="OLD", deadline=time.time() - 1)
)
VerifyState(tmp_path, on_timeout=noop).put(
make_record(member="NEW", deadline=time.time() + 600)
)
fired = []
async def on_timeout(record):
fired.append(record.key)
state = VerifyState(tmp_path, on_timeout=on_timeout)
await state.restore()
await asyncio.sleep(0.3)
# 只有已过期的触发,未到期的不触发
assert fired == [pending_key("G1", "OLD")]
assert state.get(pending_key("G1", "NEW")) is not None
asyncio.run(scenario())
def test_timeout_callback_exception_isolated(tmp_path):
"""回调抛异常不炸定时器任务本身,也不影响其他记录。"""
async def scenario():
async def boom(record):
raise RuntimeError("boom")
state = VerifyState(tmp_path, on_timeout=boom)
state.put(make_record(member="A", deadline=time.time() - 1))
state.put(make_record(member="B", deadline=time.time() - 1))
await asyncio.sleep(0.3) # 两条回调都跑完且异常被记录
asyncio.run(scenario())
def test_corrupt_state_file_backed_up(tmp_path):
async def scenario():
async def noop(record):
pass
(tmp_path / "pending.json").write_text("{not json", "utf-8")
state = VerifyState(tmp_path, on_timeout=noop)
assert state.all_pending() == []
assert (tmp_path / "pending.json.bad").exists()
asyncio.run(scenario())
def test_recent_set():
s = RecentSet(maxsize=3)
assert s.add("a") is True
assert s.add("a") is False
assert s.add("b") is True
assert s.add("c") is True
assert s.add("d") is True # 挤掉最旧的 a
assert s.add("a") is True # a 已被淘汰,重新视为首次
def test_foreign_click_tracker_counts_and_resets():
t = ForeignClickTracker(limit=3, window=1800.0)
now = 1000.0
assert t.register("k", now) == 1
assert t.register("k", now + 10) == 2
assert t.register("k", now + 20) == 3 # 到达上限
t.reset("k")
assert t.register("k", now + 30) == 1 # 处罚后重新计数
def test_foreign_click_tracker_window_prunes_old():
t = ForeignClickTracker(limit=3, window=60.0)
now = 1000.0
assert t.register("k", now) == 1
assert t.register("k", now + 61) == 1 # 窗口外的第一次点击不计入
assert t.register("k", now + 61 + 30) == 2
def test_foreign_click_tracker_keys_independent():
t = ForeignClickTracker(limit=2)
assert t.register("a") == 1
assert t.register("b") == 1
assert t.register("a") == 2
def test_get_by_token():
async def scenario():
async def noop(record):
pass
state = VerifyState(Path(tempfile.mkdtemp()), on_timeout=noop)
rec = make_record(token="tok-xyz")
state.put(rec)
assert state.get_by_token("tok-xyz").key == rec.key
assert state.get_by_token("nope") is None
state.pop(rec.key)
asyncio.run(scenario())