Files
DanDingNoneBot/tests/test_danding_sign_in_plugin.py
Mr.Xia 260651363a fix(sign_in): 修复签到命令 Rule 构造 NameError(MessageEvent 注解全局可见)
- rules.py 改为模块顶层导入 nonebot(NoneBot Rule 解析依赖参数注解在全局命名空间求值)
- 权限纯函数 is_allowed 拆至 permission.py(无 nonebot 依赖,保持可测性)
- 测试加载模块列表同步调整(config/api/service/permission)
2026-08-16 09:05:08 +08:00

376 lines
12 KiB
Python
Raw Permalink 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.

"""独立签到插件danding_sign_in测试。
直接加载 config/api/service/permission 子模块,避免执行 nonebot 插件入口;
aiohttp 调用用 FakeSession/FakeResponse 替身 mock与 test_onmyoji_gacha_http_api.py 同模式)。
"""
from __future__ import annotations
import importlib.util
import sys
import types
from pathlib import Path
import pytest
def load_modules():
"""直接加载 danding_sign_in 子模块,避免测试环境执行 nonebot 插件入口。
rules.py 顶层导入 nonebotRule 解析要求注解全局可见),无 nonebot 的
测试环境不加载它;权限纯函数在 permission.py 中测试。
"""
plugin_dir = Path(__file__).resolve().parents[1] / "danding_bot" / "plugins" / "danding_sign_in"
package_name = "_danding_sign_in_under_test"
package = types.ModuleType(package_name)
package.__path__ = [str(plugin_dir)]
sys.modules[package_name] = package
for module_name in ("config", "api", "service", "permission"):
full_name = f"{package_name}.{module_name}"
spec = importlib.util.spec_from_file_location(full_name, plugin_dir / f"{module_name}.py")
module = importlib.util.module_from_spec(spec)
sys.modules[full_name] = module
assert spec and spec.loader
spec.loader.exec_module(module)
return (
sys.modules[f"{package_name}.config"],
sys.modules[f"{package_name}.api"],
sys.modules[f"{package_name}.service"],
sys.modules[f"{package_name}.permission"],
)
config_module, api_module, service_module, permission_module = load_modules()
Config = config_module.Config
SignInAPI = api_module.SignInAPI
perform_daily_sign_in = service_module.perform_daily_sign_in
is_allowed = permission_module.is_allowed
class FakeResponse:
"""模拟 aiohttp 响应上下文。"""
def __init__(self, payload, status=200):
self.payload = payload
self.status = status
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def json(self):
return self.payload
class FakeSession:
"""记录请求参数的 aiohttp ClientSession 替身。"""
def __init__(self, responses, calls):
self.responses = responses
self.calls = calls
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
def get(self, url, params=None, timeout=None):
self.calls.append({"method": "GET", "url": url, "params": params, "timeout": timeout})
return FakeResponse(self.responses.pop(0))
def post(self, url, json=None, timeout=None):
self.calls.append({"method": "POST", "url": url, "json": json, "timeout": timeout})
return FakeResponse(self.responses.pop(0))
@pytest.fixture
def fake_aiohttp(monkeypatch):
"""构造真实 SignInAPI + 替身 aiohttp记录请求参数。"""
calls = []
responses = []
monkeypatch.setattr(api_module.aiohttp, "ClientSession", lambda: FakeSession(responses, calls))
test_config = Config(
GACHA_API_HOST="http://xapi.test/bot/gacha/",
BOT_USER_ID="robot",
BOT_TOKEN="secret",
ALLOWED_GROUP_ID=621016172,
ALLOWED_USER_ID=1424473282,
)
return SignInAPI(test_config), responses, calls
def success_data(data):
return {"code": 200, "message": "", "data": data}
class FakePointsAPI:
"""points_api 替身:记录调用,可配置成功/失败。"""
def __init__(self, ok=True, balance=50):
self.ok = ok
self.balance = balance
self.calls = []
async def add_points(self, user_id, amount, source, reason):
self.calls.append({"user_id": user_id, "amount": amount, "source": source, "reason": reason})
return self.ok, self.balance
# ---------- 首次签到成功 ----------
@pytest.mark.asyncio
async def test_first_sign_in_records_then_awards_points(fake_aiohttp):
sign_in_api, responses, calls = fake_aiohttp
points_api = FakePointsAPI(ok=True, balance=42)
responses.append(success_data({"success": True, "signed_already": False}))
result = await perform_daily_sign_in(
sign_in_api=sign_in_api,
points_api=points_api,
user_id="10001",
points_min=1,
points_max=100,
source="gacha_sign",
reason="抽卡签到",
)
assert result["success"] is True
assert result["already_signed"] is False
assert 1 <= result["points"] <= 100
assert result["balance"] == 42
# 先记录签到,再发积分
assert len(calls) == 1
assert calls[0]["method"] == "POST"
assert calls[0]["url"].endswith("/bot/gacha/sign-in")
payload = calls[0]["json"]
assert payload["user_id"] == "10001"
assert payload["points_awarded"] == result["points"]
assert payload["user"] == "robot"
assert payload["token"] == "secret"
assert len(points_api.calls) == 1
assert points_api.calls[0]["user_id"] == "10001"
assert points_api.calls[0]["amount"] == result["points"]
assert points_api.calls[0]["source"] == "gacha_sign"
assert points_api.calls[0]["reason"] == "抽卡签到"
# ---------- 重复签到:不发积分 ----------
@pytest.mark.asyncio
async def test_repeat_sign_in_returns_already_and_skips_points(fake_aiohttp):
sign_in_api, responses, calls = fake_aiohttp
points_api = FakePointsAPI()
responses.append(
success_data(
{
"success": True,
"signed_already": True,
"signed_today": True,
"sign_date": "2026-08-16",
"points_awarded": 20,
}
)
)
result = await perform_daily_sign_in(
sign_in_api=sign_in_api,
points_api=points_api,
user_id="10001",
points_min=1,
points_max=100,
source="gacha_sign",
reason="抽卡签到",
)
assert result["success"] is True
assert result["already_signed"] is True
assert result["points"] == 20
assert result["sign_date"] == "2026-08-16"
# 只调了签到记录接口,没有调积分发放
assert len(calls) == 1
assert points_api.calls == []
# ---------- 签到接口失败 ----------
@pytest.mark.asyncio
async def test_sign_in_api_failure_returns_error(fake_aiohttp):
sign_in_api, responses, calls = fake_aiohttp
points_api = FakePointsAPI()
responses.append({"code": 500, "message": "internal error", "data": None})
result = await perform_daily_sign_in(
sign_in_api=sign_in_api,
points_api=points_api,
user_id="10001",
points_min=1,
points_max=100,
source="gacha_sign",
reason="抽卡签到",
)
assert result["success"] is False
assert "暂不可用" in result["message"]
assert points_api.calls == []
@pytest.mark.asyncio
async def test_sign_in_api_network_error_returns_error(fake_aiohttp):
sign_in_api, responses, calls = fake_aiohttp
points_api = FakePointsAPI()
import aiohttp
class BoomSession:
"""进入上下文时抛网络异常,模拟连接失败。"""
async def __aenter__(self):
raise aiohttp.ClientError("connection refused")
async def __aexit__(self, exc_type, exc, tb):
return None
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr(api_module.aiohttp, "ClientSession", lambda: BoomSession())
result = await perform_daily_sign_in(
sign_in_api=sign_in_api,
points_api=points_api,
user_id="10001",
points_min=1,
points_max=100,
source="gacha_sign",
reason="抽卡签到",
)
assert result["success"] is False
assert "暂不可用" in result["message"]
assert points_api.calls == []
# ---------- 积分发放失败 ----------
@pytest.mark.asyncio
async def test_points_award_failure_returns_error(fake_aiohttp):
sign_in_api, responses, calls = fake_aiohttp
points_api = FakePointsAPI(ok=False, balance=0)
responses.append(success_data({"success": True, "signed_already": False}))
result = await perform_daily_sign_in(
sign_in_api=sign_in_api,
points_api=points_api,
user_id="10001",
points_min=1,
points_max=100,
source="gacha_sign",
reason="抽卡签到",
)
assert result["success"] is False
assert "积分发放失败" in result["message"]
assert len(points_api.calls) == 1
# ---------- 积分区间 ----------
def test_points_range_is_configurable():
"""随机积分必须落在配置区间内(倒置区间自动排序)。"""
for _ in range(50):
low, high = 5, 30
result = __import__("random").randint(low, high)
assert low <= result <= high
@pytest.mark.asyncio
async def test_service_points_within_configured_range(fake_aiohttp):
sign_in_api, responses, calls = fake_aiohttp
points_api = FakePointsAPI()
seen = []
for _ in range(20):
responses.append(success_data({"success": True, "signed_already": False}))
result = await perform_daily_sign_in(
sign_in_api=sign_in_api,
points_api=points_api,
user_id="10001",
points_min=7,
points_max=9,
source="gacha_sign",
reason="抽卡签到",
)
assert result["success"] is True
seen.append(result["points"])
assert all(7 <= p <= 9 for p in seen)
# ---------- 权限 ----------
def test_permission_allowed_group_and_admin():
"""白名单群聊成员可用;管理员任意场景可用;其他群/私聊非管理员不可用。"""
allowed_group, allowed_user = 621016172, 1424473282
assert is_allowed(10001, allowed_group, allowed_group_id=allowed_group, allowed_user_id=allowed_user) is True
assert is_allowed(allowed_user, None, allowed_group_id=allowed_group, allowed_user_id=allowed_user) is True
assert is_allowed(allowed_user, 999, allowed_group_id=allowed_group, allowed_user_id=allowed_user) is True
assert is_allowed(10001, 999, allowed_group_id=allowed_group, allowed_user_id=allowed_user) is False
assert is_allowed(10001, None, allowed_group_id=allowed_group, allowed_user_id=allowed_user) is False
# ---------- 配置读取 ----------
def test_config_reads_token_from_nonebot_driver_config(monkeypatch):
"""NoneBot 只把 .env 注入 driver.config 时,签到 API token 不能为空。"""
for key in ("DANDING_BOT_TOKEN", "ONMYOJI_BOT_TOKEN", "DANDING_API_TOKEN", "BOT_TOKEN", "DANDING_BOT_USER"):
monkeypatch.delenv(key, raising=False)
class FakeDriver:
config = types.SimpleNamespace(
danding_bot_token="driver-bot-token",
danding_bot_user="driver-user",
)
fake_nonebot = types.ModuleType("nonebot")
fake_nonebot.get_driver = lambda: FakeDriver()
monkeypatch.setitem(sys.modules, "nonebot", fake_nonebot)
test_config = Config()
assert test_config.BOT_TOKEN == "driver-bot-token"
assert test_config.BOT_USER_ID == "driver-user"
def test_config_keeps_explicit_bot_token_priority(monkeypatch):
"""显式 DANDING_BOT_TOKEN 优先于兼容旧 token。"""
for key in ("DANDING_BOT_TOKEN", "ONMYOJI_BOT_TOKEN", "DANDING_API_TOKEN", "BOT_TOKEN"):
monkeypatch.delenv(key, raising=False)
class FakeDriver:
config = types.SimpleNamespace(
danding_bot_token="driver-bot-token",
danding_api_token="driver-api-token",
)
fake_nonebot = types.ModuleType("nonebot")
fake_nonebot.get_driver = lambda: FakeDriver()
monkeypatch.setitem(sys.modules, "nonebot", fake_nonebot)
assert Config().BOT_TOKEN == "driver-bot-token"