feat(sign_in): 新增独立每日签到插件,与抽卡签到共用同一份签到数据

- 群聊命令「签到」(别名: 每日签到/打卡),白名单群聊或管理员可用
- 复用 xapi /bot/gacha/sign-in + /bot/points/add,与抽卡/WPF 共用 bot_gacha_daily_sign_in 表
- 积分随机 1-100,流水沿用 gacha_sign/抽卡签到;先到先得,重复签到提示已签到
- 新增 10 个单元测试,ruff 通过;更新 PLUGINS.md / README.md
This commit is contained in:
2026-08-16 09:02:11 +08:00
parent 7f3f744af5
commit 9ee5545cac
12 changed files with 862 additions and 1 deletions

View File

@@ -0,0 +1,371 @@
"""独立签到插件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"