- R1: /memos WS command tolerates malformed reminder_time (J1 gap) - R2: handle_interrupt no longer cancels global CLI executor - R3: unregister closes websocket + cancels in-flight processing tasks - tests: 6 new cases in test_l_fixes.py, 493 passed on Windows - docs: round-6 audit report section 16, README sync (all 72 findings fixed)
158 lines
6.4 KiB
Python
158 lines
6.4 KiB
Python
"""aibox 第六轮审计(Batch L)修复回归测试(2026-08-20)
|
||
|
||
R1:/memos WS 命令对畸形 reminder_time 不再整体报错(J1 加固漏点)
|
||
R2:handle_interrupt 只取消本会话 AI,不再触碰 CLI 主 AI 的全局 executor
|
||
R3:unregister 补齐连接回收(关闭 websocket + 取消在途处理任务)
|
||
"""
|
||
|
||
import asyncio
|
||
import sys
|
||
import types
|
||
from unittest import mock
|
||
|
||
if "websockets" not in sys.modules:
|
||
sys.modules["websockets"] = types.ModuleType("websockets")
|
||
|
||
import aibox # noqa: E402
|
||
|
||
|
||
def _make_handler():
|
||
handler = aibox.WebSocketHandler(ai_manager=mock.MagicMock())
|
||
return handler
|
||
|
||
|
||
# ==================== R1:/memos 畸形时间容错 ====================
|
||
|
||
class TestR1MemosCommand:
|
||
def test_malformed_reminder_time_shows_pending(self):
|
||
"""存量脏数据(畸形 reminder_time):/memos 按"待执行"展示,不抛 ValueError"""
|
||
handler = _make_handler()
|
||
try:
|
||
handler.ai_manager.memo_db.get_all_memos.return_value = [
|
||
{"id": 1, "title": "脏数据任务", "reminder_time": "not-a-time",
|
||
"repeat_type": "none", "repeat_interval": 0, "is_immediate": False},
|
||
{"id": 2, "title": "正常过期任务", "reminder_time": "2020-01-01 00:00:00",
|
||
"repeat_type": "none", "repeat_interval": 0, "is_immediate": False},
|
||
]
|
||
sent = []
|
||
|
||
async def fake_send(ws, msg):
|
||
sent.append(msg)
|
||
|
||
handler.send_message = fake_send
|
||
ws = mock.AsyncMock()
|
||
asyncio.run(handler.handle_command(ws, {"command": "/memos"}))
|
||
|
||
results = [m for m in sent if m.get("type") == "command_result"]
|
||
errors = [m for m in sent if m.get("type") == "error"]
|
||
assert len(results) == 1
|
||
assert not errors # 不再落入顶层兜底"处理失败"
|
||
content = results[0]["content"]
|
||
assert "脏数据任务" in content and "正常过期任务" in content
|
||
finally:
|
||
handler.shutdown()
|
||
|
||
def test_valid_reminder_time_still_marks_overdue(self):
|
||
"""合法过期时间仍标 overdue(语义不回归)"""
|
||
handler = _make_handler()
|
||
try:
|
||
handler.ai_manager.memo_db.get_all_memos.return_value = [
|
||
{"id": 1, "title": "过期任务", "reminder_time": "2020-01-01 00:00:00",
|
||
"repeat_type": "none", "repeat_interval": 0, "is_immediate": False},
|
||
{"id": 2, "title": "未来任务", "reminder_time": "2099-01-01 00:00:00",
|
||
"repeat_type": "none", "repeat_interval": 0, "is_immediate": False},
|
||
]
|
||
sent = []
|
||
|
||
async def fake_send(ws, msg):
|
||
sent.append(msg)
|
||
|
||
handler.send_message = fake_send
|
||
ws = mock.AsyncMock()
|
||
asyncio.run(handler.handle_command(ws, {"command": "/memos"}))
|
||
|
||
content = next(m["content"] for m in sent
|
||
if m.get("type") == "command_result")
|
||
overdue = aibox.i18n.get('memo_overdue')
|
||
pending = aibox.i18n.get('memo_pending')
|
||
overdue_line = next(l for l in content.splitlines() if "过期任务" in l)
|
||
pending_line = next(l for l in content.splitlines() if "未来任务" in l)
|
||
assert overdue in overdue_line and pending not in overdue_line
|
||
assert pending in pending_line and overdue not in pending_line
|
||
finally:
|
||
handler.shutdown()
|
||
|
||
|
||
# ==================== R2:interrupt 不再误杀 CLI 主 AI 子进程 ====================
|
||
|
||
class TestR2InterruptScope:
|
||
def test_interrupt_does_not_touch_global_executor(self):
|
||
"""WS 中断不再取消 ai_manager.command_executor(CLI 主 AI 的执行器)"""
|
||
handler = _make_handler()
|
||
try:
|
||
ws = mock.AsyncMock()
|
||
asyncio.run(handler.handle_interrupt(ws, {}))
|
||
handler.ai_manager.command_executor.cancel.assert_not_called()
|
||
finally:
|
||
handler.shutdown()
|
||
|
||
def test_interrupt_still_cancels_session_ai(self):
|
||
"""本会话 AI 仍被正常取消(中断能力不回归)"""
|
||
handler = _make_handler()
|
||
try:
|
||
ws = mock.AsyncMock()
|
||
ai = mock.MagicMock()
|
||
handler.client_sessions[ws] = {"ai": ai, "is_processing": True}
|
||
asyncio.run(handler.handle_interrupt(ws, {}))
|
||
ai.cancel.assert_called_once()
|
||
handler.ai_manager.command_executor.cancel.assert_not_called()
|
||
finally:
|
||
handler.shutdown()
|
||
|
||
|
||
# ==================== R3:unregister 补齐连接回收 ====================
|
||
|
||
class TestR3UnregisterCleanup:
|
||
def test_unregister_closes_websocket_and_cancels_tasks(self):
|
||
"""broadcast 超时路径走 unregister 后:websocket 被关闭、在途任务被取消、登记清空"""
|
||
handler = _make_handler()
|
||
try:
|
||
ws = mock.AsyncMock()
|
||
|
||
async def scenario():
|
||
task = asyncio.create_task(asyncio.sleep(30))
|
||
handler.connected_clients.add(ws)
|
||
handler.client_sessions[ws] = {"ai": None, "is_processing": True}
|
||
handler._processing_tasks[ws] = {task}
|
||
await handler.unregister(ws)
|
||
await asyncio.sleep(0) # 让 cancel 传播
|
||
assert task.done() # 取消即视为完成(cancelled 或已收尾)
|
||
|
||
asyncio.run(scenario())
|
||
assert ws.close.called
|
||
assert ws not in handler.connected_clients
|
||
assert ws not in handler.client_sessions
|
||
assert ws not in handler._processing_tasks
|
||
finally:
|
||
handler.shutdown()
|
||
|
||
def test_unregister_idempotent_and_mock_safe(self):
|
||
"""重复注销幂等;close 抛异常的对象不炸(AttributeError 被兜底)"""
|
||
class _BadCloseWS:
|
||
remote_address = ("127.0.0.1", 1)
|
||
|
||
async def close(self):
|
||
raise AttributeError("no close")
|
||
|
||
handler = _make_handler()
|
||
try:
|
||
ws = _BadCloseWS()
|
||
handler.connected_clients.add(ws)
|
||
handler.client_sessions[ws] = {"ai": None}
|
||
asyncio.run(handler.unregister(ws))
|
||
asyncio.run(handler.unregister(ws)) # 二次注销不抛
|
||
assert ws not in handler.connected_clients
|
||
assert ws not in handler.client_sessions
|
||
finally:
|
||
handler.shutdown()
|