"""独立签到插件(danding_sign_in)测试。 直接加载 config/api/service/rules 子模块,避免执行 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 插件入口。""" 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", "rules"): 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}.rules"], ) config_module, api_module, service_module, rules_module = load_modules() Config = config_module.Config SignInAPI = api_module.SignInAPI perform_daily_sign_in = service_module.perform_daily_sign_in is_allowed = rules_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"