Files
LangBot/tests/unit_tests/pipeline/test_pool.py
T
2026-07-19 09:58:59 +08:00

436 lines
14 KiB
Python

"""
Unit tests for QueryPool.
Tests query management, ID generation, and async context handling.
"""
from __future__ import annotations
import uuid
from types import SimpleNamespace
import pytest
from unittest.mock import Mock, patch
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.pipeline.pool import (
ExecutionContextMismatchError,
ExecutionContextRequiredError,
QueryNotFoundError,
QueryPool,
get_query_execution_context,
)
TEST_CONTEXT = ExecutionContext(
instance_uuid='instance-test',
workspace_uuid='workspace-test',
placement_generation=1,
)
def oss_pool():
"""Build the explicit singleton resolver used by the OSS compatibility path."""
return QueryPool(singleton_context_resolver=lambda: TEST_CONTEXT)
async def add_scoped_mock_query(pool, context, *, bot_uuid='bot-a'):
"""Create a Query through the real pool while keeping SDK details mocked."""
query = Mock()
query.bot_uuid = bot_uuid
query.pipeline_uuid = None
query.query_id = pool.query_id_counter
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query', return_value=query):
return await pool.add_query(
bot_uuid=bot_uuid,
launcher_type=Mock(),
launcher_id='launcher-1',
sender_id='sender-1',
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
execution_context=context,
)
pytestmark = pytest.mark.asyncio
class TestQueryPoolInit:
"""Tests for QueryPool initialization."""
def test_init_creates_empty_pool(self):
"""QueryPool initializes with empty lists."""
pool = QueryPool()
assert pool.queries == []
assert pool.cached_queries == {}
assert pool.query_id_counter == 0
assert pool.pool_lock is not None
assert pool.condition is not None
def test_init_counter_starts_at_zero(self):
"""Counter starts at zero."""
pool = QueryPool()
assert pool.query_id_counter == 0
class TestQueryPoolAddQuery:
"""Tests for add_query method."""
async def test_add_query_adds_query_with_id(self):
"""add_query creates, stores, and caches a Query with the correct ID."""
pool = oss_pool()
# Mock Query creation
mock_query = Mock()
mock_query.query_id = 0
mock_query.bot_uuid = 'test-bot-uuid'
mock_query.launcher_id = 12345
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.return_value = mock_query
await pool.add_query(
bot_uuid='test-bot-uuid',
launcher_type=Mock(),
launcher_id=12345,
sender_id=12345,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
)
# Query is added to list and cache
assert pool.queries[0] is mock_query
assert pool.cached_queries[('workspace-test', mock_query.query_uuid)] is mock_query
assert mock_query.query_id == 0
async def test_add_query_increments_counter(self):
"""Each add_query increments the counter."""
pool = oss_pool()
mock_query1 = Mock()
mock_query1.query_id = 0
mock_query2 = Mock()
mock_query2.query_id = 1
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.side_effect = [mock_query1, mock_query2]
await pool.add_query(
bot_uuid='bot1',
launcher_type=Mock(),
launcher_id=1,
sender_id=1,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
)
await pool.add_query(
bot_uuid='bot2',
launcher_type=Mock(),
launcher_id=2,
sender_id=2,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
)
assert pool.query_id_counter == 2
assert pool.queries[0].query_id == 0
assert pool.queries[1].query_id == 1
async def test_add_query_appends_to_list(self):
"""Query is appended to queries list."""
pool = oss_pool()
mock_query = Mock()
mock_query.query_id = 0
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.return_value = mock_query
await pool.add_query(
bot_uuid='bot1',
launcher_type=Mock(),
launcher_id=1,
sender_id=1,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
)
assert len(pool.queries) == 1
assert pool.queries[0] is mock_query
async def test_add_query_caches_query(self):
"""Query is cached by query_id."""
pool = oss_pool()
mock_query = Mock()
mock_query.query_id = 0
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.return_value = mock_query
await pool.add_query(
bot_uuid='bot1',
launcher_type=Mock(),
launcher_id=1,
sender_id=1,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
)
cache_key = ('workspace-test', mock_query.query_uuid)
assert cache_key in pool.cached_queries
assert pool.cached_queries[cache_key] is mock_query
async def test_add_query_with_pipeline_uuid(self):
"""Query can have pipeline_uuid set."""
pool = oss_pool()
mock_query = Mock()
mock_query.query_id = 0
mock_query.pipeline_uuid = 'test-pipeline-uuid'
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.return_value = mock_query
await pool.add_query(
bot_uuid='bot1',
launcher_type=Mock(),
launcher_id=1,
sender_id=1,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
pipeline_uuid='test-pipeline-uuid',
)
# Verify pipeline_uuid was passed to Query constructor
call_kwargs = MockQuery.call_args[1]
assert call_kwargs['pipeline_uuid'] == 'test-pipeline-uuid'
async def test_add_query_sets_routed_by_rule_variable(self):
"""Query has _routed_by_rule variable."""
pool = oss_pool()
mock_query = Mock()
mock_query.query_id = 0
mock_query.variables = {'_routed_by_rule': True}
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.return_value = mock_query
await pool.add_query(
bot_uuid='bot1',
launcher_type=Mock(),
launcher_id=1,
sender_id=1,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
routed_by_rule=True,
)
# Verify variables includes _routed_by_rule
call_kwargs = MockQuery.call_args[1]
assert call_kwargs['variables']['_routed_by_rule'] is True
async def test_add_query_notifier_condition(self):
"""add_query notifies waiting consumers."""
pool = oss_pool()
mock_query = Mock()
mock_query.query_id = 0
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.return_value = mock_query
# Track if notify_all was called
original_notify = pool.condition.notify_all
notify_called = []
def mock_notify():
notify_called.append(True)
return original_notify()
pool.condition.notify_all = mock_notify
await pool.add_query(
bot_uuid='bot1',
launcher_type=Mock(),
launcher_id=1,
sender_id=1,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
)
assert len(notify_called) == 1
class TestQueryPoolContext:
"""Tests for async context manager."""
async def test_aenter_acquires_lock(self):
"""__aenter__ acquires the pool lock."""
pool = oss_pool()
async with pool as p:
# Lock is acquired
assert pool.pool_lock.locked()
assert p is pool
async def test_aexit_releases_lock(self):
"""__aexit__ releases the pool lock."""
pool = QueryPool()
async with pool:
pass
# Lock is released after context exit
assert not pool.pool_lock.locked()
class TestQueryPoolEdgeCases:
"""Tests for edge cases."""
async def test_multiple_queries_cached_correctly(self):
"""Multiple queries are cached separately."""
pool = oss_pool()
mock_queries = []
for i in range(5):
q = Mock()
q.query_id = i
mock_queries.append(q)
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.side_effect = mock_queries
for i in range(5):
await pool.add_query(
bot_uuid=f'bot{i}',
launcher_type=Mock(),
launcher_id=i,
sender_id=i,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
)
# All cached
assert len(pool.cached_queries) == 5
# Each query is cached by its ID
for i in range(5):
query = mock_queries[i]
assert pool.cached_queries[('workspace-test', query.query_uuid)] is query
class TestQueryPoolWorkspaceIsolation:
"""Regression coverage for trusted scope and scoped cache indexes."""
async def test_add_query_requires_execution_context_by_default(self):
with pytest.raises(ExecutionContextRequiredError):
await QueryPool().add_query(
bot_uuid='bot-a',
launcher_type=Mock(),
launcher_id='launcher-1',
sender_id='sender-1',
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
)
async def test_serialized_scope_fields_are_not_trusted_context(self):
forged_query = SimpleNamespace(
instance_uuid='instance-test',
workspace_uuid='workspace-test',
placement_generation=1,
bot_uuid='bot-a',
pipeline_uuid=None,
query_uuid='forged-query',
)
with pytest.raises(ExecutionContextRequiredError):
get_query_execution_context(forged_query)
async def test_query_lookup_is_workspace_scoped(self):
pool = QueryPool()
query = await add_scoped_mock_query(pool, TEST_CONTEXT)
uuid.UUID(query.query_uuid)
assert await pool.get_query('workspace-test', query.query_uuid) is query
assert await pool.get_query('workspace-other', query.query_uuid) is None
assert await pool.get_query_by_legacy_id('workspace-test', 0) is query
assert await pool.get_query_by_legacy_id('workspace-other', 0) is None
with pytest.raises(QueryNotFoundError):
await pool.require_query('workspace-other', query.query_uuid)
async def test_cache_separates_same_opaque_id_between_workspaces(self, monkeypatch):
fixed_uuid = uuid.UUID('11111111-1111-4111-8111-111111111111')
monkeypatch.setattr('langbot.pkg.pipeline.pool.uuid.uuid4', lambda: fixed_uuid)
pool = QueryPool()
context_a = TEST_CONTEXT
context_b = ExecutionContext(
instance_uuid='instance-test',
workspace_uuid='workspace-other',
placement_generation=1,
)
query_a = await add_scoped_mock_query(pool, context_a)
query_b = await add_scoped_mock_query(pool, context_b)
assert query_a.query_uuid == query_b.query_uuid
assert await pool.get_query('workspace-test', query_a.query_uuid) is query_a
assert await pool.get_query('workspace-other', query_b.query_uuid) is query_b
async def test_remove_query_cleans_both_scoped_indexes(self):
pool = QueryPool()
query = await add_scoped_mock_query(pool, TEST_CONTEXT)
assert await pool.remove_query(query) is True
assert await pool.get_query('workspace-test', query.query_uuid) is None
assert await pool.get_query_by_legacy_id('workspace-test', query.query_id) is None
assert await pool.remove_query(query) is False
async def test_context_cannot_substitute_bot_identity(self):
context = ExecutionContext(
instance_uuid='instance-test',
workspace_uuid='workspace-test',
placement_generation=1,
bot_uuid='bot-b',
)
with pytest.raises(ExecutionContextMismatchError):
await add_scoped_mock_query(QueryPool(), context, bot_uuid='bot-a')
async def test_query_counter_is_scoped_by_workspace_and_generation(self):
pool = QueryPool()
workspace_a = TEST_CONTEXT
workspace_b = ExecutionContext(
instance_uuid='instance-test',
workspace_uuid='workspace-other',
placement_generation=1,
)
next_generation = ExecutionContext(
instance_uuid='instance-test',
workspace_uuid='workspace-test',
placement_generation=2,
)
await add_scoped_mock_query(pool, workspace_a)
await add_scoped_mock_query(pool, workspace_a)
await add_scoped_mock_query(pool, workspace_b)
assert pool.get_query_count(workspace_a) == 2
assert pool.get_query_count(workspace_b) == 1
assert pool.get_query_count(next_generation) == 0
assert pool.query_id_counter == 3