test: tighten phase 1 coverage contracts

This commit is contained in:
huanghuoguoguo
2026-05-16 10:30:17 +08:00
parent 3ba727f0e4
commit bb55cd7ba9
44 changed files with 708 additions and 1164 deletions
+5 -9
View File
@@ -12,8 +12,6 @@ Run: uv run pytest tests/e2e/test_startup.py -v -m e2e
from __future__ import annotations from __future__ import annotations
import pytest import pytest
import httpx
from pathlib import Path
pytestmark = pytest.mark.e2e pytestmark = pytest.mark.e2e
@@ -64,9 +62,8 @@ class TestStartupFlow:
def test_chroma_directory_created(self, e2e_tmpdir): def test_chroma_directory_created(self, e2e_tmpdir):
"""Verify Chroma vector database directory was created.""" """Verify Chroma vector database directory was created."""
chroma_path = e2e_tmpdir / 'chroma' chroma_path = e2e_tmpdir / 'chroma'
# Chroma should create its storage on startup or when first used # Created by the E2E config factory before startup.
# This test just verifies the directory exists (created by config factory) assert chroma_path.exists()
assert chroma_path.exists() or True # May not be created until first use
def test_pipelines_endpoint(self, e2e_client): def test_pipelines_endpoint(self, e2e_client):
"""Test /api/v1/pipelines endpoint (requires auth).""" """Test /api/v1/pipelines endpoint (requires auth)."""
@@ -86,8 +83,7 @@ class TestStartupFlow:
# - 200 if auth succeeds # - 200 if auth succeeds
# - 400 if credentials wrong # - 400 if credentials wrong
# - 401 if user not initialized # - 401 if user not initialized
# - 500 if internal error (e.g., user service not initialized) assert response.status_code in [200, 400, 401]
assert response.status_code in [200, 400, 401, 500]
class TestStartupStages: class TestStartupStages:
@@ -127,8 +123,8 @@ class TestStartupStages:
for endpoint in endpoints: for endpoint in endpoints:
response = e2e_client.get(endpoint) response = e2e_client.get(endpoint)
# Should get valid response (even if 401 unauthorized) # Should get a real route response, even if auth is required.
assert response.status_code < 500, f'{endpoint} should not return 5xx' assert response.status_code in [200, 401, 403], f'{endpoint} should be registered'
class TestMinimalStartupNoLLM: class TestMinimalStartupNoLLM:
-1
View File
@@ -9,7 +9,6 @@ import subprocess
import time import time
import signal import signal
import os import os
import shutil
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import logging import logging
+2 -1
View File
@@ -64,7 +64,8 @@ def fake_bot_app():
# Auth services # Auth services
app.user_service = Mock() app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True) 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 = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True) app.apikey_service.verify_api_key = AsyncMock(return_value=True)
+2 -1
View File
@@ -65,7 +65,8 @@ def fake_knowledge_app():
# Auth services # Auth services
app.user_service = Mock() app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True) 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 = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True) app.apikey_service.verify_api_key = AsyncMock(return_value=True)
+2 -1
View File
@@ -72,7 +72,8 @@ def fake_pipeline_app():
# Auth services # Auth services
app.user_service = Mock() app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True) 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 = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True) app.apikey_service.verify_api_key = AsyncMock(return_value=True)
+2 -1
View File
@@ -65,7 +65,8 @@ def fake_provider_app():
# Auth services # Auth services
app.user_service = Mock() app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True) 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 = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True) app.apikey_service.verify_api_key = AsyncMock(return_value=True)
@@ -9,7 +9,7 @@ Source: src/langbot/pkg/api/http/service/apikey.py
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock, patch
from types import SimpleNamespace from types import SimpleNamespace
from langbot.pkg.api.http.service.apikey import ApiKeyService from langbot.pkg.api.http.service.apikey import ApiKeyService
@@ -101,43 +101,42 @@ class TestApiKeyServiceCreateApiKey:
ap = SimpleNamespace() ap = SimpleNamespace()
ap.persistence_mgr = 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 = Mock(spec=ApiKey)
created_key.id = 1 created_key.id = 1
created_key.name = 'New Key' created_key.name = 'New Key'
created_key.key = 'lbk_generated_key' created_key.key = 'lbk_fixed-token'
created_key.description = 'Test description' created_key.description = 'Test description'
select_result = Mock() select_result = Mock()
select_result.first = Mock(return_value=created_key) select_result.first = Mock(return_value=created_key)
insert_params = []
# execute_async returns different results for insert vs select
async def mock_execute(query): async def mock_execute(query):
# First call is insert, second is select params = query.compile().params
if hasattr(query, 'values'): if {'name', 'key', 'description'}.issubset(params):
return insert_result insert_params.append(params)
return Mock()
return select_result return select_result
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock( ap.persistence_mgr.serialize_model = Mock(
return_value={ side_effect=lambda model_cls, entity: {
'id': 1, 'id': 1,
'name': 'New Key', 'name': entity.name,
'key': 'lbk_generated_key', 'key': entity.key,
'description': 'Test description', 'description': entity.description,
} }
) )
service = ApiKeyService(ap) service = ApiKeyService(ap)
# Execute 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') 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'].startswith('lbk_')
assert result['key'] == 'lbk_fixed-token'
assert result['name'] == 'New Key' assert result['name'] == 'New Key'
assert result['description'] == 'Test description' assert result['description'] == 'Test description'
@@ -313,8 +312,8 @@ class TestApiKeyServiceVerifyApiKey:
# Verify # Verify
assert result is False assert result is False
async def test_verify_api_key_wrong_prefix(self): async def test_verify_api_key_unknown_key(self):
"""Returns False for key without correct prefix.""" """Returns False when the key is not present in persistence."""
# Setup # Setup
ap = SimpleNamespace() ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace() ap.persistence_mgr = SimpleNamespace()
@@ -326,7 +325,7 @@ class TestApiKeyServiceVerifyApiKey:
service = ApiKeyService(ap) service = ApiKeyService(ap)
# Execute # Execute
result = await service.verify_api_key('invalid_prefix_key') result = await service.verify_api_key('unknown_key')
# Verify # Verify
assert result is False assert result is False
@@ -432,7 +432,7 @@ class TestBotServiceUpdateBot:
"""Tests for update_bot method.""" """Tests for update_bot method."""
async def test_update_bot_removes_uuid_from_data(self): 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 # Setup
ap = SimpleNamespace() ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace() ap.persistence_mgr = SimpleNamespace()
@@ -456,9 +456,9 @@ class TestBotServiceUpdateBot:
update_data = {'uuid': 'should-be-removed', 'name': 'Updated Name'} update_data = {'uuid': 'should-be-removed', 'name': 'Updated Name'}
await service.update_bot('test-uuid', update_data) await service.update_bot('test-uuid', update_data)
# Verify - uuid was removed from bot_data dict update_params = ap.persistence_mgr.execute_async.await_args_list[0].args[0].compile().params
assert 'uuid' not in update_data assert update_params['name'] == 'Updated Name'
assert 'name' in update_data assert 'should-be-removed' not in update_params.values()
async def test_update_bot_pipeline_not_found_raises(self): async def test_update_bot_pipeline_not_found_raises(self):
"""Raises Exception when updating with nonexistent pipeline UUID.""" """Raises Exception when updating with nonexistent pipeline UUID."""
@@ -513,7 +513,9 @@ class TestBotServiceUpdateBot:
# Execute # Execute
await service.update_bot('test-uuid', {'use_pipeline_uuid': 'pipeline-uuid'}) 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: class TestBotServiceDeleteBot:
@@ -153,7 +153,7 @@ class TestCreateKnowledgeBase:
service = knowledge_module.KnowledgeService(mock_app) service = knowledge_module.KnowledgeService(mock_app)
result = await service.create_knowledge_base({ await service.create_knowledge_base({
'knowledge_engine_plugin_id': 'author/engine' 'knowledge_engine_plugin_id': 'author/engine'
}) })
@@ -318,24 +318,22 @@ class TestPipelineServiceCreatePipeline:
service.get_pipelines = AsyncMock(return_value=[]) service.get_pipelines = AsyncMock(return_value=[])
service.get_pipeline = AsyncMock(return_value={ service.get_pipeline = AsyncMock(return_value={
'uuid': 'new-uuid', 'uuid': 'new-uuid',
'extensions_preferences': { 'extensions_preferences': {},
'enable_all_plugins': True,
'enable_all_mcp_servers': True,
'plugins': [],
'mcp_servers': [],
}
}) })
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( ap.persistence_mgr.serialize_model = Mock(
return_value={ return_value={
'uuid': 'new-uuid', 'uuid': 'new-uuid',
'extensions_preferences': { 'extensions_preferences': {},
'enable_all_plugins': True,
'enable_all_mcp_servers': True,
'plugins': [],
'mcp_servers': [],
}
} }
) )
@@ -344,8 +342,13 @@ class TestPipelineServiceCreatePipeline:
with patch('langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'): with patch('langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'):
await service.create_pipeline({'name': 'New Pipeline'}) await service.create_pipeline({'name': 'New Pipeline'})
# Verify - extensions_preferences should have been set assert len(insert_params) == 1
ap.persistence_mgr.execute_async.assert_called() assert insert_params[0]['extensions_preferences'] == {
'enable_all_plugins': True,
'enable_all_mcp_servers': True,
'plugins': [],
'mcp_servers': [],
}
class _MockResultWithBots: class _MockResultWithBots:
@@ -364,7 +367,7 @@ class TestPipelineServiceUpdatePipeline:
"""Tests for update_pipeline method.""" """Tests for update_pipeline method."""
async def test_update_pipeline_removes_protected_fields(self): 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 # Setup
ap = SimpleNamespace() ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace() ap.persistence_mgr = SimpleNamespace()
@@ -390,12 +393,11 @@ class TestPipelineServiceUpdatePipeline:
} }
await service.update_pipeline('test-uuid', pipeline_data) await service.update_pipeline('test-uuid', pipeline_data)
# Verify - protected fields removed update_params = ap.persistence_mgr.execute_async.await_args_list[0].args[0].compile().params
assert 'uuid' not in pipeline_data assert update_params['description'] == 'New description'
assert 'for_version' not in pipeline_data assert 'should-be-removed' not in update_params.values()
assert 'stages' not in pipeline_data assert ['should-be-removed'] not in update_params.values()
assert 'is_default' not in pipeline_data assert not any(value is True for value in update_params.values())
assert 'description' in pipeline_data
async def test_update_pipeline_syncs_bot_names(self): async def test_update_pipeline_syncs_bot_names(self):
"""Updates bot use_pipeline_name when pipeline name changes.""" """Updates bot use_pipeline_name when pipeline name changes."""
@@ -6,7 +6,6 @@ Tests cover:
""" """
from __future__ import annotations from __future__ import annotations
import pytest
from unittest.mock import Mock from unittest.mock import Mock
from importlib import import_module from importlib import import_module
+20 -23
View File
@@ -102,36 +102,33 @@ class TestPrecheckPluginDeps:
def test_precheck_plugin_deps_no_plugins_dir(self): def test_precheck_plugin_deps_no_plugins_dir(self):
"""precheck_plugin_deps skips when plugins dir doesn't exist.""" """precheck_plugin_deps skips when plugins dir doesn't exist."""
mocks = self._make_deps_import_mocks() from langbot.pkg.core.bootutils.deps import precheck_plugin_deps
with isolated_sys_modules(mocks):
with patch('os.path.exists', return_value=False):
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 import asyncio
asyncio.get_event_loop().run_until_complete(precheck_plugin_deps()) 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): def test_precheck_plugin_deps_with_plugins_dir(self):
"""precheck_plugin_deps checks plugins subdirectories.""" """precheck_plugin_deps checks plugins subdirectories."""
mocks = self._make_deps_import_mocks() from langbot.pkg.core.bootutils.deps import precheck_plugin_deps
mock_pkgmgr = MagicMock()
mocks['langbot.pkg.utils.pkgmgr'].install_requirements = mock_pkgmgr
with isolated_sys_modules(mocks): def mock_listdir(path):
from langbot.pkg.core.bootutils.deps import precheck_plugin_deps 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.path.exists', return_value=True): with patch('os.path.isdir', return_value=True):
with patch('os.listdir', return_value=['plugin1', 'plugin2']): with patch('os.listdir', side_effect=mock_listdir):
with patch('os.path.isdir', return_value=True): with patch('langbot.pkg.core.bootutils.deps.pkgmgr.install_requirements') as mock_install:
# plugin1 has requirements.txt, plugin2 doesn't import asyncio
def mock_listdir_subdir(path): asyncio.get_event_loop().run_until_complete(precheck_plugin_deps())
if 'plugin1' in path:
return ['requirements.txt', 'main.py']
return ['main.py']
with patch('os.listdir', side_effect=lambda p: mock_listdir_subdir(p) if 'plugin' in p else ['plugin1', 'plugin2']): mock_install.assert_called_once_with('plugins/plugin1/requirements.txt', extra_params=[])
import asyncio
asyncio.get_event_loop().run_until_complete(precheck_plugin_deps())
-1
View File
@@ -6,7 +6,6 @@ Tests I18nString, Metadata, and Component utilities.
from __future__ import annotations from __future__ import annotations
import pytest
from langbot.pkg.discover.engine import I18nString, Metadata, Component from langbot.pkg.discover.engine import I18nString, Metadata, Component
@@ -47,7 +47,7 @@ class TestExecuteAsync:
mgr.db = mock_db mgr.db = mock_db
# Execute a simple select # 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.execute.assert_called_once()
mock_conn.commit.assert_called_once() mock_conn.commit.assert_called_once()
@@ -8,6 +8,8 @@ Tests cover:
from __future__ import annotations from __future__ import annotations
import datetime import datetime
from unittest.mock import Mock
from sqlalchemy import Column, Integer, String, DateTime from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import declarative_base
from importlib import import_module from importlib import import_module
@@ -124,7 +126,3 @@ class TestSerializeModel:
assert 'id' not in result assert 'id' not in result
assert 'name' not in result assert 'name' not in result
assert 'created_at' in result assert 'created_at' in result
# Import Mock for type annotations
from unittest.mock import Mock
+5 -5
View File
@@ -91,9 +91,7 @@ class TestContentFilterStageInit:
await stage.initialize(pipeline_config) await stage.initialize(pipeline_config)
assert stage.filter_chain is not None assert [filter_impl.name for filter_impl in stage.filter_chain] == ['content-ignore']
# Should have at least 'content-ignore' filter
assert len(stage.filter_chain) >= 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_initialize_with_sensitive_words(self): async def test_initialize_with_sensitive_words(self):
@@ -121,8 +119,10 @@ class TestContentFilterStageInit:
await stage.initialize(pipeline_config) await stage.initialize(pipeline_config)
# Should have content-ignore and ban-word-filter assert [filter_impl.name for filter_impl in stage.filter_chain] == [
assert len(stage.filter_chain) >= 2 'ban-word-filter',
'content-ignore',
]
class TestPreContentFilter: class TestPreContentFilter:
+19 -64
View File
@@ -160,8 +160,11 @@ class TestLongTextProcessStageProcess:
result = await stage.process(query, 'LongTextProcessStage') result = await stage.process(query, 'LongTextProcessStage')
assert result.result_type == entities.ResultType.CONTINUE assert result.result_type == entities.ResultType.CONTINUE
# Should not transform short text assert len(result.new_query.resp_message_chain) == 1
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.Plain)
assert components[0].text == 'short response'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_non_plain_component_skips(self): async def test_non_plain_component_skips(self):
@@ -189,35 +192,13 @@ class TestLongTextProcessStageProcess:
result = await stage.process(query, 'LongTextProcessStage') result = await stage.process(query, 'LongTextProcessStage')
assert result.result_type == entities.ResultType.CONTINUE assert result.result_type == entities.ResultType.CONTINUE
# Should skip due to non-Plain component components = list(result.new_query.resp_message_chain[0])
assert [type(component) for component in components] == [
@pytest.mark.asyncio platform_message.Plain,
async def test_empty_resp_message_chain(self): platform_message.Image,
"""Empty resp_message_chain should be handled gracefully.""" ]
longtext = get_longtext_module() assert components[0].text == 'short'
entities = get_entities_module() assert components[1].url == 'https://example.com/img.png'
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
class TestForwardStrategy: class TestForwardStrategy:
"""Tests for ForwardComponentStrategy.""" """Tests for ForwardComponentStrategy."""
@@ -253,8 +234,9 @@ class TestForwardStrategy:
result = await stage.process(query, 'LongTextProcessStage') result = await stage.process(query, 'LongTextProcessStage')
assert result.result_type == entities.ResultType.CONTINUE assert result.result_type == entities.ResultType.CONTINUE
# Check that message chain was transformed components = list(result.new_query.resp_message_chain[0])
assert result.new_query.resp_message_chain is not None assert len(components) == 1
assert isinstance(components[0], platform_message.Forward)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_forward_strategy_direct_process(self): async def test_forward_strategy_direct_process(self):
@@ -288,36 +270,6 @@ class TestForwardStrategy:
class TestLongTextThreshold: class TestLongTextThreshold:
"""Tests for threshold boundary handling.""" """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 @pytest.mark.asyncio
async def test_below_threshold_not_processed(self): async def test_below_threshold_not_processed(self):
"""Text below threshold should not be transformed.""" """Text below threshold should not be transformed."""
@@ -344,7 +296,10 @@ class TestLongTextThreshold:
result = await stage.process(query, 'LongTextProcessStage') result = await stage.process(query, 'LongTextProcessStage')
assert result.result_type == entities.ResultType.CONTINUE 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: class TestLongTextProcessStageImageStrategy:
+6 -9
View File
@@ -254,13 +254,12 @@ class TestRoundTruncatorProcess:
assert result.result_type == entities.ResultType.CONTINUE assert result.result_type == entities.ResultType.CONTINUE
# Check order is preserved (user2 -> asst2 -> user3)
messages = result.new_query.messages messages = result.new_query.messages
if len(messages) >= 3: assert [(msg.role, msg.content) for msg in messages] == [
assert messages[0].role == 'user' ('user', 'user2'),
assert messages[0].content == 'user2' ('assistant', 'asst2'),
assert messages[1].role == 'assistant' ('user', 'user3'),
assert messages[1].content == 'asst2' ]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_truncate_max_round_one(self): async def test_truncate_max_round_one(self):
@@ -286,10 +285,8 @@ class TestRoundTruncatorProcess:
result = await stage.process(query, 'ConversationMessageTruncator') result = await stage.process(query, 'ConversationMessageTruncator')
assert result.result_type == entities.ResultType.CONTINUE assert result.result_type == entities.ResultType.CONTINUE
# Only last round (user + assistant pair) should remain
messages = result.new_query.messages messages = result.new_query.messages
# At most 2 messages (user + assistant before current) assert [(msg.role, msg.content) for msg in messages] == [('user', 'current')]
assert len(messages) <= 2
class TestRoundTruncatorDirect: class TestRoundTruncatorDirect:
+32 -20
View File
@@ -19,14 +19,23 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
_mock_runner = MagicMock() _mock_runner = MagicMock()
_mock_runner.runner_class = lambda name: (lambda cls: cls) # no-op decorator _mock_runner.runner_class = lambda name: (lambda cls: cls) # no-op decorator
_mock_runner.RequestRunner = object _mock_runner.RequestRunner = object
sys.modules.setdefault('langbot.pkg.provider.runner', _mock_runner) _mocked_imports = {
sys.modules.setdefault('langbot.pkg.core.app', MagicMock()) 'langbot.pkg.provider.runner': _mock_runner,
sys.modules.setdefault('langbot.pkg.utils.httpclient', MagicMock()) '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 pytest # noqa: E402
import langbot_plugin.api.entities.builtin.provider.message as provider_message # 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 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 # Helpers
@@ -82,10 +91,10 @@ async def test_stream_format_single_item():
chunks = await collect_chunks(runner, [data]) chunks = await collect_chunks(runner, [data])
assert len(chunks) >= 1 assert len(chunks) == 1
final = chunks[-1] assert chunks[0].is_final is True
assert final.is_final is True assert chunks[0].content == 'hello'
assert final.content == 'hello' assert chunks[0].msg_sequence == 1
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -100,9 +109,10 @@ async def test_stream_format_multi_item_accumulates():
chunks = await collect_chunks(runner, chunks_data) chunks = await collect_chunks(runner, chunks_data)
final = chunks[-1] assert len(chunks) == 1
assert final.is_final is True assert chunks[0].is_final is True
assert final.content == 'foobar' assert chunks[0].content == 'foobar'
assert chunks[0].msg_sequence == 1
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -115,9 +125,13 @@ async def test_stream_format_batches_every_8_items():
chunks = await collect_chunks(runner, [data]) chunks = await collect_chunks(runner, [data])
# At least the batch yield at chunk_idx==8 + final yield assert len(chunks) == 2
assert len(chunks) >= 2 assert chunks[0].is_final is False
assert chunks[-1].is_final is True 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 @pytest.mark.asyncio
@@ -129,9 +143,9 @@ async def test_stream_format_split_across_network_chunks():
chunks = await collect_chunks(runner, [part1, part2]) chunks = await collect_chunks(runner, [part1, part2])
final = chunks[-1] assert len(chunks) == 1
assert final.is_final is True assert chunks[0].is_final is True
assert final.content == 'world' assert chunks[0].content == 'world'
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -143,10 +157,8 @@ async def test_stream_format_no_spurious_empty_yield():
chunks = await collect_chunks(runner, [data]) chunks = await collect_chunks(runner, [data])
# No chunk should have empty content before the real content arrives assert len(chunks) == 1
non_final = [c for c in chunks if not c.is_final] assert chunks[0].content == 'x'
for c in non_final:
assert c.content # must be non-empty
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+6 -6
View File
@@ -7,7 +7,7 @@ Tests query management, ID generation, and async context handling.
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import Mock, patch
from langbot.pkg.pipeline.pool import QueryPool from langbot.pkg.pipeline.pool import QueryPool
@@ -37,8 +37,8 @@ class TestQueryPoolInit:
class TestQueryPoolAddQuery: class TestQueryPoolAddQuery:
"""Tests for add_query method.""" """Tests for add_query method."""
async def test_add_query_returns_query_with_id(self): async def test_add_query_adds_query_with_id(self):
"""add_query creates a Query with correct ID.""" """add_query creates, stores, and caches a Query with the correct ID."""
pool = QueryPool() pool = QueryPool()
# Mock Query creation # Mock Query creation
@@ -134,7 +134,7 @@ class TestQueryPoolAddQuery:
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery: with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.return_value = mock_query MockQuery.return_value = mock_query
query = await pool.add_query( await pool.add_query(
bot_uuid='bot1', bot_uuid='bot1',
launcher_type=Mock(), launcher_type=Mock(),
launcher_id=1, launcher_id=1,
@@ -158,7 +158,7 @@ class TestQueryPoolAddQuery:
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery: with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.return_value = mock_query MockQuery.return_value = mock_query
query = await pool.add_query( await pool.add_query(
bot_uuid='bot1', bot_uuid='bot1',
launcher_type=Mock(), launcher_type=Mock(),
launcher_id=1, launcher_id=1,
@@ -184,7 +184,7 @@ class TestQueryPoolAddQuery:
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery: with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.return_value = mock_query MockQuery.return_value = mock_query
query = await pool.add_query( await pool.add_query(
bot_uuid='bot1', bot_uuid='bot1',
launcher_type=Mock(), launcher_type=Mock(),
launcher_id=1, launcher_id=1,
+2 -3
View File
@@ -155,11 +155,10 @@ class TestPreProcessorEmptyMessage:
result = await stage.process(query, 'PreProcessor') 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.result_type == entities.ResultType.CONTINUE
assert result.new_query.user_message is not None assert result.new_query.user_message is not None
# Empty content list assert result.new_query.user_message.content == []
assert result.new_query.user_message.content == [] or result.new_query.user_message.content is None
class TestPreProcessorImageSegment: class TestPreProcessorImageSegment:
-40
View File
@@ -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
+6 -5
View File
@@ -238,9 +238,9 @@ class TestResponseWrapperAssistant:
async def test_assistant_empty_content(self): async def test_assistant_empty_content(self):
"""Assistant with empty content should not emit event.""" """Assistant with empty content should not emit event."""
wrapper = get_wrapper_module() wrapper = get_wrapper_module()
get_entities_module()
app = FakeApp() app = FakeApp()
app.plugin_connector.emit_event = AsyncMock()
stage = wrapper.ResponseWrapper(app) stage = wrapper.ResponseWrapper(app)
pipeline_config = make_wrapper_config() pipeline_config = make_wrapper_config()
@@ -262,8 +262,9 @@ class TestResponseWrapperAssistant:
async for result in stage.process(query, 'ResponseWrapper'): async for result in stage.process(query, 'ResponseWrapper'):
results.append(result) results.append(result)
# Should have at least one result (for empty content case) assert results == []
assert len(results) >= 0 assert query.resp_message_chain == []
app.plugin_connector.emit_event.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_assistant_tool_calls(self): async def test_assistant_tool_calls(self):
@@ -313,10 +314,10 @@ class TestResponseWrapperAssistant:
async for result in stage.process(query, 'ResponseWrapper'): async for result in stage.process(query, 'ResponseWrapper'):
results.append(result) results.append(result)
# Should have results for content and tool_calls assert len(results) == 2
assert len(results) >= 1
for result in results: for result in results:
assert result.result_type == entities.ResultType.CONTINUE assert result.result_type == entities.ResultType.CONTINUE
assert app.plugin_connector.emit_event.await_count == 2
class TestResponseWrapperInterrupt: class TestResponseWrapperInterrupt:
@@ -73,7 +73,7 @@ class TestListPlugins:
result = await connector.list_plugins() result = await connector.list_plugins()
connector.handler.list_plugins.assert_called_once() connector.handler.list_plugins.assert_called_once()
assert len(result) == 1 assert result == [{'manifest': {'manifest': {'metadata': {'author': 'test', 'name': 'plugin'}}}}]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_filters_by_component_kinds(self): async def test_filters_by_component_kinds(self):
@@ -171,7 +171,7 @@ class TestListKnowledgeEngines:
result = await connector.list_knowledge_engines() result = await connector.list_knowledge_engines()
connector.handler.list_knowledge_engines.assert_called_once() connector.handler.list_knowledge_engines.assert_called_once()
assert len(result) == 1 assert result == [{'plugin_id': 'author/engine', 'name': 'Engine'}]
class TestListParsers: class TestListParsers:
@@ -208,7 +208,7 @@ class TestListParsers:
result = await connector.list_parsers() result = await connector.list_parsers()
connector.handler.list_parsers.assert_called_once() connector.handler.list_parsers.assert_called_once()
assert len(result) == 1 assert result == [{'plugin_id': 'author/parser', 'supported_mime_types': ['text/plain']}]
class TestCallParser: class TestCallParser:
@@ -269,8 +269,19 @@ class TestRAGMethods:
result = await connector.call_rag_retrieve('author/engine', {'query': 'test'}) result = await connector.call_rag_retrieve('author/engine', {'query': 'test'})
connector.handler.retrieve_knowledge.assert_called_once() connector.handler.retrieve_knowledge.assert_called_once_with(
assert 'results' in result 'author', 'engine', '', {'query': 'test'}
)
assert result == {
'results': [
{
'id': 'doc1',
'content': [{'type': 'text', 'text': 'test'}],
'metadata': {},
'distance': 0.1,
}
]
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_rag_creation_schema(self): async def test_get_rag_creation_schema(self):
@@ -286,7 +297,7 @@ class TestRAGMethods:
result = await connector.get_rag_creation_schema('author/engine') result = await connector.get_rag_creation_schema('author/engine')
connector.handler.get_rag_creation_schema.assert_called_once_with('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 @pytest.mark.asyncio
async def test_get_rag_retrieval_schema(self): async def test_get_rag_retrieval_schema(self):
@@ -302,7 +313,7 @@ class TestRAGMethods:
result = await connector.get_rag_retrieval_schema('author/engine') result = await connector.get_rag_retrieval_schema('author/engine')
connector.handler.get_rag_retrieval_schema.assert_called_once_with('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 @pytest.mark.asyncio
async def test_rag_on_kb_create(self): async def test_rag_on_kb_create(self):
@@ -442,7 +453,7 @@ class TestGetPluginInfo:
result = await connector.get_plugin_info('author', 'plugin') result = await connector.get_plugin_info('author', 'plugin')
connector.handler.get_plugin_info.assert_called_once_with('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: class TestSetPluginConfig:
@@ -474,7 +485,7 @@ class TestPingPluginRuntime:
connector = create_mock_connector() connector = create_mock_connector()
# handler is not set # 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() await connector.ping_plugin_runtime()
assert 'not connected' in str(exc_info.value) assert 'not connected' in str(exc_info.value)
+16 -24
View File
@@ -1,7 +1,7 @@
"""Tests for PluginRuntimeConnector pure logic methods. """Tests for PluginRuntimeConnector pure logic methods.
Tests methods that don't require real plugin runtime processes: 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 - _parse_plugin_id: plugin ID string parsing
""" """
@@ -12,13 +12,15 @@ import zipfile
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest
class TestExtractDepsMetadata: class TestExtractDepsMetadata:
"""Tests for _extract_deps_metadata method.""" """Tests for dependency metadata extraction from plugin packages."""
def _create_connector(self): def _create_connector(self):
"""Create a connector instance for testing.""" """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 = MagicMock()
mock_app.instance_config.data.get.return_value = {'enable': True} mock_app.instance_config.data.get.return_value = {'enable': True}
@@ -39,7 +41,7 @@ class TestExtractDepsMetadata:
zip_bytes = zip_buffer.getvalue() zip_bytes = zip_buffer.getvalue()
task_context = SimpleNamespace(metadata={}) 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 assert task_context.metadata['deps_total'] == 3 # requests>=2.0, flask, numpy
# deps_list contains full requirement lines including version specifiers # deps_list contains full requirement lines including version specifiers
@@ -58,7 +60,7 @@ class TestExtractDepsMetadata:
zip_bytes = zip_buffer.getvalue() zip_bytes = zip_buffer.getvalue()
task_context = SimpleNamespace(metadata={}) 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_total'] == 0
assert task_context.metadata['deps_list'] == [] assert task_context.metadata['deps_list'] == []
@@ -74,7 +76,7 @@ class TestExtractDepsMetadata:
zip_bytes = zip_buffer.getvalue() zip_bytes = zip_buffer.getvalue()
task_context = SimpleNamespace(metadata={}) 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 # No requirements.txt found, metadata unchanged
assert 'deps_total' not in task_context.metadata assert 'deps_total' not in task_context.metadata
@@ -90,7 +92,7 @@ class TestExtractDepsMetadata:
zip_bytes = zip_buffer.getvalue() zip_bytes = zip_buffer.getvalue()
# Should return early without error # 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): def test_extract_deps_invalid_zip(self):
"""Handle invalid zip file gracefully.""" """Handle invalid zip file gracefully."""
@@ -100,7 +102,7 @@ class TestExtractDepsMetadata:
invalid_bytes = b'not a zip file' invalid_bytes = b'not a zip file'
task_context = SimpleNamespace(metadata={}) 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 # Should catch exception and pass silently
assert 'deps_total' not in task_context.metadata assert 'deps_total' not in task_context.metadata
@@ -116,7 +118,7 @@ class TestExtractDepsMetadata:
zip_bytes = zip_buffer.getvalue() zip_bytes = zip_buffer.getvalue()
task_context = SimpleNamespace(metadata={}) 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 # Should find requirements.txt in subdirectory
assert task_context.metadata['deps_total'] == 2 assert task_context.metadata['deps_total'] == 2
@@ -127,25 +129,15 @@ class TestParsePluginId:
def test_parse_valid_plugin_id(self): def test_parse_valid_plugin_id(self):
"""Parse valid plugin ID format 'author/name'.""" """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') author, name = PluginRuntimeConnector._parse_plugin_id('myauthor/myplugin')
assert author == 'myauthor' assert author == 'myauthor'
assert name == 'myplugin' 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): 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 with pytest.raises(ValueError):
parts = ''.split('/') PluginRuntimeConnector._parse_plugin_id('')
assert len(parts) == 1
assert parts[0] == ''
@@ -24,14 +24,6 @@ class TestParsePluginId:
assert author == 'langbot' assert author == 'langbot'
assert name == 'rag-engine' 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): def test_invalid_plugin_id_no_slash(self):
"""Test that ValueError is raised when no slash present.""" """Test that ValueError is raised when no slash present."""
connector = get_connector_module() connector = get_connector_module()
@@ -60,19 +52,3 @@ class TestParsePluginId:
author, name = connector.PluginRuntimeConnector._parse_plugin_id('lang-bot/my_rag_engine') author, name = connector.PluginRuntimeConnector._parse_plugin_id('lang-bot/my_rag_engine')
assert author == 'lang-bot' assert author == 'lang-bot'
assert name == 'my_rag_engine' 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'
+11 -11
View File
@@ -1,4 +1,4 @@
"""Unit tests for plugin connector _extract_deps_metadata method. """Unit tests for plugin connector _inspect_plugin_package method.
Tests cover: Tests cover:
- Extracting requirements.txt from ZIP - Extracting requirements.txt from ZIP
@@ -60,7 +60,7 @@ def create_zip_without_requirements() -> bytes:
class TestExtractDepsMetadata: class TestExtractDepsMetadata:
"""Tests for _extract_deps_metadata method.""" """Tests for dependency metadata extraction from plugin packages."""
def test_extract_simple_requirements(self): def test_extract_simple_requirements(self):
"""Test extracting simple requirements.txt.""" """Test extracting simple requirements.txt."""
@@ -73,7 +73,7 @@ class TestExtractDepsMetadata:
task_context = Mock() task_context = Mock()
task_context.metadata = {} 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_total') == 3
assert task_context.metadata.get('deps_list') == ['requests>=2.0', 'flask==1.0', 'numpy'] assert task_context.metadata.get('deps_list') == ['requests>=2.0', 'flask==1.0', 'numpy']
@@ -94,7 +94,7 @@ numpy'''
task_context = Mock() task_context = Mock()
task_context.metadata = {} 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_total') == 3
assert '# This is a comment' not in task_context.metadata.get('deps_list', []) assert '# This is a comment' not in task_context.metadata.get('deps_list', [])
@@ -108,7 +108,7 @@ numpy'''
task_context = Mock() task_context = Mock()
task_context.metadata = {} 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') # Should find nested requirements.txt (ends with 'requirements.txt')
assert task_context.metadata.get('deps_total') == 2 assert task_context.metadata.get('deps_total') == 2
@@ -122,7 +122,7 @@ numpy'''
task_context = Mock() task_context = Mock()
task_context.metadata = {} 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) # metadata should remain empty (no deps found)
assert task_context.metadata.get('deps_total') is None assert task_context.metadata.get('deps_total') is None
@@ -137,7 +137,7 @@ numpy'''
task_context = Mock() task_context = Mock()
task_context.metadata = {} 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) # deps_total should be 0 (empty list after filtering)
assert task_context.metadata.get('deps_total') == 0 assert task_context.metadata.get('deps_total') == 0
@@ -155,7 +155,7 @@ numpy'''
task_context = Mock() task_context = Mock()
task_context.metadata = {} 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_total') == 0
assert task_context.metadata.get('deps_list') == [] assert task_context.metadata.get('deps_list') == []
@@ -167,7 +167,7 @@ numpy'''
zip_bytes = create_zip_with_requirements('requests') zip_bytes = create_zip_with_requirements('requests')
# Should return without error when task_context is None # 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 # No exception should be raised
@@ -182,7 +182,7 @@ numpy'''
task_context.metadata = {} task_context.metadata = {}
# Should silently handle exception (pass in try/except) # 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 # metadata should remain unchanged
assert task_context.metadata == {} assert task_context.metadata == {}
@@ -203,7 +203,7 @@ numpy'''
task_context.metadata = {} task_context.metadata = {}
# errors='ignore' will decode \x80invalid as 'invalid' (skipping \x80) # 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) # All 3 lines will be parsed (requests, flask, invalid)
assert task_context.metadata.get('deps_total') == 3 assert task_context.metadata.get('deps_total') == 3
+47 -36
View File
@@ -6,9 +6,18 @@ Tests handler helper methods that don't require full handler setup.
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import AsyncMock, MagicMock, Mock
import pytest 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: class TestHandlerQueryVariables:
"""Tests for handler query variable logic.""" """Tests for handler query variable logic."""
@@ -29,65 +38,67 @@ class TestHandlerQueryVariables:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_query_var_query_not_found(self, mock_app): async def test_set_query_var_query_not_found(self, mock_app):
"""Test set_query_var returns error when query not found.""" """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: response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value]({
expected_error = f'Query with query_id {query_id} not found' 'query_id': 'nonexistent-query',
# Should return error response 'key': 'test_var',
assert expected_error is not None 'value': 'test_value',
})
assert response.code != 0
assert 'nonexistent-query' in response.message
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_query_var_success(self, mock_app): async def test_set_query_var_success(self, mock_app):
"""Test set_query_var sets variable on existing query.""" """Test set_query_var sets variable on existing query."""
runtime_handler = make_handler(mock_app)
mock_query = SimpleNamespace() mock_query = SimpleNamespace()
mock_query.variables = {} mock_query.variables = {}
mock_app.query_pool.cached_queries['test-query'] = mock_query mock_app.query_pool.cached_queries['test-query'] = mock_query
# Simulate set_query_var logic response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value]({
query_id = 'test-query' 'query_id': 'test-query',
var_name = 'test_var' 'key': 'test_var',
var_value = 'test_value' '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
assert response.code == 0
assert mock_query.variables['test_var'] == 'test_value' assert mock_query.variables['test_var'] == 'test_value'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_query_var_success(self, mock_app): async def test_get_query_var_success(self, mock_app):
"""Test get_query_var retrieves variable from query.""" """Test get_query_var retrieves variable from query."""
runtime_handler = make_handler(mock_app)
mock_query = SimpleNamespace() mock_query = SimpleNamespace()
mock_query.variables = {'existing_var': 'existing_value'} mock_query.variables = {'existing_var': 'existing_value'}
mock_app.query_pool.cached_queries['test-query'] = mock_query mock_app.query_pool.cached_queries['test-query'] = mock_query
# Simulate get_query_var logic response = await runtime_handler.actions[PluginToRuntimeAction.GET_QUERY_VAR.value]({
query_id = 'test-query' 'query_id': 'test-query',
var_name = 'existing_var' 'key': 'existing_var',
})
if query_id in mock_app.query_pool.cached_queries: assert response.code == 0
query = mock_app.query_pool.cached_queries[query_id] assert response.data == {'value': 'existing_value'}
if var_name in query.variables:
value = query.variables[var_name]
assert value == 'existing_value'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_query_vars_multiple(self, mock_app): 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 = SimpleNamespace()
mock_query.variables = {'var1': 'val1', 'var2': 'val2', 'var3': 'val3'} mock_query.variables = {'var1': 'val1', 'var2': 'val2', 'var3': 'val3'}
mock_app.query_pool.cached_queries['test-query'] = mock_query mock_app.query_pool.cached_queries['test-query'] = mock_query
query_id = 'test-query' response = await runtime_handler.actions[PluginToRuntimeAction.GET_QUERY_VARS.value]({
var_names = ['var1', 'var3'] 'query_id': 'test-query',
})
if query_id in mock_app.query_pool.cached_queries: assert response.code == 0
query = mock_app.query_pool.cached_queries[query_id] assert response.data == {'vars': mock_query.variables}
result = {name: query.variables.get(name) for name in var_names}
assert result == {'var1': 'val1', 'var3': 'val3'}
class TestHandlerRagErrorResponse: class TestHandlerRagErrorResponse:
@@ -95,7 +106,7 @@ class TestHandlerRagErrorResponse:
def test_make_rag_error_response_basic(self): def test_make_rag_error_response_basic(self):
"""Test basic error response creation.""" """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") error = Exception("test error")
response = _make_rag_error_response(error, 'TestError') response = _make_rag_error_response(error, 'TestError')
@@ -107,7 +118,7 @@ class TestHandlerRagErrorResponse:
def test_make_rag_error_response_with_context(self): def test_make_rag_error_response_with_context(self):
"""Test error response with extra context.""" """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") error = ValueError("invalid input")
response = _make_rag_error_response( response = _make_rag_error_response(
@@ -124,7 +135,7 @@ class TestHandlerRagErrorResponse:
def test_make_rag_error_response_exception_type(self): def test_make_rag_error_response_exception_type(self):
"""Test error response includes exception type.""" """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") error = RuntimeError("connection failed")
response = _make_rag_error_response(error, 'ConnectionError') response = _make_rag_error_response(error, 'ConnectionError')
@@ -135,7 +146,7 @@ class TestHandlerRagErrorResponse:
def test_make_rag_error_response_empty_context(self): def test_make_rag_error_response_empty_context(self):
"""Test error response with no extra context.""" """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") error = KeyError("missing_key")
response = _make_rag_error_response(error, 'LookupError') response = _make_rag_error_response(error, 'LookupError')
@@ -150,21 +161,21 @@ class TestConstantsSemanticVersion:
def test_semantic_version_exists(self): def test_semantic_version_exists(self):
"""Test semantic_version is defined.""" """Test semantic_version is defined."""
from src.langbot.pkg.utils import constants from langbot.pkg.utils import constants
assert hasattr(constants, 'semantic_version') assert hasattr(constants, 'semantic_version')
assert constants.semantic_version.startswith('v') assert constants.semantic_version.startswith('v')
def test_edition_exists(self): def test_edition_exists(self):
"""Test edition constant is defined.""" """Test edition constant is defined."""
from src.langbot.pkg.utils import constants from langbot.pkg.utils import constants
assert hasattr(constants, 'edition') assert hasattr(constants, 'edition')
assert constants.edition == 'community' assert constants.edition == 'community'
def test_required_database_version_exists(self): def test_required_database_version_exists(self):
"""Test database version constant.""" """Test database version constant."""
from src.langbot.pkg.utils import constants from langbot.pkg.utils import constants
assert hasattr(constants, 'required_database_version') assert hasattr(constants, 'required_database_version')
assert isinstance(constants.required_database_version, int) assert isinstance(constants.required_database_version, int)
+217 -320
View File
@@ -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 from __future__ import annotations
import pytest
import base64 import base64
from unittest.mock import Mock, AsyncMock, MagicMock from types import SimpleNamespace
from importlib import import_module from unittest.mock import AsyncMock, Mock
import sqlalchemy
import pytest
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction, RuntimeToLangBotAction
def get_handler_module(): def make_handler(app):
"""Lazy import to avoid circular import issues.""" """Create a RuntimeConnectionHandler with mocked external connection."""
return import_module('langbot.pkg.plugin.handler') from langbot.pkg.plugin.handler import RuntimeConnectionHandler
return RuntimeConnectionHandler(Mock(), AsyncMock(return_value=True), app)
def get_persistence_plugin_module(): def make_result(first_item=None):
"""Lazy import for plugin persistence entity.""" result = Mock()
return import_module('langbot.pkg.entity.persistence.plugin') result.first = Mock(return_value=first_item)
return result
def get_persistence_bstorage_module(): def compiled_params(statement):
"""Lazy import for binary storage entity.""" return statement.compile().params
return import_module('langbot.pkg.entity.persistence.bstorage')
class TestInitializePluginSettings: class TestInitializePluginSettings:
"""Tests for initialize_plugin_settings action handler. """Tests for initialize_plugin_settings action handler."""
IMPORTANT: Tests verify setting inheritance logic - existing settings
should be inherited when creating new plugin settings.
"""
@pytest.fixture @pytest.fixture
def mock_app_with_persistence(self): def app(self):
"""Create mock app with persistence manager."""
mock_app = Mock() mock_app = Mock()
mock_app.persistence_mgr = Mock() mock_app.persistence_mgr = Mock()
mock_app.persistence_mgr.execute_async = AsyncMock() mock_app.persistence_mgr.execute_async = AsyncMock()
@@ -47,273 +39,202 @@ class TestInitializePluginSettings:
return mock_app return mock_app
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_creates_new_setting_when_not_exists(self, mock_app_with_persistence): async def test_creates_new_setting_when_not_exists(self, app):
"""Test that new setting is created when plugin setting doesn't exist.""" """New plugin settings use default enabled, priority and config values."""
handler_module = get_handler_module() runtime_handler = make_handler(app)
persistence_plugin = get_persistence_plugin_module() app.persistence_mgr.execute_async.side_effect = [
make_result(),
Mock(),
]
# Mock select result - no existing setting response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value]({
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 = {
'plugin_author': 'test-author', 'plugin_author': 'test-author',
'plugin_name': 'test-plugin', 'plugin_name': 'test-plugin',
'install_source': 'local', 'install_source': 'local',
'install_info': {'path': '/test'}, '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 @pytest.mark.asyncio
result = await mock_app_with_persistence.persistence_mgr.execute_async( async def test_inherits_values_from_existing_setting(self, app):
sqlalchemy.select(persistence_plugin.PluginSetting) """Existing settings are replaced while preserving user-controlled values."""
.where(persistence_plugin.PluginSetting.plugin_author == data['plugin_author']) runtime_handler = make_handler(app)
.where(persistence_plugin.PluginSetting.plugin_name == data['plugin_name']) 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 response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value]({
assert mock_app_with_persistence.persistence_mgr.execute_async.called 'plugin_author': 'test-author',
'plugin_name': 'test-plugin',
'install_source': 'github',
'install_info': {'repo': 'author/name'},
})
@pytest.mark.asyncio assert response.code == 0
async def test_inherits_enabled_from_existing_setting(self, mock_app_with_persistence): assert app.persistence_mgr.execute_async.await_count == 3
"""Test that enabled status is inherited from existing setting.""" insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
handler_module = get_handler_module() assert insert_params['enabled'] is False
persistence_plugin = get_persistence_plugin_module() assert insert_params['priority'] == 5
assert insert_params['config'] == {'key': 'value'}
# Mock existing setting with enabled=False assert insert_params['install_source'] == 'github'
mock_existing_setting = Mock() assert insert_params['install_info'] == {'repo': 'author/name'}
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
class TestSetBinaryStorage: class TestSetBinaryStorage:
"""Tests for set_binary_storage action handler with size limit validation. """Tests for set_binary_storage action handler with size limit validation."""
IMPORTANT: This tests security-critical size limit validation.
"""
@pytest.fixture @pytest.fixture
def mock_app_with_size_limit(self): def app(self):
"""Create mock app with plugin binary storage size limit."""
mock_app = Mock() mock_app = Mock()
mock_app.instance_config = Mock() mock_app.instance_config = Mock()
mock_app.instance_config.data = { mock_app.instance_config.data = {
'plugin': { 'plugin': {
'binary_storage': { 'binary_storage': {
'max_value_bytes': 1024, # 1KB limit for testing 'max_value_bytes': 1024,
} },
} },
} }
mock_app.persistence_mgr = Mock() 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() mock_app.logger = Mock()
return mock_app return mock_app
@pytest.fixture @staticmethod
def mock_app_no_limit(self): def payload(value: bytes):
"""Create mock app without explicit size limit (uses default).""" return {
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 = {
'key': 'test-key', 'key': 'test-key',
'owner_type': 'plugin', 'owner_type': 'plugin',
'owner': 'test-owner', 'owner': 'test-owner',
'value_base64': value_base64, 'value_base64': base64.b64encode(value).decode('utf-8'),
} }
# Simulate size limit check logic from handler @pytest.mark.asyncio
value = base64.b64decode(data['value_base64']) async def test_rejects_value_exceeding_limit(self, app):
max_value_bytes = ( """Values larger than max_value_bytes are rejected before persistence writes."""
mock_app_with_size_limit.instance_config.data runtime_handler = make_handler(app)
.get('plugin', {})
.get('binary_storage', {}) response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
.get('max_value_bytes', 10 * 1024 * 1024) self.payload(b'x' * 2048)
) )
if max_value_bytes >= 0 and len(value) > max_value_bytes: assert response.code != 0
error_message = f'Binary storage value exceeds limit ({len(value)} > {max_value_bytes} bytes)' assert '2048 > 1024 bytes' in response.message
# Should return error response app.persistence_mgr.execute_async.assert_not_awaited()
assert len(value) > max_value_bytes
assert error_message is not None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_accepts_value_within_limit(self, mock_app_with_size_limit): async def test_accepts_value_within_limit_and_inserts_storage(self, app):
"""Test that values within limit are accepted.""" """A new small value is inserted into binary storage."""
# Value smaller than 1024 bytes runtime_handler = make_handler(app)
small_value = b'x' * 512
value_base64 = base64.b64encode(small_value).decode('utf-8')
data = { response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
'key': 'test-key', self.payload(b'x' * 512)
'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)
) )
try: assert response.code == 0
max_value_bytes = int(max_value_bytes) assert app.persistence_mgr.execute_async.await_count == 2
except (TypeError, ValueError): insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
max_value_bytes = 10 * 1024 * 1024 # Default 10MB assert insert_params['unique_key'] == 'plugin:test-owner:test-key'
assert insert_params['value'] == b'x' * 512
assert max_value_bytes == 10 * 1024 * 1024
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_negative_limit_disables_check(self, mock_app_with_size_limit): async def test_updates_existing_storage(self, app):
"""Test that negative max_value_bytes disables size check.""" """An existing binary storage row is updated instead of inserted."""
mock_app_with_size_limit.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = -1 runtime_handler = make_handler(app)
app.persistence_mgr.execute_async.return_value = make_result(SimpleNamespace(value=b'old'))
# Large value response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
large_value = b'x' * 20 * 1024 * 1024 # 20MB self.payload(b'new')
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)
) )
try: assert response.code == 0
max_value_bytes = int(max_value_bytes) assert app.persistence_mgr.execute_async.await_count == 2
except (TypeError, ValueError): update_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
max_value_bytes = 10 * 1024 * 1024 assert update_params['value'] == b'new'
# 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
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_default_limit_is_10mb(self, mock_app_no_limit): async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
"""Test that default limit is 10MB when not configured.""" """Invalid max_value_bytes uses the 10MB default limit."""
max_value_bytes = ( runtime_handler = make_handler(app)
mock_app_no_limit.instance_config.data app.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = 'invalid'
.get('plugin', {})
.get('binary_storage', {}) response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
.get('max_value_bytes', 10 * 1024 * 1024) 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 @pytest.mark.asyncio
async def test_zero_limit_rejects_all_values(self, mock_app_with_size_limit): async def test_negative_limit_disables_size_check(self, app):
"""Test that zero limit rejects all non-empty values.""" """Negative max_value_bytes allows values larger than the normal default."""
mock_app_with_size_limit.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = 0 runtime_handler = make_handler(app)
app.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = -1
small_value = b'x' # Just 1 byte response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
max_value_bytes = 0 self.payload(b'x' * 2048)
)
if max_value_bytes >= 0 and len(small_value) > max_value_bytes: assert response.code == 0
should_reject = True assert app.persistence_mgr.execute_async.await_count == 2
else:
should_reject = False
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: class TestGetPluginSettings:
"""Tests for get_plugin_settings action handler with defaults.""" """Tests for get_plugin_settings action handler with defaults."""
@pytest.fixture @pytest.fixture
def mock_app(self): def app(self):
"""Create mock app."""
mock_app = Mock() mock_app = Mock()
mock_app.persistence_mgr = Mock() mock_app.persistence_mgr = Mock()
mock_app.persistence_mgr.execute_async = AsyncMock() mock_app.persistence_mgr.execute_async = AsyncMock()
return mock_app return mock_app
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_returns_defaults_when_setting_not_found(self, mock_app): async def test_returns_defaults_when_setting_not_found(self, app):
"""Test that default values are returned when setting doesn't exist.""" """Default plugin settings are returned when no persisted row exists."""
persistence_plugin = get_persistence_plugin_module() runtime_handler = make_handler(app)
app.persistence_mgr.execute_async.return_value = make_result()
# Mock no existing setting response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value]({
mock_result = Mock() 'plugin_author': 'test-author',
mock_result.first = Mock(return_value=None) 'plugin_name': 'test-plugin',
mock_app.persistence_mgr.execute_async.return_value = mock_result })
# Simulate get_plugin_settings logic assert response.code == 0
default_data = { assert response.data == {
'enabled': True, 'enabled': True,
'priority': 0, 'priority': 0,
'plugin_config': {}, 'plugin_config': {},
@@ -321,107 +242,82 @@ class TestGetPluginSettings:
'install_info': {}, '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 @pytest.mark.asyncio
async def test_returns_actual_values_when_setting_exists(self, mock_app): async def test_returns_actual_values_when_setting_exists(self, app):
"""Test that actual setting values are returned when setting exists.""" """Persisted plugin setting values override defaults."""
persistence_plugin = get_persistence_plugin_module() 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 response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value]({
mock_setting = Mock() 'plugin_author': 'test-author',
mock_setting.enabled = False 'plugin_name': 'test-plugin',
mock_setting.priority = 10 })
mock_setting.config = {'custom': 'config'}
mock_setting.install_source = 'github'
mock_setting.install_info = {'repo': 'test/repo'}
mock_result = Mock() assert response.code == 0
mock_result.first = Mock(return_value=mock_setting) assert response.data == {
mock_app.persistence_mgr.execute_async.return_value = mock_result 'enabled': False,
'priority': 10,
# Simulate get_plugin_settings logic 'plugin_config': {'custom': 'config'},
data = { 'install_source': 'github',
'enabled': True, 'install_info': {'repo': 'test/repo'},
'priority': 0,
'plugin_config': {},
'install_source': 'local',
'install_info': {},
} }
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: class TestGetBinaryStorage:
"""Tests for get_binary_storage action handler.""" """Tests for get_binary_storage action handler."""
@pytest.fixture @pytest.fixture
def mock_app(self): def app(self):
"""Create mock app."""
mock_app = Mock() mock_app = Mock()
mock_app.persistence_mgr = Mock() mock_app.persistence_mgr = Mock()
mock_app.persistence_mgr.execute_async = AsyncMock() mock_app.persistence_mgr.execute_async = AsyncMock()
return mock_app return mock_app
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_returns_base64_encoded_value(self, mock_app): async def test_returns_base64_encoded_value(self, app):
"""Test that returned value is base64 encoded.""" """Stored bytes are returned as base64."""
persistence_bstorage = get_persistence_bstorage_module() runtime_handler = make_handler(app)
app.persistence_mgr.execute_async.return_value = make_result(SimpleNamespace(value=b'test binary content'))
# Mock existing storage response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value]({
test_value = b'test binary content' 'key': 'test-key',
mock_storage = Mock() 'owner_type': 'plugin',
mock_storage.value = test_value 'owner': 'test-owner',
})
mock_result = Mock() assert response.code == 0
mock_result.first = Mock(return_value=mock_storage) assert response.data == {
mock_app.persistence_mgr.execute_async.return_value = mock_result 'value_base64': base64.b64encode(b'test binary content').decode('utf-8'),
}
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')
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_returns_error_when_not_found(self, mock_app): async def test_returns_error_when_not_found(self, app):
"""Test that error is returned when storage not found.""" """Missing binary storage rows return an error response."""
persistence_bstorage = get_persistence_bstorage_module() runtime_handler = make_handler(app)
app.persistence_mgr.execute_async.return_value = make_result()
mock_result = Mock() response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value]({
mock_result.first = Mock(return_value=None) 'key': 'test-key',
mock_app.persistence_mgr.execute_async.return_value = mock_result 'owner_type': 'plugin',
'owner': 'test-owner',
})
storage = mock_result.first() assert response.code != 0
if storage is None: assert 'Storage with key test-key not found' in response.message
key = 'test-key'
error_message = f'Storage with key {key} not found'
assert error_message is not None
class TestHandlerQueryLookup: class TestHandlerQueryLookup:
"""Tests for query lookup in cached_queries.""" """Tests for query lookup in cached_queries."""
@pytest.fixture @pytest.fixture
def mock_app_with_query_pool(self): def app(self):
"""Create mock app with query pool."""
mock_app = Mock() mock_app = Mock()
mock_app.query_pool = Mock() mock_app.query_pool = Mock()
mock_app.query_pool.cached_queries = {} mock_app.query_pool.cached_queries = {}
@@ -429,26 +325,27 @@ class TestHandlerQueryLookup:
return mock_app return mock_app
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_query_not_found_returns_error(self, mock_app_with_query_pool): async def test_query_not_found_returns_error(self, app):
"""Test that operations return error when query_id not found.""" """Query-bound actions return error when query_id is not cached."""
query_id = 'nonexistent-query' runtime_handler = make_handler(app)
if query_id not in mock_app_with_query_pool.query_pool.cached_queries: response = await runtime_handler.actions[PluginToRuntimeAction.GET_BOT_UUID.value]({
error_message = f'Query with query_id {query_id} not found' 'query_id': 'nonexistent-query',
# Should return error response })
assert error_message is not None
assert response.code != 0
assert 'nonexistent-query' in response.message
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_query_found_returns_success(self, mock_app_with_query_pool): async def test_query_found_returns_success(self, app):
"""Test that operations succeed when query exists.""" """Query-bound actions read data from the cached query object."""
mock_query = Mock() runtime_handler = make_handler(app)
mock_query.variables = {} query = SimpleNamespace(variables={}, bot_uuid='test-bot-uuid')
mock_query.bot_uuid = 'test-bot-uuid' app.query_pool.cached_queries['existing-query'] = query
query_id = 'existing-query' response = await runtime_handler.actions[PluginToRuntimeAction.GET_BOT_UUID.value]({
mock_app_with_query_pool.query_pool.cached_queries[query_id] = mock_query 'query_id': 'existing-query',
})
if query_id in mock_app_with_query_pool.query_pool.cached_queries: assert response.code == 0
query = mock_app_with_query_pool.query_pool.cached_queries[query_id] assert response.data == {'bot_uuid': 'test-bot-uuid'}
# Operations can proceed
assert query is mock_query
@@ -7,7 +7,7 @@ import pytest
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_plugin_list_filter_by_component_kinds(): async def test_plugin_list_filter_by_component_kinds():
"""Test that plugins can be filtered 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 the application
mock_app = MagicMock() mock_app = MagicMock()
@@ -113,7 +113,7 @@ async def test_plugin_list_filter_by_component_kinds():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_plugin_list_filter_no_filter(): async def test_plugin_list_filter_no_filter():
"""Test that all plugins are returned when no filter is specified.""" """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 the application
mock_app = MagicMock() mock_app = MagicMock()
@@ -174,7 +174,7 @@ async def test_plugin_list_filter_no_filter():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_plugin_list_filter_empty_result(): async def test_plugin_list_filter_empty_result():
"""Test that empty list is returned when no plugins match the filter.""" """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 the application
mock_app = MagicMock() mock_app = MagicMock()
@@ -220,7 +220,7 @@ async def test_plugin_list_filter_empty_result():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_plugin_list_filter_plugin_without_components(): async def test_plugin_list_filter_plugin_without_components():
"""Test that plugins without components are excluded when filtering.""" """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 the application
mock_app = MagicMock() mock_app = MagicMock()
@@ -8,7 +8,7 @@ import pytest
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_plugin_list_sorting_debug_first(): async def test_plugin_list_sorting_debug_first():
"""Test that debug plugins appear before non-debug plugins.""" """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 the application
mock_app = MagicMock() mock_app = MagicMock()
@@ -110,7 +110,7 @@ async def test_plugin_list_sorting_debug_first():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_plugin_list_sorting_by_installation_time(): async def test_plugin_list_sorting_by_installation_time():
"""Test that non-debug plugins are sorted by installation time (newest first).""" """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 the application
mock_app = MagicMock() mock_app = MagicMock()
@@ -207,7 +207,7 @@ async def test_plugin_list_sorting_by_installation_time():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_plugin_list_empty(): async def test_plugin_list_empty():
"""Test that empty plugin list is handled correctly.""" """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 the application
mock_app = MagicMock() mock_app = MagicMock()
@@ -11,6 +11,8 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
import openai # Import real openai package import openai # Import real openai package
from langbot.pkg.provider.modelmgr.errors import RequesterError
class TestInvokeLLMErrorHandling: class TestInvokeLLMErrorHandling:
"""Tests for invoke_llm error handling branches.""" """Tests for invoke_llm error handling branches."""
@@ -66,7 +68,7 @@ class TestInvokeLLMErrorHandling:
side_effect=asyncio.TimeoutError() side_effect=asyncio.TimeoutError()
) )
with pytest.raises(Exception) as exc: with pytest.raises(RequesterError) as exc:
await requester_with_mocked_client.invoke_llm( await requester_with_mocked_client.invoke_llm(
query=None, query=None,
model=mock_model, model=mock_model,
@@ -87,7 +89,7 @@ class TestInvokeLLMErrorHandling:
side_effect=error side_effect=error
) )
with pytest.raises(Exception) as exc: with pytest.raises(RequesterError) as exc:
await requester_with_mocked_client.invoke_llm( await requester_with_mocked_client.invoke_llm(
query=None, query=None,
model=mock_model, model=mock_model,
@@ -108,7 +110,7 @@ class TestInvokeLLMErrorHandling:
side_effect=error side_effect=error
) )
with pytest.raises(Exception) as exc: with pytest.raises(RequesterError) as exc:
await requester_with_mocked_client.invoke_llm( await requester_with_mocked_client.invoke_llm(
query=None, query=None,
model=mock_model, model=mock_model,
@@ -129,7 +131,7 @@ class TestInvokeLLMErrorHandling:
side_effect=error side_effect=error
) )
with pytest.raises(Exception) as exc: with pytest.raises(RequesterError) as exc:
await requester_with_mocked_client.invoke_llm( await requester_with_mocked_client.invoke_llm(
query=None, query=None,
model=mock_model, model=mock_model,
@@ -175,7 +177,7 @@ class TestInvokeEmbeddingErrorHandling:
side_effect=asyncio.TimeoutError() side_effect=asyncio.TimeoutError()
) )
with pytest.raises(Exception) as exc: with pytest.raises(RequesterError) as exc:
await requester_with_mocked_client.invoke_embedding( await requester_with_mocked_client.invoke_embedding(
model=mock_embedding_model, model=mock_embedding_model,
input_text=['test'], input_text=['test'],
@@ -195,7 +197,7 @@ class TestInvokeEmbeddingErrorHandling:
side_effect=error side_effect=error
) )
with pytest.raises(Exception) as exc: with pytest.raises(RequesterError) as exc:
await requester_with_mocked_client.invoke_embedding( await requester_with_mocked_client.invoke_embedding(
model=mock_embedding_model, model=mock_embedding_model,
input_text=['test'], input_text=['test'],
@@ -141,25 +141,21 @@ class TestNormalizeModalities:
requester = self._create_requester_with_mocks() requester = self._create_requester_with_mocks()
result = requester._normalize_modalities('text,image') result = requester._normalize_modalities('text,image')
assert 'text' in result assert result == ['text', 'image']
assert 'image' in result
def test_normalize_list_modalities(self): def test_normalize_list_modalities(self):
"""Normalize list of modalities.""" """Normalize list of modalities."""
requester = self._create_requester_with_mocks() requester = self._create_requester_with_mocks()
result = requester._normalize_modalities(['text', 'image', 'audio']) result = requester._normalize_modalities(['text', 'image', 'audio'])
assert 'text' in result assert result == ['text', 'image', 'audio']
assert 'image' in result
assert 'audio' in result
def test_normalize_dict_modalities(self): def test_normalize_dict_modalities(self):
"""Normalize dict with nested modalities.""" """Normalize dict with nested modalities."""
requester = self._create_requester_with_mocks() requester = self._create_requester_with_mocks()
result = requester._normalize_modalities({'input': ['text'], 'output': ['text', 'image']}) result = requester._normalize_modalities({'input': ['text'], 'output': ['text', 'image']})
assert 'text' in result assert result == ['text', 'image']
assert 'image' in result
def test_normalize_none(self): def test_normalize_none(self):
"""Handle None input.""" """Handle None input."""
@@ -173,8 +169,7 @@ class TestNormalizeModalities:
requester = self._create_requester_with_mocks() requester = self._create_requester_with_mocks()
result = requester._normalize_modalities('text->image') result = requester._normalize_modalities('text->image')
assert 'text' in result assert result == ['text', 'image']
assert 'image' in result
class TestParseRerankResponse: class TestParseRerankResponse:
@@ -192,9 +187,10 @@ class TestParseRerankResponse:
} }
result = OpenAIChatCompletions._parse_rerank_response(data) result = OpenAIChatCompletions._parse_rerank_response(data)
assert len(result) == 2 assert result == [
assert result[0]['index'] == 0 {'index': 0, 'relevance_score': 0.95},
assert result[0]['relevance_score'] == 0.95 {'index': 1, 'relevance_score': 0.80},
]
def test_parse_voyage_format(self): def test_parse_voyage_format(self):
"""Parse Voyage AI format.""" """Parse Voyage AI format."""
@@ -208,8 +204,10 @@ class TestParseRerankResponse:
} }
result = OpenAIChatCompletions._parse_rerank_response(data) result = OpenAIChatCompletions._parse_rerank_response(data)
assert len(result) == 2 assert result == [
assert result[0]['index'] == 0 {'index': 0, 'relevance_score': 0.90},
{'index': 2, 'relevance_score': 0.75},
]
def test_parse_dashscope_format(self): def test_parse_dashscope_format(self):
"""Parse DashScope format.""" """Parse DashScope format."""
@@ -224,8 +222,7 @@ class TestParseRerankResponse:
} }
result = OpenAIChatCompletions._parse_rerank_response(data) result = OpenAIChatCompletions._parse_rerank_response(data)
assert len(result) == 1 assert result == [{'index': 0, 'relevance_score': 0.85}]
assert result[0]['index'] == 0
def test_parse_unknown_format(self): def test_parse_unknown_format(self):
"""Handle unknown format returns empty list.""" """Handle unknown format returns empty list."""
@@ -9,6 +9,8 @@ import asyncio
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from langbot.pkg.provider.modelmgr.errors import RequesterError
class TestOllamaRequesterConfig: class TestOllamaRequesterConfig:
"""Tests for default config.""" """Tests for default config."""
@@ -228,7 +230,7 @@ class TestOllamaErrorHandling:
"""TimeoutError is converted to RequesterError.""" """TimeoutError is converted to RequesterError."""
requester_with_mocked_client.client.chat = AsyncMock(side_effect=asyncio.TimeoutError()) 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( await requester_with_mocked_client.invoke_llm(
query=None, query=None,
model=mock_model, model=mock_model,
@@ -13,7 +13,6 @@ from unittest.mock import Mock
from importlib import import_module from importlib import import_module
import langbot_plugin.api.entities.builtin.provider.session as provider_session 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 import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
+148 -368
View File
@@ -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 from __future__ import annotations
import pytest import io
import zipfile import zipfile
import tempfile from types import SimpleNamespace
import os from unittest.mock import AsyncMock, Mock
from unittest.mock import Mock, AsyncMock, patch, MagicMock
from importlib import import_module import pytest
from langbot.pkg.rag.knowledge.kbmgr import RuntimeKnowledgeBase
def get_kbmgr_module(): def _make_zip_bytes(entries: dict[str, bytes]) -> bytes:
"""Lazy import to avoid circular import issues.""" buffer = io.BytesIO()
return import_module('langbot.pkg.rag.knowledge.kbmgr') 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: 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 create_user_task(coro, **kwargs):
def mock_kb(self): coro.close()
"""Create mock RuntimeKnowledgeBase.""" return SimpleNamespace(id='task-1', kwargs=kwargs)
kbmgr = get_kbmgr_module()
mock_app = Mock() kb.ap.task_mgr.create_user_task = Mock(side_effect=create_user_task)
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()
mock_kb_entity = Mock() task_id = await kb.store_file('documents/test.pdf')
mock_kb_entity.uuid = 'test-kb-uuid'
kb = kbmgr.RuntimeKnowledgeBase(mock_app, mock_kb_entity) assert task_id == 'task-1'
kb._on_kb_create = AsyncMock() kb.ap.storage_mgr.storage_provider.exists.assert_awaited_once_with('documents/test.pdf')
return kb 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 @pytest.mark.asyncio
async def test_creates_pending_file_record(self, mock_kb): async def test_store_file_raises_when_source_file_missing(self):
"""Test that store_file creates a pending file record.""" kb = _make_kb()
# Mock persistence for file record creation kb.ap.storage_mgr.storage_provider.exists = AsyncMock(return_value=False)
mock_result = Mock()
mock_result.first = Mock(return_value=None)
mock_kb.ap.persistence_mgr.execute_async.return_value = mock_result
# Mock file exists in storage with pytest.raises(Exception, match='File missing.pdf not found'):
mock_kb.ap.storage_mgr.storage_provider.exists = AsyncMock(return_value=True) await kb.store_file('missing.pdf')
# We can't directly test store_file without full setup kb.ap.persistence_mgr.execute_async.assert_not_awaited()
# But we verify the expected behavior pattern kb.ap.task_mgr.create_user_task.assert_not_called()
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
class TestStoreZipFile: 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 task_id = await kb._store_zip_file('archive.zip', parser_plugin_id='parser/plugin')
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)
@pytest.fixture assert task_id == 'task-pdf'
def temp_zip_with_no_supported(self): assert kb.ap.storage_mgr.storage_provider.save.await_count == 4
"""Create a ZIP with no supported file types.""" saved_names = [call.args[0] for call in kb.ap.storage_mgr.storage_provider.save.await_args_list]
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp: assert any(name.startswith('doc1_') and name.endswith('.pdf') for name in saved_names)
with zipfile.ZipFile(tmp, 'w') as zf: assert any(name.startswith('doc2_') and name.endswith('.txt') for name in saved_names)
zf.writestr('image.jpg', b'JPEG content') assert any(name.startswith('subdir_doc3_') and name.endswith('.md') for name in saved_names)
zf.writestr('video.mp4', b'video content') assert any(name.startswith('page_') and name.endswith('.html') for name in saved_names)
yield tmp.name assert not any('image' in name for name in saved_names)
os.unlink(tmp.name) assert not any('hidden' in name for name in saved_names)
assert not any('__MACOSX' in name for name in saved_names)
@pytest.fixture kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with('archive.zip')
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."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_status_transitions_to_processing(self): async def test_store_zip_file_raises_when_no_supported_files(self):
"""Test that file status transitions from pending to processing.""" kb = _make_kb()
# Status values from source code kb.ap.storage_mgr.storage_provider.load = AsyncMock(
STATUS_PENDING = 'pending' return_value=_make_zip_bytes({'image.png': b'png', 'video.mp4': b'video'})
STATUS_PROCESSING = 'processing' )
STATUS_COMPLETED = 'completed' kb.store_file = AsyncMock()
STATUS_FAILED = 'failed'
# Simulate status transitions with pytest.raises(Exception, match='No supported files found'):
initial_status = STATUS_PENDING await kb._store_zip_file('archive.zip')
after_process_start = STATUS_PROCESSING
after_success = STATUS_COMPLETED
assert initial_status == 'pending' kb.store_file.assert_not_awaited()
assert after_process_start == 'processing' kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with('archive.zip')
assert after_success == 'completed'
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 @pytest.mark.asyncio
async def test_status_transitions_to_failed_on_error(self): async def test_store_file_task_marks_failed_and_cleans_storage(self):
"""Test that file status transitions to failed on exception.""" kb = _make_kb()
STATUS_PENDING = 'pending' kb._ingest_document = AsyncMock(return_value={'status': 'failed', 'error_message': 'parser failed'})
STATUS_PROCESSING = 'processing' file_obj = SimpleNamespace(uuid='file-uuid', file_name='bad.pdf', extension='pdf')
STATUS_FAILED = 'failed' task_context = Mock()
# Simulate error scenario with pytest.raises(Exception, match='parser failed'):
initial_status = STATUS_PENDING await kb._store_file_task(file_obj, task_context)
after_error = STATUS_FAILED
assert initial_status == 'pending' assert kb.ap.persistence_mgr.execute_async.await_count == 2
assert after_error == 'failed' kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with('bad.pdf')
@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()
class TestDeleteDocument: 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 result = await kb._delete_document('doc-id')
def mock_kb_with_plugin(self):
"""Create mock KB with plugin ID."""
kbmgr = get_kbmgr_module()
mock_app = Mock() assert result is False
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
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_returns_false_when_no_plugin_id(self, mock_kb_without_plugin): async def test_delete_document_calls_configured_rag_plugin(self):
"""Test that _delete_document returns False when no plugin ID.""" kb = _make_kb()
kb_entity = mock_kb_without_plugin.knowledge_base_entity kb.ap.plugin_connector.call_rag_delete_document = AsyncMock(return_value=True)
if kb_entity.knowledge_engine_plugin_id is None: result = await kb._delete_document('doc-id')
# Source code returns False early
expected_result = False assert result is True
assert expected_result is False kb.ap.plugin_connector.call_rag_delete_document.assert_awaited_once_with(
'author/engine', 'doc-id', 'test-kb-uuid'
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_returns_true_on_success(self, mock_kb_with_plugin): async def test_delete_document_returns_false_on_plugin_error(self):
"""Test that _delete_document returns True on successful delete.""" kb = _make_kb()
kb_entity = mock_kb_with_plugin.knowledge_base_entity kb.ap.plugin_connector.call_rag_delete_document = AsyncMock(side_effect=Exception('plugin error'))
plugin_id = kb_entity.knowledge_engine_plugin_id
if plugin_id is not None: result = await kb._delete_document('doc-id')
# 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
@pytest.mark.asyncio assert result is False
async def test_returns_false_on_plugin_error(self, mock_kb_with_plugin): kb.ap.logger.error.assert_called_once()
"""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
@@ -38,31 +38,6 @@ class TestTelemetryManagerInit:
manager = telemetry.TelemetryManager(mock_app) manager = telemetry.TelemetryManager(mock_app)
assert manager.telemetry_config == {} 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: class TestTelemetryManagerInitialize:
"""Tests for initialize() method.""" """Tests for initialize() method."""
+21 -9
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import pytest import pytest
import aiohttp import aiohttp
from aiohttp import web
from langbot.pkg.utils import httpclient from langbot.pkg.utils import httpclient
@@ -108,19 +109,30 @@ class TestSessionPoolIntegration:
"""Integration tests for session pool behavior.""" """Integration tests for session pool behavior."""
async def test_session_can_make_request(self): 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() session = httpclient.get_session()
# Make a simple request (using httpbin or similar)
# This is a basic smoke test
try: 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 assert resp.status == 200
except Exception: assert await resp.json() == {'ok': True}
# Network may be unavailable in CI, just verify session is usable finally:
pass await httpclient.close_all()
await runner.cleanup()
await httpclient.close_all()
async def test_multiple_requests_same_session(self): async def test_multiple_requests_same_session(self):
"""Multiple requests can use the same session.""" """Multiple requests can use the same session."""
+10 -17
View File
@@ -137,11 +137,9 @@ class TestReadResourceFile:
"""Should read content from a resource file.""" """Should read content from a resource file."""
from langbot.pkg.utils import importutil from langbot.pkg.utils import importutil
try: content = importutil.read_resource_file("templates/config.yaml")
content = importutil.read_resource_file("templates/config.yaml") assert "admins:" in content
assert isinstance(content, str) assert "edition: community" in content
except FileNotFoundError:
pass
def test_raises_for_nonexistent_file(self): def test_raises_for_nonexistent_file(self):
"""Should raise exception for non-existent resource file.""" """Should raise exception for non-existent resource file."""
@@ -158,11 +156,9 @@ class TestReadResourceFileBytes:
"""Should read content as bytes from a resource file.""" """Should read content as bytes from a resource file."""
from langbot.pkg.utils import importutil from langbot.pkg.utils import importutil
try: content = importutil.read_resource_file_bytes("templates/config.yaml")
content = importutil.read_resource_file_bytes("templates/config.yaml") assert b"admins:" in content
assert isinstance(content, bytes) assert b"edition: community" in content
except FileNotFoundError:
pass
def test_raises_for_nonexistent_file_bytes(self): def test_raises_for_nonexistent_file_bytes(self):
"""Should raise exception for non-existent resource file.""" """Should raise exception for non-existent resource file."""
@@ -179,13 +175,10 @@ class TestListResourceFiles:
"""Should list files in a resource directory.""" """Should list files in a resource directory."""
from langbot.pkg.utils import importutil from langbot.pkg.utils import importutil
try: files = importutil.list_resource_files("templates")
files = importutil.list_resource_files("templates") assert "config.yaml" in files
assert isinstance(files, list) assert "default-pipeline-config.json" in files
for f in files: assert all(isinstance(file, str) for file in files)
assert isinstance(f, str)
except (FileNotFoundError, Exception):
pass
def test_raises_for_nonexistent_directory(self): def test_raises_for_nonexistent_directory(self):
"""Should raise exception for non-existent directory.""" """Should raise exception for non-existent directory."""
-1
View File
@@ -6,7 +6,6 @@ Tests log page management and pointer-based retrieval.
from __future__ import annotations from __future__ import annotations
import pytest
from langbot.pkg.utils.logcache import LogPage, LogCache, LOG_PAGE_SIZE, MAX_CACHED_PAGES from langbot.pkg.utils.logcache import LogPage, LogCache, LOG_PAGE_SIZE, MAX_CACHED_PAGES
+1 -2
View File
@@ -6,8 +6,7 @@ Tests pip command generation without actual installation.
from __future__ import annotations from __future__ import annotations
import pytest from unittest.mock import patch
from unittest.mock import patch, Mock
from langbot.pkg.utils import pkgmgr from langbot.pkg.utils import pkgmgr
-17
View File
@@ -87,15 +87,6 @@ class TestGetRunnerCategory:
assert get_runner_category("test", "https://example.com") == RunnerCategory.CLOUD assert get_runner_category("test", "https://example.com") == RunnerCategory.CLOUD
assert get_runner_category("test", "https://myserver.example.org") == 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): def test_urlparse_exception_returns_unknown(self):
"""Exception during URL parsing should return UNKNOWN.""" """Exception during URL parsing should return UNKNOWN."""
# Test by mocking urlparse to raise an exception # Test by mocking urlparse to raise an exception
@@ -108,14 +99,6 @@ class TestGetRunnerCategory:
result = runner.get_runner_category("test", "http://example.com") result = runner.get_runner_category("test", "http://example.com")
assert result == RunnerCategory.UNKNOWN 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: class TestIsCloudRunner:
"""Test is_cloud_runner helper function.""" """Test is_cloud_runner helper function."""
-1
View File
@@ -6,7 +6,6 @@ Tests version comparison logic without network calls.
from __future__ import annotations from __future__ import annotations
import pytest
from unittest.mock import Mock from unittest.mock import Mock
from langbot.pkg.utils.version import VersionManager from langbot.pkg.utils.version import VersionManager
@@ -7,8 +7,6 @@ Tests cover:
""" """
from __future__ import annotations from __future__ import annotations
import pytest
from unittest.mock import Mock
from importlib import import_module from importlib import import_module