feat(cloud): harden multi-tenant runtime resources

This commit is contained in:
Junyan Qin
2026-07-29 11:32:26 +08:00
parent 32abbb636f
commit ae85ac2b16
211 changed files with 14963 additions and 1968 deletions
@@ -68,6 +68,25 @@ async def test_connection_listener_only_suppresses_exact_duplicates():
]
@pytest.mark.asyncio
async def test_connection_event_cache_is_bounded():
adapter, _ = _make_adapter()
for index in range(150):
await adapter._on_websocket_connection(aiocqhttp.Event({'self_id': index, 'time': index}))
assert len(adapter.on_websocket_connection_event_cache) == 100
def test_group_lookup_caches_are_bounded():
converter = AiocqhttpEventConverter()
converter._group_name_cache = {index: (str(index), 10_000.0) for index in range(5000)}
converter._prune_caches(1.0)
assert len(converter._group_name_cache) == 4096
def test_unregister_listener_removes_registered_wrapper():
adapter, _ = _make_adapter()
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import contextlib
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -8,7 +9,10 @@ import pytest
from langbot.pkg.api.http.authz import WorkspaceRequiredError
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.entity.persistence.bot import Bot
from langbot.pkg.platform.botmgr import PlatformManager, RuntimeBot
from langbot.pkg.workspace.entities import WorkspaceExecutionBinding
from langbot.pkg.workspace.errors import WorkspaceInvariantError
import langbot_plugin.api.entities.builtin.platform.events as platform_events
@@ -83,7 +87,220 @@ async def test_public_route_key_resolves_bound_runtime_and_rejects_non_opaque_in
@pytest.mark.asyncio
async def test_stale_runtime_generation_is_not_returned(manager):
assert await manager.get_bot_by_uuid(_context(WORKSPACE_A, BOT_A, generation=5), BOT_A) is None
with pytest.raises(ValueError, match='stale'):
await manager.get_bot_by_uuid(_context(WORKSPACE_A, BOT_A, generation=5), BOT_A)
@pytest.mark.asyncio
async def test_generation_advance_shuts_down_and_prunes_old_workspace_bots():
class NoGlobalIterationDict(dict):
def __iter__(self):
raise AssertionError('generation advance scanned every bot runtime')
def items(self):
raise AssertionError('generation advance scanned every bot runtime')
def values(self):
raise AssertionError('generation advance scanned every bot runtime')
manager = PlatformManager(SimpleNamespace())
old_bot = SimpleNamespace(
workspace_uuid=WORKSPACE_A,
placement_generation=4,
enable=True,
shutdown=AsyncMock(),
)
other_bot = SimpleNamespace(
workspace_uuid=WORKSPACE_B,
placement_generation=4,
enable=True,
shutdown=AsyncMock(),
)
unrelated_bots = [
SimpleNamespace(
workspace_uuid=f'workspace-{index}',
placement_generation=4,
enable=False,
shutdown=AsyncMock(),
)
for index in range(1_000)
]
manager.bots = [old_bot, other_bot, *unrelated_bots]
old_context = _context(WORKSPACE_A, BOT_A, generation=4)
next_context = _context(WORKSPACE_A, BOT_A, generation=5)
await manager._observe_execution_context(old_context)
manager._bots_by_key = NoGlobalIterationDict(manager._bots_by_key)
await manager._observe_execution_context(next_context)
manager._bots_by_key = dict(manager._bots_by_key)
old_bot.shutdown.assert_awaited_once_with()
assert manager.bots == [other_bot, *unrelated_bots]
with pytest.raises(WorkspaceInvariantError, match='rolled back'):
await manager._observe_execution_context(old_context)
@pytest.mark.asyncio
async def test_concurrent_websocket_proxy_creation_reuses_one_runtime():
created_adapters = []
class WebsocketAdapter:
def __init__(self, *_args, **_kwargs):
created_adapters.append(self)
def register_listener(self, *_args):
pass
application = SimpleNamespace(workspace_service=_WorkspaceService())
manager = PlatformManager(application)
manager.adapter_dict = {'websocket': WebsocketAdapter}
context = ExecutionContext(
instance_uuid='instance',
workspace_uuid=WORKSPACE_A,
placement_generation=4,
)
runtimes = await asyncio.gather(*(manager.get_websocket_proxy_bot(context) for _ in range(20)))
assert len(created_adapters) == 1
assert len({id(runtime) for runtime in runtimes}) == 1
assert manager.websocket_proxy_bots == {WORKSPACE_A: runtimes[0]}
@pytest.mark.asyncio
async def test_websocket_proxy_cache_evicts_oldest_idle_workspace():
created_adapters = []
class WebsocketAdapter:
def __init__(self, *_args, **_kwargs):
self.kill = AsyncMock()
self.inbound_listener_tasks = set()
created_adapters.append(self)
def register_listener(self, *_args):
pass
application = SimpleNamespace(
workspace_service=_WorkspaceService(),
instance_config=SimpleNamespace(
data={
'system': {
'websocket_retention': {'max_workspace_proxies': 1},
}
}
),
)
manager = PlatformManager(application)
manager.adapter_dict = {'websocket': WebsocketAdapter}
await manager.get_websocket_proxy_bot(
ExecutionContext(
instance_uuid='instance',
workspace_uuid=WORKSPACE_A,
placement_generation=4,
)
)
second = await manager.get_websocket_proxy_bot(
ExecutionContext(
instance_uuid='instance',
workspace_uuid=WORKSPACE_B,
placement_generation=4,
)
)
created_adapters[0].kill.assert_awaited_once_with()
assert manager.websocket_proxy_bots == {WORKSPACE_B: second}
assert WORKSPACE_A not in manager._proxy_last_accessed
@pytest.mark.asyncio
async def test_reload_stops_and_drops_existing_platform_runtimes():
old_bot = SimpleNamespace(enable=True, shutdown=AsyncMock())
old_proxy = SimpleNamespace(enable=True, shutdown=AsyncMock())
persistence_mgr = SimpleNamespace(
execute_async=AsyncMock(return_value=SimpleNamespace(all=lambda: [])),
)
application = SimpleNamespace(
logger=SimpleNamespace(info=lambda *_args: None, warning=lambda *_args: None),
persistence_mgr=persistence_mgr,
workspace_service=SimpleNamespace(),
)
manager = PlatformManager(application)
manager.bots = [old_bot]
manager.websocket_proxy_bots = {WORKSPACE_A: old_proxy}
manager._scope_generations = {('instance', WORKSPACE_A): 4}
await manager.load_bots_from_db()
old_bot.shutdown.assert_awaited_once_with()
old_proxy.shutdown.assert_awaited_once_with()
assert manager.bots == []
assert manager.websocket_proxy_bots == {}
assert manager._scope_generations == {}
@pytest.mark.asyncio
async def test_cloud_startup_reuses_validated_platform_binding():
class TenantUow:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
class ProbeAdapter:
def __init__(self, _config, _logger):
self.listeners = []
def register_listener(self, event_type, listener):
self.listeners.append((event_type, listener))
async def kill(self):
return None
binding = WorkspaceExecutionBinding(
instance_uuid='instance',
workspace_uuid=WORKSPACE_A,
placement_generation=4,
write_fenced=False,
state='active',
)
bot = Bot(
uuid=BOT_A,
workspace_uuid=WORKSPACE_A,
name='Probe',
description='',
adapter='probe',
adapter_config={},
enable=False,
pipeline_routing_rules=[],
)
workspace_service = SimpleNamespace(
list_active_execution_bindings=AsyncMock(return_value=[binding]),
get_execution_binding=AsyncMock(
side_effect=AssertionError('startup platform loader repeated a validated binding lookup')
),
)
application = SimpleNamespace(
logger=SimpleNamespace(
info=lambda *_args, **_kwargs: None,
warning=lambda *_args, **_kwargs: None,
error=lambda *_args, **_kwargs: None,
),
persistence_mgr=SimpleNamespace(
mode=SimpleNamespace(value='cloud_runtime'),
tenant_uow=lambda _workspace_uuid: TenantUow(),
execute_async=AsyncMock(return_value=SimpleNamespace(all=lambda: [bot])),
),
workspace_service=workspace_service,
)
manager = PlatformManager(application)
manager.adapter_dict = {'probe': ProbeAdapter}
await manager.load_bots_from_db()
assert len(manager.bots) == 1
workspace_service.get_execution_binding.assert_not_awaited()
@pytest.mark.asyncio
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
from langbot.pkg.platform.sources.dingtalk import (
DingTalkAdapter,
_dingtalk_card_markdown,
@@ -17,6 +18,17 @@ from langbot.pkg.platform.sources.dingtalk import (
)
def test_dingtalk_auxiliary_tasks_are_bounded():
adapter = DingTalkAdapter.model_construct()
adapter._background_tasks = {MagicMock(done=MagicMock(return_value=False)) for _ in range(100)}
async def callback():
raise AssertionError('rejected callback must not run')
assert adapter._start_background_task(callback()) is False
assert len(adapter._background_tasks) == 100
def test_dingtalk_select_component_params_expose_options():
params = _dingtalk_form_component_params(
{
@@ -0,0 +1,12 @@
from __future__ import annotations
import pytest
from langbot.pkg.platform.sources import discord
def test_discord_base64_decode_is_bounded(monkeypatch):
monkeypatch.setattr(discord, '_MAX_DISCORD_MEDIA_BYTES', 4)
with pytest.raises(ValueError, match='exceeds'):
discord._decode_discord_base64_limited('A' * 12)
@@ -1,11 +1,13 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import pytest
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.platform.sources.http_bot import HttpBotAdapter
from langbot.pkg.platform.sources import http_bot as http_bot_module
def _session(key):
@@ -22,6 +24,7 @@ def _adapter(app, execution_context) -> HttpBotAdapter:
outbound_states={},
idempotency_cache={},
sync_waiters={},
inbound_tasks=set(),
)
object.__setattr__(adapter, 'ap', app)
return adapter
@@ -64,3 +67,28 @@ async def test_http_bot_reset_fails_closed_without_trusted_scope():
with pytest.raises(RuntimeError, match='trusted execution scope'):
await adapter._reset_session('person', 'shared-session')
@pytest.mark.asyncio
async def test_http_bot_bounds_inbound_listener_tasks(monkeypatch):
monkeypatch.setattr(http_bot_module, '_INBOUND_TASK_MAX', 1)
adapter = _adapter(SimpleNamespace(), None)
started = asyncio.Event()
release = asyncio.Event()
async def blocking_listener():
started.set()
await release.wait()
first = adapter._start_inbound_task(blocking_listener())
await started.wait()
rejected = adapter._start_inbound_task(blocking_listener())
assert first is not None
assert rejected is None
assert len(adapter.inbound_tasks) == 1
release.set()
await first
await asyncio.sleep(0)
assert adapter.inbound_tasks == set()
@@ -0,0 +1,24 @@
from __future__ import annotations
import json
import zlib
import pytest
from langbot.pkg.platform.sources import kook
def test_kook_gateway_decoder_accepts_raw_and_compressed_json():
payload = {'s': 1, 'd': {'session_id': 'session-a'}}
encoded = json.dumps(payload).encode()
assert kook._decode_gateway_message(encoded) == payload
assert kook._decode_gateway_message(zlib.compress(encoded)) == payload
def test_kook_gateway_decoder_rejects_decompression_bomb(monkeypatch):
monkeypatch.setattr(kook, '_KOOK_MAX_GATEWAY_MESSAGE_BYTES', 1024)
compressed = zlib.compress(b'x' * 1025)
with pytest.raises(ValueError, match='decompressed size limit'):
kook._decode_gateway_message(compressed)
@@ -1,7 +1,13 @@
"""Tests for Lark adapter helper behavior."""
import threading
from unittest.mock import MagicMock
import pytest
from langbot.pkg.platform.sources.lark import (
LarkAdapter,
_decode_lark_base64_limited,
_lark_clean_form_content,
_lark_completed_input_lines,
_lark_current_input_defs,
@@ -11,6 +17,27 @@ from langbot.pkg.platform.sources.lark import (
)
def test_lark_base64_decode_is_bounded(monkeypatch):
import langbot.pkg.platform.sources.lark as lark_module
monkeypatch.setattr(lark_module, '_MAX_LARK_MEDIA_BYTES', 4)
with pytest.raises(ValueError, match='exceeds'):
_decode_lark_base64_limited('A' * 12)
def test_lark_threadsafe_callbacks_are_bounded():
adapter = LarkAdapter.model_construct()
adapter.threadsafe_event_lock = threading.Lock()
adapter.threadsafe_event_futures = {MagicMock(done=MagicMock(return_value=False)) for _ in range(100)}
async def callback():
raise AssertionError('rejected callback must not run')
assert adapter._schedule_threadsafe_event(callback()) is None
assert len(adapter.threadsafe_event_futures) == 100
def test_lark_current_input_defs_only_returns_active_stage():
input_defs = [
{'output_variable_name': 'us_input', 'type': 'paragraph'},
@@ -0,0 +1,30 @@
from __future__ import annotations
import pytest
from unittest.mock import MagicMock
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
from langbot.pkg.platform.sources import line
def test_line_media_content_accepts_limit_boundary(monkeypatch) -> None:
monkeypatch.setattr(line, 'MAX_LINE_MEDIA_BYTES', 4)
content = b'1234'
assert line._validate_line_media_content(content) is content
def test_line_media_content_rejects_oversized_payload(monkeypatch) -> None:
monkeypatch.setattr(line, 'MAX_LINE_MEDIA_BYTES', 4)
with pytest.raises(ValueError, match='LINE media exceeds'):
line._validate_line_media_content(b'12345')
@pytest.mark.asyncio
async def test_line_kill_closes_api_client() -> None:
api_client = MagicMock()
adapter = line.LINEAdapter.model_construct(api_client=api_client)
assert await adapter.kill() is True
api_client.close.assert_called_once_with()
@@ -0,0 +1,21 @@
from __future__ import annotations
import pytest
from langbot.pkg.platform.sources import matrix
def test_matrix_base64_decode_is_bounded(monkeypatch):
monkeypatch.setattr(matrix, '_MAX_MATRIX_MEDIA_BYTES', 4)
with pytest.raises(ValueError, match='exceeds'):
matrix._decode_matrix_base64_limited('A' * 12)
def test_matrix_local_file_read_is_bounded(tmp_path, monkeypatch):
monkeypatch.setattr(matrix, '_MAX_MATRIX_MEDIA_BYTES', 4)
path = tmp_path / 'large.bin'
path.write_bytes(b'12345')
with pytest.raises(ValueError, match='exceeds'):
matrix._read_matrix_file_limited(str(path))
@@ -0,0 +1,31 @@
from __future__ import annotations
import pytest
from langbot.libs.openclaw_weixin_api.client import (
MAX_CDN_MEDIA_BYTES,
OpenClawWeixinClient,
_decrypt_cdn_payload,
_encrypt_cdn_payload,
)
from langbot.libs.openclaw_weixin_api.types import ApiError
def test_cdn_crypto_helpers_round_trip():
original = b'tenant-media' * 128
aes_key_hex, _encoded_key, encrypted, _raw_md5 = _encrypt_cdn_payload(original)
assert _decrypt_cdn_payload(encrypted, bytes.fromhex(aes_key_hex)) == original
@pytest.mark.asyncio
async def test_upload_media_rejects_oversized_input_before_network_access():
client = OpenClawWeixinClient('https://example.invalid', 'token')
with pytest.raises(ApiError, match='exceeds the size limit'):
await client.upload_media(
b'x' * (MAX_CDN_MEDIA_BYTES + 1),
'recipient',
3,
)
@@ -2,8 +2,10 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.platform.sources import openclaw_weixin
from langbot.pkg.platform.sources.openclaw_weixin import OpenClawWeixinAdapter
@@ -82,3 +84,12 @@ async def test_persist_config_fails_closed_without_matching_execution_context(ex
app.persistence_mgr.execute_async.assert_not_awaited()
logger.warning.assert_awaited_once()
@pytest.mark.asyncio
async def test_component_base64_decode_is_bounded(monkeypatch):
monkeypatch.setattr(openclaw_weixin, '_MAX_OPENCLAW_COMPONENT_BYTES', 4)
component = platform_message.File(base64='MTIzNDU=')
with pytest.raises(ValueError, match='exceeds'):
await OpenClawWeixinAdapter._get_component_bytes(component)
@@ -10,6 +10,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.libs.qq_official_api.api import (
QQ_SELECT_ACTION_PREFIX,
QQOfficialClient,
build_keyboard_from_select_field,
get_select_field_options,
resolve_select_button_action,
@@ -49,6 +50,28 @@ def test_qq_select_button_resolves_field_and_value():
assert resolve_select_button_action(form_data, f'{QQ_SELECT_ACTION_PREFIX}99') is None
@pytest.mark.asyncio
async def test_qq_seed_rejects_empty_secret_without_spinning():
client = QQOfficialClient('', 'token', 'app-id', AsyncMock())
with pytest.raises(ValueError, match='must not be empty'):
await asyncio.wait_for(client.repeat_seed(''), timeout=0.1)
def test_qq_auxiliary_tasks_are_bounded():
import langbot.pkg.core.app # noqa: F401
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
adapter = QQOfficialAdapter.model_construct()
adapter._background_tasks = {MagicMock(done=MagicMock(return_value=False)) for _ in range(100)}
async def callback():
raise AssertionError('rejected callback must not run')
assert adapter._start_background_task(callback()) is False
assert len(adapter._background_tasks) == 100
def test_qq_select_keyboard_fits_twenty_five_options():
form_data = _select_form_data()
form_data['input_defs'][0]['option_source']['value'] = [f'Option {idx}' for idx in range(25)]
@@ -11,11 +11,21 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform.sources.telegram import (
TelegramAdapter,
_decode_telegram_base64_limited,
_telegram_form_action_from_callback,
_telegram_select_field_options,
)
def test_telegram_base64_decode_is_bounded(monkeypatch):
import langbot.pkg.platform.sources.telegram as telegram_module
monkeypatch.setattr(telegram_module, '_MAX_TELEGRAM_MEDIA_BYTES', 4)
with pytest.raises(ValueError, match='exceeds'):
_decode_telegram_base64_limited('A' * 12)
def _select_form_data() -> dict:
return {
'_current_input_field': 'choice',
@@ -88,6 +98,18 @@ def test_telegram_form_callback_cache_preserves_pipeline_uuid():
)
def test_telegram_form_callback_cache_is_bounded():
adapter = TelegramAdapter.model_construct()
adapter._form_action_titles = {}
adapter._cache_form_action_titles(
{f'callback-{index}': str(index) for index in range(5000)},
now=100.0,
)
assert len(adapter._form_action_titles) == adapter._MAX_FORM_ACTION_TITLES
@pytest.mark.asyncio
async def test_telegram_select_field_sends_two_column_inline_keyboard():
bot = MagicMock()
@@ -108,6 +108,71 @@ async def test_pipeline_indexes_and_broadcasts_are_workspace_scoped():
assert manager.get_stats(scope=SCOPE_A)['total_connections'] == 1
@pytest.mark.asyncio
async def test_connection_admission_is_bounded_globally_and_per_workspace():
manager = WebSocketConnectionManager()
await manager.add_connection(
websocket=Mock(),
scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='person',
max_connections=2,
max_connections_per_workspace=1,
)
with pytest.raises(RuntimeError, match='Workspace WebSocket'):
await manager.add_connection(
websocket=Mock(),
scope=SCOPE_A,
pipeline_uuid='pipeline-2',
session_type='person',
max_connections=2,
max_connections_per_workspace=1,
)
await manager.add_connection(
websocket=Mock(),
scope=SCOPE_B,
pipeline_uuid='pipeline-1',
session_type='person',
max_connections=2,
max_connections_per_workspace=1,
)
with pytest.raises(RuntimeError, match='WebSocket connection capacity'):
await manager.add_connection(
websocket=Mock(),
scope=WebSocketScope('instance-a', 'workspace-c', 1),
pipeline_uuid='pipeline-1',
session_type='person',
max_connections=2,
max_connections_per_workspace=1,
)
@pytest.mark.asyncio
async def test_close_scope_closes_and_removes_only_matching_connections():
manager = WebSocketConnectionManager()
websocket_a = Mock(close=AsyncMock())
connection_a = await manager.add_connection(
websocket=websocket_a,
scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='person',
)
connection_b = await manager.add_connection(
websocket=Mock(close=AsyncMock()),
scope=SCOPE_B,
pipeline_uuid='pipeline-1',
session_type='person',
)
await manager.close_scope(SCOPE_A)
websocket_a.close.assert_awaited_once()
assert await manager.get_connection(connection_a.connection_id, scope=SCOPE_A) is None
assert await manager.get_connection(connection_b.connection_id, scope=SCOPE_B) is connection_b
@pytest.mark.asyncio
async def test_embed_event_uses_stable_session_launcher(monkeypatch):
manager = WebSocketConnectionManager()
@@ -0,0 +1,39 @@
from __future__ import annotations
import httpx
import pytest
from langbot.libs.wechatpad_api.api import downloadpai
from langbot.libs.wechatpad_api.util import http_util
class _Response:
headers = {}
def __init__(self, chunks: list[bytes]):
self._chunks = chunks
def iter_content(self, chunk_size=None):
del chunk_size
yield from self._chunks
def test_wechatpad_response_reader_is_bounded(monkeypatch):
monkeypatch.setattr(http_util, '_MAX_WECHATPAD_RESPONSE_BYTES', 4)
with pytest.raises(RuntimeError, match='exceeds the runtime limit'):
http_util._read_requests_response_limited(_Response([b'1234', b'5']))
def test_wechatpad_response_reader_requires_json_object():
with pytest.raises(RuntimeError, match='non-object'):
http_util._read_requests_response_limited(_Response([b'[]']))
@pytest.mark.asyncio
async def test_wechatpad_media_reader_is_bounded(monkeypatch):
monkeypatch.setattr(downloadpai, '_MAX_WECHATPAD_MEDIA_BYTES', 4)
response = httpx.Response(200, content=b'oversized')
with pytest.raises(RuntimeError, match='exceeds'):
await downloadpai._read_media_limited(response)
@@ -0,0 +1,31 @@
from __future__ import annotations
import pytest
from langbot.libs.wecom_api.api import (
_EXTENDED_HTTP_TIMEOUT_SECONDS,
_decode_media_base64_limited,
WecomClient,
)
@pytest.mark.asyncio
async def test_wecom_extended_client_timeout_is_still_bounded() -> None:
client = object.__new__(WecomClient)
client._http_clients = {}
try:
async with client._http_client_context(unbounded_timeout=True) as http_client:
assert http_client.timeout.read == _EXTENDED_HTTP_TIMEOUT_SECONDS
finally:
await client.close()
@pytest.mark.asyncio
async def test_wecom_base64_decode_is_bounded(monkeypatch) -> None:
import langbot.libs.wecom_api.api as wecom_api
monkeypatch.setattr(wecom_api, '_MAX_MEDIA_BYTES', 4)
with pytest.raises(ValueError, match='exceeds'):
await _decode_media_base64_limited('MTIzNDU=')
@@ -1,5 +1,6 @@
import sys
import types
from unittest.mock import Mock
import pytest
@@ -24,6 +25,25 @@ from langbot.libs.wecom_ai_bot_api.api import ( # noqa: E402
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient # noqa: E402
def test_ws_callback_tasks_are_bounded():
client = WecomBotWsClient('bot-id', 'secret', object())
client._callback_tasks = {Mock(done=Mock(return_value=False)) for _ in range(100)}
async def callback():
raise AssertionError('rejected callback must not run')
assert client._start_callback_task(callback()) is False
assert len(client._callback_tasks) == 100
def test_webhook_dispatch_tasks_are_bounded():
client = WecomBotClient('', '', '', object(), unified_mode=True)
client._dispatch_tasks = {Mock(done=Mock(return_value=False)) for _ in range(100)}
assert client._start_dispatch_task(Mock()) is False
assert len(client._dispatch_tasks) == 100
def test_extract_template_card_action_supports_nested_button_key():
task_id, event_key, card_type = extract_template_card_action(
{
@@ -287,16 +307,9 @@ async def test_webhook_stream_queues_cumulative_snapshots_for_followups():
assert await client.push_stream_chunk('msg-1', '你好', is_final=False)
assert await client.push_stream_chunk('msg-1', '你好', is_final=True)
chunks = [
await client.stream_sessions.consume(session.stream_id),
await client.stream_sessions.consume(session.stream_id),
await client.stream_sessions.consume(session.stream_id),
]
assert [(chunk.content, chunk.is_final) for chunk in chunks] == [
('', False),
('你好', False),
('你好', True),
]
assert session.queue.qsize() == 1
chunk = await client.stream_sessions.consume(session.stream_id)
assert (chunk.content, chunk.is_final) == ('你好', True)
def test_human_input_payload_keeps_action_select_stage_as_buttons():