feat(bot): use runtime api for bot data
This commit is contained in:
217
tests/test_danding_points_http_api.py
Normal file
217
tests/test_danding_points_http_api.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""danding_points HTTP PointsAPI 测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import aiohttp
|
||||
import importlib.util
|
||||
import pytest
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_points_modules():
|
||||
"""直接加载 danding_points 子模块,避免测试环境缺 nonebot 时执行插件元数据。"""
|
||||
|
||||
plugin_dir = Path(__file__).resolve().parents[1] / "danding_bot" / "plugins" / "danding_points"
|
||||
package_name = "_danding_points_under_test"
|
||||
package = types.ModuleType(package_name)
|
||||
package.__path__ = [str(plugin_dir)]
|
||||
sys.modules[package_name] = package
|
||||
|
||||
for module_name in ("config", "api"):
|
||||
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}.api"], sys.modules[f"{package_name}.config"]
|
||||
|
||||
|
||||
api_module, config_module = load_points_modules()
|
||||
PointsAPI = api_module.PointsAPI
|
||||
Config = config_module.Config
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
def make_points_api() -> PointsAPI:
|
||||
return PointsAPI(
|
||||
Config(
|
||||
POINTS_API_HOST="http://xapi.test/bot/points/",
|
||||
BOT_USER="robot",
|
||||
BOT_TOKEN="secret",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_aiohttp(monkeypatch):
|
||||
calls = []
|
||||
responses = []
|
||||
monkeypatch.setattr(api_module.aiohttp, "ClientSession", lambda: FakeSession(responses, calls))
|
||||
return responses, calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_balance_sends_auth_in_query(fake_aiohttp):
|
||||
responses, calls = fake_aiohttp
|
||||
responses.append({"code": 200, "message": "", "data": {"balance": 88}})
|
||||
points = make_points_api()
|
||||
|
||||
balance = await points.get_balance("10001")
|
||||
|
||||
assert balance == 88
|
||||
assert not hasattr(points, "db")
|
||||
assert calls[0]["method"] == "GET"
|
||||
assert calls[0]["url"] == "http://xapi.test/bot/points/balance"
|
||||
assert calls[0]["params"] == {"user": "robot", "token": "secret", "user_id": "10001"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_spend_set_send_auth_in_post_body(fake_aiohttp):
|
||||
responses, calls = fake_aiohttp
|
||||
responses.extend(
|
||||
[
|
||||
{"code": 200, "message": "", "data": {"success": True, "balance": 10}},
|
||||
{"code": 200, "message": "", "data": {"success": False, "balance": 7}},
|
||||
{"code": 200, "message": "", "data": {"success": True, "balance": 99}},
|
||||
]
|
||||
)
|
||||
points = make_points_api()
|
||||
|
||||
add_result = await points.add_points("10001", 10, "gacha_sign", "签到")
|
||||
spend_result = await points.spend_points("10001", 5, "horse_race", "下注")
|
||||
set_result = await points.set_points("10001", 99, "admin", "调整")
|
||||
|
||||
assert add_result == (True, 10)
|
||||
assert spend_result == (False, 7)
|
||||
assert set_result == (True, 99)
|
||||
assert [call["method"] for call in calls] == ["POST", "POST", "POST"]
|
||||
assert calls[0]["json"] == {
|
||||
"user": "robot",
|
||||
"token": "secret",
|
||||
"user_id": "10001",
|
||||
"amount": 10,
|
||||
"source": "gacha_sign",
|
||||
"reason": "签到",
|
||||
}
|
||||
assert calls[1]["url"].endswith("/spend")
|
||||
assert calls[2]["url"].endswith("/set")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transactions_and_ranking_return_items_unchanged(fake_aiohttp):
|
||||
responses, calls = fake_aiohttp
|
||||
tx_item = {
|
||||
"id": 1,
|
||||
"user_id": "10001",
|
||||
"amount": 10,
|
||||
"balance_after": 10,
|
||||
"source": "gacha_sign",
|
||||
"reason": "签到",
|
||||
"created_at": "2026-06-20T12:00:00",
|
||||
}
|
||||
ranking_item = {
|
||||
"rank": 1,
|
||||
"user_id": "10001",
|
||||
"points": 10,
|
||||
"total_earned": 10,
|
||||
"total_spent": 0,
|
||||
}
|
||||
responses.extend(
|
||||
[
|
||||
{"code": 200, "message": "", "data": {"items": [tx_item]}},
|
||||
{"code": 200, "message": "", "data": {"items": [ranking_item]}},
|
||||
]
|
||||
)
|
||||
points = make_points_api()
|
||||
|
||||
transactions = await points.get_transactions("10001", limit=5, offset=2)
|
||||
ranking = await points.get_ranking(limit=3, order_by="unknown")
|
||||
|
||||
assert transactions == [tx_item]
|
||||
assert ranking == [ranking_item]
|
||||
assert calls[0]["params"] == {
|
||||
"user": "robot",
|
||||
"token": "secret",
|
||||
"user_id": "10001",
|
||||
"limit": 5,
|
||||
"offset": 2,
|
||||
}
|
||||
assert calls[1]["params"] == {
|
||||
"user": "robot",
|
||||
"token": "secret",
|
||||
"limit": 3,
|
||||
"order_by": "points",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_error_keeps_old_failure_returns(monkeypatch):
|
||||
class FailingSession:
|
||||
async def __aenter__(self):
|
||||
raise aiohttp.ClientError("offline")
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(api_module.aiohttp, "ClientSession", lambda: FailingSession())
|
||||
points = make_points_api()
|
||||
|
||||
assert await points.get_balance("10001") == 0
|
||||
assert await points.add_points("10001", 1, "gacha_sign") == (False, 0)
|
||||
assert await points.spend_points("10001", 1, "horse_race") == (False, 0)
|
||||
assert await points.set_points("10001", 1, "admin") == (False, 0)
|
||||
assert await points.get_transactions("10001") == []
|
||||
assert await points.get_ranking() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_change_request_does_not_call_http(fake_aiohttp):
|
||||
_responses, calls = fake_aiohttp
|
||||
points = make_points_api()
|
||||
|
||||
assert await points.add_points("10001", 0, "gacha_sign") == (False, 0)
|
||||
assert await points.spend_points("", 1, "horse_race") == (False, 0)
|
||||
assert await points.set_points("10001", -1, "admin") == (False, 0)
|
||||
assert calls == []
|
||||
474
tests/test_group_horse_racing_runtime_api.py
Normal file
474
tests/test_group_horse_racing_runtime_api.py
Normal file
@@ -0,0 +1,474 @@
|
||||
"""group_horse_racing 运行时 API 改造测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
import aiosqlite
|
||||
import pytest
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
PLUGIN_DIR = PROJECT_ROOT / "danding_bot" / "plugins" / "group_horse_racing"
|
||||
|
||||
|
||||
def _load_module(full_name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(full_name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[full_name] = module
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _install_nonebot_stubs() -> None:
|
||||
"""安装 shared.py 导入所需的最小 NoneBot 类型桩。"""
|
||||
|
||||
nonebot = sys.modules.setdefault("nonebot", types.ModuleType("nonebot"))
|
||||
adapters = sys.modules.setdefault("nonebot.adapters", types.ModuleType("nonebot.adapters"))
|
||||
onebot = sys.modules.setdefault("nonebot.adapters.onebot", types.ModuleType("nonebot.adapters.onebot"))
|
||||
v11 = types.ModuleType("nonebot.adapters.onebot.v11")
|
||||
|
||||
class Bot:
|
||||
async def get_group_member_info(self, **kwargs):
|
||||
return {}
|
||||
|
||||
class Event:
|
||||
def get_user_id(self):
|
||||
return str(getattr(self, "user_id", ""))
|
||||
|
||||
class GroupMessageEvent(Event):
|
||||
pass
|
||||
|
||||
class PrivateMessageEvent(Event):
|
||||
pass
|
||||
|
||||
class Message(list):
|
||||
pass
|
||||
|
||||
class MessageSegment:
|
||||
@staticmethod
|
||||
def image(data):
|
||||
return {"type": "image", "data": data}
|
||||
|
||||
v11.Bot = Bot
|
||||
v11.Event = Event
|
||||
v11.GroupMessageEvent = GroupMessageEvent
|
||||
v11.PrivateMessageEvent = PrivateMessageEvent
|
||||
v11.Message = Message
|
||||
v11.MessageSegment = MessageSegment
|
||||
sys.modules["nonebot.adapters.onebot.v11"] = v11
|
||||
nonebot.adapters = adapters
|
||||
adapters.onebot = onebot
|
||||
onebot.v11 = v11
|
||||
|
||||
|
||||
def _install_qqpush_stubs() -> None:
|
||||
qqpush_config = types.ModuleType("danding_bot.plugins.danding_qqpush.config")
|
||||
qqpush_image = types.ModuleType("danding_bot.plugins.danding_qqpush.image_render")
|
||||
|
||||
class QqPushConfig:
|
||||
FontPaths = []
|
||||
|
||||
class ImageRenderer:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
def render_to_base64(self, body, title=""):
|
||||
return f"base64://{title}:{body}"
|
||||
|
||||
qqpush_config.Config = QqPushConfig
|
||||
qqpush_image.ImageRenderer = ImageRenderer
|
||||
sys.modules["danding_bot.plugins.danding_qqpush.config"] = qqpush_config
|
||||
sys.modules["danding_bot.plugins.danding_qqpush.image_render"] = qqpush_image
|
||||
|
||||
|
||||
def _install_points_stub() -> None:
|
||||
points_package = types.ModuleType("danding_bot.plugins.danding_points")
|
||||
|
||||
class PointsApi:
|
||||
async def add_points(self, user_id, amount, source, reason=None):
|
||||
return True, 0
|
||||
|
||||
async def spend_points(self, user_id, amount, source, reason=None):
|
||||
return True, 0
|
||||
|
||||
async def set_points(self, user_id, amount, source, reason=None):
|
||||
return True, amount
|
||||
|
||||
async def get_balance(self, user_id):
|
||||
return 0
|
||||
|
||||
points_package.points_api = PointsApi()
|
||||
sys.modules["danding_bot.plugins.danding_points"] = points_package
|
||||
|
||||
|
||||
def load_room_store_modules():
|
||||
"""直接加载 room_store 相关子模块,避免执行插件入口。"""
|
||||
|
||||
package_name = "_horse_racing_room_store_under_test"
|
||||
package = types.ModuleType(package_name)
|
||||
package.__path__ = [str(PLUGIN_DIR)]
|
||||
sys.modules[package_name] = package
|
||||
|
||||
config_module = _load_module(f"{package_name}.config", PLUGIN_DIR / "config.py")
|
||||
models_module = _load_module(f"{package_name}.models", PLUGIN_DIR / "models.py")
|
||||
room_store_module = _load_module(f"{package_name}.room_store", PLUGIN_DIR / "room_store.py")
|
||||
return config_module, models_module, room_store_module
|
||||
|
||||
|
||||
def load_shared_modules():
|
||||
"""加载 shared.py 及其依赖,同时隔离 NoneBot 和外部插件依赖。"""
|
||||
|
||||
_install_nonebot_stubs()
|
||||
_install_qqpush_stubs()
|
||||
_install_points_stub()
|
||||
|
||||
package_name = "_horse_racing_shared_under_test"
|
||||
package = types.ModuleType(package_name)
|
||||
package.__path__ = [str(PLUGIN_DIR)]
|
||||
sys.modules[package_name] = package
|
||||
|
||||
config_module = _load_module(f"{package_name}.config", PLUGIN_DIR / "config.py")
|
||||
models_module = _load_module(f"{package_name}.models", PLUGIN_DIR / "models.py")
|
||||
package.plugin_config = config_module.Config(RACE_RENDER_AS_IMAGE=False, RACE_TICK_INTERVAL=0, RACE_DISTANCE=1)
|
||||
_load_module(f"{package_name}.room_store", PLUGIN_DIR / "room_store.py")
|
||||
_load_module(f"{package_name}.points_service", PLUGIN_DIR / "points_service.py")
|
||||
_load_module(f"{package_name}.race_engine", PLUGIN_DIR / "race_engine.py")
|
||||
_load_module(f"{package_name}.message_service", PLUGIN_DIR / "message_service.py")
|
||||
|
||||
commands_package_name = f"{package_name}.commands"
|
||||
commands_package = types.ModuleType(commands_package_name)
|
||||
commands_package.__path__ = [str(PLUGIN_DIR / "commands")]
|
||||
sys.modules[commands_package_name] = commands_package
|
||||
access_module = types.ModuleType(f"{commands_package_name}.access")
|
||||
access_module.get_event_id = lambda event: str(getattr(event, "user_id", ""))
|
||||
access_module.get_scope = lambda event: f"group_{getattr(event, 'group_id', '')}"
|
||||
async def _check_access(bot, event):
|
||||
return True
|
||||
access_module.check_access = _check_access
|
||||
sys.modules[f"{commands_package_name}.access"] = access_module
|
||||
|
||||
shared_module = _load_module(f"{commands_package_name}.shared", PLUGIN_DIR / "commands" / "shared.py")
|
||||
return config_module, models_module, shared_module
|
||||
|
||||
|
||||
config_module, models_module, room_store_module = load_room_store_modules()
|
||||
Config = config_module.Config
|
||||
RoomStore = room_store_module.RoomStore
|
||||
RaceResult = models_module.RaceResult
|
||||
|
||||
|
||||
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:
|
||||
"""记录赛马 HTTP 请求参数的 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))
|
||||
|
||||
def put(self, url, json=None, timeout=None):
|
||||
self.calls.append({"method": "PUT", "url": url, "json": json, "timeout": timeout})
|
||||
return FakeResponse(self.responses.pop(0))
|
||||
|
||||
|
||||
def success_data(data):
|
||||
return {"code": 200, "message": "", "data": data}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_aiohttp(monkeypatch):
|
||||
calls = []
|
||||
responses = []
|
||||
monkeypatch.setattr(room_store_module.aiohttp, "ClientSession", lambda: FakeSession(responses, calls))
|
||||
return responses, calls
|
||||
|
||||
|
||||
def make_store(tmp_path: Path) -> RoomStore:
|
||||
return RoomStore(
|
||||
Config(
|
||||
RACE_DB_FILE=str(tmp_path / "race.db"),
|
||||
RACE_API_HOST="http://xapi.test/bot/race/",
|
||||
BOT_USER="robot",
|
||||
BOT_TOKEN="secret",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_store_race_history_and_horse_names_use_xapi(fake_aiohttp, tmp_path):
|
||||
responses, calls = fake_aiohttp
|
||||
responses.extend(
|
||||
[
|
||||
success_data({"user_id": "10001", "horse_name": "赤焰"}),
|
||||
success_data({"success": True}),
|
||||
success_data({"race_id": "race-001"}),
|
||||
]
|
||||
)
|
||||
store = make_store(tmp_path)
|
||||
result = RaceResult(
|
||||
race_id="race-001",
|
||||
scope="group_1000",
|
||||
champion_name="赤焰",
|
||||
champion_owner="10001",
|
||||
participants=["赤焰", "青岚"],
|
||||
bet_distribution={"赤焰": 30, "青岚": 10},
|
||||
duration_ticks=8,
|
||||
completed_at=datetime(2026, 6, 20, 10, 0, 0),
|
||||
point_changes={"10001": 170},
|
||||
point_change_summaries={"10001": "大赚特赚"},
|
||||
odds_snapshot={"赤焰": 1.5},
|
||||
)
|
||||
|
||||
horse_name = await store.get_last_horse_name("10001")
|
||||
await store.set_last_horse_name("10001", "青岚")
|
||||
await store.save_race_result(result)
|
||||
|
||||
assert horse_name == "赤焰"
|
||||
assert calls[0]["method"] == "GET"
|
||||
assert calls[0]["url"] == "http://xapi.test/bot/race/horse-name"
|
||||
assert calls[0]["params"] == {"user": "robot", "token": "secret", "user_id": "10001"}
|
||||
assert calls[1]["method"] == "PUT"
|
||||
assert calls[1]["json"] == {"user": "robot", "token": "secret", "user_id": "10001", "horse_name": "青岚"}
|
||||
assert calls[2]["method"] == "POST"
|
||||
assert calls[2]["url"] == "http://xapi.test/bot/race/history"
|
||||
assert calls[2]["json"]["race_id"] == "race-001"
|
||||
assert calls[2]["json"]["participants"] == ["赤焰", "青岚"]
|
||||
assert calls[2]["json"]["odds_snapshot"] == {"赤焰": 1.5}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_snapshots_still_use_local_sqlite_without_race_http(fake_aiohttp, tmp_path):
|
||||
_responses, calls = fake_aiohttp
|
||||
store = make_store(tmp_path)
|
||||
|
||||
room = await store.create_room("group_1000")
|
||||
loaded = store.get_room("group_1000")
|
||||
|
||||
assert loaded is room
|
||||
assert calls == []
|
||||
async with aiosqlite.connect(store.db_path) as db:
|
||||
cursor = await db.execute("SELECT scope, state FROM room_snapshots")
|
||||
rows = await cursor.fetchall()
|
||||
await store.close()
|
||||
assert rows == [("group_1000", "waiting")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_race_api_network_error_keeps_old_failure_shapes(monkeypatch, tmp_path):
|
||||
class FailingSession:
|
||||
async def __aenter__(self):
|
||||
raise aiohttp.ClientError("offline")
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(room_store_module.aiohttp, "ClientSession", lambda: FailingSession())
|
||||
store = make_store(tmp_path)
|
||||
|
||||
assert await store.get_last_horse_name("10001") is None
|
||||
await store.set_last_horse_name("10001", "赤焰")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_race_result_raises_when_xapi_rejects(fake_aiohttp, tmp_path):
|
||||
responses, _calls = fake_aiohttp
|
||||
responses.append({"code": 500, "message": "写入失败", "data": None})
|
||||
store = make_store(tmp_path)
|
||||
result = RaceResult(
|
||||
race_id="race-rejected",
|
||||
scope="group_1000",
|
||||
champion_name="赤焰",
|
||||
champion_owner="10001",
|
||||
participants=["赤焰"],
|
||||
bet_distribution={"赤焰": 0},
|
||||
duration_ticks=1,
|
||||
completed_at=datetime(2026, 6, 20, 10, 0, 0),
|
||||
point_changes={"10001": 170},
|
||||
point_change_summaries={"10001": "大赚特赚"},
|
||||
odds_snapshot={"赤焰": 1.2},
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="赛马赛果写入 xapi 失败"):
|
||||
await store.save_race_result(result)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settle_race_awaits_balances_and_builds_complete_result():
|
||||
_config_module, shared_models, shared = load_shared_modules()
|
||||
Room = shared_models.Room
|
||||
Horse = shared_models.Horse
|
||||
HorseState = shared_models.HorseState
|
||||
Bet = shared_models.Bet
|
||||
|
||||
class FakePointsService:
|
||||
def __init__(self):
|
||||
self.balances = {"10001": 100, "10002": 50, "20001": 200}
|
||||
|
||||
async def get_balance(self, user_id: str) -> int:
|
||||
return self.balances[user_id]
|
||||
|
||||
async def reward_participant(self, user_id: str):
|
||||
self.balances[user_id] += shared.config.PARTICIPANT_REWARD
|
||||
return True, self.balances[user_id]
|
||||
|
||||
async def reward_champion(self, user_id: str):
|
||||
self.balances[user_id] += shared.config.CHAMPION_REWARD
|
||||
return True, self.balances[user_id]
|
||||
|
||||
async def payout_winnings(self, user_id: str, amount: int, odds: float):
|
||||
self.balances[user_id] += max(1, round(amount * odds))
|
||||
return True, self.balances[user_id]
|
||||
|
||||
shared.points_service = FakePointsService()
|
||||
room = Room(scope="group_1000")
|
||||
room.horses = {
|
||||
"赤焰": Horse(owner_id="10001", name="赤焰", index=1, state=HorseState.RACING),
|
||||
"青岚": Horse(owner_id="10002", name="青岚", index=2, state=HorseState.RACING),
|
||||
}
|
||||
room.bets = [Bet(user_id="20001", horse_name="赤焰", amount=30)]
|
||||
room.champion_name = "赤焰"
|
||||
room.tick_count = 7
|
||||
|
||||
settlement = await shared.settle_race(room)
|
||||
|
||||
assert settlement is not None
|
||||
result, odds = settlement
|
||||
assert result.race_id
|
||||
assert result.scope == "group_1000"
|
||||
assert result.participants == ["赤焰", "青岚"]
|
||||
assert result.bet_distribution == {"赤焰": 30, "青岚": 0}
|
||||
assert result.duration_ticks == 7
|
||||
assert result.odds_snapshot == odds
|
||||
assert result.point_changes == {"10001": 170, "10002": 20, "20001": 36}
|
||||
assert all(isinstance(value, int) for value in result.point_changes.values())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_race_with_settlement_saves_result_before_deleting_room():
|
||||
_config_module, shared_models, shared = load_shared_modules()
|
||||
Room = shared_models.Room
|
||||
Horse = shared_models.Horse
|
||||
HorseState = shared_models.HorseState
|
||||
Bet = shared_models.Bet
|
||||
|
||||
class FakeRaceEngine:
|
||||
def __init__(self):
|
||||
self.stopped = []
|
||||
|
||||
def tick(self, room):
|
||||
room.tick_count = 3
|
||||
return [room.horses["赤焰"]]
|
||||
|
||||
def format_progress(self, room):
|
||||
return "progress"
|
||||
|
||||
def determine_champion(self, horses):
|
||||
return horses[0]
|
||||
|
||||
def stop_race(self, scope):
|
||||
self.stopped.append(scope)
|
||||
|
||||
class FakePointsService:
|
||||
def __init__(self):
|
||||
self.balances = {"10001": 100, "10002": 50, "20001": 200}
|
||||
|
||||
async def get_balance(self, user_id: str) -> int:
|
||||
return self.balances[user_id]
|
||||
|
||||
async def reward_participant(self, user_id: str):
|
||||
self.balances[user_id] += shared.config.PARTICIPANT_REWARD
|
||||
return True, self.balances[user_id]
|
||||
|
||||
async def reward_champion(self, user_id: str):
|
||||
self.balances[user_id] += shared.config.CHAMPION_REWARD
|
||||
return True, self.balances[user_id]
|
||||
|
||||
async def payout_winnings(self, user_id: str, amount: int, odds: float):
|
||||
self.balances[user_id] += max(1, round(amount * odds))
|
||||
return True, self.balances[user_id]
|
||||
|
||||
class FakeMessageService:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
self.cleared = []
|
||||
|
||||
async def send_with_recall(self, bot, scope, message_type, message):
|
||||
self.sent.append((scope, message_type, str(message)))
|
||||
return "msg"
|
||||
|
||||
async def recall_previous_of_type(self, bot, scope, message_type):
|
||||
return None
|
||||
|
||||
def clear_pending_recalls(self, scope):
|
||||
self.cleared.append(scope)
|
||||
|
||||
class FakeRoomStore:
|
||||
def __init__(self):
|
||||
self.saved = []
|
||||
self.deleted = []
|
||||
|
||||
async def save_race_result(self, result):
|
||||
self.saved.append(result)
|
||||
|
||||
def delete_room(self, scope):
|
||||
self.deleted.append(scope)
|
||||
|
||||
shared.race_engine = FakeRaceEngine()
|
||||
shared.points_service = FakePointsService()
|
||||
shared.message_service = FakeMessageService()
|
||||
shared.room_store = FakeRoomStore()
|
||||
shared.config.RACE_TICK_INTERVAL = 0
|
||||
|
||||
room = Room(scope="group_1000")
|
||||
room.horses = {
|
||||
"赤焰": Horse(owner_id="10001", name="赤焰", index=1, state=HorseState.RACING),
|
||||
"青岚": Horse(owner_id="10002", name="青岚", index=2, state=HorseState.RACING),
|
||||
}
|
||||
room.bets = [Bet(user_id="20001", horse_name="赤焰", amount=30)]
|
||||
|
||||
await shared.run_race_with_settlement(object(), room, "group_1000")
|
||||
|
||||
assert len(shared.room_store.saved) == 1
|
||||
saved = shared.room_store.saved[0]
|
||||
assert saved.scope == "group_1000"
|
||||
assert saved.duration_ticks == 3
|
||||
assert saved.odds_snapshot == {"赤焰": 1.2, "青岚": 1.2}
|
||||
assert shared.room_store.deleted == ["group_1000"]
|
||||
assert shared.race_engine.stopped == ["group_1000"]
|
||||
assert shared.message_service.cleared == ["group_1000"]
|
||||
247
tests/test_onmyoji_gacha_http_api.py
Normal file
247
tests/test_onmyoji_gacha_http_api.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""onmyoji_gacha HTTP DataManager 测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import aiohttp
|
||||
import importlib.util
|
||||
import inspect
|
||||
import pytest
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_gacha_modules():
|
||||
"""直接加载 onmyoji_gacha 子模块,避免测试环境执行 nonebot 插件入口。"""
|
||||
|
||||
plugin_dir = Path(__file__).resolve().parents[1] / "danding_bot" / "plugins" / "onmyoji_gacha"
|
||||
package_name = "_onmyoji_gacha_under_test"
|
||||
package = types.ModuleType(package_name)
|
||||
package.__path__ = [str(plugin_dir)]
|
||||
sys.modules[package_name] = package
|
||||
|
||||
for module_name in ("config", "data_manager", "gacha"):
|
||||
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}.data_manager"],
|
||||
sys.modules[f"{package_name}.gacha"],
|
||||
)
|
||||
|
||||
|
||||
config_module, data_manager_module, gacha_module = load_gacha_modules()
|
||||
Config = config_module.Config
|
||||
DataManager = data_manager_module.DataManager
|
||||
GachaSystem = gacha_module.GachaSystem
|
||||
|
||||
|
||||
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):
|
||||
calls = []
|
||||
responses = []
|
||||
monkeypatch.setattr(data_manager_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",
|
||||
)
|
||||
monkeypatch.setattr(data_manager_module, "config", test_config)
|
||||
monkeypatch.setattr(gacha_module, "config", test_config)
|
||||
return responses, calls
|
||||
|
||||
|
||||
def success_data(data):
|
||||
return {"code": 200, "message": "", "data": data}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shikigami_cache_and_draw_send_auth_to_xapi(fake_aiohttp):
|
||||
responses, calls = fake_aiohttp
|
||||
responses.extend(
|
||||
[
|
||||
success_data(
|
||||
{
|
||||
"items": [
|
||||
{"id": 1, "name": "灯笼鬼", "rarity": "R", "image_path": "/r/灯笼鬼.png"},
|
||||
{"id": 3, "name": "茨木童子", "rarity": "SSR", "image_path": "/ssr/茨木童子.png"},
|
||||
]
|
||||
}
|
||||
),
|
||||
success_data(
|
||||
{
|
||||
"success": True,
|
||||
"rarity": "SSR",
|
||||
"name": "茨木童子",
|
||||
"image_path": "/ssr/茨木童子.png",
|
||||
"image_url": "/ssr/茨木童子.png",
|
||||
"draws_left": 2,
|
||||
"unlocked_achievements": ["no_ssr_60"],
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
manager = DataManager()
|
||||
|
||||
grouped = await manager.refresh_shikigami_data()
|
||||
draw_result = await manager.record_draw_result("10001", "SSR", grouped["SSR"][0])
|
||||
|
||||
assert grouped["SSR"][0]["image_url"] == "/ssr/茨木童子.png"
|
||||
assert draw_result["unlocked_achievements"] == ["no_ssr_60"]
|
||||
assert calls[0]["method"] == "GET"
|
||||
assert calls[0]["url"] == "http://xapi.test/bot/gacha/shikigami"
|
||||
assert calls[0]["params"] == {"user": "robot", "token": "secret"}
|
||||
assert calls[1]["method"] == "POST"
|
||||
assert calls[1]["json"] == {
|
||||
"user": "robot",
|
||||
"token": "secret",
|
||||
"user_id": "10001",
|
||||
"shikigami_id": 3,
|
||||
"rarity": "SSR",
|
||||
"name": "茨木童子",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gacha_system_draw_keeps_probability_local_and_uses_xapi(fake_aiohttp, monkeypatch):
|
||||
responses, calls = fake_aiohttp
|
||||
responses.extend(
|
||||
[
|
||||
success_data({"draws_left": 3, "daily_limit": 3}),
|
||||
success_data({"items": [{"id": 3, "name": "茨木童子", "rarity": "SSR", "image_path": "/ssr/茨木童子.png"}]}),
|
||||
success_data(
|
||||
{
|
||||
"success": True,
|
||||
"rarity": "SSR",
|
||||
"name": "茨木童子",
|
||||
"image_path": "/ssr/茨木童子.png",
|
||||
"image_url": "/ssr/茨木童子.png",
|
||||
"draws_left": 2,
|
||||
"unlocked_achievements": [],
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
manager = DataManager()
|
||||
monkeypatch.setattr(gacha_module, "data_manager", manager)
|
||||
system = GachaSystem()
|
||||
monkeypatch.setattr(system, "_draw_rarity", lambda user_id=None: "SSR")
|
||||
|
||||
result = await system.draw("10001")
|
||||
|
||||
assert result == {
|
||||
"success": True,
|
||||
"rarity": "SSR",
|
||||
"name": "茨木童子",
|
||||
"image_url": "/ssr/茨木童子.png",
|
||||
"draws_left": 2,
|
||||
"unlocked_achievements": [],
|
||||
}
|
||||
assert [call["url"].rsplit("/", 1)[-1] for call in calls] == ["draws-left", "shikigami", "draw"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_triple_sign_in_claim_and_query_shapes(fake_aiohttp):
|
||||
responses, calls = fake_aiohttp
|
||||
responses.extend(
|
||||
[
|
||||
success_data({"success": True, "results": [], "draws_left": 0, "unlocked_achievements": ["no_ssr_60"]}),
|
||||
success_data({"success": True, "signed_already": True}),
|
||||
success_data({"success": True, "reward_type": "天卡"}),
|
||||
success_data({"success": True, "total_draws": 1, "R_count": 1, "SR_count": 0, "SSR_count": 0, "SP_count": 0, "recent_draws": []}),
|
||||
success_data({"success": True, "date": "2026-06-20", "stats": {"total_users": 1}}),
|
||||
success_data({"items": [{"user_id": "10001", "total_draws": 1, "R_count": 1, "SR_count": 0, "SSR_count": 0, "SP_count": 0, "ssr_sp_total": 0}]}),
|
||||
success_data({"achievements": {"no_ssr_60": {"unlocked_date": "2026-06-20", "reward_claimed": False}}, "progress": {"no_ssr_streak": 60}}),
|
||||
success_data({"success": True, "date": "2026-06-20", "records": [], "total_count": 0}),
|
||||
]
|
||||
)
|
||||
manager = DataManager()
|
||||
draws = [
|
||||
{"id": 1, "name": "灯笼鬼", "rarity": "R", "image_url": "/r/灯笼鬼.png"},
|
||||
{"id": 2, "name": "雪女", "rarity": "SR", "image_url": "/sr/雪女.png"},
|
||||
{"id": 3, "name": "茨木童子", "rarity": "SSR", "image_url": "/ssr/茨木童子.png"},
|
||||
]
|
||||
|
||||
triple = await manager.record_triple_draw_result("10001", draws)
|
||||
signed = await manager.record_sign_in("10001", 20)
|
||||
claimed = await manager.claim_achievement_reward("10001", "no_ssr_60")
|
||||
stats = await manager.get_user_stats("10001")
|
||||
daily = await manager.get_daily_stats("2026-06-20")
|
||||
rank = await manager.get_rank()
|
||||
achievements = await manager.get_user_achievements("10001")
|
||||
records = await manager.get_daily_records("2026-06-20")
|
||||
|
||||
assert triple["unlocked_achievements"] == ["no_ssr_60"]
|
||||
assert signed is False
|
||||
assert claimed is True
|
||||
assert stats["success"] is True
|
||||
assert daily["date"] == "2026-06-20"
|
||||
assert rank[0]["user_id"] == "10001"
|
||||
assert "unlocked" in achievements and "progress" in achievements
|
||||
assert records["total_count"] == 0
|
||||
assert calls[0]["json"]["draws"][0] == {"shikigami_id": 1, "rarity": "R", "name": "灯笼鬼"}
|
||||
assert calls[1]["json"]["points_awarded"] == 20
|
||||
assert calls[2]["json"]["achievement_id"] == "no_ssr_60"
|
||||
assert calls[3]["params"]["user_id"] == "10001"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_error_keeps_failure_shapes_and_no_sqlite(monkeypatch):
|
||||
class FailingSession:
|
||||
async def __aenter__(self):
|
||||
raise aiohttp.ClientError("offline")
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(data_manager_module.aiohttp, "ClientSession", lambda: FailingSession())
|
||||
manager = DataManager()
|
||||
|
||||
assert await manager.get_draws_left("10001") == 0
|
||||
assert await manager.record_sign_in("10001", 20) is False
|
||||
assert await manager.get_rank() == []
|
||||
assert "sqlite3" not in inspect.getsource(data_manager_module)
|
||||
Reference in New Issue
Block a user