mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
test: add gateway WebSocket manager tests
This commit is contained in:
@@ -1,39 +1,44 @@
|
||||
schema: spec-driven
|
||||
|
||||
# Project context
|
||||
# 项目上下文
|
||||
context: |
|
||||
Tech stack: Python 3.12, anyio (async), FastAPI, pytest, ruff, uv
|
||||
Domain: Multi-platform LLM chatbot framework (QQ, Telegram, Discord, etc.)
|
||||
Architecture: Protocol-based system (LSP, MCP, ACP, ABP)
|
||||
技术栈: Python 3.12, anyio (异步), FastAPI, pytest, ruff, uv
|
||||
领域: 多平台 LLM 聊天机器人框架 (QQ, Telegram, Discord 等)
|
||||
架构: 基于协议的系统 (LSP, MCP, ACP, ABP)
|
||||
|
||||
# Per-artifact rules
|
||||
# artifact 规则
|
||||
rules:
|
||||
tasks:
|
||||
- Break tasks into chunks of max 2 hours
|
||||
- Always write tests for new functionality
|
||||
- Run ruff and pytest before committing
|
||||
- 任务拆分为最大2小时块
|
||||
- 新功能必须编写测试
|
||||
- 提交前运行 ruff 和 pytest
|
||||
code:
|
||||
- Use absolute imports via `astrbot.` prefix
|
||||
- Type hints required (no Any when possible)
|
||||
- Comments in English
|
||||
- 使用绝对导入,前缀 `astrbot.`
|
||||
- 必须有类型标注(尽量避免 Any)
|
||||
- 注释使用英文
|
||||
commits:
|
||||
- Use conventional commits (feat:, fix:, docs:, refactor:, test:, chore:)
|
||||
- 使用 conventional commits (feat:, fix:, docs:, refactor:, test:, chore:)
|
||||
|
||||
# Project directives (最高旨意)
|
||||
# 项目最高旨意
|
||||
directives:
|
||||
architecture: |
|
||||
New architecture goes in _internal package to avoid branch conflicts.
|
||||
Protocol system: LSP (client), MCP (client+server), ACP (client+server), ABP (client+server).
|
||||
Runtime coordinates protocols, Gateway provides FastAPI backend.
|
||||
Stars are plugins - Galaxy is the collection of stars.
|
||||
新架构放在 _internal 包中,避免分支冲突。
|
||||
协议系统: LSP (客户端), MCP (客户端+服务端), ACP (客户端+服务端), ABP (客户端+服务端)。
|
||||
Runtime 协调协议,Gateway 提供 FastAPI 后端。
|
||||
Stars 是插件 - Galaxy 是星星的集合。
|
||||
async_library: |
|
||||
Use anyio as the async library (not asyncio).
|
||||
使用 anyio 作为异步库(不是 asyncio)。
|
||||
coverage: |
|
||||
Coverage testing must be improved.
|
||||
必须提高覆盖率测试。
|
||||
type_checking: |
|
||||
Code must pass `uvx ty check` (astral's pyright).
|
||||
Avoid using Any and cast.
|
||||
代码必须通过 `uvx ty check` (astral 的 pyright)。
|
||||
避免使用 Any 和 cast。
|
||||
code_style: |
|
||||
Code must pass `ruff check .` and `ruff format .`.
|
||||
代码必须通过 `ruff check .` 和 `ruff format .`。
|
||||
execution: |
|
||||
Always use `uv run` for running Python scripts, not bare `python`.
|
||||
运行 Python 脚本必须使用 `uv run`,不要用裸 python。
|
||||
goal: |
|
||||
最终目标:实现 ABP 协议
|
||||
代码美观
|
||||
类型标注完美
|
||||
将现有功能迁移到新架构
|
||||
|
||||
248
tests/unit/test_internal/test_gateway.py
Normal file
248
tests/unit/test_internal/test_gateway.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
Tests for AstrBot Gateway - FastAPI-based HTTP/WebSocket server.
|
||||
|
||||
Gateway provides:
|
||||
- HTTP REST API (stats, inspector, config)
|
||||
- WebSocket for real-time events
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestWebSocketManager:
|
||||
"""Test suite for WebSocketManager."""
|
||||
|
||||
@pytest.fixture
|
||||
def ws_manager(self):
|
||||
"""Create a WebSocketManager instance."""
|
||||
from astrbot._internal.geteway.ws_manager import WebSocketManager
|
||||
|
||||
return WebSocketManager()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_websocket(self):
|
||||
"""Create a mock WebSocket."""
|
||||
ws = AsyncMock()
|
||||
ws.accept = AsyncMock()
|
||||
return ws
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_accepts_and_registers(self, ws_manager, mock_websocket):
|
||||
"""connect() should accept and register the WebSocket."""
|
||||
await ws_manager.connect(mock_websocket)
|
||||
|
||||
mock_websocket.accept.assert_called_once()
|
||||
assert mock_websocket in ws_manager._connections
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_removes_connection(self, ws_manager, mock_websocket):
|
||||
"""disconnect() should remove the WebSocket from connections."""
|
||||
await ws_manager.connect(mock_websocket)
|
||||
await ws_manager.disconnect(mock_websocket)
|
||||
|
||||
assert mock_websocket not in ws_manager._connections
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_json_success(self, ws_manager, mock_websocket):
|
||||
"""send_json() should send JSON data to WebSocket."""
|
||||
await ws_manager.connect(mock_websocket)
|
||||
|
||||
data = {"type": "test", "data": {"key": "value"}}
|
||||
await ws_manager.send_json(mock_websocket, data)
|
||||
|
||||
mock_websocket.send_json.assert_called_once_with(data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_json_disconnects_on_error(self, ws_manager, mock_websocket):
|
||||
"""send_json() should disconnect on error."""
|
||||
await ws_manager.connect(mock_websocket)
|
||||
mock_websocket.send_json.side_effect = Exception("Connection error")
|
||||
|
||||
await ws_manager.send_json(mock_websocket, {})
|
||||
|
||||
assert mock_websocket not in ws_manager._connections
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_sends_to_all(self, ws_manager):
|
||||
"""broadcast() should send to all registered connections."""
|
||||
ws1 = AsyncMock()
|
||||
ws1.accept = AsyncMock()
|
||||
ws2 = AsyncMock()
|
||||
ws2.accept = AsyncMock()
|
||||
|
||||
await ws_manager.connect(ws1)
|
||||
await ws_manager.connect(ws2)
|
||||
|
||||
data = {"type": "broadcast"}
|
||||
await ws_manager.broadcast(data)
|
||||
|
||||
ws1.send_json.assert_called_once_with(data)
|
||||
ws2.send_json.assert_called_once_with(data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_removes_failed_connections(self, ws_manager):
|
||||
"""broadcast() should remove connections that fail."""
|
||||
ws1 = AsyncMock()
|
||||
ws1.accept = AsyncMock()
|
||||
ws1.send_json.side_effect = Exception("Connection error")
|
||||
|
||||
ws2 = AsyncMock()
|
||||
ws2.accept = AsyncMock()
|
||||
|
||||
await ws_manager.connect(ws1)
|
||||
await ws_manager.connect(ws2)
|
||||
|
||||
await ws_manager.broadcast({})
|
||||
|
||||
assert ws1 not in ws_manager._connections
|
||||
assert ws2 in ws_manager._connections
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_to_text(self, ws_manager, mock_websocket):
|
||||
"""send_to() should send text when message is string."""
|
||||
await ws_manager.connect(mock_websocket)
|
||||
|
||||
await ws_manager.send_to(mock_websocket, "hello world")
|
||||
|
||||
mock_websocket.send_text.assert_called_once_with("hello world")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_to_json(self, ws_manager, mock_websocket):
|
||||
"""send_to() should send JSON when message is dict."""
|
||||
await ws_manager.connect(mock_websocket)
|
||||
|
||||
message = {"type": "test"}
|
||||
await ws_manager.send_to(mock_websocket, message)
|
||||
|
||||
mock_websocket.send_json.assert_called_once_with(message)
|
||||
|
||||
def test_connection_count(self, ws_manager, mock_websocket):
|
||||
"""connection_count should return number of active connections."""
|
||||
assert ws_manager.connection_count == 0
|
||||
|
||||
# Note: can't use await in sync test, so just check property
|
||||
ws_manager._connections.add(mock_websocket)
|
||||
assert ws_manager.connection_count == 1
|
||||
|
||||
|
||||
class TestAstrbotGateway:
|
||||
"""Test suite for AstrbotGateway."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_orchestrator(self):
|
||||
"""Create a mock orchestrator."""
|
||||
orch = MagicMock()
|
||||
orch.abp = MagicMock()
|
||||
orch.abp._stars = {"test-star": MagicMock()}
|
||||
orch.lsp = MagicMock()
|
||||
orch.lsp._connected = True
|
||||
orch.mcp = MagicMock()
|
||||
orch.mcp.session = MagicMock()
|
||||
orch.acp = MagicMock()
|
||||
orch.acp._clients = []
|
||||
return orch
|
||||
|
||||
@pytest.fixture
|
||||
def gateway(self, mock_orchestrator):
|
||||
"""Create a gateway instance."""
|
||||
from astrbot._internal.geteway.server import AstrbotGateway
|
||||
|
||||
return AstrbotGateway(mock_orchestrator)
|
||||
|
||||
def test_gateway_initializes_with_orchestrator(self, gateway, mock_orchestrator):
|
||||
"""Gateway should store orchestrator reference."""
|
||||
assert gateway.orchestrator is mock_orchestrator
|
||||
|
||||
def test_gateway_has_websocket_manager(self, gateway):
|
||||
"""Gateway should have a WebSocketManager."""
|
||||
assert hasattr(gateway, "ws_manager")
|
||||
assert gateway.ws_manager is not None
|
||||
|
||||
def test_default_host_and_port(self, gateway):
|
||||
"""Gateway should have default host and port."""
|
||||
assert gateway._host == "0.0.0.0"
|
||||
assert gateway._port == 8765
|
||||
|
||||
def test_set_listen_address(self, gateway):
|
||||
"""set_listen_address should update host and port."""
|
||||
gateway.set_listen_address("127.0.0.1", 9000)
|
||||
|
||||
assert gateway._host == "127.0.0.1"
|
||||
assert gateway._port == 9000
|
||||
|
||||
|
||||
class TestGatewayRouteHandlers:
|
||||
"""Test gateway REST/WebSocket route handlers."""
|
||||
|
||||
@pytest.fixture
|
||||
def gateway_with_mock_orchestrator(self):
|
||||
"""Create gateway with mock orchestrator for route testing."""
|
||||
from astrbot._internal.geteway.server import AstrbotGateway
|
||||
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.abp = MagicMock()
|
||||
mock_orch.abp._stars = {"star-1": MagicMock(), "star-2": MagicMock()}
|
||||
mock_orch.lsp = MagicMock()
|
||||
mock_orch.lsp._connected = True
|
||||
mock_orch.mcp = MagicMock()
|
||||
mock_orch.mcp.session = MagicMock()
|
||||
mock_orch.acp = MagicMock()
|
||||
mock_orch.acp._clients = []
|
||||
|
||||
return AstrbotGateway(mock_orch)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_stars(self, gateway_with_mock_orchestrator):
|
||||
"""_list_stars should return list of star names."""
|
||||
result = await gateway_with_mock_orchestrator._list_stars()
|
||||
|
||||
assert len(result) == 2
|
||||
star_names = [s["name"] for s in result]
|
||||
assert "star-1" in star_names
|
||||
assert "star-2" in star_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_star_detail(self, gateway_with_mock_orchestrator):
|
||||
"""_get_star_detail should return star details."""
|
||||
result = await gateway_with_mock_orchestrator._get_star_detail("star-1")
|
||||
|
||||
assert result["name"] == "star-1"
|
||||
assert result["status"] == "active"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_star_detail_not_found(self, gateway_with_mock_orchestrator):
|
||||
"""_get_star_detail should return error for non-existent star."""
|
||||
result = await gateway_with_mock_orchestrator._get_star_detail("non-existent")
|
||||
|
||||
assert "error" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_ws_message_ping(self, gateway_with_mock_orchestrator):
|
||||
"""_handle_ws_message should respond to ping."""
|
||||
result = await gateway_with_mock_orchestrator._handle_ws_message(
|
||||
{"type": "ping", "data": {}}
|
||||
)
|
||||
|
||||
assert result == {"type": "pong", "data": {}}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_ws_message_unknown_type(self, gateway_with_mock_orchestrator):
|
||||
"""_handle_ws_message should return error for unknown type."""
|
||||
result = await gateway_with_mock_orchestrator._handle_ws_message(
|
||||
{"type": "unknown", "data": {}}
|
||||
)
|
||||
|
||||
assert result["type"] == "error"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_info(self, gateway_with_mock_orchestrator):
|
||||
"""_get_memory_info should return memory stats."""
|
||||
result = await gateway_with_mock_orchestrator._get_memory_info()
|
||||
|
||||
assert "gc_objects" in result
|
||||
assert "python_memory" in result
|
||||
Reference in New Issue
Block a user