mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 16:35:20 +08:00
Merge pull request #2665 from langbot-app/test/regression-boundary-cleanup-20261017
test: remove self-confirming tests and strengthen regression boundaries
This commit is contained in:
@@ -11,7 +11,6 @@ Avoids circular imports by using proper import structure.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -174,65 +173,6 @@ class MockApplication:
|
||||
self.sess_mgr.get_conversation = AsyncMock()
|
||||
|
||||
|
||||
class TestStreamingBehavior:
|
||||
"""Tests for streaming mode behavior."""
|
||||
|
||||
def test_single_resp_message_id_for_streaming(self):
|
||||
"""Streaming mode should use single resp_message_id for entire response."""
|
||||
# Simulate the streaming logic: resp_message_id created outside loop
|
||||
resp_message_id = uuid.uuid4()
|
||||
|
||||
chunks = ['Hello', ' World', '!']
|
||||
resp_messages = []
|
||||
|
||||
for chunk in chunks:
|
||||
result = MockMessageChunk(chunk)
|
||||
result.resp_message_id = str(resp_message_id)
|
||||
|
||||
# Pop old chunk (streaming behavior)
|
||||
if resp_messages:
|
||||
resp_messages.pop()
|
||||
resp_messages.append(result)
|
||||
|
||||
# All chunks should have same resp_message_id
|
||||
assert len(resp_messages) == 1 # Only last chunk remains after pop/append
|
||||
assert resp_messages[0].resp_message_id == str(resp_message_id)
|
||||
|
||||
def test_pop_before_append_in_streaming(self):
|
||||
"""Streaming mode should pop old chunk before appending new."""
|
||||
resp_message_id = uuid.uuid4()
|
||||
resp_messages = []
|
||||
|
||||
# First chunk - no pop
|
||||
chunk1 = MockMessageChunk('Hello')
|
||||
chunk1.resp_message_id = str(resp_message_id)
|
||||
resp_messages.append(chunk1)
|
||||
assert len(resp_messages) == 1
|
||||
|
||||
# Second chunk - pop first, then append
|
||||
if resp_messages:
|
||||
resp_messages.pop()
|
||||
chunk2 = MockMessageChunk('Hello World')
|
||||
chunk2.resp_message_id = str(resp_message_id)
|
||||
resp_messages.append(chunk2)
|
||||
assert len(resp_messages) == 1
|
||||
assert resp_messages[0].content == 'Hello World'
|
||||
|
||||
def test_non_streaming_no_pop(self):
|
||||
"""Non-streaming mode should NOT pop previous responses."""
|
||||
resp_messages = []
|
||||
|
||||
# First message
|
||||
msg1 = MockMessageChunk('Response 1')
|
||||
resp_messages.append(msg1)
|
||||
assert len(resp_messages) == 1
|
||||
|
||||
# Second message - should NOT pop in non-streaming
|
||||
msg2 = MockMessageChunk('Response 2')
|
||||
resp_messages.append(msg2)
|
||||
assert len(resp_messages) == 2
|
||||
|
||||
|
||||
class TestRunnerConfigResolverInChatHandler:
|
||||
"""Tests for RunnerConfigResolver usage in chat handler context."""
|
||||
|
||||
|
||||
@@ -135,53 +135,6 @@ class TestEventLogStore:
|
||||
assert '"status": "queued"' in stored_event.metadata_json
|
||||
assert '"claimed_by_run_id": "run_1"' in stored_event.metadata_json
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_event_truncates_input_summary(self, mock_db_engine):
|
||||
"""Test that long input summaries are truncated."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
store = EventLogStore(mock_db_engine)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
long_text = 'x' * 2000
|
||||
event_id = await store.append_event(
|
||||
event_id='evt_2',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
input_summary=long_text,
|
||||
)
|
||||
|
||||
assert event_id == 'evt_2'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_page_events_with_conversation_filter(self, mock_db_engine):
|
||||
"""Test paging events with conversation_id filter."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
store = EventLogStore(mock_db_engine)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
items, next_seq, has_more = await store.page_events(
|
||||
conversation_id='conv_1',
|
||||
limit=10,
|
||||
)
|
||||
|
||||
assert isinstance(items, list)
|
||||
|
||||
|
||||
class TestTranscriptStore:
|
||||
"""Test TranscriptStore operations."""
|
||||
@@ -212,123 +165,10 @@ class TestTranscriptStore:
|
||||
|
||||
assert transcript_id is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_transcript_with_attachments(self, mock_db_engine):
|
||||
"""Test appending transcript with attachment refs."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
store = TranscriptStore(mock_db_engine)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
with patch.object(store, '_get_next_seq', return_value=1):
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
transcript_id = await store.append_transcript(
|
||||
transcript_id=None, # Auto-generate
|
||||
event_id='evt_2',
|
||||
conversation_id='conv_1',
|
||||
role='assistant',
|
||||
content="Here's an image",
|
||||
attachment_refs=[{'id': 'att_1', 'type': 'image', 'url': 'http://example.com/img.png'}],
|
||||
)
|
||||
|
||||
assert transcript_id is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_page_transcript_backward(self, mock_db_engine):
|
||||
"""Test paging transcript backward (older items)."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
store = TranscriptStore(mock_db_engine)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
items, next_seq, prev_seq, has_more = await store.page_transcript(
|
||||
conversation_id='conv_1',
|
||||
limit=10,
|
||||
direction='backward',
|
||||
)
|
||||
|
||||
assert isinstance(items, list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_page_transcript_has_hard_limit(self, mock_db_engine):
|
||||
"""Test that transcript paging has a hard limit."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
store = TranscriptStore(mock_db_engine)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
# Request more than the hard limit
|
||||
items, next_seq, prev_seq, has_more = await store.page_transcript(
|
||||
conversation_id='conv_1',
|
||||
limit=200, # Request 200, but hard limit is 100
|
||||
)
|
||||
|
||||
# The store should cap at 100
|
||||
assert len(items) <= store.HARD_LIMIT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_transcript(self, mock_db_engine):
|
||||
"""Test searching transcript."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
store = TranscriptStore(mock_db_engine)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
items = await store.search_transcript(
|
||||
conversation_id='conv_1',
|
||||
query_text='database',
|
||||
top_k=10,
|
||||
)
|
||||
|
||||
assert isinstance(items, list)
|
||||
|
||||
|
||||
class TestHistoryPageAuthorization:
|
||||
"""Test history.page authorization."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_page_requires_run_id(self, mock_handler, mock_db_engine):
|
||||
"""Test history.page requires run_id."""
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
|
||||
|
||||
# Mock call_action to simulate the handler
|
||||
result = await mock_handler.call_action(
|
||||
PluginToRuntimeAction.HISTORY_PAGE,
|
||||
{'run_id': None},
|
||||
)
|
||||
|
||||
# Should return error
|
||||
assert result.get('ok') is False or 'error' in str(result).lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_page_validates_conversation_scope(self, mock_db_engine):
|
||||
"""Test history.page only allows access to run's conversation."""
|
||||
@@ -353,66 +193,6 @@ class TestHistoryPageAuthorization:
|
||||
await session_registry.unregister('run_1')
|
||||
|
||||
|
||||
class TestEventGetAuthorization:
|
||||
"""Test event.get authorization."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_get_requires_run_id(self, mock_handler):
|
||||
"""Test event.get requires run_id."""
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
|
||||
|
||||
result = await mock_handler.call_action(
|
||||
PluginToRuntimeAction.EVENT_GET,
|
||||
{'run_id': None, 'event_id': 'evt_1'},
|
||||
)
|
||||
|
||||
# Should return error
|
||||
assert result.get('ok') is False or 'error' in str(result).lower()
|
||||
|
||||
|
||||
class TestContextAccessPopulation:
|
||||
"""Test ContextAccess population in build_context_from_event."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_access_has_history_apis_when_permitted(self, mock_db_engine):
|
||||
"""Test ContextAccess shows available APIs based on permissions."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
store = TranscriptStore(mock_db_engine)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = None
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
cursor = await store.get_latest_cursor('conv_1')
|
||||
# Should return None or a cursor string
|
||||
assert cursor is None or isinstance(cursor, str)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_access_shows_has_history_before(self, mock_db_engine):
|
||||
"""Test ContextAccess indicates if history exists."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
store = TranscriptStore(mock_db_engine)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar.return_value = 0
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
has_history = await store.has_history_before('conv_1', 10)
|
||||
assert isinstance(has_history, bool)
|
||||
|
||||
|
||||
class TestEventLogStoreRealSQLite:
|
||||
"""Test EventLogStore with real SQLite database."""
|
||||
|
||||
@@ -433,8 +213,9 @@ class TestEventLogStoreRealSQLite:
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_get_event_round_trip(self, db_engine):
|
||||
"""Test append_event -> get_event round trip with real DB."""
|
||||
@pytest.mark.parametrize(('summary', 'stored_summary'), [('x' * 1000, 'x' * 1000), ('x' * 1001, 'x' * 997 + '...')])
|
||||
async def test_append_get_event_round_trip(self, db_engine, summary, stored_summary):
|
||||
"""Event fields and the summary length boundary survive a real DB round trip."""
|
||||
store = EventLogStore(db_engine)
|
||||
|
||||
# Append event
|
||||
@@ -447,7 +228,7 @@ class TestEventLogStoreRealSQLite:
|
||||
actor_type='user',
|
||||
actor_id='user_001',
|
||||
actor_name='Test User',
|
||||
input_summary='Hello world',
|
||||
input_summary=summary,
|
||||
run_id='run_001',
|
||||
runner_id='plugin:test/plugin/runner',
|
||||
)
|
||||
@@ -463,6 +244,7 @@ class TestEventLogStoreRealSQLite:
|
||||
assert event['conversation_id'] == 'conv_001'
|
||||
assert event['actor_type'] == 'user'
|
||||
assert event['actor_id'] == 'user_001'
|
||||
assert event['input_summary'] == stored_summary
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_page_events(self, db_engine):
|
||||
@@ -478,6 +260,12 @@ class TestEventLogStoreRealSQLite:
|
||||
conversation_id='conv_001',
|
||||
input_summary=f'Message {i}',
|
||||
)
|
||||
await store.append_event(
|
||||
event_id='evt_other_conversation',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
conversation_id='conv_other',
|
||||
)
|
||||
|
||||
# Page events
|
||||
items, next_seq, has_more = await store.page_events(
|
||||
@@ -485,8 +273,14 @@ class TestEventLogStoreRealSQLite:
|
||||
limit=3,
|
||||
)
|
||||
|
||||
assert len(items) == 3
|
||||
assert [item['event_id'] for item in items] == ['evt_real_004', 'evt_real_003', 'evt_real_002']
|
||||
assert has_more is True
|
||||
assert next_seq == items[-1]['id']
|
||||
|
||||
older, next_seq, has_more = await store.page_events(conversation_id='conv_001', before_seq=next_seq, limit=3)
|
||||
assert [item['event_id'] for item in older] == ['evt_real_001', 'evt_real_000']
|
||||
assert next_seq is None
|
||||
assert has_more is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_latest_cursor(self, db_engine):
|
||||
@@ -568,8 +362,9 @@ class TestTranscriptStoreRealSQLite:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_page_transcript_round_trip(self, db_engine):
|
||||
"""Test append_transcript -> page_transcript round trip with real DB."""
|
||||
"""Persist attachments and page backward without crossing conversations."""
|
||||
store = TranscriptStore(db_engine)
|
||||
attachments = [{'id': 'att_1', 'type': 'image', 'url': 'https://example.test/image.png'}]
|
||||
|
||||
# Append transcript items
|
||||
for i in range(3):
|
||||
@@ -579,16 +374,51 @@ class TestTranscriptStoreRealSQLite:
|
||||
conversation_id='conv_001',
|
||||
role='user' if i % 2 == 0 else 'assistant',
|
||||
content=f'Message {i}',
|
||||
attachment_refs=attachments if i == 2 else None,
|
||||
)
|
||||
await store.append_transcript(
|
||||
transcript_id='trans_other',
|
||||
event_id='evt_other',
|
||||
conversation_id='conv_other',
|
||||
role='user',
|
||||
content='Other conversation',
|
||||
)
|
||||
|
||||
# Page transcript
|
||||
items, next_seq, prev_seq, has_more = await store.page_transcript(
|
||||
conversation_id='conv_001',
|
||||
limit=10,
|
||||
limit=2,
|
||||
include_attachments=True,
|
||||
)
|
||||
|
||||
assert len(items) == 3
|
||||
assert items[0]['conversation_id'] == 'conv_001'
|
||||
assert [item['transcript_id'] for item in items] == ['trans_real_002', 'trans_real_001']
|
||||
assert items[0]['attachment_refs'] == attachments
|
||||
assert has_more is True
|
||||
assert next_seq == items[-1]['seq']
|
||||
assert prev_seq == items[0]['seq']
|
||||
|
||||
older, next_seq, prev_seq, has_more = await store.page_transcript(
|
||||
'conv_001', before_seq=next_seq, limit=2, direction='backward'
|
||||
)
|
||||
assert [item['transcript_id'] for item in older] == ['trans_real_000']
|
||||
assert next_seq is None
|
||||
assert prev_seq == older[0]['seq']
|
||||
assert has_more is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_page_transcript_caps_results_at_100(self, db_engine):
|
||||
store = TranscriptStore(db_engine)
|
||||
for i in range(101):
|
||||
await store.append_transcript(
|
||||
transcript_id=f'cap_{i}', event_id=f'evt_{i}', conversation_id='conv_cap', role='user', content=str(i)
|
||||
)
|
||||
|
||||
items, next_seq, prev_seq, has_more = await store.page_transcript('conv_cap', limit=200)
|
||||
|
||||
assert [item['transcript_id'] for item in items] == [f'cap_{i}' for i in range(100, 0, -1)]
|
||||
assert has_more is True
|
||||
assert next_seq == items[-1]['seq']
|
||||
assert prev_seq == items[0]['seq']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_legacy_provider_messages_projects_transcript_history(self, db_engine):
|
||||
@@ -694,6 +524,20 @@ class TestTranscriptStoreRealSQLite:
|
||||
role='assistant',
|
||||
content='Here is information about databases',
|
||||
)
|
||||
await store.append_transcript(
|
||||
transcript_id='nonmatch',
|
||||
event_id='evt_nonmatch',
|
||||
conversation_id='conv_search',
|
||||
role='user',
|
||||
content='hello',
|
||||
)
|
||||
await store.append_transcript(
|
||||
transcript_id='foreign_match',
|
||||
event_id='evt_foreign',
|
||||
conversation_id='conv_other',
|
||||
role='user',
|
||||
content='foreign database',
|
||||
)
|
||||
|
||||
# Search for "database"
|
||||
items = await store.search_transcript(
|
||||
@@ -701,13 +545,17 @@ class TestTranscriptStoreRealSQLite:
|
||||
query_text='database',
|
||||
)
|
||||
|
||||
# Should find at least one match
|
||||
assert len(items) >= 1
|
||||
assert [item['transcript_id'] for item in items] == ['trans_search_002', 'trans_search_001']
|
||||
limited = await store.search_transcript('conv_search', query_text='database', top_k=1)
|
||||
assert [item['transcript_id'] for item in limited] == ['trans_search_002']
|
||||
assert await store.search_transcript('conv_search', query_text='missing') == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_latest_cursor_real_db(self, db_engine):
|
||||
"""Test get_latest_cursor with real DB."""
|
||||
"""Cursors and history-before checks reflect persisted conversation rows."""
|
||||
store = TranscriptStore(db_engine)
|
||||
assert await store.get_latest_cursor('conv_cursor') is None
|
||||
assert await store.has_history_before('conv_cursor', 1) is False
|
||||
|
||||
# Append transcript items
|
||||
for i in range(3):
|
||||
@@ -721,8 +569,10 @@ class TestTranscriptStoreRealSQLite:
|
||||
|
||||
# Get latest cursor
|
||||
cursor = await store.get_latest_cursor('conv_cursor')
|
||||
assert cursor is not None
|
||||
assert int(cursor) > 0
|
||||
assert cursor == '3'
|
||||
assert await store.has_history_before('conv_cursor', 1) is False
|
||||
assert await store.has_history_before('conv_cursor', 2) is True
|
||||
assert await store.has_history_before('conv_other', 4) is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_transcripts_older_than(self, db_engine):
|
||||
@@ -775,21 +625,3 @@ def mock_db_engine():
|
||||
|
||||
engine = MagicMock(spec=AsyncEngine)
|
||||
return engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_handler():
|
||||
"""Create a mock handler for testing actions."""
|
||||
from langbot_plugin.runtime.io.handler import Handler
|
||||
|
||||
class MockHandler(Handler):
|
||||
def __init__(self):
|
||||
self._responses = {}
|
||||
|
||||
async def call_action(self, action, data, timeout=30):
|
||||
# Simulate error response for missing run_id
|
||||
if not data.get('run_id'):
|
||||
return {'ok': False, 'message': 'run_id is required'}
|
||||
return {'ok': True, 'data': {}}
|
||||
|
||||
return MockHandler()
|
||||
|
||||
@@ -235,17 +235,6 @@ class TestInvokeLLMAuthorization:
|
||||
session = await registry.get('run_nonexistent')
|
||||
assert session is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_llm_no_run_id_unrestricted(self):
|
||||
"""INVOKE_LLM: no run_id should be unrestricted (backward compat)."""
|
||||
# When no run_id is provided, the authorization check is skipped
|
||||
# This is the unscoped path for regular plugin calls
|
||||
|
||||
# Simulate: if not run_id, skip authorization
|
||||
run_id = None
|
||||
# Authorization check should NOT be triggered
|
||||
assert run_id is None # No authorization check
|
||||
|
||||
|
||||
class TestInvokeLLMStreamAuthorization:
|
||||
"""Tests for INVOKE_LLM_STREAM authorization."""
|
||||
@@ -290,13 +279,6 @@ class TestInvokeLLMStreamAuthorization:
|
||||
|
||||
await registry.unregister('run_stream_unauthorized')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_llm_stream_no_run_id_unrestricted(self):
|
||||
"""INVOKE_LLM_STREAM: no run_id should be unrestricted."""
|
||||
run_id = None
|
||||
# No authorization check
|
||||
assert run_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_manager_get_tool_detail_returns_uniform_schema():
|
||||
@@ -376,13 +358,6 @@ class TestCallToolAuthorization:
|
||||
|
||||
await registry.unregister('run_tool_unauthorized')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_no_run_id_unrestricted(self):
|
||||
"""CALL_TOOL: no run_id should be unrestricted."""
|
||||
run_id = None
|
||||
# No authorization check
|
||||
assert run_id is None
|
||||
|
||||
|
||||
class TestRetrieveKnowledgeBaseAuthorization:
|
||||
"""Tests for RETRIEVE_KNOWLEDGE_BASE authorization."""
|
||||
@@ -495,49 +470,6 @@ class TestAuthorizationPathDifferentiation:
|
||||
|
||||
await registry.unregister(run_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_plugin_path_no_run_id(self):
|
||||
"""Regular plugin calls have no run_id and skip session check."""
|
||||
# Regular plugin call has no run_id
|
||||
run_id = None
|
||||
|
||||
# Authorization check should be skipped when run_id is None.
|
||||
# This is handled in handler.py with: if run_id: ...
|
||||
assert run_id is None
|
||||
|
||||
# For regular plugins:
|
||||
# - INVOKE_LLM: unrestricted access to any model
|
||||
# - CALL_TOOL: unrestricted access to any tool
|
||||
# - RETRIEVE_KNOWLEDGE_BASE: checks pipeline config instead
|
||||
|
||||
|
||||
class TestHandlerAuthorizationErrorMessages:
|
||||
"""Tests for error message content in authorization failures."""
|
||||
|
||||
def test_model_not_authorized_error_message(self):
|
||||
"""Error message should mention model not authorized."""
|
||||
expected_msg = 'Model model_999 is not authorized for this agent run'
|
||||
assert 'not authorized' in expected_msg
|
||||
assert 'model_999' in expected_msg
|
||||
|
||||
def test_tool_not_authorized_error_message(self):
|
||||
"""Error message should mention tool not authorized."""
|
||||
expected_msg = 'Tool image_gen is not authorized for this agent run'
|
||||
assert 'not authorized' in expected_msg
|
||||
assert 'image_gen' in expected_msg
|
||||
|
||||
def test_kb_not_authorized_error_message(self):
|
||||
"""Error message should mention kb not authorized."""
|
||||
expected_msg = 'Knowledge base kb_999 is not authorized for this agent run'
|
||||
assert 'not authorized' in expected_msg
|
||||
assert 'kb_999' in expected_msg
|
||||
|
||||
def test_session_not_found_error_message(self):
|
||||
"""Error message should mention session not found."""
|
||||
expected_msg = 'Run session run_xyz not found or expired'
|
||||
assert 'not found' in expected_msg
|
||||
assert 'run_xyz' in expected_msg
|
||||
|
||||
|
||||
class TestRETRIEVEKNOWLEDGEBASEBugFix:
|
||||
"""Tests for the RETRIEVE_KNOWLEDGE_BASE bug fix in handler.py.
|
||||
@@ -696,16 +628,6 @@ class TestHandlerActionAuthorization:
|
||||
expected_error = f'Run session {run_id} not found or expired'
|
||||
assert 'not found' in expected_error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_llm_handler_no_run_id_unrestricted(self):
|
||||
"""INVOKE_LLM handler: no run_id skips authorization (backward compat)."""
|
||||
# Simulate handler logic: if not run_id, skip authorization
|
||||
run_id = None
|
||||
|
||||
# In handler.py, authorization check is inside: if run_id: ...
|
||||
# So when run_id is None, authorization is skipped.
|
||||
assert run_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_handler_authorized_path(self):
|
||||
"""CALL_TOOL handler: authorized when tool in resources."""
|
||||
@@ -764,14 +686,6 @@ class TestHandlerActionAuthorization:
|
||||
|
||||
await registry.unregister(run_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_handler_no_run_id_unrestricted(self):
|
||||
"""CALL_TOOL handler: no run_id skips authorization."""
|
||||
run_id = None
|
||||
|
||||
# Authorization check is inside: if run_id: ...
|
||||
assert run_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve_knowledge_base_handler_authorized_path(self):
|
||||
"""RETRIEVE_KNOWLEDGE_BASE handler: authorized when kb in resources."""
|
||||
@@ -837,53 +751,6 @@ class TestSDKRunnerAPIProxyFieldConsistency:
|
||||
These tests verify that SDK sends field names that match what Host handler reads.
|
||||
"""
|
||||
|
||||
def test_call_tool_field_names_match(self):
|
||||
"""CALL_TOOL: SDK 'parameters' matches Host 'parameters'."""
|
||||
# SDK agent_run_api.py line 146: "parameters": parameters
|
||||
# Host handler.py line 457: parameters = data['parameters']
|
||||
sdk_field = 'parameters'
|
||||
host_field = 'parameters'
|
||||
assert sdk_field == host_field
|
||||
|
||||
def test_call_tool_run_id_field_present(self):
|
||||
"""CALL_TOOL: SDK includes 'run_id' field."""
|
||||
# SDK agent_run_api.py line 144: "run_id": self.run_id
|
||||
# Host handler.py line 458: run_id = data.get('run_id')
|
||||
sdk_fields = ['run_id', 'tool_name', 'parameters']
|
||||
host_expected_fields = ['tool_name', 'parameters', 'run_id']
|
||||
|
||||
for field in host_expected_fields:
|
||||
assert field in sdk_fields
|
||||
|
||||
def test_invoke_llm_field_names_match(self):
|
||||
"""INVOKE_LLM: SDK fields match Host handler."""
|
||||
# SDK agent_run_api.py lines 77-82
|
||||
sdk_fields = ['run_id', 'llm_model_uuid', 'messages', 'funcs', 'extra_args', 'timeout']
|
||||
# Host handler.py lines 333-337
|
||||
host_fields = ['llm_model_uuid', 'messages', 'funcs', 'extra_args', 'run_id']
|
||||
|
||||
for field in host_fields:
|
||||
assert field in sdk_fields
|
||||
|
||||
def test_invoke_llm_stream_field_names_match(self):
|
||||
"""INVOKE_LLM_STREAM: SDK fields match Host handler."""
|
||||
# SDK agent_run_api.py lines 111-116
|
||||
sdk_fields = ['run_id', 'llm_model_uuid', 'messages', 'funcs', 'extra_args']
|
||||
# Host handler.py lines 397-401
|
||||
host_fields = ['llm_model_uuid', 'messages', 'funcs', 'extra_args', 'run_id']
|
||||
|
||||
for field in host_fields:
|
||||
assert field in sdk_fields
|
||||
|
||||
def test_retrieve_knowledge_base_field_names_match(self):
|
||||
"""RETRIEVE_KNOWLEDGE_BASE: SDK fields match Host handler."""
|
||||
# SDK agent_run_api.py lines 178-183
|
||||
sdk_fields = ['run_id', 'kb_id', 'query_text', 'top_k', 'filters']
|
||||
|
||||
# Note: query_id is from query context, not SDK proxy
|
||||
for field in ['run_id', 'kb_id', 'query_text', 'top_k', 'filters']:
|
||||
assert field in sdk_fields
|
||||
|
||||
def test_retrieve_knowledge_base_action_enum_correct(self):
|
||||
"""RETRIEVE_KNOWLEDGE_BASE: SDK uses correct action enum."""
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
|
||||
@@ -906,31 +773,6 @@ class TestNoRunIdBackwardCompatPath:
|
||||
have unrestricted access to certain APIs.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_llm_no_run_id_unrestricted_access(self):
|
||||
"""INVOKE_LLM: no run_id means unrestricted model access."""
|
||||
# Handler.py line 340: if run_id: ...
|
||||
# When run_id is None, the authorization block is skipped
|
||||
run_id = None
|
||||
llm_model_uuid = 'any_model'
|
||||
|
||||
# Simulate handler logic: no run_id skips authorization.
|
||||
assert run_id is None
|
||||
|
||||
# Model can be any UUID (unrestricted)
|
||||
assert llm_model_uuid == 'any_model'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_no_run_id_unrestricted_access(self):
|
||||
"""CALL_TOOL: no run_id means unrestricted tool access."""
|
||||
run_id = None
|
||||
tool_name = 'any_tool'
|
||||
|
||||
# Handler.py line 463: if run_id: ...
|
||||
assert run_id is None
|
||||
|
||||
assert tool_name == 'any_tool'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve_knowledge_base_no_run_id_pipeline_check(self):
|
||||
"""RETRIEVE_KNOWLEDGE_BASE: no run_id uses pipeline config check."""
|
||||
@@ -963,70 +805,6 @@ class TestNoRunIdBackwardCompatPath:
|
||||
class TestSessionExpiryAndCleanup:
|
||||
"""Tests for session expiry and cleanup scenarios."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_expiry_detection(self):
|
||||
"""Session expiry: old session should be considered expired."""
|
||||
import time
|
||||
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(models=[{'model_id': 'model_001'}])
|
||||
|
||||
# Register session
|
||||
await registry.register(
|
||||
run_id='run_expiry_test',
|
||||
runner_id='plugin:test/runner/default',
|
||||
query_id=1,
|
||||
plugin_identity='test/runner',
|
||||
resources=resources,
|
||||
)
|
||||
|
||||
session = await registry.get('run_expiry_test')
|
||||
assert session is not None
|
||||
|
||||
# Check session status
|
||||
started_at = session['status']['started_at']
|
||||
last_activity = session['status']['last_activity_at']
|
||||
assert last_activity >= started_at
|
||||
|
||||
# Session should be valid initially
|
||||
current_time = int(time.time())
|
||||
assert current_time - started_at < 10 # Less than 10 seconds old
|
||||
|
||||
await registry.unregister('run_expiry_test')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_stale_sessions(self):
|
||||
"""Cleanup: stale sessions should be removed."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(models=[{'model_id': 'model_001'}])
|
||||
|
||||
# Register session
|
||||
await registry.register(
|
||||
run_id='run_cleanup_test',
|
||||
runner_id='plugin:test/runner/default',
|
||||
query_id=1,
|
||||
plugin_identity='test/runner',
|
||||
resources=resources,
|
||||
)
|
||||
|
||||
# Session exists
|
||||
session = await registry.get('run_cleanup_test')
|
||||
assert session is not None
|
||||
|
||||
# Cleanup with max_age=0 (immediate cleanup)
|
||||
# Note: This won't actually cleanup because session is just created
|
||||
# We need to manually test cleanup logic
|
||||
cleaned = await registry.cleanup_stale_sessions(max_age_seconds=0)
|
||||
assert isinstance(cleaned, int)
|
||||
|
||||
# Session should still exist (it was just created)
|
||||
# With max_age=0, sessions with last_activity > 0 seconds ago would be cleaned
|
||||
# But since it's just created, last_activity_at is current time
|
||||
session_after = await registry.get('run_cleanup_test')
|
||||
assert session_after is not None
|
||||
|
||||
await registry.unregister('run_cleanup_test')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unregister_removes_session(self):
|
||||
"""Unregister: session should be removed from registry."""
|
||||
@@ -2002,32 +1780,3 @@ class TestCallerPluginIdentityValidation:
|
||||
assert 'no plugin_identity' in error.message
|
||||
|
||||
await registry.unregister('run_missing_pull_identity')
|
||||
|
||||
|
||||
class TestBackwardCompatStorageNoRunId:
|
||||
"""Tests for unscoped storage actions without run_id.
|
||||
|
||||
Regular plugins (non-Runner) don't have run_id and should
|
||||
have unrestricted access to storage APIs.
|
||||
"""
|
||||
|
||||
def test_storage_no_run_id_skips_validation(self):
|
||||
"""Storage actions without run_id skip Host-side validation."""
|
||||
# Handler.py: if run_id: ...validation...
|
||||
# When run_id is None, validation is skipped
|
||||
run_id = None
|
||||
|
||||
# Simulate handler logic: no run_id skips validation.
|
||||
assert run_id is None
|
||||
|
||||
# Storage access unrestricted for regular plugins
|
||||
assert run_id is None
|
||||
|
||||
def test_file_no_run_id_skips_validation(self):
|
||||
"""GET_CONFIG_FILE without run_id skips Host-side validation."""
|
||||
run_id = None
|
||||
|
||||
assert run_id is None
|
||||
|
||||
# File access unrestricted for regular plugins
|
||||
assert run_id is None
|
||||
|
||||
@@ -388,38 +388,24 @@ class TestUserServiceAuthenticate:
|
||||
class TestUserServiceGenerateJwtToken:
|
||||
"""Tests for generate_jwt_token method."""
|
||||
|
||||
async def test_generate_jwt_token_returns_valid_token(self):
|
||||
"""Generates valid JWT token."""
|
||||
@pytest.mark.parametrize('expire', [3600, 7200])
|
||||
async def test_generate_jwt_token_returns_valid_token(self, expire):
|
||||
"""The signed token retains the account and configured lifetime."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 3600}}}
|
||||
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': expire}}}
|
||||
|
||||
service = UserService(ap)
|
||||
|
||||
# Execute
|
||||
before = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||
token = await service.generate_jwt_token('[email protected]')
|
||||
after = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||
|
||||
# Verify - JWT format (base64 encoded parts)
|
||||
assert token is not None
|
||||
assert len(token) > 0
|
||||
parts = token.split('.')
|
||||
assert len(parts) == 3 # JWT has 3 parts
|
||||
|
||||
async def test_generate_jwt_token_custom_expire(self):
|
||||
"""Generates token with custom expiry."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 7200}}}
|
||||
|
||||
service = UserService(ap)
|
||||
|
||||
# Execute
|
||||
token = await service.generate_jwt_token('[email protected]')
|
||||
|
||||
# Verify
|
||||
assert token is not None
|
||||
claims = jwt.decode(token, 'test_secret', algorithms=['HS256'], options={'verify_aud': False})
|
||||
assert claims['user'] == '[email protected]'
|
||||
assert before + expire <= claims['exp'] <= after + expire
|
||||
|
||||
|
||||
class TestUserServiceVerifyJwtToken:
|
||||
|
||||
@@ -486,54 +486,6 @@ class TestWebhookServiceGetEnabledWebhooks:
|
||||
# Verify
|
||||
assert result == []
|
||||
|
||||
async def test_get_enabled_webhooks_filters_enabled(self):
|
||||
"""Returns only enabled webhooks."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
|
||||
# All returned webhooks should be enabled (SQL filter)
|
||||
webhook1 = _create_mock_webhook(webhook_id=1, name='Enabled 1', enabled=True)
|
||||
webhook2 = _create_mock_webhook(webhook_id=2, name='Enabled 2', enabled=True)
|
||||
|
||||
mock_result = _create_mock_result([webhook1, webhook2])
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
side_effect=lambda model_cls, entity: {
|
||||
'id': entity.id,
|
||||
'name': entity.name,
|
||||
'enabled': entity.enabled,
|
||||
}
|
||||
)
|
||||
|
||||
service = WebhookService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service.get_enabled_webhooks(WORKSPACE_UUID)
|
||||
|
||||
# Verify
|
||||
assert len(result) == 2
|
||||
assert all(w['enabled'] for w in result)
|
||||
|
||||
async def test_get_enabled_webhooks_filters_disabled(self):
|
||||
"""Does not return disabled webhooks."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
|
||||
# Empty result because query filters on enabled=True
|
||||
mock_result = _create_mock_result([])
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={})
|
||||
|
||||
service = WebhookService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service.get_enabled_webhooks(WORKSPACE_UUID)
|
||||
|
||||
# Verify - should be empty (SQL would filter disabled)
|
||||
assert result == []
|
||||
|
||||
|
||||
ISOLATION_WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
|
||||
ISOLATION_WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
|
||||
@@ -639,6 +591,11 @@ async def test_cross_workspace_id_guessing_is_not_found(tenant_webhook_service):
|
||||
|
||||
|
||||
async def test_update_and_delete_are_scoped(tenant_webhook_service):
|
||||
enabled = await tenant_webhook_service.create_webhook(
|
||||
ISOLATION_WORKSPACE_A,
|
||||
'keep enabled',
|
||||
'https://a.invalid/enabled',
|
||||
)
|
||||
created = await tenant_webhook_service.create_webhook(
|
||||
ISOLATION_WORKSPACE_A,
|
||||
'old',
|
||||
@@ -654,7 +611,9 @@ async def test_update_and_delete_are_scoped(tenant_webhook_service):
|
||||
assert updated['name'] == 'new'
|
||||
assert updated['enabled'] is False
|
||||
assert updated['url'] == 'https://a.invalid/old'
|
||||
assert await tenant_webhook_service.get_enabled_webhooks(ISOLATION_WORKSPACE_A) == []
|
||||
assert [item['id'] for item in await tenant_webhook_service.get_enabled_webhooks(ISOLATION_WORKSPACE_A)] == [
|
||||
enabled['id']
|
||||
]
|
||||
assert await tenant_webhook_service.delete_webhook(ISOLATION_WORKSPACE_A, created['id'])
|
||||
assert await tenant_webhook_service.get_webhook(ISOLATION_WORKSPACE_A, created['id']) is None
|
||||
|
||||
|
||||
@@ -219,90 +219,44 @@ class TestCommandManagerExecute:
|
||||
assert hasattr(result, '__aiter__')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_sets_privilege_for_admin(self):
|
||||
"""execute() sets privilege=2 for admin users."""
|
||||
|
||||
fake_app = FakeApp(admins=['person_12345'])
|
||||
mgr = CommandManager(fake_app)
|
||||
mgr.cmd_list = []
|
||||
|
||||
# Mock plugin_connector
|
||||
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
|
||||
|
||||
query = command_query('status')
|
||||
query.launcher_type = provider_session.LauncherTypes.PERSON
|
||||
query.launcher_id = 12345
|
||||
|
||||
session = self._create_session()
|
||||
|
||||
results = []
|
||||
async for ret in mgr.execute('status', '/status', query, session):
|
||||
results.append(ret)
|
||||
|
||||
# Verify admin config was checked
|
||||
assert 'person_12345' in fake_app.instance_config.data['admins']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_sets_privilege_for_non_admin(self):
|
||||
"""execute() sets privilege=1 for non-admin users."""
|
||||
|
||||
fake_app = FakeApp(admins=['person_12345'])
|
||||
mgr = CommandManager(fake_app)
|
||||
mgr.cmd_list = []
|
||||
|
||||
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
|
||||
|
||||
query = command_query('status')
|
||||
query.launcher_type = provider_session.LauncherTypes.PERSON
|
||||
query.launcher_id = 67890 # Not in admins list
|
||||
|
||||
session = self._create_session(launcher_id=67890)
|
||||
|
||||
results = []
|
||||
async for ret in mgr.execute('status', '/status', query, session):
|
||||
results.append(ret)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_parses_command_text(self):
|
||||
"""execute() splits command_text into params."""
|
||||
@pytest.mark.parametrize(('is_admin', 'privilege'), [(True, 2), (False, 1)])
|
||||
@pytest.mark.parametrize('bound_plugins', [None, ['plugin1', 'plugin2']])
|
||||
async def test_execute_delivers_scoped_command_context(self, is_admin, privilege, bound_plugins):
|
||||
from langbot_plugin.api.entities.builtin.command.context import CommandReturn
|
||||
|
||||
fake_app = FakeApp()
|
||||
fake_app.persistence_mgr.execute_async = AsyncMock(
|
||||
return_value=Mock(first=Mock(return_value=object() if is_admin else None))
|
||||
)
|
||||
command = Mock()
|
||||
command.metadata.name = 'help'
|
||||
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[command])
|
||||
received = []
|
||||
|
||||
async def execute_command(context, plugins):
|
||||
received.append((context, plugins))
|
||||
yield CommandReturn(text='plugin response')
|
||||
|
||||
fake_app.plugin_connector.execute_command = execute_command
|
||||
mgr = CommandManager(fake_app)
|
||||
mgr.cmd_list = []
|
||||
|
||||
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
|
||||
|
||||
query = command_query('help arg1 arg2')
|
||||
query.variables['_pipeline_bound_plugins'] = bound_plugins
|
||||
session = self._create_session()
|
||||
|
||||
results = []
|
||||
async for ret in mgr.execute('help arg1 arg2', '/help arg1 arg2', query, session):
|
||||
results.append(ret)
|
||||
results = [result async for result in mgr.execute('help arg1 arg2', '/help arg1 arg2', query, session)]
|
||||
|
||||
# Command text parsing happens inside execute()
|
||||
# We verify it doesn't crash
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_passes_bound_plugins(self):
|
||||
"""execute() passes bound_plugins from query variables."""
|
||||
|
||||
fake_app = FakeApp()
|
||||
mgr = CommandManager(fake_app)
|
||||
mgr.cmd_list = []
|
||||
|
||||
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
|
||||
|
||||
query = command_query('help')
|
||||
query.variables = {'_pipeline_bound_plugins': ['plugin1', 'plugin2']}
|
||||
|
||||
session = self._create_session()
|
||||
|
||||
results = []
|
||||
async for ret in mgr.execute('help', '/help', query, session):
|
||||
results.append(ret)
|
||||
|
||||
# Bound plugins are extracted from query.variables
|
||||
assert query.variables.get('_pipeline_bound_plugins') == ['plugin1', 'plugin2']
|
||||
assert [result.text for result in results] == ['plugin response']
|
||||
fake_app.plugin_connector.list_commands.assert_awaited_once_with(bound_plugins)
|
||||
assert len(received) == 1
|
||||
context, plugins = received[0]
|
||||
assert plugins == bound_plugins
|
||||
assert context.privilege == privilege
|
||||
assert context.command == context.crt_command == 'help'
|
||||
assert context.command_text == 'help arg1 arg2'
|
||||
assert context.full_command_text == '/help arg1 arg2'
|
||||
assert context.params == ['help', 'arg1', 'arg2']
|
||||
assert context.crt_params == ['arg1', 'arg2']
|
||||
|
||||
|
||||
class TestCommandManagerInternalExecute:
|
||||
@@ -420,31 +374,6 @@ class TestCommandManagerInternalExecute:
|
||||
assert selected.placement_generation == 4
|
||||
assert selected.query_uuid == 'query-a'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_with_bound_plugins(self):
|
||||
"""_execute passes bound_plugins to plugin connector."""
|
||||
|
||||
fake_app = FakeApp()
|
||||
mgr = CommandManager(fake_app)
|
||||
mgr.cmd_list = []
|
||||
|
||||
# Mock plugin command
|
||||
mock_command = Mock()
|
||||
mock_command.metadata.name = 'test_cmd'
|
||||
|
||||
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[mock_command])
|
||||
|
||||
async def mock_execute_command(ctx, bound_plugins):
|
||||
yield Mock(text='ok')
|
||||
|
||||
fake_app.plugin_connector.execute_command = mock_execute_command
|
||||
|
||||
ctx = self._create_context(command='test_cmd')
|
||||
|
||||
# Execute with bound_plugins parameter
|
||||
async for _ in mgr._execute(ctx, mgr.cmd_list, bound_plugins=['test_plugin']):
|
||||
pass
|
||||
|
||||
|
||||
class TestEmptyAndEdgeInputs:
|
||||
"""Tests for empty and edge inputs."""
|
||||
|
||||
@@ -194,16 +194,11 @@ class TestLogCache:
|
||||
assert result == ''
|
||||
|
||||
def test_get_log_by_pointer_nonexistent_page(self):
|
||||
"""get_log_by_pointer handles nonexistent page number."""
|
||||
"""A cursor beyond the tail returns no logs and resumes at the actual tail."""
|
||||
cache = LogCache()
|
||||
|
||||
cache.add_log('log 1')
|
||||
|
||||
# Request page that doesn't exist
|
||||
result, page_num, offset = cache.get_log_by_pointer(99, 0)
|
||||
|
||||
# Returns empty or last available
|
||||
# Behavior depends on implementation
|
||||
assert cache.get_log_by_pointer(99, 0) == ('', 0, 1)
|
||||
|
||||
def test_max_cached_pages_constant(self):
|
||||
"""MAX_CACHED_PAGES is defined and reasonable."""
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.vector.vdb import (
|
||||
@@ -134,89 +133,3 @@ class TestVectorDatabaseAbstractMethods:
|
||||
|
||||
result = asyncio.run(db.list_by_filter('test_collection'))
|
||||
assert result == ([], -1)
|
||||
|
||||
|
||||
class TestVectorDatabaseInterface:
|
||||
"""Tests for VectorDatabase interface contracts."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_vector_db(self):
|
||||
"""Create a minimal mock VectorDatabase for testing."""
|
||||
|
||||
class MockVectorDB(VectorDatabase):
|
||||
def __init__(self):
|
||||
self.add_embeddings = AsyncMock()
|
||||
self.search = AsyncMock(
|
||||
return_value={
|
||||
'ids': [['id1', 'id2']],
|
||||
'distances': [[0.1, 0.2]],
|
||||
'metadatas': [[{'key': 'val1'}, {'key': 'val2'}]],
|
||||
}
|
||||
)
|
||||
self.delete_by_file_id = AsyncMock()
|
||||
self.delete_by_filter = AsyncMock(return_value=5)
|
||||
self.get_or_create_collection = AsyncMock()
|
||||
self.delete_collection = AsyncMock()
|
||||
|
||||
async def add_embeddings(self, collection, ids, embeddings_list, metadatas, documents=None):
|
||||
pass
|
||||
|
||||
async def search(
|
||||
self,
|
||||
collection,
|
||||
query_embedding,
|
||||
k=5,
|
||||
search_type='vector',
|
||||
query_text='',
|
||||
filter=None,
|
||||
vector_weight=None,
|
||||
):
|
||||
pass
|
||||
|
||||
async def delete_by_file_id(self, collection, file_id):
|
||||
pass
|
||||
|
||||
async def delete_by_filter(self, collection, filter):
|
||||
pass
|
||||
|
||||
async def get_or_create_collection(self, collection):
|
||||
pass
|
||||
|
||||
async def delete_collection(self, collection):
|
||||
pass
|
||||
|
||||
return MockVectorDB()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_embeddings_signature(self, mock_vector_db):
|
||||
"""add_embeddings has expected signature."""
|
||||
await mock_vector_db.add_embeddings(
|
||||
collection='test',
|
||||
ids=['id1', 'id2'],
|
||||
embeddings_list=[[0.1, 0.2], [0.3, 0.4]],
|
||||
metadatas=[{'a': 1}, {'b': 2}],
|
||||
documents=['doc1', 'doc2'],
|
||||
)
|
||||
mock_vector_db.add_embeddings.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_signature(self, mock_vector_db):
|
||||
"""search has expected signature with all optional params."""
|
||||
import numpy as np
|
||||
|
||||
await mock_vector_db.search(
|
||||
collection='test',
|
||||
query_embedding=np.array([0.1, 0.2]),
|
||||
k=10,
|
||||
search_type='hybrid',
|
||||
query_text='search text',
|
||||
filter={'file_id': 'abc'},
|
||||
vector_weight=0.7,
|
||||
)
|
||||
mock_vector_db.search.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_by_filter_returns_int(self, mock_vector_db):
|
||||
"""delete_by_filter returns int count."""
|
||||
result = await mock_vector_db.delete_by_filter('test', {'file_id': 'abc'})
|
||||
assert isinstance(result, int)
|
||||
|
||||
Reference in New Issue
Block a user