From bb55cd7ba954fe3027d727a2700b0f19767f08de Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Sat, 16 May 2026 10:30:17 +0800 Subject: [PATCH] test: tighten phase 1 coverage contracts --- tests/e2e/test_startup.py | 16 +- tests/e2e/utils/process_manager.py | 1 - tests/integration/api/test_bots.py | 5 +- tests/integration/api/test_knowledge.py | 5 +- tests/integration/api/test_pipelines.py | 5 +- tests/integration/api/test_providers.py | 5 +- .../api/service/test_apikey_service.py | 43 +- .../api/service/test_bot_service.py | 14 +- .../api/service/test_knowledge_service.py | 2 +- .../api/service/test_pipeline_service.py | 48 +- .../core/test_app_config_validation.py | 1 - tests/unit_tests/core/test_bootutils_deps.py | 43 +- tests/unit_tests/discover/test_engine.py | 1 - .../persistence/test_mgr_methods.py | 2 +- .../persistence/test_serialize_model.py | 6 +- tests/unit_tests/pipeline/test_cntfilter.py | 12 +- tests/unit_tests/pipeline/test_longtext.py | 85 +-- tests/unit_tests/pipeline/test_msgtrun.py | 17 +- tests/unit_tests/pipeline/test_n8nsvapi.py | 52 +- tests/unit_tests/pipeline/test_pool.py | 12 +- tests/unit_tests/pipeline/test_preproc.py | 7 +- tests/unit_tests/pipeline/test_simple.py | 40 -- tests/unit_tests/pipeline/test_wrapper.py | 13 +- .../plugin/test_connector_methods.py | 31 +- .../unit_tests/plugin/test_connector_pure.py | 40 +- .../plugin/test_connector_static.py | 24 - tests/unit_tests/plugin/test_extract_deps.py | 24 +- tests/unit_tests/plugin/test_handler.py | 85 +-- .../unit_tests/plugin/test_handler_actions.py | 537 +++++++----------- .../plugin/test_plugin_component_filtering.py | 8 +- .../plugin/test_plugin_list_sorting.py | 6 +- .../requesters/test_chatcmpl_errors_direct.py | 16 +- .../requesters/test_chatcmpl_utils.py | 31 +- .../requesters/test_ollama_requester.py | 6 +- .../provider/test_session_manager.py | 1 - tests/unit_tests/rag/test_file_storage.py | 516 +++++------------ tests/unit_tests/telemetry/test_telemetry.py | 27 +- tests/unit_tests/utils/test_httpclient.py | 30 +- tests/unit_tests/utils/test_importutil.py | 29 +- tests/unit_tests/utils/test_logcache.py | 1 - tests/unit_tests/utils/test_pkgmgr.py | 3 +- tests/unit_tests/utils/test_runner.py | 19 +- tests/unit_tests/utils/test_version.py | 1 - .../vector/test_vdb_filter_conversion.py | 2 - 44 files changed, 708 insertions(+), 1164 deletions(-) delete mode 100644 tests/unit_tests/pipeline/test_simple.py diff --git a/tests/e2e/test_startup.py b/tests/e2e/test_startup.py index b971a2ba..dcbe8e75 100644 --- a/tests/e2e/test_startup.py +++ b/tests/e2e/test_startup.py @@ -12,8 +12,6 @@ Run: uv run pytest tests/e2e/test_startup.py -v -m e2e from __future__ import annotations import pytest -import httpx -from pathlib import Path pytestmark = pytest.mark.e2e @@ -64,9 +62,8 @@ class TestStartupFlow: def test_chroma_directory_created(self, e2e_tmpdir): """Verify Chroma vector database directory was created.""" chroma_path = e2e_tmpdir / 'chroma' - # Chroma should create its storage on startup or when first used - # This test just verifies the directory exists (created by config factory) - assert chroma_path.exists() or True # May not be created until first use + # Created by the E2E config factory before startup. + assert chroma_path.exists() def test_pipelines_endpoint(self, e2e_client): """Test /api/v1/pipelines endpoint (requires auth).""" @@ -86,8 +83,7 @@ class TestStartupFlow: # - 200 if auth succeeds # - 400 if credentials wrong # - 401 if user not initialized - # - 500 if internal error (e.g., user service not initialized) - assert response.status_code in [200, 400, 401, 500] + assert response.status_code in [200, 400, 401] class TestStartupStages: @@ -127,8 +123,8 @@ class TestStartupStages: for endpoint in endpoints: response = e2e_client.get(endpoint) - # Should get valid response (even if 401 unauthorized) - assert response.status_code < 500, f'{endpoint} should not return 5xx' + # Should get a real route response, even if auth is required. + assert response.status_code in [200, 401, 403], f'{endpoint} should be registered' class TestMinimalStartupNoLLM: @@ -143,4 +139,4 @@ class TestMinimalStartupNoLLM: """Pipeline metadata endpoint should work without LLM.""" # Requires auth, but endpoint should exist response = e2e_client.get('/api/v1/pipelines/_/metadata') - assert response.status_code in [200, 401] # Not 404 or 500 \ No newline at end of file + assert response.status_code in [200, 401] # Not 404 or 500 diff --git a/tests/e2e/utils/process_manager.py b/tests/e2e/utils/process_manager.py index abcc67d7..888b5dec 100644 --- a/tests/e2e/utils/process_manager.py +++ b/tests/e2e/utils/process_manager.py @@ -9,7 +9,6 @@ import subprocess import time import signal import os -import shutil from pathlib import Path from typing import Optional import logging diff --git a/tests/integration/api/test_bots.py b/tests/integration/api/test_bots.py index 5a3b0b9d..b8928f7b 100644 --- a/tests/integration/api/test_bots.py +++ b/tests/integration/api/test_bots.py @@ -64,7 +64,8 @@ def fake_bot_app(): # Auth services app.user_service = Mock() app.user_service.is_initialized = AsyncMock(return_value=True) - app.user_service.verify_jwt_token = AsyncMock(return_value={'email': 'test@example.com'}) + app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com') + app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com')) app.apikey_service = Mock() app.apikey_service.verify_api_key = AsyncMock(return_value=True) @@ -251,4 +252,4 @@ class TestBotSendMessageEndpoint: assert response.status_code == 400 data = await response.get_json() - assert data['code'] == -1 \ No newline at end of file + assert data['code'] == -1 diff --git a/tests/integration/api/test_knowledge.py b/tests/integration/api/test_knowledge.py index b2274d45..27f3bd35 100644 --- a/tests/integration/api/test_knowledge.py +++ b/tests/integration/api/test_knowledge.py @@ -65,7 +65,8 @@ def fake_knowledge_app(): # Auth services app.user_service = Mock() app.user_service.is_initialized = AsyncMock(return_value=True) - app.user_service.verify_jwt_token = AsyncMock(return_value={'email': 'test@example.com'}) + app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com') + app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com')) app.apikey_service = Mock() app.apikey_service.verify_api_key = AsyncMock(return_value=True) @@ -257,4 +258,4 @@ class TestKnowledgeBaseRetrieveEndpoint: assert response.status_code == 400 data = await response.get_json() - assert data['code'] == -1 \ No newline at end of file + assert data['code'] == -1 diff --git a/tests/integration/api/test_pipelines.py b/tests/integration/api/test_pipelines.py index e802f1de..c039c9fa 100644 --- a/tests/integration/api/test_pipelines.py +++ b/tests/integration/api/test_pipelines.py @@ -72,7 +72,8 @@ def fake_pipeline_app(): # Auth services app.user_service = Mock() app.user_service.is_initialized = AsyncMock(return_value=True) - app.user_service.verify_jwt_token = AsyncMock(return_value={'email': 'test@example.com'}) + app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com') + app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com')) app.apikey_service = Mock() app.apikey_service.verify_api_key = AsyncMock(return_value=True) @@ -271,4 +272,4 @@ class TestPipelineExtensionsEndpoint: # Should return 200 if pipeline found assert response.status_code == 200 data = await response.get_json() - assert data['code'] == 0 \ No newline at end of file + assert data['code'] == 0 diff --git a/tests/integration/api/test_providers.py b/tests/integration/api/test_providers.py index 2bfacfb6..a963ed1b 100644 --- a/tests/integration/api/test_providers.py +++ b/tests/integration/api/test_providers.py @@ -65,7 +65,8 @@ def fake_provider_app(): # Auth services app.user_service = Mock() app.user_service.is_initialized = AsyncMock(return_value=True) - app.user_service.verify_jwt_token = AsyncMock(return_value={'email': 'test@example.com'}) + app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com') + app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com')) app.apikey_service = Mock() app.apikey_service.verify_api_key = AsyncMock(return_value=True) @@ -345,4 +346,4 @@ class TestRerankModelEndpoints: assert response.status_code == 200 data = await response.get_json() assert data['code'] == 0 - assert 'uuid' in data['data'] \ No newline at end of file + assert 'uuid' in data['data'] diff --git a/tests/unit_tests/api/service/test_apikey_service.py b/tests/unit_tests/api/service/test_apikey_service.py index f46d606e..e7187987 100644 --- a/tests/unit_tests/api/service/test_apikey_service.py +++ b/tests/unit_tests/api/service/test_apikey_service.py @@ -9,7 +9,7 @@ Source: src/langbot/pkg/api/http/service/apikey.py from __future__ import annotations import pytest -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch from types import SimpleNamespace from langbot.pkg.api.http.service.apikey import ApiKeyService @@ -101,43 +101,42 @@ class TestApiKeyServiceCreateApiKey: ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() - # Mock insert result - insert_result = Mock() - insert_result.all = Mock(return_value=[]) - - # Mock select result for retrieving created key created_key = Mock(spec=ApiKey) created_key.id = 1 created_key.name = 'New Key' - created_key.key = 'lbk_generated_key' + created_key.key = 'lbk_fixed-token' created_key.description = 'Test description' select_result = Mock() select_result.first = Mock(return_value=created_key) + insert_params = [] - # execute_async returns different results for insert vs select async def mock_execute(query): - # First call is insert, second is select - if hasattr(query, 'values'): - return insert_result + params = query.compile().params + if {'name', 'key', 'description'}.issubset(params): + insert_params.append(params) + return Mock() return select_result ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) ap.persistence_mgr.serialize_model = Mock( - return_value={ + side_effect=lambda model_cls, entity: { 'id': 1, - 'name': 'New Key', - 'key': 'lbk_generated_key', - 'description': 'Test description', + 'name': entity.name, + 'key': entity.key, + 'description': entity.description, } ) service = ApiKeyService(ap) - # Execute - result = await service.create_api_key('New Key', 'Test description') + with patch('langbot.pkg.api.http.service.apikey.secrets.token_urlsafe', return_value='fixed-token'): + result = await service.create_api_key('New Key', 'Test description') - # Verify key format + assert insert_params == [ + {'name': 'New Key', 'key': 'lbk_fixed-token', 'description': 'Test description'} + ] assert result['key'].startswith('lbk_') + assert result['key'] == 'lbk_fixed-token' assert result['name'] == 'New Key' assert result['description'] == 'Test description' @@ -313,8 +312,8 @@ class TestApiKeyServiceVerifyApiKey: # Verify assert result is False - async def test_verify_api_key_wrong_prefix(self): - """Returns False for key without correct prefix.""" + async def test_verify_api_key_unknown_key(self): + """Returns False when the key is not present in persistence.""" # Setup ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() @@ -326,7 +325,7 @@ class TestApiKeyServiceVerifyApiKey: service = ApiKeyService(ap) # Execute - result = await service.verify_api_key('invalid_prefix_key') + result = await service.verify_api_key('unknown_key') # Verify assert result is False @@ -427,4 +426,4 @@ class TestApiKeyServiceUpdateApiKey: await service.update_api_key(1) # Verify - no execute call since no update_data - ap.persistence_mgr.execute_async.assert_not_called() \ No newline at end of file + ap.persistence_mgr.execute_async.assert_not_called() diff --git a/tests/unit_tests/api/service/test_bot_service.py b/tests/unit_tests/api/service/test_bot_service.py index 91806870..c1e5abfe 100644 --- a/tests/unit_tests/api/service/test_bot_service.py +++ b/tests/unit_tests/api/service/test_bot_service.py @@ -432,7 +432,7 @@ class TestBotServiceUpdateBot: """Tests for update_bot method.""" async def test_update_bot_removes_uuid_from_data(self): - """Removes uuid field from update data.""" + """Does not persist caller-provided uuid in update payload.""" # Setup ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() @@ -456,9 +456,9 @@ class TestBotServiceUpdateBot: update_data = {'uuid': 'should-be-removed', 'name': 'Updated Name'} await service.update_bot('test-uuid', update_data) - # Verify - uuid was removed from bot_data dict - assert 'uuid' not in update_data - assert 'name' in update_data + update_params = ap.persistence_mgr.execute_async.await_args_list[0].args[0].compile().params + assert update_params['name'] == 'Updated Name' + assert 'should-be-removed' not in update_params.values() async def test_update_bot_pipeline_not_found_raises(self): """Raises Exception when updating with nonexistent pipeline UUID.""" @@ -513,7 +513,9 @@ class TestBotServiceUpdateBot: # Execute await service.update_bot('test-uuid', {'use_pipeline_uuid': 'pipeline-uuid'}) - # Verify - pipeline name was captured + update_params = ap.persistence_mgr.execute_async.await_args_list[1].args[0].compile().params + assert update_params['use_pipeline_uuid'] == 'pipeline-uuid' + assert update_params['use_pipeline_name'] == 'Updated Pipeline' class TestBotServiceDeleteBot: @@ -657,4 +659,4 @@ class TestBotServiceSendMessage: await service.send_message('bot-uuid', 'group', '123', message_chain_data) # Verify adapter.send_message was called - runtime_bot.adapter.send_message.assert_called_once_with('group', '123', mock_chain) \ No newline at end of file + runtime_bot.adapter.send_message.assert_called_once_with('group', '123', mock_chain) diff --git a/tests/unit_tests/api/service/test_knowledge_service.py b/tests/unit_tests/api/service/test_knowledge_service.py index 563aec18..87aeddcf 100644 --- a/tests/unit_tests/api/service/test_knowledge_service.py +++ b/tests/unit_tests/api/service/test_knowledge_service.py @@ -153,7 +153,7 @@ class TestCreateKnowledgeBase: service = knowledge_module.KnowledgeService(mock_app) - result = await service.create_knowledge_base({ + await service.create_knowledge_base({ 'knowledge_engine_plugin_id': 'author/engine' }) diff --git a/tests/unit_tests/api/service/test_pipeline_service.py b/tests/unit_tests/api/service/test_pipeline_service.py index 763b335c..a84adab8 100644 --- a/tests/unit_tests/api/service/test_pipeline_service.py +++ b/tests/unit_tests/api/service/test_pipeline_service.py @@ -318,24 +318,22 @@ class TestPipelineServiceCreatePipeline: service.get_pipelines = AsyncMock(return_value=[]) service.get_pipeline = AsyncMock(return_value={ 'uuid': 'new-uuid', - 'extensions_preferences': { - 'enable_all_plugins': True, - 'enable_all_mcp_servers': True, - 'plugins': [], - 'mcp_servers': [], - } + 'extensions_preferences': {}, }) - ap.persistence_mgr.execute_async = AsyncMock() + insert_params = [] + + async def mock_execute(query): + params = query.compile().params + if 'extensions_preferences' in params: + insert_params.append(params) + return Mock() + + ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) ap.persistence_mgr.serialize_model = Mock( return_value={ 'uuid': 'new-uuid', - 'extensions_preferences': { - 'enable_all_plugins': True, - 'enable_all_mcp_servers': True, - 'plugins': [], - 'mcp_servers': [], - } + 'extensions_preferences': {}, } ) @@ -344,8 +342,13 @@ class TestPipelineServiceCreatePipeline: with patch('langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'): await service.create_pipeline({'name': 'New Pipeline'}) - # Verify - extensions_preferences should have been set - ap.persistence_mgr.execute_async.assert_called() + assert len(insert_params) == 1 + assert insert_params[0]['extensions_preferences'] == { + 'enable_all_plugins': True, + 'enable_all_mcp_servers': True, + 'plugins': [], + 'mcp_servers': [], + } class _MockResultWithBots: @@ -364,7 +367,7 @@ class TestPipelineServiceUpdatePipeline: """Tests for update_pipeline method.""" async def test_update_pipeline_removes_protected_fields(self): - """Removes uuid, for_version, stages, is_default from update data.""" + """Does not persist protected fields from update data.""" # Setup ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() @@ -390,12 +393,11 @@ class TestPipelineServiceUpdatePipeline: } await service.update_pipeline('test-uuid', pipeline_data) - # Verify - protected fields removed - assert 'uuid' not in pipeline_data - assert 'for_version' not in pipeline_data - assert 'stages' not in pipeline_data - assert 'is_default' not in pipeline_data - assert 'description' in pipeline_data + update_params = ap.persistence_mgr.execute_async.await_args_list[0].args[0].compile().params + assert update_params['description'] == 'New description' + assert 'should-be-removed' not in update_params.values() + assert ['should-be-removed'] not in update_params.values() + assert not any(value is True for value in update_params.values()) async def test_update_pipeline_syncs_bot_names(self): """Updates bot use_pipeline_name when pipeline name changes.""" @@ -826,4 +828,4 @@ class TestDefaultStageOrder: def test_default_stage_order_contains_key_stages(self): """Default stage order contains key processing stages.""" assert 'MessageProcessor' in default_stage_order - assert 'SendResponseBackStage' in default_stage_order \ No newline at end of file + assert 'SendResponseBackStage' in default_stage_order diff --git a/tests/unit_tests/core/test_app_config_validation.py b/tests/unit_tests/core/test_app_config_validation.py index fb1e3df6..b90a3bd7 100644 --- a/tests/unit_tests/core/test_app_config_validation.py +++ b/tests/unit_tests/core/test_app_config_validation.py @@ -6,7 +6,6 @@ Tests cover: """ from __future__ import annotations -import pytest from unittest.mock import Mock from importlib import import_module diff --git a/tests/unit_tests/core/test_bootutils_deps.py b/tests/unit_tests/core/test_bootutils_deps.py index ef4f0a65..35e928b9 100644 --- a/tests/unit_tests/core/test_bootutils_deps.py +++ b/tests/unit_tests/core/test_bootutils_deps.py @@ -102,36 +102,33 @@ class TestPrecheckPluginDeps: def test_precheck_plugin_deps_no_plugins_dir(self): """precheck_plugin_deps skips when plugins dir doesn't exist.""" - mocks = self._make_deps_import_mocks() - - with isolated_sys_modules(mocks): - with patch('os.path.exists', return_value=False): - from langbot.pkg.core.bootutils.deps import precheck_plugin_deps + from langbot.pkg.core.bootutils.deps import precheck_plugin_deps + with patch('os.path.exists', return_value=False): + with patch('langbot.pkg.core.bootutils.deps.pkgmgr.install_requirements') as mock_install: import asyncio asyncio.get_event_loop().run_until_complete(precheck_plugin_deps()) - # Should not raise, just skip + mock_install.assert_not_called() def test_precheck_plugin_deps_with_plugins_dir(self): """precheck_plugin_deps checks plugins subdirectories.""" - mocks = self._make_deps_import_mocks() - mock_pkgmgr = MagicMock() - mocks['langbot.pkg.utils.pkgmgr'].install_requirements = mock_pkgmgr + from langbot.pkg.core.bootutils.deps import precheck_plugin_deps - with isolated_sys_modules(mocks): - from langbot.pkg.core.bootutils.deps import precheck_plugin_deps + def mock_listdir(path): + if path == 'plugins': + return ['plugin1', 'plugin2'] + if path == 'plugins/plugin1': + return ['requirements.txt', 'main.py'] + if path == 'plugins/plugin2': + return ['main.py'] + return [] - # Mock os functions - with patch('os.path.exists', return_value=True): - with patch('os.listdir', return_value=['plugin1', 'plugin2']): - with patch('os.path.isdir', return_value=True): - # plugin1 has requirements.txt, plugin2 doesn't - def mock_listdir_subdir(path): - if 'plugin1' in path: - return ['requirements.txt', 'main.py'] - return ['main.py'] + with patch('os.path.exists', return_value=True): + with patch('os.path.isdir', return_value=True): + with patch('os.listdir', side_effect=mock_listdir): + with patch('langbot.pkg.core.bootutils.deps.pkgmgr.install_requirements') as mock_install: + import asyncio + asyncio.get_event_loop().run_until_complete(precheck_plugin_deps()) - with patch('os.listdir', side_effect=lambda p: mock_listdir_subdir(p) if 'plugin' in p else ['plugin1', 'plugin2']): - import asyncio - asyncio.get_event_loop().run_until_complete(precheck_plugin_deps()) \ No newline at end of file + mock_install.assert_called_once_with('plugins/plugin1/requirements.txt', extra_params=[]) diff --git a/tests/unit_tests/discover/test_engine.py b/tests/unit_tests/discover/test_engine.py index 6342cc70..63ce82d8 100644 --- a/tests/unit_tests/discover/test_engine.py +++ b/tests/unit_tests/discover/test_engine.py @@ -6,7 +6,6 @@ Tests I18nString, Metadata, and Component utilities. from __future__ import annotations -import pytest from langbot.pkg.discover.engine import I18nString, Metadata, Component diff --git a/tests/unit_tests/persistence/test_mgr_methods.py b/tests/unit_tests/persistence/test_mgr_methods.py index 52ac6c0b..2145f84e 100644 --- a/tests/unit_tests/persistence/test_mgr_methods.py +++ b/tests/unit_tests/persistence/test_mgr_methods.py @@ -47,7 +47,7 @@ class TestExecuteAsync: mgr.db = mock_db # Execute a simple select - result = await mgr.execute_async(sqlalchemy.select(1)) + await mgr.execute_async(sqlalchemy.select(1)) mock_conn.execute.assert_called_once() mock_conn.commit.assert_called_once() diff --git a/tests/unit_tests/persistence/test_serialize_model.py b/tests/unit_tests/persistence/test_serialize_model.py index 7981c1c0..199c3a8f 100644 --- a/tests/unit_tests/persistence/test_serialize_model.py +++ b/tests/unit_tests/persistence/test_serialize_model.py @@ -8,6 +8,8 @@ Tests cover: from __future__ import annotations import datetime +from unittest.mock import Mock + from sqlalchemy import Column, Integer, String, DateTime from sqlalchemy.orm import declarative_base from importlib import import_module @@ -124,7 +126,3 @@ class TestSerializeModel: assert 'id' not in result assert 'name' not in result assert 'created_at' in result - - -# Import Mock for type annotations -from unittest.mock import Mock \ No newline at end of file diff --git a/tests/unit_tests/pipeline/test_cntfilter.py b/tests/unit_tests/pipeline/test_cntfilter.py index e5015a07..a506dd06 100644 --- a/tests/unit_tests/pipeline/test_cntfilter.py +++ b/tests/unit_tests/pipeline/test_cntfilter.py @@ -91,9 +91,7 @@ class TestContentFilterStageInit: await stage.initialize(pipeline_config) - assert stage.filter_chain is not None - # Should have at least 'content-ignore' filter - assert len(stage.filter_chain) >= 1 + assert [filter_impl.name for filter_impl in stage.filter_chain] == ['content-ignore'] @pytest.mark.asyncio async def test_initialize_with_sensitive_words(self): @@ -121,8 +119,10 @@ class TestContentFilterStageInit: await stage.initialize(pipeline_config) - # Should have content-ignore and ban-word-filter - assert len(stage.filter_chain) >= 2 + assert [filter_impl.name for filter_impl in stage.filter_chain] == [ + 'ban-word-filter', + 'content-ignore', + ] class TestPreContentFilter: @@ -511,4 +511,4 @@ class TestContentIgnoreFilterDirect: result = await stage.process(query, 'PreContentFilterStage') - assert result.result_type == cntfilter.entities.ResultType.CONTINUE \ No newline at end of file + assert result.result_type == cntfilter.entities.ResultType.CONTINUE diff --git a/tests/unit_tests/pipeline/test_longtext.py b/tests/unit_tests/pipeline/test_longtext.py index 9d3dde91..8110fd71 100644 --- a/tests/unit_tests/pipeline/test_longtext.py +++ b/tests/unit_tests/pipeline/test_longtext.py @@ -160,8 +160,11 @@ class TestLongTextProcessStageProcess: result = await stage.process(query, 'LongTextProcessStage') assert result.result_type == entities.ResultType.CONTINUE - # Should not transform short text - assert result.new_query.resp_message_chain is not None + assert len(result.new_query.resp_message_chain) == 1 + components = list(result.new_query.resp_message_chain[0]) + assert len(components) == 1 + assert isinstance(components[0], platform_message.Plain) + assert components[0].text == 'short response' @pytest.mark.asyncio async def test_non_plain_component_skips(self): @@ -189,35 +192,13 @@ class TestLongTextProcessStageProcess: result = await stage.process(query, 'LongTextProcessStage') assert result.result_type == entities.ResultType.CONTINUE - # Should skip due to non-Plain component - - @pytest.mark.asyncio - async def test_empty_resp_message_chain(self): - """Empty resp_message_chain should be handled gracefully.""" - longtext = get_longtext_module() - entities = get_entities_module() - - app = FakeApp() - stage = longtext.LongTextProcessStage(app) - - pipeline_config = make_longtext_config(strategy='forward') - - await stage.initialize(pipeline_config) - - query = text_query("hello") - query.pipeline_config = pipeline_config - query.resp_message_chain = [] - - # Should handle gracefully (may raise or return CONTINUE) - # This tests the defensive behavior - try: - result = await stage.process(query, 'LongTextProcessStage') - # If it returns, should be CONTINUE - assert result.result_type == entities.ResultType.CONTINUE - except (IndexError, AttributeError): - # Expected if resp_message_chain is empty - pass - + components = list(result.new_query.resp_message_chain[0]) + assert [type(component) for component in components] == [ + platform_message.Plain, + platform_message.Image, + ] + assert components[0].text == 'short' + assert components[1].url == 'https://example.com/img.png' class TestForwardStrategy: """Tests for ForwardComponentStrategy.""" @@ -253,8 +234,9 @@ class TestForwardStrategy: result = await stage.process(query, 'LongTextProcessStage') assert result.result_type == entities.ResultType.CONTINUE - # Check that message chain was transformed - assert result.new_query.resp_message_chain is not None + components = list(result.new_query.resp_message_chain[0]) + assert len(components) == 1 + assert isinstance(components[0], platform_message.Forward) @pytest.mark.asyncio async def test_forward_strategy_direct_process(self): @@ -288,36 +270,6 @@ class TestForwardStrategy: class TestLongTextThreshold: """Tests for threshold boundary handling.""" - @pytest.mark.asyncio - async def test_exact_threshold_continues(self): - """Text exactly at threshold should trigger processing.""" - longtext = get_longtext_module() - entities = get_entities_module() - - app = FakeApp() - stage = longtext.LongTextProcessStage(app) - - threshold = 50 - pipeline_config = make_longtext_config(strategy='forward', threshold=threshold) - - await stage.initialize(pipeline_config) - - query = text_query("hello") - query.pipeline_config = pipeline_config - mock_adapter = Mock() - mock_adapter.bot_account_id = '12345' - query.adapter = mock_adapter - - # Text exactly at threshold - exact_text = "x" * threshold - query.resp_message_chain = [ - platform_message.MessageChain([platform_message.Plain(text=exact_text)]) - ] - - result = await stage.process(query, 'LongTextProcessStage') - - assert result.result_type == entities.ResultType.CONTINUE - @pytest.mark.asyncio async def test_below_threshold_not_processed(self): """Text below threshold should not be transformed.""" @@ -344,7 +296,10 @@ class TestLongTextThreshold: result = await stage.process(query, 'LongTextProcessStage') assert result.result_type == entities.ResultType.CONTINUE - # Original chain should remain unchanged + components = list(result.new_query.resp_message_chain[0]) + assert len(components) == 1 + assert isinstance(components[0], platform_message.Plain) + assert components[0].text == short_text class TestLongTextProcessStageImageStrategy: @@ -367,4 +322,4 @@ class TestLongTextProcessStageImageStrategy: # Should have initialized (possibly with fallback strategy) if stage.strategy_impl is not None: - assert isinstance(stage.strategy_impl, strategy.LongTextStrategy) \ No newline at end of file + assert isinstance(stage.strategy_impl, strategy.LongTextStrategy) diff --git a/tests/unit_tests/pipeline/test_msgtrun.py b/tests/unit_tests/pipeline/test_msgtrun.py index 35e42ffb..9cfdabab 100644 --- a/tests/unit_tests/pipeline/test_msgtrun.py +++ b/tests/unit_tests/pipeline/test_msgtrun.py @@ -254,13 +254,12 @@ class TestRoundTruncatorProcess: assert result.result_type == entities.ResultType.CONTINUE - # Check order is preserved (user2 -> asst2 -> user3) messages = result.new_query.messages - if len(messages) >= 3: - assert messages[0].role == 'user' - assert messages[0].content == 'user2' - assert messages[1].role == 'assistant' - assert messages[1].content == 'asst2' + assert [(msg.role, msg.content) for msg in messages] == [ + ('user', 'user2'), + ('assistant', 'asst2'), + ('user', 'user3'), + ] @pytest.mark.asyncio async def test_truncate_max_round_one(self): @@ -286,10 +285,8 @@ class TestRoundTruncatorProcess: result = await stage.process(query, 'ConversationMessageTruncator') assert result.result_type == entities.ResultType.CONTINUE - # Only last round (user + assistant pair) should remain messages = result.new_query.messages - # At most 2 messages (user + assistant before current) - assert len(messages) <= 2 + assert [(msg.role, msg.content) for msg in messages] == [('user', 'current')] class TestRoundTruncatorDirect: @@ -321,4 +318,4 @@ class TestRoundTruncatorDirect: result = await trun.truncate(query) assert result is not None - assert hasattr(result, 'messages') \ No newline at end of file + assert hasattr(result, 'messages') diff --git a/tests/unit_tests/pipeline/test_n8nsvapi.py b/tests/unit_tests/pipeline/test_n8nsvapi.py index 975fd0d2..b9bbcc2d 100644 --- a/tests/unit_tests/pipeline/test_n8nsvapi.py +++ b/tests/unit_tests/pipeline/test_n8nsvapi.py @@ -19,14 +19,23 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch _mock_runner = MagicMock() _mock_runner.runner_class = lambda name: (lambda cls: cls) # no-op decorator _mock_runner.RequestRunner = object -sys.modules.setdefault('langbot.pkg.provider.runner', _mock_runner) -sys.modules.setdefault('langbot.pkg.core.app', MagicMock()) -sys.modules.setdefault('langbot.pkg.utils.httpclient', MagicMock()) +_mocked_imports = { + 'langbot.pkg.provider.runner': _mock_runner, + 'langbot.pkg.core.app': MagicMock(), +} +_original_imports = {name: sys.modules.get(name) for name in _mocked_imports} +sys.modules.update(_mocked_imports) import pytest # noqa: E402 import langbot_plugin.api.entities.builtin.provider.message as provider_message # noqa: E402 from langbot.pkg.provider.runners.n8nsvapi import N8nServiceAPIRunner # noqa: E402 +for _name, _original in _original_imports.items(): + if _original is None: + sys.modules.pop(_name, None) + else: + sys.modules[_name] = _original + # --------------------------------------------------------------------------- # Helpers @@ -82,10 +91,10 @@ async def test_stream_format_single_item(): chunks = await collect_chunks(runner, [data]) - assert len(chunks) >= 1 - final = chunks[-1] - assert final.is_final is True - assert final.content == 'hello' + assert len(chunks) == 1 + assert chunks[0].is_final is True + assert chunks[0].content == 'hello' + assert chunks[0].msg_sequence == 1 @pytest.mark.asyncio @@ -100,9 +109,10 @@ async def test_stream_format_multi_item_accumulates(): chunks = await collect_chunks(runner, chunks_data) - final = chunks[-1] - assert final.is_final is True - assert final.content == 'foobar' + assert len(chunks) == 1 + assert chunks[0].is_final is True + assert chunks[0].content == 'foobar' + assert chunks[0].msg_sequence == 1 @pytest.mark.asyncio @@ -115,9 +125,13 @@ async def test_stream_format_batches_every_8_items(): chunks = await collect_chunks(runner, [data]) - # At least the batch yield at chunk_idx==8 + final yield - assert len(chunks) >= 2 - assert chunks[-1].is_final is True + assert len(chunks) == 2 + assert chunks[0].is_final is False + assert chunks[0].content == '01234567' + assert chunks[0].msg_sequence == 1 + assert chunks[1].is_final is True + assert chunks[1].content == '01234567' + assert chunks[1].msg_sequence == 2 @pytest.mark.asyncio @@ -129,9 +143,9 @@ async def test_stream_format_split_across_network_chunks(): chunks = await collect_chunks(runner, [part1, part2]) - final = chunks[-1] - assert final.is_final is True - assert final.content == 'world' + assert len(chunks) == 1 + assert chunks[0].is_final is True + assert chunks[0].content == 'world' @pytest.mark.asyncio @@ -143,10 +157,8 @@ async def test_stream_format_no_spurious_empty_yield(): chunks = await collect_chunks(runner, [data]) - # No chunk should have empty content before the real content arrives - non_final = [c for c in chunks if not c.is_final] - for c in non_final: - assert c.content # must be non-empty + assert len(chunks) == 1 + assert chunks[0].content == 'x' # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/pipeline/test_pool.py b/tests/unit_tests/pipeline/test_pool.py index 79bec087..86515e7f 100644 --- a/tests/unit_tests/pipeline/test_pool.py +++ b/tests/unit_tests/pipeline/test_pool.py @@ -7,7 +7,7 @@ Tests query management, ID generation, and async context handling. from __future__ import annotations import pytest -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import Mock, patch from langbot.pkg.pipeline.pool import QueryPool @@ -37,8 +37,8 @@ class TestQueryPoolInit: class TestQueryPoolAddQuery: """Tests for add_query method.""" - async def test_add_query_returns_query_with_id(self): - """add_query creates a Query with correct ID.""" + async def test_add_query_adds_query_with_id(self): + """add_query creates, stores, and caches a Query with the correct ID.""" pool = QueryPool() # Mock Query creation @@ -134,7 +134,7 @@ class TestQueryPoolAddQuery: with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery: MockQuery.return_value = mock_query - query = await pool.add_query( + await pool.add_query( bot_uuid='bot1', launcher_type=Mock(), launcher_id=1, @@ -158,7 +158,7 @@ class TestQueryPoolAddQuery: with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery: MockQuery.return_value = mock_query - query = await pool.add_query( + await pool.add_query( bot_uuid='bot1', launcher_type=Mock(), launcher_id=1, @@ -184,7 +184,7 @@ class TestQueryPoolAddQuery: with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery: MockQuery.return_value = mock_query - query = await pool.add_query( + await pool.add_query( bot_uuid='bot1', launcher_type=Mock(), launcher_id=1, diff --git a/tests/unit_tests/pipeline/test_preproc.py b/tests/unit_tests/pipeline/test_preproc.py index 2623ceae..1413f5f7 100644 --- a/tests/unit_tests/pipeline/test_preproc.py +++ b/tests/unit_tests/pipeline/test_preproc.py @@ -155,11 +155,10 @@ class TestPreProcessorEmptyMessage: result = await stage.process(query, 'PreProcessor') - # Empty message should still continue (behavior depends on code) + # Empty message should still continue with an empty provider content list. assert result.result_type == entities.ResultType.CONTINUE assert result.new_query.user_message is not None - # Empty content list - assert result.new_query.user_message.content == [] or result.new_query.user_message.content is None + assert result.new_query.user_message.content == [] class TestPreProcessorImageSegment: @@ -428,4 +427,4 @@ class TestPreProcessorVariables: variables = result.new_query.variables assert 'group_name' in variables - assert 'sender_name' in variables \ No newline at end of file + assert 'sender_name' in variables diff --git a/tests/unit_tests/pipeline/test_simple.py b/tests/unit_tests/pipeline/test_simple.py deleted file mode 100644 index c300b1ba..00000000 --- a/tests/unit_tests/pipeline/test_simple.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -Simple standalone tests to verify test infrastructure -These tests don't import the actual pipeline code to avoid circular import issues -""" - -import pytest -from unittest.mock import Mock, AsyncMock - - -def test_pytest_works(): - """Verify pytest is working""" - assert True - - -@pytest.mark.asyncio -async def test_async_works(): - """Verify async tests work""" - mock = AsyncMock(return_value=42) - result = await mock() - assert result == 42 - - -def test_mocks_work(): - """Verify mocking works""" - mock = Mock() - mock.return_value = 'test' - assert mock() == 'test' - - -def test_fixtures_work(mock_app): - """Verify fixtures are loaded""" - assert mock_app is not None - assert mock_app.logger is not None - assert mock_app.sess_mgr is not None - - -def test_sample_query(sample_query): - """Verify sample query fixture works""" - assert sample_query.query_id == 'test-query-id' - assert sample_query.launcher_id == 12345 diff --git a/tests/unit_tests/pipeline/test_wrapper.py b/tests/unit_tests/pipeline/test_wrapper.py index 0b541140..e5d47c76 100644 --- a/tests/unit_tests/pipeline/test_wrapper.py +++ b/tests/unit_tests/pipeline/test_wrapper.py @@ -238,9 +238,9 @@ class TestResponseWrapperAssistant: async def test_assistant_empty_content(self): """Assistant with empty content should not emit event.""" wrapper = get_wrapper_module() - get_entities_module() app = FakeApp() + app.plugin_connector.emit_event = AsyncMock() stage = wrapper.ResponseWrapper(app) pipeline_config = make_wrapper_config() @@ -262,8 +262,9 @@ class TestResponseWrapperAssistant: async for result in stage.process(query, 'ResponseWrapper'): results.append(result) - # Should have at least one result (for empty content case) - assert len(results) >= 0 + assert results == [] + assert query.resp_message_chain == [] + app.plugin_connector.emit_event.assert_not_called() @pytest.mark.asyncio async def test_assistant_tool_calls(self): @@ -313,10 +314,10 @@ class TestResponseWrapperAssistant: async for result in stage.process(query, 'ResponseWrapper'): results.append(result) - # Should have results for content and tool_calls - assert len(results) >= 1 + assert len(results) == 2 for result in results: assert result.result_type == entities.ResultType.CONTINUE + assert app.plugin_connector.emit_event.await_count == 2 class TestResponseWrapperInterrupt: @@ -472,4 +473,4 @@ class TestResponseWrapperVariables: # Check that bound_plugins was passed emit_call = app.plugin_connector.emit_event.call_args - assert emit_call[0][1] == ['plugin1', 'plugin2'] # Second argument is bound_plugins \ No newline at end of file + assert emit_call[0][1] == ['plugin1', 'plugin2'] # Second argument is bound_plugins diff --git a/tests/unit_tests/plugin/test_connector_methods.py b/tests/unit_tests/plugin/test_connector_methods.py index ec479a3c..10ce2419 100644 --- a/tests/unit_tests/plugin/test_connector_methods.py +++ b/tests/unit_tests/plugin/test_connector_methods.py @@ -73,7 +73,7 @@ class TestListPlugins: result = await connector.list_plugins() connector.handler.list_plugins.assert_called_once() - assert len(result) == 1 + assert result == [{'manifest': {'manifest': {'metadata': {'author': 'test', 'name': 'plugin'}}}}] @pytest.mark.asyncio async def test_filters_by_component_kinds(self): @@ -171,7 +171,7 @@ class TestListKnowledgeEngines: result = await connector.list_knowledge_engines() connector.handler.list_knowledge_engines.assert_called_once() - assert len(result) == 1 + assert result == [{'plugin_id': 'author/engine', 'name': 'Engine'}] class TestListParsers: @@ -208,7 +208,7 @@ class TestListParsers: result = await connector.list_parsers() connector.handler.list_parsers.assert_called_once() - assert len(result) == 1 + assert result == [{'plugin_id': 'author/parser', 'supported_mime_types': ['text/plain']}] class TestCallParser: @@ -269,8 +269,19 @@ class TestRAGMethods: result = await connector.call_rag_retrieve('author/engine', {'query': 'test'}) - connector.handler.retrieve_knowledge.assert_called_once() - assert 'results' in result + connector.handler.retrieve_knowledge.assert_called_once_with( + 'author', 'engine', '', {'query': 'test'} + ) + assert result == { + 'results': [ + { + 'id': 'doc1', + 'content': [{'type': 'text', 'text': 'test'}], + 'metadata': {}, + 'distance': 0.1, + } + ] + } @pytest.mark.asyncio async def test_get_rag_creation_schema(self): @@ -286,7 +297,7 @@ class TestRAGMethods: result = await connector.get_rag_creation_schema('author/engine') connector.handler.get_rag_creation_schema.assert_called_once_with('author', 'engine') - assert 'properties' in result + assert result == {'properties': {'name': {'type': 'string'}}} @pytest.mark.asyncio async def test_get_rag_retrieval_schema(self): @@ -302,7 +313,7 @@ class TestRAGMethods: result = await connector.get_rag_retrieval_schema('author/engine') connector.handler.get_rag_retrieval_schema.assert_called_once_with('author', 'engine') - assert 'properties' in result + assert result == {'properties': {'top_k': {'type': 'integer'}}} @pytest.mark.asyncio async def test_rag_on_kb_create(self): @@ -442,7 +453,7 @@ class TestGetPluginInfo: result = await connector.get_plugin_info('author', 'plugin') connector.handler.get_plugin_info.assert_called_once_with('author', 'plugin') - assert 'manifest' in result + assert result == {'manifest': {'metadata': {'name': 'plugin'}}} class TestSetPluginConfig: @@ -474,7 +485,7 @@ class TestPingPluginRuntime: connector = create_mock_connector() # handler is not set - with pytest.raises(Exception) as exc_info: + with pytest.raises(Exception, match='Plugin runtime is not connected') as exc_info: await connector.ping_plugin_runtime() assert 'not connected' in str(exc_info.value) @@ -490,4 +501,4 @@ class TestPingPluginRuntime: await connector.ping_plugin_runtime() - connector.handler.ping.assert_called_once() \ No newline at end of file + connector.handler.ping.assert_called_once() diff --git a/tests/unit_tests/plugin/test_connector_pure.py b/tests/unit_tests/plugin/test_connector_pure.py index beaf7a24..13ba29b5 100644 --- a/tests/unit_tests/plugin/test_connector_pure.py +++ b/tests/unit_tests/plugin/test_connector_pure.py @@ -1,7 +1,7 @@ """Tests for PluginRuntimeConnector pure logic methods. Tests methods that don't require real plugin runtime processes: -- _extract_deps_metadata: deps extraction from zip files +- _inspect_plugin_package: identity and deps extraction from zip files - _parse_plugin_id: plugin ID string parsing """ @@ -12,13 +12,15 @@ import zipfile from types import SimpleNamespace from unittest.mock import MagicMock +import pytest + class TestExtractDepsMetadata: - """Tests for _extract_deps_metadata method.""" + """Tests for dependency metadata extraction from plugin packages.""" def _create_connector(self): """Create a connector instance for testing.""" - from src.langbot.pkg.plugin.connector import PluginRuntimeConnector + from langbot.pkg.plugin.connector import PluginRuntimeConnector mock_app = MagicMock() mock_app.instance_config.data.get.return_value = {'enable': True} @@ -39,7 +41,7 @@ class TestExtractDepsMetadata: zip_bytes = zip_buffer.getvalue() task_context = SimpleNamespace(metadata={}) - connector._extract_deps_metadata(zip_bytes, task_context) + connector._inspect_plugin_package(zip_bytes, task_context) assert task_context.metadata['deps_total'] == 3 # requests>=2.0, flask, numpy # deps_list contains full requirement lines including version specifiers @@ -58,7 +60,7 @@ class TestExtractDepsMetadata: zip_bytes = zip_buffer.getvalue() task_context = SimpleNamespace(metadata={}) - connector._extract_deps_metadata(zip_bytes, task_context) + connector._inspect_plugin_package(zip_bytes, task_context) assert task_context.metadata['deps_total'] == 0 assert task_context.metadata['deps_list'] == [] @@ -74,7 +76,7 @@ class TestExtractDepsMetadata: zip_bytes = zip_buffer.getvalue() task_context = SimpleNamespace(metadata={}) - connector._extract_deps_metadata(zip_bytes, task_context) + connector._inspect_plugin_package(zip_bytes, task_context) # No requirements.txt found, metadata unchanged assert 'deps_total' not in task_context.metadata @@ -90,7 +92,7 @@ class TestExtractDepsMetadata: zip_bytes = zip_buffer.getvalue() # Should return early without error - connector._extract_deps_metadata(zip_bytes, None) + connector._inspect_plugin_package(zip_bytes, None) def test_extract_deps_invalid_zip(self): """Handle invalid zip file gracefully.""" @@ -100,7 +102,7 @@ class TestExtractDepsMetadata: invalid_bytes = b'not a zip file' task_context = SimpleNamespace(metadata={}) - connector._extract_deps_metadata(invalid_bytes, task_context) + connector._inspect_plugin_package(invalid_bytes, task_context) # Should catch exception and pass silently assert 'deps_total' not in task_context.metadata @@ -116,7 +118,7 @@ class TestExtractDepsMetadata: zip_bytes = zip_buffer.getvalue() task_context = SimpleNamespace(metadata={}) - connector._extract_deps_metadata(zip_bytes, task_context) + connector._inspect_plugin_package(zip_bytes, task_context) # Should find requirements.txt in subdirectory assert task_context.metadata['deps_total'] == 2 @@ -127,25 +129,15 @@ class TestParsePluginId: def test_parse_valid_plugin_id(self): """Parse valid plugin ID format 'author/name'.""" - from src.langbot.pkg.plugin.connector import PluginRuntimeConnector + from langbot.pkg.plugin.connector import PluginRuntimeConnector author, name = PluginRuntimeConnector._parse_plugin_id('myauthor/myplugin') assert author == 'myauthor' assert name == 'myplugin' - def test_parse_plugin_id_with_multiple_slashes(self): - """Parse plugin ID with multiple slashes uses split('/', 1).""" - from src.langbot.pkg.plugin.connector import PluginRuntimeConnector - - # split('/', 1) only splits on first slash - author, name = PluginRuntimeConnector._parse_plugin_id('org/author/plugin-name') - assert author == 'org' - assert name == 'author/plugin-name' - def test_parse_plugin_id_empty(self): - """Handle empty plugin ID.""" + """Empty plugin ID is invalid.""" + from langbot.pkg.plugin.connector import PluginRuntimeConnector - # Empty string behavior - parts = ''.split('/') - assert len(parts) == 1 - assert parts[0] == '' \ No newline at end of file + with pytest.raises(ValueError): + PluginRuntimeConnector._parse_plugin_id('') diff --git a/tests/unit_tests/plugin/test_connector_static.py b/tests/unit_tests/plugin/test_connector_static.py index ebce1ba0..77747b7b 100644 --- a/tests/unit_tests/plugin/test_connector_static.py +++ b/tests/unit_tests/plugin/test_connector_static.py @@ -24,14 +24,6 @@ class TestParsePluginId: assert author == 'langbot' assert name == 'rag-engine' - def test_valid_plugin_id_with_slash_in_name(self): - """Test parsing plugin ID where name contains additional slashes.""" - connector = get_connector_module() - # split('/', 1) only splits on first slash - author, name = connector.PluginRuntimeConnector._parse_plugin_id('author/name/with/slashes') - assert author == 'author' - assert name == 'name/with/slashes' - def test_invalid_plugin_id_no_slash(self): """Test that ValueError is raised when no slash present.""" connector = get_connector_module() @@ -60,19 +52,3 @@ class TestParsePluginId: author, name = connector.PluginRuntimeConnector._parse_plugin_id('lang-bot/my_rag_engine') assert author == 'lang-bot' assert name == 'my_rag_engine' - - def test_valid_plugin_id_author_only(self): - """Test that plugin ID with only author (trailing slash) is parsed.""" - connector = get_connector_module() - # 'author/' - split returns ('author', '') - author, name = connector.PluginRuntimeConnector._parse_plugin_id('author/') - assert author == 'author' - assert name == '' - - def test_valid_plugin_id_name_only(self): - """Test that plugin ID with only name (leading slash) is parsed.""" - connector = get_connector_module() - # '/name' - split returns ('', 'name') - author, name = connector.PluginRuntimeConnector._parse_plugin_id('/name') - assert author == '' - assert name == 'name' \ No newline at end of file diff --git a/tests/unit_tests/plugin/test_extract_deps.py b/tests/unit_tests/plugin/test_extract_deps.py index 9501b161..e9c30ec9 100644 --- a/tests/unit_tests/plugin/test_extract_deps.py +++ b/tests/unit_tests/plugin/test_extract_deps.py @@ -1,4 +1,4 @@ -"""Unit tests for plugin connector _extract_deps_metadata method. +"""Unit tests for plugin connector _inspect_plugin_package method. Tests cover: - Extracting requirements.txt from ZIP @@ -60,7 +60,7 @@ def create_zip_without_requirements() -> bytes: class TestExtractDepsMetadata: - """Tests for _extract_deps_metadata method.""" + """Tests for dependency metadata extraction from plugin packages.""" def test_extract_simple_requirements(self): """Test extracting simple requirements.txt.""" @@ -73,7 +73,7 @@ class TestExtractDepsMetadata: task_context = Mock() task_context.metadata = {} - connector_instance._extract_deps_metadata(zip_bytes, task_context) + connector_instance._inspect_plugin_package(zip_bytes, task_context) assert task_context.metadata.get('deps_total') == 3 assert task_context.metadata.get('deps_list') == ['requests>=2.0', 'flask==1.0', 'numpy'] @@ -94,7 +94,7 @@ numpy''' task_context = Mock() task_context.metadata = {} - connector_instance._extract_deps_metadata(zip_bytes, task_context) + connector_instance._inspect_plugin_package(zip_bytes, task_context) assert task_context.metadata.get('deps_total') == 3 assert '# This is a comment' not in task_context.metadata.get('deps_list', []) @@ -108,7 +108,7 @@ numpy''' task_context = Mock() task_context.metadata = {} - connector_instance._extract_deps_metadata(zip_bytes, task_context) + connector_instance._inspect_plugin_package(zip_bytes, task_context) # Should find nested requirements.txt (ends with 'requirements.txt') assert task_context.metadata.get('deps_total') == 2 @@ -122,7 +122,7 @@ numpy''' task_context = Mock() task_context.metadata = {} - connector_instance._extract_deps_metadata(zip_bytes, task_context) + connector_instance._inspect_plugin_package(zip_bytes, task_context) # metadata should remain empty (no deps found) assert task_context.metadata.get('deps_total') is None @@ -137,7 +137,7 @@ numpy''' task_context = Mock() task_context.metadata = {} - connector_instance._extract_deps_metadata(zip_bytes, task_context) + connector_instance._inspect_plugin_package(zip_bytes, task_context) # deps_total should be 0 (empty list after filtering) assert task_context.metadata.get('deps_total') == 0 @@ -155,7 +155,7 @@ numpy''' task_context = Mock() task_context.metadata = {} - connector_instance._extract_deps_metadata(zip_bytes, task_context) + connector_instance._inspect_plugin_package(zip_bytes, task_context) assert task_context.metadata.get('deps_total') == 0 assert task_context.metadata.get('deps_list') == [] @@ -167,7 +167,7 @@ numpy''' zip_bytes = create_zip_with_requirements('requests') # Should return without error when task_context is None - connector_instance._extract_deps_metadata(zip_bytes, None) + connector_instance._inspect_plugin_package(zip_bytes, None) # No exception should be raised @@ -182,7 +182,7 @@ numpy''' task_context.metadata = {} # Should silently handle exception (pass in try/except) - connector_instance._extract_deps_metadata(invalid_bytes, task_context) + connector_instance._inspect_plugin_package(invalid_bytes, task_context) # metadata should remain unchanged assert task_context.metadata == {} @@ -203,8 +203,8 @@ numpy''' task_context.metadata = {} # errors='ignore' will decode \x80invalid as 'invalid' (skipping \x80) - connector_instance._extract_deps_metadata(zip_bytes, task_context) + connector_instance._inspect_plugin_package(zip_bytes, task_context) # All 3 lines will be parsed (requests, flask, invalid) assert task_context.metadata.get('deps_total') == 3 - assert 'invalid' in task_context.metadata.get('deps_list', []) \ No newline at end of file + assert 'invalid' in task_context.metadata.get('deps_list', []) diff --git a/tests/unit_tests/plugin/test_handler.py b/tests/unit_tests/plugin/test_handler.py index 845016a5..44522ef4 100644 --- a/tests/unit_tests/plugin/test_handler.py +++ b/tests/unit_tests/plugin/test_handler.py @@ -6,9 +6,18 @@ Tests handler helper methods that don't require full handler setup. from __future__ import annotations from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, Mock import pytest +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + + +def make_handler(app): + """Create a RuntimeConnectionHandler with mocked external connection.""" + from langbot.pkg.plugin.handler import RuntimeConnectionHandler + + return RuntimeConnectionHandler(Mock(), AsyncMock(return_value=True), app) + class TestHandlerQueryVariables: """Tests for handler query variable logic.""" @@ -29,65 +38,67 @@ class TestHandlerQueryVariables: @pytest.mark.asyncio async def test_set_query_var_query_not_found(self, mock_app): """Test set_query_var returns error when query not found.""" - query_id = 'nonexistent-query' + runtime_handler = make_handler(mock_app) - if query_id not in mock_app.query_pool.cached_queries: - expected_error = f'Query with query_id {query_id} not found' - # Should return error response - assert expected_error is not None + response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value]({ + 'query_id': 'nonexistent-query', + 'key': 'test_var', + 'value': 'test_value', + }) + + assert response.code != 0 + assert 'nonexistent-query' in response.message @pytest.mark.asyncio async def test_set_query_var_success(self, mock_app): """Test set_query_var sets variable on existing query.""" + runtime_handler = make_handler(mock_app) mock_query = SimpleNamespace() mock_query.variables = {} mock_app.query_pool.cached_queries['test-query'] = mock_query - # Simulate set_query_var logic - query_id = 'test-query' - var_name = 'test_var' - var_value = 'test_value' - - if query_id in mock_app.query_pool.cached_queries: - query = mock_app.query_pool.cached_queries[query_id] - query.variables[var_name] = var_value + response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value]({ + 'query_id': 'test-query', + 'key': 'test_var', + 'value': 'test_value', + }) + assert response.code == 0 assert mock_query.variables['test_var'] == 'test_value' @pytest.mark.asyncio async def test_get_query_var_success(self, mock_app): """Test get_query_var retrieves variable from query.""" + runtime_handler = make_handler(mock_app) mock_query = SimpleNamespace() mock_query.variables = {'existing_var': 'existing_value'} mock_app.query_pool.cached_queries['test-query'] = mock_query - # Simulate get_query_var logic - query_id = 'test-query' - var_name = 'existing_var' + response = await runtime_handler.actions[PluginToRuntimeAction.GET_QUERY_VAR.value]({ + 'query_id': 'test-query', + 'key': 'existing_var', + }) - if query_id in mock_app.query_pool.cached_queries: - query = mock_app.query_pool.cached_queries[query_id] - if var_name in query.variables: - value = query.variables[var_name] - assert value == 'existing_value' + assert response.code == 0 + assert response.data == {'value': 'existing_value'} @pytest.mark.asyncio async def test_get_query_vars_multiple(self, mock_app): - """Test get_query_vars retrieves multiple variables.""" + """Test get_query_vars returns the query's variable mapping.""" + runtime_handler = make_handler(mock_app) mock_query = SimpleNamespace() mock_query.variables = {'var1': 'val1', 'var2': 'val2', 'var3': 'val3'} mock_app.query_pool.cached_queries['test-query'] = mock_query - query_id = 'test-query' - var_names = ['var1', 'var3'] + response = await runtime_handler.actions[PluginToRuntimeAction.GET_QUERY_VARS.value]({ + 'query_id': 'test-query', + }) - if query_id in mock_app.query_pool.cached_queries: - query = mock_app.query_pool.cached_queries[query_id] - result = {name: query.variables.get(name) for name in var_names} - assert result == {'var1': 'val1', 'var3': 'val3'} + assert response.code == 0 + assert response.data == {'vars': mock_query.variables} class TestHandlerRagErrorResponse: @@ -95,7 +106,7 @@ class TestHandlerRagErrorResponse: def test_make_rag_error_response_basic(self): """Test basic error response creation.""" - from src.langbot.pkg.plugin.handler import _make_rag_error_response + from langbot.pkg.plugin.handler import _make_rag_error_response error = Exception("test error") response = _make_rag_error_response(error, 'TestError') @@ -107,7 +118,7 @@ class TestHandlerRagErrorResponse: def test_make_rag_error_response_with_context(self): """Test error response with extra context.""" - from src.langbot.pkg.plugin.handler import _make_rag_error_response + from langbot.pkg.plugin.handler import _make_rag_error_response error = ValueError("invalid input") response = _make_rag_error_response( @@ -124,7 +135,7 @@ class TestHandlerRagErrorResponse: def test_make_rag_error_response_exception_type(self): """Test error response includes exception type.""" - from src.langbot.pkg.plugin.handler import _make_rag_error_response + from langbot.pkg.plugin.handler import _make_rag_error_response error = RuntimeError("connection failed") response = _make_rag_error_response(error, 'ConnectionError') @@ -135,7 +146,7 @@ class TestHandlerRagErrorResponse: def test_make_rag_error_response_empty_context(self): """Test error response with no extra context.""" - from src.langbot.pkg.plugin.handler import _make_rag_error_response + from langbot.pkg.plugin.handler import _make_rag_error_response error = KeyError("missing_key") response = _make_rag_error_response(error, 'LookupError') @@ -150,21 +161,21 @@ class TestConstantsSemanticVersion: def test_semantic_version_exists(self): """Test semantic_version is defined.""" - from src.langbot.pkg.utils import constants + from langbot.pkg.utils import constants assert hasattr(constants, 'semantic_version') assert constants.semantic_version.startswith('v') def test_edition_exists(self): """Test edition constant is defined.""" - from src.langbot.pkg.utils import constants + from langbot.pkg.utils import constants assert hasattr(constants, 'edition') assert constants.edition == 'community' def test_required_database_version_exists(self): """Test database version constant.""" - from src.langbot.pkg.utils import constants + from langbot.pkg.utils import constants assert hasattr(constants, 'required_database_version') - assert isinstance(constants.required_database_version, int) \ No newline at end of file + assert isinstance(constants.required_database_version, int) diff --git a/tests/unit_tests/plugin/test_handler_actions.py b/tests/unit_tests/plugin/test_handler_actions.py index 5aa6e295..81bc7570 100644 --- a/tests/unit_tests/plugin/test_handler_actions.py +++ b/tests/unit_tests/plugin/test_handler_actions.py @@ -1,45 +1,37 @@ -"""Unit tests for RuntimeConnectionHandler action handlers. +"""Unit tests for RuntimeConnectionHandler action handlers.""" -Tests cover critical action handlers: -- initialize_plugin_settings with setting inheritance -- set_binary_storage with size limit validation -- get_binary_storage -- get_plugin_settings with defaults -""" from __future__ import annotations -import pytest import base64 -from unittest.mock import Mock, AsyncMock, MagicMock -from importlib import import_module -import sqlalchemy +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction, RuntimeToLangBotAction -def get_handler_module(): - """Lazy import to avoid circular import issues.""" - return import_module('langbot.pkg.plugin.handler') +def make_handler(app): + """Create a RuntimeConnectionHandler with mocked external connection.""" + from langbot.pkg.plugin.handler import RuntimeConnectionHandler + + return RuntimeConnectionHandler(Mock(), AsyncMock(return_value=True), app) -def get_persistence_plugin_module(): - """Lazy import for plugin persistence entity.""" - return import_module('langbot.pkg.entity.persistence.plugin') +def make_result(first_item=None): + result = Mock() + result.first = Mock(return_value=first_item) + return result -def get_persistence_bstorage_module(): - """Lazy import for binary storage entity.""" - return import_module('langbot.pkg.entity.persistence.bstorage') +def compiled_params(statement): + return statement.compile().params class TestInitializePluginSettings: - """Tests for initialize_plugin_settings action handler. - - IMPORTANT: Tests verify setting inheritance logic - existing settings - should be inherited when creating new plugin settings. - """ + """Tests for initialize_plugin_settings action handler.""" @pytest.fixture - def mock_app_with_persistence(self): - """Create mock app with persistence manager.""" + def app(self): mock_app = Mock() mock_app.persistence_mgr = Mock() mock_app.persistence_mgr.execute_async = AsyncMock() @@ -47,273 +39,202 @@ class TestInitializePluginSettings: return mock_app @pytest.mark.asyncio - async def test_creates_new_setting_when_not_exists(self, mock_app_with_persistence): - """Test that new setting is created when plugin setting doesn't exist.""" - handler_module = get_handler_module() - persistence_plugin = get_persistence_plugin_module() + async def test_creates_new_setting_when_not_exists(self, app): + """New plugin settings use default enabled, priority and config values.""" + runtime_handler = make_handler(app) + app.persistence_mgr.execute_async.side_effect = [ + make_result(), + Mock(), + ] - # Mock select result - no existing setting - mock_result = Mock() - mock_result.first = Mock(return_value=None) - mock_app_with_persistence.persistence_mgr.execute_async.return_value = mock_result - - # Create handler instance with mock connection - from langbot_plugin.runtime.io.connection import Connection - mock_connection = Mock(spec=Connection) - - handler = handler_module.RuntimeConnectionHandler( - mock_connection, - AsyncMock(return_value=True), - mock_app_with_persistence - ) - - # Get the initialize_plugin_settings action handler - # Action handlers are registered via @self.action decorator - # We test by calling the persistence operations directly - data = { + response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value]({ 'plugin_author': 'test-author', 'plugin_name': 'test-plugin', 'install_source': 'local', 'install_info': {'path': '/test'}, + }) + + assert response.code == 0 + assert app.persistence_mgr.execute_async.await_count == 2 + insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0]) + assert insert_params == { + 'plugin_author': 'test-author', + 'plugin_name': 'test-plugin', + 'install_source': 'local', + 'install_info': {'path': '/test'}, + 'enabled': True, + 'priority': 0, + 'config': {}, } - # Simulate the action handler logic - result = await mock_app_with_persistence.persistence_mgr.execute_async( - sqlalchemy.select(persistence_plugin.PluginSetting) - .where(persistence_plugin.PluginSetting.plugin_author == data['plugin_author']) - .where(persistence_plugin.PluginSetting.plugin_name == data['plugin_name']) + @pytest.mark.asyncio + async def test_inherits_values_from_existing_setting(self, app): + """Existing settings are replaced while preserving user-controlled values.""" + runtime_handler = make_handler(app) + existing_setting = SimpleNamespace( + enabled=False, + priority=5, + config={'key': 'value'}, ) + app.persistence_mgr.execute_async.side_effect = [ + make_result(existing_setting), + Mock(), + Mock(), + ] - # Verify select was called - assert mock_app_with_persistence.persistence_mgr.execute_async.called + response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value]({ + 'plugin_author': 'test-author', + 'plugin_name': 'test-plugin', + 'install_source': 'github', + 'install_info': {'repo': 'author/name'}, + }) - @pytest.mark.asyncio - async def test_inherits_enabled_from_existing_setting(self, mock_app_with_persistence): - """Test that enabled status is inherited from existing setting.""" - handler_module = get_handler_module() - persistence_plugin = get_persistence_plugin_module() - - # Mock existing setting with enabled=False - mock_existing_setting = Mock() - mock_existing_setting.enabled = False - mock_existing_setting.priority = 5 - mock_existing_setting.config = {'key': 'value'} - - mock_result = Mock() - mock_result.first = Mock(return_value=mock_existing_setting) - mock_app_with_persistence.persistence_mgr.execute_async.return_value = mock_result - - # Simulate inheritance logic - # When existing setting exists, delete old and create new with inherited values - setting = mock_result.first() - inherited_enabled = setting.enabled if setting is not None else True - inherited_priority = setting.priority if setting is not None else 0 - inherited_config = setting.config if setting is not None else {} - - assert inherited_enabled is False - assert inherited_priority == 5 - assert inherited_config == {'key': 'value'} - - @pytest.mark.asyncio - async def test_defaults_enabled_true_when_no_existing(self, mock_app_with_persistence): - """Test that enabled defaults to True when no existing setting.""" - # No existing setting - mock_result = Mock() - mock_result.first = Mock(return_value=None) - mock_app_with_persistence.persistence_mgr.execute_async.return_value = mock_result - - setting = mock_result.first() - default_enabled = setting.enabled if setting is not None else True - - assert default_enabled is True + assert response.code == 0 + assert app.persistence_mgr.execute_async.await_count == 3 + insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0]) + assert insert_params['enabled'] is False + assert insert_params['priority'] == 5 + assert insert_params['config'] == {'key': 'value'} + assert insert_params['install_source'] == 'github' + assert insert_params['install_info'] == {'repo': 'author/name'} class TestSetBinaryStorage: - """Tests for set_binary_storage action handler with size limit validation. - - IMPORTANT: This tests security-critical size limit validation. - """ + """Tests for set_binary_storage action handler with size limit validation.""" @pytest.fixture - def mock_app_with_size_limit(self): - """Create mock app with plugin binary storage size limit.""" + def app(self): mock_app = Mock() mock_app.instance_config = Mock() mock_app.instance_config.data = { 'plugin': { 'binary_storage': { - 'max_value_bytes': 1024, # 1KB limit for testing - } - } + 'max_value_bytes': 1024, + }, + }, } mock_app.persistence_mgr = Mock() - mock_app.persistence_mgr.execute_async = AsyncMock() + mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result()) mock_app.logger = Mock() return mock_app - @pytest.fixture - def mock_app_no_limit(self): - """Create mock app without explicit size limit (uses default).""" - mock_app = Mock() - mock_app.instance_config = Mock() - mock_app.instance_config.data = { - 'plugin': {} - } - mock_app.persistence_mgr = Mock() - mock_app.persistence_mgr.execute_async = AsyncMock() - mock_app.logger = Mock() - return mock_app - - @pytest.mark.asyncio - async def test_rejects_value_exceeding_limit(self, mock_app_with_size_limit): - """Test that values exceeding max_value_bytes are rejected.""" - handler_module = get_handler_module() - - # Value larger than 1024 bytes - large_value = b'x' * 2048 - value_base64 = base64.b64encode(large_value).decode('utf-8') - - data = { + @staticmethod + def payload(value: bytes): + return { 'key': 'test-key', 'owner_type': 'plugin', 'owner': 'test-owner', - 'value_base64': value_base64, + 'value_base64': base64.b64encode(value).decode('utf-8'), } - # Simulate size limit check logic from handler - value = base64.b64decode(data['value_base64']) - max_value_bytes = ( - mock_app_with_size_limit.instance_config.data - .get('plugin', {}) - .get('binary_storage', {}) - .get('max_value_bytes', 10 * 1024 * 1024) + @pytest.mark.asyncio + async def test_rejects_value_exceeding_limit(self, app): + """Values larger than max_value_bytes are rejected before persistence writes.""" + runtime_handler = make_handler(app) + + response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value]( + self.payload(b'x' * 2048) ) - if max_value_bytes >= 0 and len(value) > max_value_bytes: - error_message = f'Binary storage value exceeds limit ({len(value)} > {max_value_bytes} bytes)' - # Should return error response - assert len(value) > max_value_bytes - assert error_message is not None + assert response.code != 0 + assert '2048 > 1024 bytes' in response.message + app.persistence_mgr.execute_async.assert_not_awaited() @pytest.mark.asyncio - async def test_accepts_value_within_limit(self, mock_app_with_size_limit): - """Test that values within limit are accepted.""" - # Value smaller than 1024 bytes - small_value = b'x' * 512 - value_base64 = base64.b64encode(small_value).decode('utf-8') + async def test_accepts_value_within_limit_and_inserts_storage(self, app): + """A new small value is inserted into binary storage.""" + runtime_handler = make_handler(app) - data = { - 'key': 'test-key', - 'owner_type': 'plugin', - 'owner': 'test-owner', - 'value_base64': value_base64, - } - - value = base64.b64decode(data['value_base64']) - max_value_bytes = 1024 - - assert len(value) <= max_value_bytes - - @pytest.mark.asyncio - async def test_handles_invalid_max_value_bytes(self, mock_app_with_size_limit): - """Test that invalid max_value_bytes falls back to default.""" - # Invalid config value - mock_app_with_size_limit.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = 'invalid' - - max_value_bytes = ( - mock_app_with_size_limit.instance_config.data - .get('plugin', {}) - .get('binary_storage', {}) - .get('max_value_bytes', 10 * 1024 * 1024) + response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value]( + self.payload(b'x' * 512) ) - try: - max_value_bytes = int(max_value_bytes) - except (TypeError, ValueError): - max_value_bytes = 10 * 1024 * 1024 # Default 10MB - - assert max_value_bytes == 10 * 1024 * 1024 + assert response.code == 0 + assert app.persistence_mgr.execute_async.await_count == 2 + insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0]) + assert insert_params['unique_key'] == 'plugin:test-owner:test-key' + assert insert_params['value'] == b'x' * 512 @pytest.mark.asyncio - async def test_negative_limit_disables_check(self, mock_app_with_size_limit): - """Test that negative max_value_bytes disables size check.""" - mock_app_with_size_limit.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = -1 + async def test_updates_existing_storage(self, app): + """An existing binary storage row is updated instead of inserted.""" + runtime_handler = make_handler(app) + app.persistence_mgr.execute_async.return_value = make_result(SimpleNamespace(value=b'old')) - # Large value - large_value = b'x' * 20 * 1024 * 1024 # 20MB - value_base64 = base64.b64encode(large_value).decode('utf-8') - - max_value_bytes = ( - mock_app_with_size_limit.instance_config.data - .get('plugin', {}) - .get('binary_storage', {}) - .get('max_value_bytes', 10 * 1024 * 1024) + response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value]( + self.payload(b'new') ) - try: - max_value_bytes = int(max_value_bytes) - except (TypeError, ValueError): - max_value_bytes = 10 * 1024 * 1024 - - # When max_value_bytes < 0, size check is disabled (condition: max_value_bytes >= 0) - if max_value_bytes >= 0 and len(large_value) > max_value_bytes: - should_reject = True - else: - should_reject = False - - assert should_reject is False # Negative limit disables check + assert response.code == 0 + assert app.persistence_mgr.execute_async.await_count == 2 + update_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0]) + assert update_params['value'] == b'new' @pytest.mark.asyncio - async def test_default_limit_is_10mb(self, mock_app_no_limit): - """Test that default limit is 10MB when not configured.""" - max_value_bytes = ( - mock_app_no_limit.instance_config.data - .get('plugin', {}) - .get('binary_storage', {}) - .get('max_value_bytes', 10 * 1024 * 1024) + async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app): + """Invalid max_value_bytes uses the 10MB default limit.""" + runtime_handler = make_handler(app) + app.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = 'invalid' + + response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value]( + self.payload(b'x' * (10 * 1024 * 1024 + 1)) ) - assert max_value_bytes == 10 * 1024 * 1024 + assert response.code != 0 + assert '10485761 > 10485760 bytes' in response.message + app.persistence_mgr.execute_async.assert_not_awaited() @pytest.mark.asyncio - async def test_zero_limit_rejects_all_values(self, mock_app_with_size_limit): - """Test that zero limit rejects all non-empty values.""" - mock_app_with_size_limit.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = 0 + async def test_negative_limit_disables_size_check(self, app): + """Negative max_value_bytes allows values larger than the normal default.""" + runtime_handler = make_handler(app) + app.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = -1 - small_value = b'x' # Just 1 byte - max_value_bytes = 0 + response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value]( + self.payload(b'x' * 2048) + ) - if max_value_bytes >= 0 and len(small_value) > max_value_bytes: - should_reject = True - else: - should_reject = False + assert response.code == 0 + assert app.persistence_mgr.execute_async.await_count == 2 - assert should_reject is True + @pytest.mark.asyncio + async def test_zero_limit_rejects_non_empty_values(self, app): + """A zero byte limit rejects non-empty values.""" + runtime_handler = make_handler(app) + app.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = 0 + + response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value]( + self.payload(b'x') + ) + + assert response.code != 0 + assert '1 > 0 bytes' in response.message + app.persistence_mgr.execute_async.assert_not_awaited() class TestGetPluginSettings: """Tests for get_plugin_settings action handler with defaults.""" @pytest.fixture - def mock_app(self): - """Create mock app.""" + def app(self): mock_app = Mock() mock_app.persistence_mgr = Mock() mock_app.persistence_mgr.execute_async = AsyncMock() return mock_app @pytest.mark.asyncio - async def test_returns_defaults_when_setting_not_found(self, mock_app): - """Test that default values are returned when setting doesn't exist.""" - persistence_plugin = get_persistence_plugin_module() + async def test_returns_defaults_when_setting_not_found(self, app): + """Default plugin settings are returned when no persisted row exists.""" + runtime_handler = make_handler(app) + app.persistence_mgr.execute_async.return_value = make_result() - # Mock no existing setting - mock_result = Mock() - mock_result.first = Mock(return_value=None) - mock_app.persistence_mgr.execute_async.return_value = mock_result + response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value]({ + 'plugin_author': 'test-author', + 'plugin_name': 'test-plugin', + }) - # Simulate get_plugin_settings logic - default_data = { + assert response.code == 0 + assert response.data == { 'enabled': True, 'priority': 0, 'plugin_config': {}, @@ -321,107 +242,82 @@ class TestGetPluginSettings: 'install_info': {}, } - setting = mock_result.first() - if setting is None: - result_data = default_data - - assert result_data['enabled'] is True - assert result_data['priority'] == 0 - assert result_data['plugin_config'] == {} - @pytest.mark.asyncio - async def test_returns_actual_values_when_setting_exists(self, mock_app): - """Test that actual setting values are returned when setting exists.""" - persistence_plugin = get_persistence_plugin_module() + async def test_returns_actual_values_when_setting_exists(self, app): + """Persisted plugin setting values override defaults.""" + runtime_handler = make_handler(app) + setting = SimpleNamespace( + enabled=False, + priority=10, + config={'custom': 'config'}, + install_source='github', + install_info={'repo': 'test/repo'}, + ) + app.persistence_mgr.execute_async.return_value = make_result(setting) - # Mock existing setting - mock_setting = Mock() - mock_setting.enabled = False - mock_setting.priority = 10 - mock_setting.config = {'custom': 'config'} - mock_setting.install_source = 'github' - mock_setting.install_info = {'repo': 'test/repo'} + response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value]({ + 'plugin_author': 'test-author', + 'plugin_name': 'test-plugin', + }) - mock_result = Mock() - mock_result.first = Mock(return_value=mock_setting) - mock_app.persistence_mgr.execute_async.return_value = mock_result - - # Simulate get_plugin_settings logic - data = { - 'enabled': True, - 'priority': 0, - 'plugin_config': {}, - 'install_source': 'local', - 'install_info': {}, + assert response.code == 0 + assert response.data == { + 'enabled': False, + 'priority': 10, + 'plugin_config': {'custom': 'config'}, + 'install_source': 'github', + 'install_info': {'repo': 'test/repo'}, } - setting = mock_result.first() - if setting is not None: - data['enabled'] = setting.enabled - data['priority'] = setting.priority - data['plugin_config'] = setting.config - data['install_source'] = setting.install_source - data['install_info'] = setting.install_info - - assert data['enabled'] is False - assert data['priority'] == 10 - assert data['plugin_config'] == {'custom': 'config'} - assert data['install_source'] == 'github' - class TestGetBinaryStorage: """Tests for get_binary_storage action handler.""" @pytest.fixture - def mock_app(self): - """Create mock app.""" + def app(self): mock_app = Mock() mock_app.persistence_mgr = Mock() mock_app.persistence_mgr.execute_async = AsyncMock() return mock_app @pytest.mark.asyncio - async def test_returns_base64_encoded_value(self, mock_app): - """Test that returned value is base64 encoded.""" - persistence_bstorage = get_persistence_bstorage_module() + async def test_returns_base64_encoded_value(self, app): + """Stored bytes are returned as base64.""" + runtime_handler = make_handler(app) + app.persistence_mgr.execute_async.return_value = make_result(SimpleNamespace(value=b'test binary content')) - # Mock existing storage - test_value = b'test binary content' - mock_storage = Mock() - mock_storage.value = test_value + response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value]({ + 'key': 'test-key', + 'owner_type': 'plugin', + 'owner': 'test-owner', + }) - mock_result = Mock() - mock_result.first = Mock(return_value=mock_storage) - mock_app.persistence_mgr.execute_async.return_value = mock_result - - storage = mock_result.first() - if storage is not None: - value_base64 = base64.b64encode(storage.value).decode('utf-8') - - assert value_base64 == base64.b64encode(test_value).decode('utf-8') + assert response.code == 0 + assert response.data == { + 'value_base64': base64.b64encode(b'test binary content').decode('utf-8'), + } @pytest.mark.asyncio - async def test_returns_error_when_not_found(self, mock_app): - """Test that error is returned when storage not found.""" - persistence_bstorage = get_persistence_bstorage_module() + async def test_returns_error_when_not_found(self, app): + """Missing binary storage rows return an error response.""" + runtime_handler = make_handler(app) + app.persistence_mgr.execute_async.return_value = make_result() - mock_result = Mock() - mock_result.first = Mock(return_value=None) - mock_app.persistence_mgr.execute_async.return_value = mock_result + response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value]({ + 'key': 'test-key', + 'owner_type': 'plugin', + 'owner': 'test-owner', + }) - storage = mock_result.first() - if storage is None: - key = 'test-key' - error_message = f'Storage with key {key} not found' - assert error_message is not None + assert response.code != 0 + assert 'Storage with key test-key not found' in response.message class TestHandlerQueryLookup: """Tests for query lookup in cached_queries.""" @pytest.fixture - def mock_app_with_query_pool(self): - """Create mock app with query pool.""" + def app(self): mock_app = Mock() mock_app.query_pool = Mock() mock_app.query_pool.cached_queries = {} @@ -429,26 +325,27 @@ class TestHandlerQueryLookup: return mock_app @pytest.mark.asyncio - async def test_query_not_found_returns_error(self, mock_app_with_query_pool): - """Test that operations return error when query_id not found.""" - query_id = 'nonexistent-query' + async def test_query_not_found_returns_error(self, app): + """Query-bound actions return error when query_id is not cached.""" + runtime_handler = make_handler(app) - if query_id not in mock_app_with_query_pool.query_pool.cached_queries: - error_message = f'Query with query_id {query_id} not found' - # Should return error response - assert error_message is not None + response = await runtime_handler.actions[PluginToRuntimeAction.GET_BOT_UUID.value]({ + 'query_id': 'nonexistent-query', + }) + + assert response.code != 0 + assert 'nonexistent-query' in response.message @pytest.mark.asyncio - async def test_query_found_returns_success(self, mock_app_with_query_pool): - """Test that operations succeed when query exists.""" - mock_query = Mock() - mock_query.variables = {} - mock_query.bot_uuid = 'test-bot-uuid' + async def test_query_found_returns_success(self, app): + """Query-bound actions read data from the cached query object.""" + runtime_handler = make_handler(app) + query = SimpleNamespace(variables={}, bot_uuid='test-bot-uuid') + app.query_pool.cached_queries['existing-query'] = query - query_id = 'existing-query' - mock_app_with_query_pool.query_pool.cached_queries[query_id] = mock_query + response = await runtime_handler.actions[PluginToRuntimeAction.GET_BOT_UUID.value]({ + 'query_id': 'existing-query', + }) - if query_id in mock_app_with_query_pool.query_pool.cached_queries: - query = mock_app_with_query_pool.query_pool.cached_queries[query_id] - # Operations can proceed - assert query is mock_query \ No newline at end of file + assert response.code == 0 + assert response.data == {'bot_uuid': 'test-bot-uuid'} diff --git a/tests/unit_tests/plugin/test_plugin_component_filtering.py b/tests/unit_tests/plugin/test_plugin_component_filtering.py index 45940fed..da8991dc 100644 --- a/tests/unit_tests/plugin/test_plugin_component_filtering.py +++ b/tests/unit_tests/plugin/test_plugin_component_filtering.py @@ -7,7 +7,7 @@ import pytest @pytest.mark.asyncio async def test_plugin_list_filter_by_component_kinds(): """Test that plugins can be filtered by component kinds.""" - from src.langbot.pkg.plugin.connector import PluginRuntimeConnector + from langbot.pkg.plugin.connector import PluginRuntimeConnector # Mock the application mock_app = MagicMock() @@ -113,7 +113,7 @@ async def test_plugin_list_filter_by_component_kinds(): @pytest.mark.asyncio async def test_plugin_list_filter_no_filter(): """Test that all plugins are returned when no filter is specified.""" - from src.langbot.pkg.plugin.connector import PluginRuntimeConnector + from langbot.pkg.plugin.connector import PluginRuntimeConnector # Mock the application mock_app = MagicMock() @@ -174,7 +174,7 @@ async def test_plugin_list_filter_no_filter(): @pytest.mark.asyncio async def test_plugin_list_filter_empty_result(): """Test that empty list is returned when no plugins match the filter.""" - from src.langbot.pkg.plugin.connector import PluginRuntimeConnector + from langbot.pkg.plugin.connector import PluginRuntimeConnector # Mock the application mock_app = MagicMock() @@ -220,7 +220,7 @@ async def test_plugin_list_filter_empty_result(): @pytest.mark.asyncio async def test_plugin_list_filter_plugin_without_components(): """Test that plugins without components are excluded when filtering.""" - from src.langbot.pkg.plugin.connector import PluginRuntimeConnector + from langbot.pkg.plugin.connector import PluginRuntimeConnector # Mock the application mock_app = MagicMock() diff --git a/tests/unit_tests/plugin/test_plugin_list_sorting.py b/tests/unit_tests/plugin/test_plugin_list_sorting.py index 09fc173e..2d26aec3 100644 --- a/tests/unit_tests/plugin/test_plugin_list_sorting.py +++ b/tests/unit_tests/plugin/test_plugin_list_sorting.py @@ -8,7 +8,7 @@ import pytest @pytest.mark.asyncio async def test_plugin_list_sorting_debug_first(): """Test that debug plugins appear before non-debug plugins.""" - from src.langbot.pkg.plugin.connector import PluginRuntimeConnector + from langbot.pkg.plugin.connector import PluginRuntimeConnector # Mock the application mock_app = MagicMock() @@ -110,7 +110,7 @@ async def test_plugin_list_sorting_debug_first(): @pytest.mark.asyncio async def test_plugin_list_sorting_by_installation_time(): """Test that non-debug plugins are sorted by installation time (newest first).""" - from src.langbot.pkg.plugin.connector import PluginRuntimeConnector + from langbot.pkg.plugin.connector import PluginRuntimeConnector # Mock the application mock_app = MagicMock() @@ -207,7 +207,7 @@ async def test_plugin_list_sorting_by_installation_time(): @pytest.mark.asyncio async def test_plugin_list_empty(): """Test that empty plugin list is handled correctly.""" - from src.langbot.pkg.plugin.connector import PluginRuntimeConnector + from langbot.pkg.plugin.connector import PluginRuntimeConnector # Mock the application mock_app = MagicMock() diff --git a/tests/unit_tests/provider/requesters/test_chatcmpl_errors_direct.py b/tests/unit_tests/provider/requesters/test_chatcmpl_errors_direct.py index 9c844956..c51476c2 100644 --- a/tests/unit_tests/provider/requesters/test_chatcmpl_errors_direct.py +++ b/tests/unit_tests/provider/requesters/test_chatcmpl_errors_direct.py @@ -11,6 +11,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest import openai # Import real openai package +from langbot.pkg.provider.modelmgr.errors import RequesterError + class TestInvokeLLMErrorHandling: """Tests for invoke_llm error handling branches.""" @@ -66,7 +68,7 @@ class TestInvokeLLMErrorHandling: side_effect=asyncio.TimeoutError() ) - with pytest.raises(Exception) as exc: + with pytest.raises(RequesterError) as exc: await requester_with_mocked_client.invoke_llm( query=None, model=mock_model, @@ -87,7 +89,7 @@ class TestInvokeLLMErrorHandling: side_effect=error ) - with pytest.raises(Exception) as exc: + with pytest.raises(RequesterError) as exc: await requester_with_mocked_client.invoke_llm( query=None, model=mock_model, @@ -108,7 +110,7 @@ class TestInvokeLLMErrorHandling: side_effect=error ) - with pytest.raises(Exception) as exc: + with pytest.raises(RequesterError) as exc: await requester_with_mocked_client.invoke_llm( query=None, model=mock_model, @@ -129,7 +131,7 @@ class TestInvokeLLMErrorHandling: side_effect=error ) - with pytest.raises(Exception) as exc: + with pytest.raises(RequesterError) as exc: await requester_with_mocked_client.invoke_llm( query=None, model=mock_model, @@ -175,7 +177,7 @@ class TestInvokeEmbeddingErrorHandling: side_effect=asyncio.TimeoutError() ) - with pytest.raises(Exception) as exc: + with pytest.raises(RequesterError) as exc: await requester_with_mocked_client.invoke_embedding( model=mock_embedding_model, input_text=['test'], @@ -195,7 +197,7 @@ class TestInvokeEmbeddingErrorHandling: side_effect=error ) - with pytest.raises(Exception) as exc: + with pytest.raises(RequesterError) as exc: await requester_with_mocked_client.invoke_embedding( model=mock_embedding_model, input_text=['test'], @@ -242,4 +244,4 @@ class TestDefaultConfig: }) assert req.requester_cfg['base_url'] == 'https://custom.com/v1' - assert req.requester_cfg['timeout'] == 60 \ No newline at end of file + assert req.requester_cfg['timeout'] == 60 diff --git a/tests/unit_tests/provider/requesters/test_chatcmpl_utils.py b/tests/unit_tests/provider/requesters/test_chatcmpl_utils.py index 10153c7e..38d9df1c 100644 --- a/tests/unit_tests/provider/requesters/test_chatcmpl_utils.py +++ b/tests/unit_tests/provider/requesters/test_chatcmpl_utils.py @@ -141,25 +141,21 @@ class TestNormalizeModalities: requester = self._create_requester_with_mocks() result = requester._normalize_modalities('text,image') - assert 'text' in result - assert 'image' in result + assert result == ['text', 'image'] def test_normalize_list_modalities(self): """Normalize list of modalities.""" requester = self._create_requester_with_mocks() result = requester._normalize_modalities(['text', 'image', 'audio']) - assert 'text' in result - assert 'image' in result - assert 'audio' in result + assert result == ['text', 'image', 'audio'] def test_normalize_dict_modalities(self): """Normalize dict with nested modalities.""" requester = self._create_requester_with_mocks() result = requester._normalize_modalities({'input': ['text'], 'output': ['text', 'image']}) - assert 'text' in result - assert 'image' in result + assert result == ['text', 'image'] def test_normalize_none(self): """Handle None input.""" @@ -173,8 +169,7 @@ class TestNormalizeModalities: requester = self._create_requester_with_mocks() result = requester._normalize_modalities('text->image') - assert 'text' in result - assert 'image' in result + assert result == ['text', 'image'] class TestParseRerankResponse: @@ -192,9 +187,10 @@ class TestParseRerankResponse: } result = OpenAIChatCompletions._parse_rerank_response(data) - assert len(result) == 2 - assert result[0]['index'] == 0 - assert result[0]['relevance_score'] == 0.95 + assert result == [ + {'index': 0, 'relevance_score': 0.95}, + {'index': 1, 'relevance_score': 0.80}, + ] def test_parse_voyage_format(self): """Parse Voyage AI format.""" @@ -208,8 +204,10 @@ class TestParseRerankResponse: } result = OpenAIChatCompletions._parse_rerank_response(data) - assert len(result) == 2 - assert result[0]['index'] == 0 + assert result == [ + {'index': 0, 'relevance_score': 0.90}, + {'index': 2, 'relevance_score': 0.75}, + ] def test_parse_dashscope_format(self): """Parse DashScope format.""" @@ -224,8 +222,7 @@ class TestParseRerankResponse: } result = OpenAIChatCompletions._parse_rerank_response(data) - assert len(result) == 1 - assert result[0]['index'] == 0 + assert result == [{'index': 0, 'relevance_score': 0.85}] def test_parse_unknown_format(self): """Handle unknown format returns empty list.""" @@ -340,4 +337,4 @@ class TestExtractScanMetadata: result = requester._extract_scan_metadata(item, 'gpt-4') - assert result['display_name'] is None \ No newline at end of file + assert result['display_name'] is None diff --git a/tests/unit_tests/provider/requesters/test_ollama_requester.py b/tests/unit_tests/provider/requesters/test_ollama_requester.py index c1ff2e89..993115ab 100644 --- a/tests/unit_tests/provider/requesters/test_ollama_requester.py +++ b/tests/unit_tests/provider/requesters/test_ollama_requester.py @@ -9,6 +9,8 @@ import asyncio from unittest.mock import AsyncMock, MagicMock import pytest +from langbot.pkg.provider.modelmgr.errors import RequesterError + class TestOllamaRequesterConfig: """Tests for default config.""" @@ -228,7 +230,7 @@ class TestOllamaErrorHandling: """TimeoutError is converted to RequesterError.""" requester_with_mocked_client.client.chat = AsyncMock(side_effect=asyncio.TimeoutError()) - with pytest.raises(Exception) as exc: + with pytest.raises(RequesterError) as exc: await requester_with_mocked_client.invoke_llm( query=None, model=mock_model, @@ -259,4 +261,4 @@ class TestOllamaScanModels: """REQUESTER_NAME constant exists.""" from langbot.pkg.provider.modelmgr.requesters.ollamachat import REQUESTER_NAME - assert REQUESTER_NAME == 'ollama-chat' \ No newline at end of file + assert REQUESTER_NAME == 'ollama-chat' diff --git a/tests/unit_tests/provider/test_session_manager.py b/tests/unit_tests/provider/test_session_manager.py index 12805724..4698bc49 100644 --- a/tests/unit_tests/provider/test_session_manager.py +++ b/tests/unit_tests/provider/test_session_manager.py @@ -13,7 +13,6 @@ from unittest.mock import Mock from importlib import import_module import langbot_plugin.api.entities.builtin.provider.session as provider_session -import langbot_plugin.api.entities.builtin.provider.message as provider_message import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query diff --git a/tests/unit_tests/rag/test_file_storage.py b/tests/unit_tests/rag/test_file_storage.py index d1f7d49c..d4a6f223 100644 --- a/tests/unit_tests/rag/test_file_storage.py +++ b/tests/unit_tests/rag/test_file_storage.py @@ -1,410 +1,190 @@ -"""Unit tests for RuntimeKnowledgeBase file storage and ZIP processing. +"""Unit tests for RuntimeKnowledgeBase file storage behavior.""" -Tests cover: -- store_file entry point -- _store_file_task background processing -- _store_zip_file ZIP extraction -- File status management (pending -> processing -> completed/failed) -- MIME type detection -""" from __future__ import annotations -import pytest +import io import zipfile -import tempfile -import os -from unittest.mock import Mock, AsyncMock, patch, MagicMock -from importlib import import_module +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot.pkg.rag.knowledge.kbmgr import RuntimeKnowledgeBase -def get_kbmgr_module(): - """Lazy import to avoid circular import issues.""" - return import_module('langbot.pkg.rag.knowledge.kbmgr') +def _make_zip_bytes(entries: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, 'w') as zf: + for name, content in entries.items(): + zf.writestr(name, content) + zf.mkdir('emptydir') + return buffer.getvalue() + + +def _make_app() -> Mock: + app = Mock() + app.logger = Mock() + app.task_mgr = Mock() + app.storage_mgr = Mock() + app.storage_mgr.storage_provider = Mock() + app.storage_mgr.storage_provider.exists = AsyncMock(return_value=True) + app.storage_mgr.storage_provider.load = AsyncMock() + app.storage_mgr.storage_provider.save = AsyncMock() + app.storage_mgr.storage_provider.size = AsyncMock(return_value=123) + app.storage_mgr.storage_provider.delete = AsyncMock() + app.persistence_mgr = Mock() + app.persistence_mgr.execute_async = AsyncMock() + app.plugin_connector = Mock() + return app + + +def _make_kb(plugin_id: str | None = 'author/engine') -> RuntimeKnowledgeBase: + kb_entity = Mock() + kb_entity.uuid = 'test-kb-uuid' + kb_entity.collection_id = 'test-collection' + kb_entity.creation_settings = {} + kb_entity.knowledge_engine_plugin_id = plugin_id + return RuntimeKnowledgeBase(_make_app(), kb_entity) class TestStoreFile: - """Tests for store_file method - entry point for file storage.""" + @pytest.mark.asyncio + async def test_store_file_creates_pending_record_and_user_task(self): + kb = _make_kb() - @pytest.fixture - def mock_kb(self): - """Create mock RuntimeKnowledgeBase.""" - kbmgr = get_kbmgr_module() + def create_user_task(coro, **kwargs): + coro.close() + return SimpleNamespace(id='task-1', kwargs=kwargs) - mock_app = Mock() - mock_app.logger = Mock() - mock_app.task_mgr = Mock() - mock_app.task_mgr.create_user_task = Mock(return_value=Mock(id=1)) - mock_app.storage_mgr = Mock() - mock_app.storage_mgr.storage_provider = Mock() - mock_app.storage_mgr.storage_provider.exists = AsyncMock(return_value=True) - mock_app.persistence_mgr = Mock() - mock_app.persistence_mgr.execute_async = AsyncMock() + kb.ap.task_mgr.create_user_task = Mock(side_effect=create_user_task) - mock_kb_entity = Mock() - mock_kb_entity.uuid = 'test-kb-uuid' + task_id = await kb.store_file('documents/test.pdf') - kb = kbmgr.RuntimeKnowledgeBase(mock_app, mock_kb_entity) - kb._on_kb_create = AsyncMock() - return kb + assert task_id == 'task-1' + kb.ap.storage_mgr.storage_provider.exists.assert_awaited_once_with('documents/test.pdf') + kb.ap.persistence_mgr.execute_async.assert_awaited_once() + call_kwargs = kb.ap.task_mgr.create_user_task.call_args.kwargs + assert call_kwargs['kind'] == 'knowledge-operation' + assert call_kwargs['name'] == 'knowledge-store-file-documents/test.pdf' + assert call_kwargs['label'] == 'Store file documents/test.pdf' @pytest.mark.asyncio - async def test_creates_pending_file_record(self, mock_kb): - """Test that store_file creates a pending file record.""" - # Mock persistence for file record creation - mock_result = Mock() - mock_result.first = Mock(return_value=None) - mock_kb.ap.persistence_mgr.execute_async.return_value = mock_result + async def test_store_file_raises_when_source_file_missing(self): + kb = _make_kb() + kb.ap.storage_mgr.storage_provider.exists = AsyncMock(return_value=False) - # Mock file exists in storage - mock_kb.ap.storage_mgr.storage_provider.exists = AsyncMock(return_value=True) + with pytest.raises(Exception, match='File missing.pdf not found'): + await kb.store_file('missing.pdf') - # We can't directly test store_file without full setup - # But we verify the expected behavior pattern - file_name = 'test.pdf' - storage_path = 'kb/test-kb-uuid/test.pdf' - mime_type = 'application/pdf' - - # Verify storage provider would be called - assert mock_kb.ap.storage_mgr.storage_provider is not None - - @pytest.mark.asyncio - async def test_returns_early_when_file_not_exists(self, mock_kb): - """Test that store_file returns early when file doesn't exist in storage.""" - mock_kb.ap.storage_mgr.storage_provider.exists = AsyncMock(return_value=False) - - storage_path = 'kb/test-kb-uuid/nonexistent.pdf' - - # Should check existence before proceeding - exists = await mock_kb.ap.storage_mgr.storage_provider.exists(storage_path) - assert exists is False + kb.ap.persistence_mgr.execute_async.assert_not_awaited() + kb.ap.task_mgr.create_user_task.assert_not_called() class TestStoreZipFile: - """Tests for _store_zip_file method - ZIP extraction and processing.""" + @pytest.mark.asyncio + async def test_store_zip_file_extracts_supported_files_and_skips_noise(self): + kb = _make_kb() + kb.ap.storage_mgr.storage_provider.load = AsyncMock( + return_value=_make_zip_bytes( + { + 'doc1.pdf': b'pdf', + 'doc2.txt': b'text', + 'subdir/doc3.md': b'markdown', + 'page.html': b'html', + 'image.png': b'png', + '.hidden': b'hidden', + '__MACOSX/doc1.pdf': b'metadata', + } + ) + ) + kb.store_file = AsyncMock(side_effect=['task-pdf', 'task-txt', 'task-md', 'task-html']) - @pytest.fixture - def temp_zip_with_files(self): - """Create a temporary ZIP file with multiple supported files.""" - with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp: - with zipfile.ZipFile(tmp, 'w') as zf: - # Add supported files - zf.writestr('doc1.pdf', b'PDF content 1') - zf.writestr('doc2.txt', b'Text content') - zf.writestr('subdir/doc3.md', b'Markdown content') - # Add unsupported file - zf.writestr('image.png', b'PNG binary') - # Add hidden file (should be skipped) - zf.writestr('.hidden', b'hidden content') - # Add __MACOSX file (should be skipped) - zf.writestr('__MACOSX/doc1.pdf', b'macos metadata') - # Add directory entry - zf.mkdir('emptydir') - yield tmp.name - os.unlink(tmp.name) + task_id = await kb._store_zip_file('archive.zip', parser_plugin_id='parser/plugin') - @pytest.fixture - def temp_zip_with_no_supported(self): - """Create a ZIP with no supported file types.""" - with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp: - with zipfile.ZipFile(tmp, 'w') as zf: - zf.writestr('image.jpg', b'JPEG content') - zf.writestr('video.mp4', b'video content') - yield tmp.name - os.unlink(tmp.name) - - @pytest.fixture - def temp_empty_zip(self): - """Create an empty ZIP file.""" - with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp: - with zipfile.ZipFile(tmp, 'w') as zf: - pass # Empty - yield tmp.name - os.unlink(tmp.name) - - def test_zip_extraction_identifies_supported_files(self, temp_zip_with_files): - """Test that ZIP extraction identifies supported file types.""" - # Supported extensions based on source code - supported_extensions = ['.pdf', '.txt', '.md', '.doc', '.docx'] - - with zipfile.ZipFile(temp_zip_with_files, 'r') as zf: - supported_files = [] - for info in zf.infolist(): - if info.is_dir(): - continue - name = info.filename - # Skip hidden files - if name.startswith('.') or '/.' in name: - continue - # Skip __MACOSX - if '__MACOSX' in name: - continue - # Check extension - ext = os.path.splitext(name)[1].lower() - if ext in supported_extensions: - supported_files.append(name) - - assert 'doc1.pdf' in supported_files - assert 'doc2.txt' in supported_files - assert 'subdir/doc3.md' in supported_files - assert 'image.png' not in supported_files - assert '.hidden' not in supported_files - assert '__MACOSX/doc1.pdf' not in supported_files - - def test_skips_directory_entries(self, temp_zip_with_files): - """Test that directory entries are skipped.""" - with zipfile.ZipFile(temp_zip_with_files, 'r') as zf: - for info in zf.infolist(): - if info.is_dir(): - # Directory should be skipped - ZIP directories have trailing slash - assert info.filename.rstrip('/') == 'emptydir' - - def test_skips_hidden_files(self, temp_zip_with_files): - """Test that hidden files (starting with .) are skipped.""" - with zipfile.ZipFile(temp_zip_with_files, 'r') as zf: - hidden_files = [] - for info in zf.infolist(): - if not info.is_dir(): - name = info.filename - if name.startswith('.') or '/.' in name: - hidden_files.append(name) - - # Hidden files exist in ZIP but should be filtered - assert '.hidden' in hidden_files - - def test_skips_macos_metadata(self, temp_zip_with_files): - """Test that __MACOSX files are skipped.""" - with zipfile.ZipFile(temp_zip_with_files, 'r') as zf: - macos_files = [] - for info in zf.infolist(): - if not info.is_dir(): - if '__MACOSX' in info.filename: - macos_files.append(info.filename) - - assert '__MACOSX/doc1.pdf' in macos_files - - def test_raises_when_no_supported_files(self, temp_zip_with_no_supported): - """Test that ValueError is raised when no supported files found.""" - supported_extensions = ['.pdf', '.txt', '.md', '.doc', '.docx'] - - with zipfile.ZipFile(temp_zip_with_no_supported, 'r') as zf: - supported_files = [] - for info in zf.infolist(): - if info.is_dir(): - continue - ext = os.path.splitext(info.filename)[1].lower() - if ext in supported_extensions: - supported_files.append(info.filename) - - assert len(supported_files) == 0 - # Source code raises ValueError in this case - - def test_handles_empty_zip(self, temp_empty_zip): - """Test handling of empty ZIP file.""" - with zipfile.ZipFile(temp_empty_zip, 'r') as zf: - files = [info for info in zf.infolist() if not info.is_dir()] - assert len(files) == 0 - - -class TestFileStatusManagement: - """Tests for file status transitions during storage.""" + assert task_id == 'task-pdf' + assert kb.ap.storage_mgr.storage_provider.save.await_count == 4 + saved_names = [call.args[0] for call in kb.ap.storage_mgr.storage_provider.save.await_args_list] + assert any(name.startswith('doc1_') and name.endswith('.pdf') for name in saved_names) + assert any(name.startswith('doc2_') and name.endswith('.txt') for name in saved_names) + assert any(name.startswith('subdir_doc3_') and name.endswith('.md') for name in saved_names) + assert any(name.startswith('page_') and name.endswith('.html') for name in saved_names) + assert not any('image' in name for name in saved_names) + assert not any('hidden' in name for name in saved_names) + assert not any('__MACOSX' in name for name in saved_names) + kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with('archive.zip') @pytest.mark.asyncio - async def test_status_transitions_to_processing(self): - """Test that file status transitions from pending to processing.""" - # Status values from source code - STATUS_PENDING = 'pending' - STATUS_PROCESSING = 'processing' - STATUS_COMPLETED = 'completed' - STATUS_FAILED = 'failed' + async def test_store_zip_file_raises_when_no_supported_files(self): + kb = _make_kb() + kb.ap.storage_mgr.storage_provider.load = AsyncMock( + return_value=_make_zip_bytes({'image.png': b'png', 'video.mp4': b'video'}) + ) + kb.store_file = AsyncMock() - # Simulate status transitions - initial_status = STATUS_PENDING - after_process_start = STATUS_PROCESSING - after_success = STATUS_COMPLETED + with pytest.raises(Exception, match='No supported files found'): + await kb._store_zip_file('archive.zip') - assert initial_status == 'pending' - assert after_process_start == 'processing' - assert after_success == 'completed' + kb.store_file.assert_not_awaited() + kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with('archive.zip') + + +class TestStoreFileTask: + @pytest.mark.asyncio + async def test_store_file_task_marks_completed_and_cleans_storage(self): + kb = _make_kb() + kb._ingest_document = AsyncMock(return_value={'status': 'completed'}) + file_obj = SimpleNamespace(uuid='file-uuid', file_name='test.pdf', extension='pdf') + task_context = Mock() + + await kb._store_file_task(file_obj, task_context) + + task_context.set_current_action.assert_called_once_with('Processing file') + kb.ap.storage_mgr.storage_provider.size.assert_awaited_once_with('test.pdf') + kb._ingest_document.assert_awaited_once() + assert kb.ap.persistence_mgr.execute_async.await_count == 2 + kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with('test.pdf') @pytest.mark.asyncio - async def test_status_transitions_to_failed_on_error(self): - """Test that file status transitions to failed on exception.""" - STATUS_PENDING = 'pending' - STATUS_PROCESSING = 'processing' - STATUS_FAILED = 'failed' + async def test_store_file_task_marks_failed_and_cleans_storage(self): + kb = _make_kb() + kb._ingest_document = AsyncMock(return_value={'status': 'failed', 'error_message': 'parser failed'}) + file_obj = SimpleNamespace(uuid='file-uuid', file_name='bad.pdf', extension='pdf') + task_context = Mock() - # Simulate error scenario - initial_status = STATUS_PENDING - after_error = STATUS_FAILED + with pytest.raises(Exception, match='parser failed'): + await kb._store_file_task(file_obj, task_context) - assert initial_status == 'pending' - assert after_error == 'failed' - - @pytest.mark.asyncio - async def test_failed_status_preserves_error_info(self): - """Test that failed status includes error information for debugging.""" - # File record should have error field populated on failure - mock_file_record = Mock() - mock_file_record.status = 'failed' - mock_file_record.error = 'ParserError: invalid format' - - assert mock_file_record.status == 'failed' - assert 'ParserError' in mock_file_record.error - - -class TestMimeTypeDetection: - """Tests for MIME type detection in file storage.""" - - def test_pdf_mime_type(self): - """Test PDF MIME type detection.""" - filename = 'document.pdf' - ext = os.path.splitext(filename)[1].lower() - expected_mime = 'application/pdf' - assert ext == '.pdf' - - def test_text_mime_type(self): - """Test text MIME type detection.""" - filename = 'notes.txt' - ext = os.path.splitext(filename)[1].lower() - expected_mime = 'text/plain' - assert ext == '.txt' - - def test_markdown_mime_type(self): - """Test markdown MIME type detection.""" - filename = 'readme.md' - ext = os.path.splitext(filename)[1].lower() - expected_mime = 'text/markdown' - assert ext == '.md' - - def test_doc_mime_type(self): - """Test DOC MIME type detection.""" - filename = 'report.doc' - ext = os.path.splitext(filename)[1].lower() - expected_mime = 'application/msword' - assert ext == '.doc' - - def test_docx_mime_type(self): - """Test DOCX MIME type detection.""" - filename = 'report.docx' - ext = os.path.splitext(filename)[1].lower() - expected_mime = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' - assert ext == '.docx' - - -class TestStoreFileTaskCleanup: - """Tests for cleanup behavior in _store_file_task.""" - - @pytest.mark.asyncio - async def test_cleanup_storage_on_success(self): - """Test that storage is cleaned up after successful processing.""" - mock_storage_provider = Mock() - mock_storage_provider.delete = AsyncMock() - - storage_path = 'kb/test/file.pdf' - should_cleanup = True # Based on source code finally block - - if should_cleanup: - await mock_storage_provider.delete(storage_path) - - mock_storage_provider.delete.assert_called_once_with(storage_path) - - @pytest.mark.asyncio - async def test_cleanup_storage_on_failure(self): - """Test that storage is cleaned up even when processing fails.""" - mock_storage_provider = Mock() - mock_storage_provider.delete = AsyncMock() - - storage_path = 'kb/test/file.pdf' - - # Simulate processing failure and cleanup - try: - raise Exception("Processing failed") - except Exception: - pass # Error handled - - # Cleanup should still happen in finally block - await mock_storage_provider.delete(storage_path) - mock_storage_provider.delete.assert_called_once() + assert kb.ap.persistence_mgr.execute_async.await_count == 2 + kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with('bad.pdf') class TestDeleteDocument: - """Tests for _delete_document method.""" + @pytest.mark.asyncio + async def test_delete_document_returns_false_when_no_plugin_id(self): + kb = _make_kb(plugin_id=None) - @pytest.fixture - def mock_kb_with_plugin(self): - """Create mock KB with plugin ID.""" - kbmgr = get_kbmgr_module() + result = await kb._delete_document('doc-id') - mock_app = Mock() - mock_app.logger = Mock() - mock_app.plugin_connector = Mock() - mock_app.plugin_connector.rag_delete_document = AsyncMock(return_value={'success': True}) - - mock_kb_entity = Mock() - mock_kb_entity.uuid = 'test-kb-uuid' - mock_kb_entity.knowledge_engine_plugin_id = 'author/engine' - - kb = kbmgr.RuntimeKnowledgeBase(mock_app, mock_kb_entity) - return kb - - @pytest.fixture - def mock_kb_without_plugin(self): - """Create mock KB without plugin ID.""" - kbmgr = get_kbmgr_module() - - mock_app = Mock() - mock_app.logger = Mock() - - mock_kb_entity = Mock() - mock_kb_entity.uuid = 'test-kb-uuid' - mock_kb_entity.knowledge_engine_plugin_id = None - - kb = kbmgr.RuntimeKnowledgeBase(mock_app, mock_kb_entity) - return kb + assert result is False @pytest.mark.asyncio - async def test_returns_false_when_no_plugin_id(self, mock_kb_without_plugin): - """Test that _delete_document returns False when no plugin ID.""" - kb_entity = mock_kb_without_plugin.knowledge_base_entity + async def test_delete_document_calls_configured_rag_plugin(self): + kb = _make_kb() + kb.ap.plugin_connector.call_rag_delete_document = AsyncMock(return_value=True) - if kb_entity.knowledge_engine_plugin_id is None: - # Source code returns False early - expected_result = False - assert expected_result is False + result = await kb._delete_document('doc-id') + + assert result is True + kb.ap.plugin_connector.call_rag_delete_document.assert_awaited_once_with( + 'author/engine', 'doc-id', 'test-kb-uuid' + ) @pytest.mark.asyncio - async def test_returns_true_on_success(self, mock_kb_with_plugin): - """Test that _delete_document returns True on successful delete.""" - kb_entity = mock_kb_with_plugin.knowledge_base_entity - plugin_id = kb_entity.knowledge_engine_plugin_id + async def test_delete_document_returns_false_on_plugin_error(self): + kb = _make_kb() + kb.ap.plugin_connector.call_rag_delete_document = AsyncMock(side_effect=Exception('plugin error')) - if plugin_id is not None: - # Simulate successful plugin call - mock_kb_with_plugin.ap.plugin_connector.rag_delete_document = AsyncMock( - return_value={'success': True} - ) - result = await mock_kb_with_plugin.ap.plugin_connector.rag_delete_document( - plugin_id.split('/'), 'test-doc-id', kb_entity.uuid - ) - assert result.get('success') is True + result = await kb._delete_document('doc-id') - @pytest.mark.asyncio - async def test_returns_false_on_plugin_error(self, mock_kb_with_plugin): - """Test that _delete_document returns False on plugin error.""" - kb_entity = mock_kb_with_plugin.knowledge_base_entity - plugin_id = kb_entity.knowledge_engine_plugin_id - - if plugin_id is not None: - # Simulate plugin error - mock_kb_with_plugin.ap.plugin_connector.rag_delete_document = AsyncMock( - side_effect=Exception("Plugin error") - ) - try: - await mock_kb_with_plugin.ap.plugin_connector.rag_delete_document( - plugin_id.split('/'), 'test-doc-id', kb_entity.uuid - ) - result = True - except Exception: - result = False # Source code catches and returns False - - assert result is False \ No newline at end of file + assert result is False + kb.ap.logger.error.assert_called_once() diff --git a/tests/unit_tests/telemetry/test_telemetry.py b/tests/unit_tests/telemetry/test_telemetry.py index 15333e91..2ceb1f09 100644 --- a/tests/unit_tests/telemetry/test_telemetry.py +++ b/tests/unit_tests/telemetry/test_telemetry.py @@ -38,31 +38,6 @@ class TestTelemetryManagerInit: manager = telemetry.TelemetryManager(mock_app) assert manager.telemetry_config == {} - def test_send_tasks_is_instance_variable(self): - """Test that send_tasks is an instance variable (not class variable). - - NOTE: This test documents a known bug - send_tasks is currently - a class variable which causes state pollution between instances. - The source code should be fixed to make it an instance variable. - """ - telemetry = get_telemetry_module() - mock_app1 = Mock() - mock_app2 = Mock() - - manager1 = telemetry.TelemetryManager(mock_app1) - manager2 = telemetry.TelemetryManager(mock_app2) - - # Current behavior (bug): send_tasks is shared across instances - # This test will FAIL after source bug is fixed - # After fix: manager1.send_tasks should be independent from manager2.send_tasks - assert manager1.send_tasks is manager2.send_tasks # BUG - they share same list - - # Expected behavior after fix: - # assert manager1.send_tasks is not manager2.send_tasks - # assert manager1.send_tasks == [] - # assert manager2.send_tasks == [] - - class TestTelemetryManagerInitialize: """Tests for initialize() method.""" @@ -644,4 +619,4 @@ class TestStartSendTask: for task in manager.send_tasks: if not task.done(): task.cancel() - manager.send_tasks.clear() \ No newline at end of file + manager.send_tasks.clear() diff --git a/tests/unit_tests/utils/test_httpclient.py b/tests/unit_tests/utils/test_httpclient.py index c5b12182..0a102969 100644 --- a/tests/unit_tests/utils/test_httpclient.py +++ b/tests/unit_tests/utils/test_httpclient.py @@ -8,6 +8,7 @@ from __future__ import annotations import pytest import aiohttp +from aiohttp import web from langbot.pkg.utils import httpclient @@ -108,19 +109,30 @@ class TestSessionPoolIntegration: """Integration tests for session pool behavior.""" async def test_session_can_make_request(self): - """Session can be used for actual HTTP requests.""" + """Session can be used for HTTP requests without relying on external network.""" + app = web.Application() + + async def handle_get(request): + return web.json_response({'ok': True}) + + app.router.add_get('/get', handle_get) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, '127.0.0.1', 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] session = httpclient.get_session() - # Make a simple request (using httpbin or similar) - # This is a basic smoke test try: - async with session.get('https://httpbin.org/get', timeout=aiohttp.ClientTimeout(total=5)) as resp: + async with session.get( + f'http://127.0.0.1:{port}/get', + timeout=aiohttp.ClientTimeout(total=5), + ) as resp: assert resp.status == 200 - except Exception: - # Network may be unavailable in CI, just verify session is usable - pass - - await httpclient.close_all() + assert await resp.json() == {'ok': True} + finally: + await httpclient.close_all() + await runner.cleanup() async def test_multiple_requests_same_session(self): """Multiple requests can use the same session.""" diff --git a/tests/unit_tests/utils/test_importutil.py b/tests/unit_tests/utils/test_importutil.py index 0348e18c..b0ea0ad7 100644 --- a/tests/unit_tests/utils/test_importutil.py +++ b/tests/unit_tests/utils/test_importutil.py @@ -137,11 +137,9 @@ class TestReadResourceFile: """Should read content from a resource file.""" from langbot.pkg.utils import importutil - try: - content = importutil.read_resource_file("templates/config.yaml") - assert isinstance(content, str) - except FileNotFoundError: - pass + content = importutil.read_resource_file("templates/config.yaml") + assert "admins:" in content + assert "edition: community" in content def test_raises_for_nonexistent_file(self): """Should raise exception for non-existent resource file.""" @@ -158,11 +156,9 @@ class TestReadResourceFileBytes: """Should read content as bytes from a resource file.""" from langbot.pkg.utils import importutil - try: - content = importutil.read_resource_file_bytes("templates/config.yaml") - assert isinstance(content, bytes) - except FileNotFoundError: - pass + content = importutil.read_resource_file_bytes("templates/config.yaml") + assert b"admins:" in content + assert b"edition: community" in content def test_raises_for_nonexistent_file_bytes(self): """Should raise exception for non-existent resource file.""" @@ -179,13 +175,10 @@ class TestListResourceFiles: """Should list files in a resource directory.""" from langbot.pkg.utils import importutil - try: - files = importutil.list_resource_files("templates") - assert isinstance(files, list) - for f in files: - assert isinstance(f, str) - except (FileNotFoundError, Exception): - pass + files = importutil.list_resource_files("templates") + assert "config.yaml" in files + assert "default-pipeline-config.json" in files + assert all(isinstance(file, str) for file in files) def test_raises_for_nonexistent_directory(self): """Should raise exception for non-existent directory.""" @@ -196,4 +189,4 @@ class TestListResourceFiles: if __name__ == "__main__": - pytest.main([__file__, "-v"]) \ No newline at end of file + pytest.main([__file__, "-v"]) diff --git a/tests/unit_tests/utils/test_logcache.py b/tests/unit_tests/utils/test_logcache.py index 91e48f28..ed05d0cc 100644 --- a/tests/unit_tests/utils/test_logcache.py +++ b/tests/unit_tests/utils/test_logcache.py @@ -6,7 +6,6 @@ Tests log page management and pointer-based retrieval. from __future__ import annotations -import pytest from langbot.pkg.utils.logcache import LogPage, LogCache, LOG_PAGE_SIZE, MAX_CACHED_PAGES diff --git a/tests/unit_tests/utils/test_pkgmgr.py b/tests/unit_tests/utils/test_pkgmgr.py index a6805851..721ce090 100644 --- a/tests/unit_tests/utils/test_pkgmgr.py +++ b/tests/unit_tests/utils/test_pkgmgr.py @@ -6,8 +6,7 @@ Tests pip command generation without actual installation. from __future__ import annotations -import pytest -from unittest.mock import patch, Mock +from unittest.mock import patch from langbot.pkg.utils import pkgmgr diff --git a/tests/unit_tests/utils/test_runner.py b/tests/unit_tests/utils/test_runner.py index 99aaa64f..155f6e77 100644 --- a/tests/unit_tests/utils/test_runner.py +++ b/tests/unit_tests/utils/test_runner.py @@ -87,15 +87,6 @@ class TestGetRunnerCategory: assert get_runner_category("test", "https://example.com") == RunnerCategory.CLOUD assert get_runner_category("test", "https://myserver.example.org") == RunnerCategory.CLOUD - def test_invalid_url_returns_unknown(self): - """Invalid URL that causes parsing error should return UNKNOWN.""" - # URLs that cause exceptions during parsing return UNKNOWN - # Note: "not a valid url" is actually parseable by urlparse, it just has no scheme - # Use a URL that genuinely causes an exception - result = get_runner_category("test", "://invalid") - # urlparse may handle this differently, but exceptions return UNKNOWN - assert result in (RunnerCategory.UNKNOWN, RunnerCategory.CLOUD) - def test_urlparse_exception_returns_unknown(self): """Exception during URL parsing should return UNKNOWN.""" # Test by mocking urlparse to raise an exception @@ -108,14 +99,6 @@ class TestGetRunnerCategory: result = runner.get_runner_category("test", "http://example.com") assert result == RunnerCategory.UNKNOWN - def test_url_without_scheme(self): - """URL without scheme should still be parseable.""" - # urlparse can parse this, hostname might be None - result = get_runner_category("test", "example.com") - # Without scheme, urlparse treats it as path, so hostname is None - # This should return UNKNOWN or CLOUD depending on implementation - assert result in (RunnerCategory.UNKNOWN, RunnerCategory.CLOUD) - class TestIsCloudRunner: """Test is_cloud_runner helper function.""" @@ -295,4 +278,4 @@ class TestConstants: if __name__ == "__main__": - pytest.main([__file__, "-v"]) \ No newline at end of file + pytest.main([__file__, "-v"]) diff --git a/tests/unit_tests/utils/test_version.py b/tests/unit_tests/utils/test_version.py index 1da0ae94..df698caf 100644 --- a/tests/unit_tests/utils/test_version.py +++ b/tests/unit_tests/utils/test_version.py @@ -6,7 +6,6 @@ Tests version comparison logic without network calls. from __future__ import annotations -import pytest from unittest.mock import Mock from langbot.pkg.utils.version import VersionManager diff --git a/tests/unit_tests/vector/test_vdb_filter_conversion.py b/tests/unit_tests/vector/test_vdb_filter_conversion.py index 9297ae33..5499b908 100644 --- a/tests/unit_tests/vector/test_vdb_filter_conversion.py +++ b/tests/unit_tests/vector/test_vdb_filter_conversion.py @@ -7,8 +7,6 @@ Tests cover: """ from __future__ import annotations -import pytest -from unittest.mock import Mock from importlib import import_module