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:
huanghuoguoguo
2026-10-17 11:26:46 +08:00
committed by GitHub
parent e11c3e2599
commit 5641637d3d
8 changed files with 132 additions and 829 deletions
@@ -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()
-251
View File
@@ -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
+30 -101
View File
@@ -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."""
+2 -7
View File
@@ -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."""
-87
View File
@@ -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)