From 5641637d3df6a0906b89ce0e669e0ce0b6ecfd0e Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Sat, 17 Oct 2026 11:26:46 +0800 Subject: [PATCH] Merge pull request #2665 from langbot-app/test/regression-boundary-cleanup-20261017 test: remove self-confirming tests and strengthen regression boundaries --- tests/unit_tests/agent/test_chat_handler.py | 60 ---- .../agent/test_event_log_transcript.py | 334 +++++------------- tests/unit_tests/agent/test_handler_auth.py | 251 ------------- .../api/service/test_user_service.py | 32 +- .../api/service/test_webhook_service.py | 57 +-- tests/unit_tests/command/test_cmdmgr.py | 131 ++----- tests/unit_tests/utils/test_logcache.py | 9 +- tests/unit_tests/vector/test_vdb_base.py | 87 ----- 8 files changed, 132 insertions(+), 829 deletions(-) diff --git a/tests/unit_tests/agent/test_chat_handler.py b/tests/unit_tests/agent/test_chat_handler.py index d6ba5f386..4d73ba2b9 100644 --- a/tests/unit_tests/agent/test_chat_handler.py +++ b/tests/unit_tests/agent/test_chat_handler.py @@ -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.""" diff --git a/tests/unit_tests/agent/test_event_log_transcript.py b/tests/unit_tests/agent/test_event_log_transcript.py index af709e33f..eb0ab3b52 100644 --- a/tests/unit_tests/agent/test_event_log_transcript.py +++ b/tests/unit_tests/agent/test_event_log_transcript.py @@ -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() diff --git a/tests/unit_tests/agent/test_handler_auth.py b/tests/unit_tests/agent/test_handler_auth.py index 60668414e..67d2316da 100644 --- a/tests/unit_tests/agent/test_handler_auth.py +++ b/tests/unit_tests/agent/test_handler_auth.py @@ -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 diff --git a/tests/unit_tests/api/service/test_user_service.py b/tests/unit_tests/api/service/test_user_service.py index 493c033a2..ae430d097 100644 --- a/tests/unit_tests/api/service/test_user_service.py +++ b/tests/unit_tests/api/service/test_user_service.py @@ -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('test@example.com') + 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('test@example.com') - - # Verify - assert token is not None + claims = jwt.decode(token, 'test_secret', algorithms=['HS256'], options={'verify_aud': False}) + assert claims['user'] == 'test@example.com' + assert before + expire <= claims['exp'] <= after + expire class TestUserServiceVerifyJwtToken: diff --git a/tests/unit_tests/api/service/test_webhook_service.py b/tests/unit_tests/api/service/test_webhook_service.py index fcf44c094..86046cdb1 100644 --- a/tests/unit_tests/api/service/test_webhook_service.py +++ b/tests/unit_tests/api/service/test_webhook_service.py @@ -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 diff --git a/tests/unit_tests/command/test_cmdmgr.py b/tests/unit_tests/command/test_cmdmgr.py index afc251a13..c4c9156a6 100644 --- a/tests/unit_tests/command/test_cmdmgr.py +++ b/tests/unit_tests/command/test_cmdmgr.py @@ -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.""" diff --git a/tests/unit_tests/utils/test_logcache.py b/tests/unit_tests/utils/test_logcache.py index 775a3ed27..03f2b509a 100644 --- a/tests/unit_tests/utils/test_logcache.py +++ b/tests/unit_tests/utils/test_logcache.py @@ -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.""" diff --git a/tests/unit_tests/vector/test_vdb_base.py b/tests/unit_tests/vector/test_vdb_base.py index a044ac216..43f831e0d 100644 --- a/tests/unit_tests/vector/test_vdb_base.py +++ b/tests/unit_tests/vector/test_vdb_base.py @@ -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)