mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-21 20:06:06 +00:00
186 lines
5.7 KiB
Python
186 lines
5.7 KiB
Python
from __future__ import annotations
|
|
|
|
import contextlib
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from langbot.pkg.api.http.authz import WorkspaceRequiredError
|
|
from langbot.pkg.api.http.context import ExecutionContext
|
|
from langbot.pkg.platform.botmgr import PlatformManager, RuntimeBot
|
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
|
|
|
|
|
WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
|
|
WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
|
|
BOT_A = '10000000-0000-0000-0000-00000000000a'
|
|
BOT_B = '10000000-0000-0000-0000-00000000000b'
|
|
|
|
|
|
def _context(workspace_uuid: str, bot_uuid: str, generation: int = 4) -> ExecutionContext:
|
|
return ExecutionContext(
|
|
instance_uuid='instance',
|
|
workspace_uuid=workspace_uuid,
|
|
placement_generation=generation,
|
|
bot_uuid=bot_uuid,
|
|
)
|
|
|
|
|
|
def _runtime(application, workspace_uuid: str, bot_uuid: str) -> RuntimeBot:
|
|
entity = SimpleNamespace(
|
|
uuid=bot_uuid,
|
|
workspace_uuid=workspace_uuid,
|
|
name='Same Name',
|
|
enable=True,
|
|
pipeline_routing_rules=[],
|
|
use_pipeline_uuid=None,
|
|
)
|
|
return RuntimeBot(
|
|
ap=application,
|
|
bot_entity=entity,
|
|
adapter=SimpleNamespace(),
|
|
logger=SimpleNamespace(),
|
|
execution_context=_context(workspace_uuid, bot_uuid),
|
|
)
|
|
|
|
|
|
class _WorkspaceService:
|
|
async def get_execution_binding(self, workspace_uuid, expected_generation=None):
|
|
if workspace_uuid not in {WORKSPACE_A, WORKSPACE_B} or expected_generation != 4:
|
|
raise ValueError('stale')
|
|
return SimpleNamespace(
|
|
instance_uuid='instance',
|
|
workspace_uuid=workspace_uuid,
|
|
placement_generation=4,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def manager():
|
|
application = SimpleNamespace(workspace_service=_WorkspaceService())
|
|
platform_manager = PlatformManager(application)
|
|
platform_manager.bots = [
|
|
_runtime(application, WORKSPACE_A, BOT_A),
|
|
_runtime(application, WORKSPACE_B, BOT_B),
|
|
]
|
|
return platform_manager
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_lookup_cannot_guess_another_workspace_bot(manager):
|
|
assert await manager.get_bot_by_uuid(_context(WORKSPACE_A, BOT_A), BOT_A) is manager.bots[0]
|
|
assert await manager.get_bot_by_uuid(_context(WORKSPACE_B, BOT_A), BOT_A) is None
|
|
assert await manager.get_bot_by_uuid(_context(WORKSPACE_A, BOT_B), BOT_B) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_public_route_key_resolves_bound_runtime_and_rejects_non_opaque_input(manager):
|
|
assert await manager.resolve_public_bot(BOT_A) is manager.bots[0]
|
|
assert await manager.resolve_public_bot('Same Name') is None
|
|
assert await manager.resolve_public_bot('not-a-uuid') is None
|
|
|
|
|
|
@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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_bot_revalidates_its_generation_before_handling_events(manager):
|
|
runtime_bot = manager.bots[0]
|
|
|
|
await runtime_bot.assert_execution_active()
|
|
|
|
runtime_bot.placement_generation = 5
|
|
with pytest.raises(ValueError, match='stale'):
|
|
await runtime_bot.assert_execution_active()
|
|
|
|
|
|
def test_runtime_bot_rejects_workspace_mismatch():
|
|
application = SimpleNamespace()
|
|
entity = SimpleNamespace(
|
|
uuid=BOT_A,
|
|
workspace_uuid=WORKSPACE_A,
|
|
name='Bot',
|
|
enable=True,
|
|
pipeline_routing_rules=[],
|
|
use_pipeline_uuid=None,
|
|
)
|
|
with pytest.raises(WorkspaceRequiredError):
|
|
RuntimeBot(
|
|
ap=application,
|
|
bot_entity=entity,
|
|
adapter=SimpleNamespace(),
|
|
logger=SimpleNamespace(),
|
|
execution_context=_context(WORKSPACE_B, BOT_A),
|
|
)
|
|
|
|
|
|
class _ScopeOnlyPersistenceManager:
|
|
mode = SimpleNamespace(value='cloud_runtime')
|
|
|
|
def __init__(self):
|
|
self.active_workspace = None
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def tenant_scope(self, workspace_uuid: str):
|
|
assert self.active_workspace is None
|
|
self.active_workspace = workspace_uuid
|
|
try:
|
|
yield
|
|
finally:
|
|
self.active_workspace = None
|
|
|
|
def current_session(self):
|
|
return None
|
|
|
|
|
|
class _ListenerAdapter:
|
|
def __init__(self):
|
|
self.listeners = {}
|
|
|
|
def register_listener(self, event_type, listener):
|
|
self.listeners[event_type] = listener
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_platform_callback_carries_scope_without_holding_database_session():
|
|
persistence_mgr = _ScopeOnlyPersistenceManager()
|
|
adapter = _ListenerAdapter()
|
|
|
|
async def push_person_message(*_args, **_kwargs):
|
|
assert persistence_mgr.active_workspace == WORKSPACE_A
|
|
assert persistence_mgr.current_session() is None
|
|
return True
|
|
|
|
application = SimpleNamespace(
|
|
persistence_mgr=persistence_mgr,
|
|
workspace_service=_WorkspaceService(),
|
|
webhook_pusher=SimpleNamespace(push_person_message=push_person_message),
|
|
)
|
|
entity = SimpleNamespace(
|
|
uuid=BOT_A,
|
|
workspace_uuid=WORKSPACE_A,
|
|
name='Bot',
|
|
enable=True,
|
|
pipeline_routing_rules=[],
|
|
use_pipeline_uuid=None,
|
|
)
|
|
logger = SimpleNamespace(info=AsyncMock(), error=AsyncMock())
|
|
runtime = RuntimeBot(
|
|
ap=application,
|
|
bot_entity=entity,
|
|
adapter=adapter,
|
|
logger=logger,
|
|
execution_context=_context(WORKSPACE_A, BOT_A),
|
|
)
|
|
await runtime.initialize()
|
|
|
|
listener = adapter.listeners[platform_events.FriendMessage]
|
|
event = SimpleNamespace(message_chain=[], sender=SimpleNamespace(id='user'))
|
|
await listener(event, adapter)
|
|
|
|
assert persistence_mgr.active_workspace is None
|
|
logger.info.assert_awaited()
|