test: add gateway WebSocket manager tests

This commit is contained in:
LIghtJUNction
2026-03-23 21:49:19 +08:00
parent 20886c3855
commit 9058505593
2 changed files with 276 additions and 23 deletions

View File

@@ -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 协议
代码美观
类型标注完美
将现有功能迁移到新架构

View 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