mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-11 00:06:04 +00:00
test: tighten phase 1 coverage contracts
This commit is contained in:
@@ -73,7 +73,7 @@ class TestListPlugins:
|
||||
result = await connector.list_plugins()
|
||||
|
||||
connector.handler.list_plugins.assert_called_once()
|
||||
assert len(result) == 1
|
||||
assert result == [{'manifest': {'manifest': {'metadata': {'author': 'test', 'name': 'plugin'}}}}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filters_by_component_kinds(self):
|
||||
@@ -171,7 +171,7 @@ class TestListKnowledgeEngines:
|
||||
result = await connector.list_knowledge_engines()
|
||||
|
||||
connector.handler.list_knowledge_engines.assert_called_once()
|
||||
assert len(result) == 1
|
||||
assert result == [{'plugin_id': 'author/engine', 'name': 'Engine'}]
|
||||
|
||||
|
||||
class TestListParsers:
|
||||
@@ -208,7 +208,7 @@ class TestListParsers:
|
||||
result = await connector.list_parsers()
|
||||
|
||||
connector.handler.list_parsers.assert_called_once()
|
||||
assert len(result) == 1
|
||||
assert result == [{'plugin_id': 'author/parser', 'supported_mime_types': ['text/plain']}]
|
||||
|
||||
|
||||
class TestCallParser:
|
||||
@@ -269,8 +269,19 @@ class TestRAGMethods:
|
||||
|
||||
result = await connector.call_rag_retrieve('author/engine', {'query': 'test'})
|
||||
|
||||
connector.handler.retrieve_knowledge.assert_called_once()
|
||||
assert 'results' in result
|
||||
connector.handler.retrieve_knowledge.assert_called_once_with(
|
||||
'author', 'engine', '', {'query': 'test'}
|
||||
)
|
||||
assert result == {
|
||||
'results': [
|
||||
{
|
||||
'id': 'doc1',
|
||||
'content': [{'type': 'text', 'text': 'test'}],
|
||||
'metadata': {},
|
||||
'distance': 0.1,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_rag_creation_schema(self):
|
||||
@@ -286,7 +297,7 @@ class TestRAGMethods:
|
||||
result = await connector.get_rag_creation_schema('author/engine')
|
||||
|
||||
connector.handler.get_rag_creation_schema.assert_called_once_with('author', 'engine')
|
||||
assert 'properties' in result
|
||||
assert result == {'properties': {'name': {'type': 'string'}}}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_rag_retrieval_schema(self):
|
||||
@@ -302,7 +313,7 @@ class TestRAGMethods:
|
||||
result = await connector.get_rag_retrieval_schema('author/engine')
|
||||
|
||||
connector.handler.get_rag_retrieval_schema.assert_called_once_with('author', 'engine')
|
||||
assert 'properties' in result
|
||||
assert result == {'properties': {'top_k': {'type': 'integer'}}}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rag_on_kb_create(self):
|
||||
@@ -442,7 +453,7 @@ class TestGetPluginInfo:
|
||||
result = await connector.get_plugin_info('author', 'plugin')
|
||||
|
||||
connector.handler.get_plugin_info.assert_called_once_with('author', 'plugin')
|
||||
assert 'manifest' in result
|
||||
assert result == {'manifest': {'metadata': {'name': 'plugin'}}}
|
||||
|
||||
|
||||
class TestSetPluginConfig:
|
||||
@@ -474,7 +485,7 @@ class TestPingPluginRuntime:
|
||||
connector = create_mock_connector()
|
||||
|
||||
# handler is not set
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
with pytest.raises(Exception, match='Plugin runtime is not connected') as exc_info:
|
||||
await connector.ping_plugin_runtime()
|
||||
|
||||
assert 'not connected' in str(exc_info.value)
|
||||
@@ -490,4 +501,4 @@ class TestPingPluginRuntime:
|
||||
|
||||
await connector.ping_plugin_runtime()
|
||||
|
||||
connector.handler.ping.assert_called_once()
|
||||
connector.handler.ping.assert_called_once()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for PluginRuntimeConnector pure logic methods.
|
||||
|
||||
Tests methods that don't require real plugin runtime processes:
|
||||
- _extract_deps_metadata: deps extraction from zip files
|
||||
- _inspect_plugin_package: identity and deps extraction from zip files
|
||||
- _parse_plugin_id: plugin ID string parsing
|
||||
"""
|
||||
|
||||
@@ -12,13 +12,15 @@ import zipfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestExtractDepsMetadata:
|
||||
"""Tests for _extract_deps_metadata method."""
|
||||
"""Tests for dependency metadata extraction from plugin packages."""
|
||||
|
||||
def _create_connector(self):
|
||||
"""Create a connector instance for testing."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.instance_config.data.get.return_value = {'enable': True}
|
||||
@@ -39,7 +41,7 @@ class TestExtractDepsMetadata:
|
||||
zip_bytes = zip_buffer.getvalue()
|
||||
|
||||
task_context = SimpleNamespace(metadata={})
|
||||
connector._extract_deps_metadata(zip_bytes, task_context)
|
||||
connector._inspect_plugin_package(zip_bytes, task_context)
|
||||
|
||||
assert task_context.metadata['deps_total'] == 3 # requests>=2.0, flask, numpy
|
||||
# deps_list contains full requirement lines including version specifiers
|
||||
@@ -58,7 +60,7 @@ class TestExtractDepsMetadata:
|
||||
zip_bytes = zip_buffer.getvalue()
|
||||
|
||||
task_context = SimpleNamespace(metadata={})
|
||||
connector._extract_deps_metadata(zip_bytes, task_context)
|
||||
connector._inspect_plugin_package(zip_bytes, task_context)
|
||||
|
||||
assert task_context.metadata['deps_total'] == 0
|
||||
assert task_context.metadata['deps_list'] == []
|
||||
@@ -74,7 +76,7 @@ class TestExtractDepsMetadata:
|
||||
zip_bytes = zip_buffer.getvalue()
|
||||
|
||||
task_context = SimpleNamespace(metadata={})
|
||||
connector._extract_deps_metadata(zip_bytes, task_context)
|
||||
connector._inspect_plugin_package(zip_bytes, task_context)
|
||||
|
||||
# No requirements.txt found, metadata unchanged
|
||||
assert 'deps_total' not in task_context.metadata
|
||||
@@ -90,7 +92,7 @@ class TestExtractDepsMetadata:
|
||||
zip_bytes = zip_buffer.getvalue()
|
||||
|
||||
# Should return early without error
|
||||
connector._extract_deps_metadata(zip_bytes, None)
|
||||
connector._inspect_plugin_package(zip_bytes, None)
|
||||
|
||||
def test_extract_deps_invalid_zip(self):
|
||||
"""Handle invalid zip file gracefully."""
|
||||
@@ -100,7 +102,7 @@ class TestExtractDepsMetadata:
|
||||
invalid_bytes = b'not a zip file'
|
||||
|
||||
task_context = SimpleNamespace(metadata={})
|
||||
connector._extract_deps_metadata(invalid_bytes, task_context)
|
||||
connector._inspect_plugin_package(invalid_bytes, task_context)
|
||||
|
||||
# Should catch exception and pass silently
|
||||
assert 'deps_total' not in task_context.metadata
|
||||
@@ -116,7 +118,7 @@ class TestExtractDepsMetadata:
|
||||
zip_bytes = zip_buffer.getvalue()
|
||||
|
||||
task_context = SimpleNamespace(metadata={})
|
||||
connector._extract_deps_metadata(zip_bytes, task_context)
|
||||
connector._inspect_plugin_package(zip_bytes, task_context)
|
||||
|
||||
# Should find requirements.txt in subdirectory
|
||||
assert task_context.metadata['deps_total'] == 2
|
||||
@@ -127,25 +129,15 @@ class TestParsePluginId:
|
||||
|
||||
def test_parse_valid_plugin_id(self):
|
||||
"""Parse valid plugin ID format 'author/name'."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
author, name = PluginRuntimeConnector._parse_plugin_id('myauthor/myplugin')
|
||||
assert author == 'myauthor'
|
||||
assert name == 'myplugin'
|
||||
|
||||
def test_parse_plugin_id_with_multiple_slashes(self):
|
||||
"""Parse plugin ID with multiple slashes uses split('/', 1)."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# split('/', 1) only splits on first slash
|
||||
author, name = PluginRuntimeConnector._parse_plugin_id('org/author/plugin-name')
|
||||
assert author == 'org'
|
||||
assert name == 'author/plugin-name'
|
||||
|
||||
def test_parse_plugin_id_empty(self):
|
||||
"""Handle empty plugin ID."""
|
||||
"""Empty plugin ID is invalid."""
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Empty string behavior
|
||||
parts = ''.split('/')
|
||||
assert len(parts) == 1
|
||||
assert parts[0] == ''
|
||||
with pytest.raises(ValueError):
|
||||
PluginRuntimeConnector._parse_plugin_id('')
|
||||
|
||||
@@ -24,14 +24,6 @@ class TestParsePluginId:
|
||||
assert author == 'langbot'
|
||||
assert name == 'rag-engine'
|
||||
|
||||
def test_valid_plugin_id_with_slash_in_name(self):
|
||||
"""Test parsing plugin ID where name contains additional slashes."""
|
||||
connector = get_connector_module()
|
||||
# split('/', 1) only splits on first slash
|
||||
author, name = connector.PluginRuntimeConnector._parse_plugin_id('author/name/with/slashes')
|
||||
assert author == 'author'
|
||||
assert name == 'name/with/slashes'
|
||||
|
||||
def test_invalid_plugin_id_no_slash(self):
|
||||
"""Test that ValueError is raised when no slash present."""
|
||||
connector = get_connector_module()
|
||||
@@ -60,19 +52,3 @@ class TestParsePluginId:
|
||||
author, name = connector.PluginRuntimeConnector._parse_plugin_id('lang-bot/my_rag_engine')
|
||||
assert author == 'lang-bot'
|
||||
assert name == 'my_rag_engine'
|
||||
|
||||
def test_valid_plugin_id_author_only(self):
|
||||
"""Test that plugin ID with only author (trailing slash) is parsed."""
|
||||
connector = get_connector_module()
|
||||
# 'author/' - split returns ('author', '')
|
||||
author, name = connector.PluginRuntimeConnector._parse_plugin_id('author/')
|
||||
assert author == 'author'
|
||||
assert name == ''
|
||||
|
||||
def test_valid_plugin_id_name_only(self):
|
||||
"""Test that plugin ID with only name (leading slash) is parsed."""
|
||||
connector = get_connector_module()
|
||||
# '/name' - split returns ('', 'name')
|
||||
author, name = connector.PluginRuntimeConnector._parse_plugin_id('/name')
|
||||
assert author == ''
|
||||
assert name == 'name'
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for plugin connector _extract_deps_metadata method.
|
||||
"""Unit tests for plugin connector _inspect_plugin_package method.
|
||||
|
||||
Tests cover:
|
||||
- Extracting requirements.txt from ZIP
|
||||
@@ -60,7 +60,7 @@ def create_zip_without_requirements() -> bytes:
|
||||
|
||||
|
||||
class TestExtractDepsMetadata:
|
||||
"""Tests for _extract_deps_metadata method."""
|
||||
"""Tests for dependency metadata extraction from plugin packages."""
|
||||
|
||||
def test_extract_simple_requirements(self):
|
||||
"""Test extracting simple requirements.txt."""
|
||||
@@ -73,7 +73,7 @@ class TestExtractDepsMetadata:
|
||||
task_context = Mock()
|
||||
task_context.metadata = {}
|
||||
|
||||
connector_instance._extract_deps_metadata(zip_bytes, task_context)
|
||||
connector_instance._inspect_plugin_package(zip_bytes, task_context)
|
||||
|
||||
assert task_context.metadata.get('deps_total') == 3
|
||||
assert task_context.metadata.get('deps_list') == ['requests>=2.0', 'flask==1.0', 'numpy']
|
||||
@@ -94,7 +94,7 @@ numpy'''
|
||||
task_context = Mock()
|
||||
task_context.metadata = {}
|
||||
|
||||
connector_instance._extract_deps_metadata(zip_bytes, task_context)
|
||||
connector_instance._inspect_plugin_package(zip_bytes, task_context)
|
||||
|
||||
assert task_context.metadata.get('deps_total') == 3
|
||||
assert '# This is a comment' not in task_context.metadata.get('deps_list', [])
|
||||
@@ -108,7 +108,7 @@ numpy'''
|
||||
task_context = Mock()
|
||||
task_context.metadata = {}
|
||||
|
||||
connector_instance._extract_deps_metadata(zip_bytes, task_context)
|
||||
connector_instance._inspect_plugin_package(zip_bytes, task_context)
|
||||
|
||||
# Should find nested requirements.txt (ends with 'requirements.txt')
|
||||
assert task_context.metadata.get('deps_total') == 2
|
||||
@@ -122,7 +122,7 @@ numpy'''
|
||||
task_context = Mock()
|
||||
task_context.metadata = {}
|
||||
|
||||
connector_instance._extract_deps_metadata(zip_bytes, task_context)
|
||||
connector_instance._inspect_plugin_package(zip_bytes, task_context)
|
||||
|
||||
# metadata should remain empty (no deps found)
|
||||
assert task_context.metadata.get('deps_total') is None
|
||||
@@ -137,7 +137,7 @@ numpy'''
|
||||
task_context = Mock()
|
||||
task_context.metadata = {}
|
||||
|
||||
connector_instance._extract_deps_metadata(zip_bytes, task_context)
|
||||
connector_instance._inspect_plugin_package(zip_bytes, task_context)
|
||||
|
||||
# deps_total should be 0 (empty list after filtering)
|
||||
assert task_context.metadata.get('deps_total') == 0
|
||||
@@ -155,7 +155,7 @@ numpy'''
|
||||
task_context = Mock()
|
||||
task_context.metadata = {}
|
||||
|
||||
connector_instance._extract_deps_metadata(zip_bytes, task_context)
|
||||
connector_instance._inspect_plugin_package(zip_bytes, task_context)
|
||||
|
||||
assert task_context.metadata.get('deps_total') == 0
|
||||
assert task_context.metadata.get('deps_list') == []
|
||||
@@ -167,7 +167,7 @@ numpy'''
|
||||
zip_bytes = create_zip_with_requirements('requests')
|
||||
|
||||
# Should return without error when task_context is None
|
||||
connector_instance._extract_deps_metadata(zip_bytes, None)
|
||||
connector_instance._inspect_plugin_package(zip_bytes, None)
|
||||
|
||||
# No exception should be raised
|
||||
|
||||
@@ -182,7 +182,7 @@ numpy'''
|
||||
task_context.metadata = {}
|
||||
|
||||
# Should silently handle exception (pass in try/except)
|
||||
connector_instance._extract_deps_metadata(invalid_bytes, task_context)
|
||||
connector_instance._inspect_plugin_package(invalid_bytes, task_context)
|
||||
|
||||
# metadata should remain unchanged
|
||||
assert task_context.metadata == {}
|
||||
@@ -203,8 +203,8 @@ numpy'''
|
||||
task_context.metadata = {}
|
||||
|
||||
# errors='ignore' will decode \x80invalid as 'invalid' (skipping \x80)
|
||||
connector_instance._extract_deps_metadata(zip_bytes, task_context)
|
||||
connector_instance._inspect_plugin_package(zip_bytes, task_context)
|
||||
|
||||
# All 3 lines will be parsed (requests, flask, invalid)
|
||||
assert task_context.metadata.get('deps_total') == 3
|
||||
assert 'invalid' in task_context.metadata.get('deps_list', [])
|
||||
assert 'invalid' in task_context.metadata.get('deps_list', [])
|
||||
|
||||
@@ -6,9 +6,18 @@ Tests handler helper methods that don't require full handler setup.
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock
|
||||
import pytest
|
||||
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
|
||||
|
||||
|
||||
def make_handler(app):
|
||||
"""Create a RuntimeConnectionHandler with mocked external connection."""
|
||||
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
|
||||
|
||||
return RuntimeConnectionHandler(Mock(), AsyncMock(return_value=True), app)
|
||||
|
||||
|
||||
class TestHandlerQueryVariables:
|
||||
"""Tests for handler query variable logic."""
|
||||
@@ -29,65 +38,67 @@ class TestHandlerQueryVariables:
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_query_var_query_not_found(self, mock_app):
|
||||
"""Test set_query_var returns error when query not found."""
|
||||
query_id = 'nonexistent-query'
|
||||
runtime_handler = make_handler(mock_app)
|
||||
|
||||
if query_id not in mock_app.query_pool.cached_queries:
|
||||
expected_error = f'Query with query_id {query_id} not found'
|
||||
# Should return error response
|
||||
assert expected_error is not None
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value]({
|
||||
'query_id': 'nonexistent-query',
|
||||
'key': 'test_var',
|
||||
'value': 'test_value',
|
||||
})
|
||||
|
||||
assert response.code != 0
|
||||
assert 'nonexistent-query' in response.message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_query_var_success(self, mock_app):
|
||||
"""Test set_query_var sets variable on existing query."""
|
||||
runtime_handler = make_handler(mock_app)
|
||||
mock_query = SimpleNamespace()
|
||||
mock_query.variables = {}
|
||||
|
||||
mock_app.query_pool.cached_queries['test-query'] = mock_query
|
||||
|
||||
# Simulate set_query_var logic
|
||||
query_id = 'test-query'
|
||||
var_name = 'test_var'
|
||||
var_value = 'test_value'
|
||||
|
||||
if query_id in mock_app.query_pool.cached_queries:
|
||||
query = mock_app.query_pool.cached_queries[query_id]
|
||||
query.variables[var_name] = var_value
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value]({
|
||||
'query_id': 'test-query',
|
||||
'key': 'test_var',
|
||||
'value': 'test_value',
|
||||
})
|
||||
|
||||
assert response.code == 0
|
||||
assert mock_query.variables['test_var'] == 'test_value'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_query_var_success(self, mock_app):
|
||||
"""Test get_query_var retrieves variable from query."""
|
||||
runtime_handler = make_handler(mock_app)
|
||||
mock_query = SimpleNamespace()
|
||||
mock_query.variables = {'existing_var': 'existing_value'}
|
||||
|
||||
mock_app.query_pool.cached_queries['test-query'] = mock_query
|
||||
|
||||
# Simulate get_query_var logic
|
||||
query_id = 'test-query'
|
||||
var_name = 'existing_var'
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.GET_QUERY_VAR.value]({
|
||||
'query_id': 'test-query',
|
||||
'key': 'existing_var',
|
||||
})
|
||||
|
||||
if query_id in mock_app.query_pool.cached_queries:
|
||||
query = mock_app.query_pool.cached_queries[query_id]
|
||||
if var_name in query.variables:
|
||||
value = query.variables[var_name]
|
||||
assert value == 'existing_value'
|
||||
assert response.code == 0
|
||||
assert response.data == {'value': 'existing_value'}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_query_vars_multiple(self, mock_app):
|
||||
"""Test get_query_vars retrieves multiple variables."""
|
||||
"""Test get_query_vars returns the query's variable mapping."""
|
||||
runtime_handler = make_handler(mock_app)
|
||||
mock_query = SimpleNamespace()
|
||||
mock_query.variables = {'var1': 'val1', 'var2': 'val2', 'var3': 'val3'}
|
||||
|
||||
mock_app.query_pool.cached_queries['test-query'] = mock_query
|
||||
|
||||
query_id = 'test-query'
|
||||
var_names = ['var1', 'var3']
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.GET_QUERY_VARS.value]({
|
||||
'query_id': 'test-query',
|
||||
})
|
||||
|
||||
if query_id in mock_app.query_pool.cached_queries:
|
||||
query = mock_app.query_pool.cached_queries[query_id]
|
||||
result = {name: query.variables.get(name) for name in var_names}
|
||||
assert result == {'var1': 'val1', 'var3': 'val3'}
|
||||
assert response.code == 0
|
||||
assert response.data == {'vars': mock_query.variables}
|
||||
|
||||
|
||||
class TestHandlerRagErrorResponse:
|
||||
@@ -95,7 +106,7 @@ class TestHandlerRagErrorResponse:
|
||||
|
||||
def test_make_rag_error_response_basic(self):
|
||||
"""Test basic error response creation."""
|
||||
from src.langbot.pkg.plugin.handler import _make_rag_error_response
|
||||
from langbot.pkg.plugin.handler import _make_rag_error_response
|
||||
|
||||
error = Exception("test error")
|
||||
response = _make_rag_error_response(error, 'TestError')
|
||||
@@ -107,7 +118,7 @@ class TestHandlerRagErrorResponse:
|
||||
|
||||
def test_make_rag_error_response_with_context(self):
|
||||
"""Test error response with extra context."""
|
||||
from src.langbot.pkg.plugin.handler import _make_rag_error_response
|
||||
from langbot.pkg.plugin.handler import _make_rag_error_response
|
||||
|
||||
error = ValueError("invalid input")
|
||||
response = _make_rag_error_response(
|
||||
@@ -124,7 +135,7 @@ class TestHandlerRagErrorResponse:
|
||||
|
||||
def test_make_rag_error_response_exception_type(self):
|
||||
"""Test error response includes exception type."""
|
||||
from src.langbot.pkg.plugin.handler import _make_rag_error_response
|
||||
from langbot.pkg.plugin.handler import _make_rag_error_response
|
||||
|
||||
error = RuntimeError("connection failed")
|
||||
response = _make_rag_error_response(error, 'ConnectionError')
|
||||
@@ -135,7 +146,7 @@ class TestHandlerRagErrorResponse:
|
||||
|
||||
def test_make_rag_error_response_empty_context(self):
|
||||
"""Test error response with no extra context."""
|
||||
from src.langbot.pkg.plugin.handler import _make_rag_error_response
|
||||
from langbot.pkg.plugin.handler import _make_rag_error_response
|
||||
|
||||
error = KeyError("missing_key")
|
||||
response = _make_rag_error_response(error, 'LookupError')
|
||||
@@ -150,21 +161,21 @@ class TestConstantsSemanticVersion:
|
||||
|
||||
def test_semantic_version_exists(self):
|
||||
"""Test semantic_version is defined."""
|
||||
from src.langbot.pkg.utils import constants
|
||||
from langbot.pkg.utils import constants
|
||||
|
||||
assert hasattr(constants, 'semantic_version')
|
||||
assert constants.semantic_version.startswith('v')
|
||||
|
||||
def test_edition_exists(self):
|
||||
"""Test edition constant is defined."""
|
||||
from src.langbot.pkg.utils import constants
|
||||
from langbot.pkg.utils import constants
|
||||
|
||||
assert hasattr(constants, 'edition')
|
||||
assert constants.edition == 'community'
|
||||
|
||||
def test_required_database_version_exists(self):
|
||||
"""Test database version constant."""
|
||||
from src.langbot.pkg.utils import constants
|
||||
from langbot.pkg.utils import constants
|
||||
|
||||
assert hasattr(constants, 'required_database_version')
|
||||
assert isinstance(constants.required_database_version, int)
|
||||
assert isinstance(constants.required_database_version, int)
|
||||
|
||||
@@ -1,45 +1,37 @@
|
||||
"""Unit tests for RuntimeConnectionHandler action handlers.
|
||||
"""Unit tests for RuntimeConnectionHandler action handlers."""
|
||||
|
||||
Tests cover critical action handlers:
|
||||
- initialize_plugin_settings with setting inheritance
|
||||
- set_binary_storage with size limit validation
|
||||
- get_binary_storage
|
||||
- get_plugin_settings with defaults
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import base64
|
||||
from unittest.mock import Mock, AsyncMock, MagicMock
|
||||
from importlib import import_module
|
||||
import sqlalchemy
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction, RuntimeToLangBotAction
|
||||
|
||||
|
||||
def get_handler_module():
|
||||
"""Lazy import to avoid circular import issues."""
|
||||
return import_module('langbot.pkg.plugin.handler')
|
||||
def make_handler(app):
|
||||
"""Create a RuntimeConnectionHandler with mocked external connection."""
|
||||
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
|
||||
|
||||
return RuntimeConnectionHandler(Mock(), AsyncMock(return_value=True), app)
|
||||
|
||||
|
||||
def get_persistence_plugin_module():
|
||||
"""Lazy import for plugin persistence entity."""
|
||||
return import_module('langbot.pkg.entity.persistence.plugin')
|
||||
def make_result(first_item=None):
|
||||
result = Mock()
|
||||
result.first = Mock(return_value=first_item)
|
||||
return result
|
||||
|
||||
|
||||
def get_persistence_bstorage_module():
|
||||
"""Lazy import for binary storage entity."""
|
||||
return import_module('langbot.pkg.entity.persistence.bstorage')
|
||||
def compiled_params(statement):
|
||||
return statement.compile().params
|
||||
|
||||
|
||||
class TestInitializePluginSettings:
|
||||
"""Tests for initialize_plugin_settings action handler.
|
||||
|
||||
IMPORTANT: Tests verify setting inheritance logic - existing settings
|
||||
should be inherited when creating new plugin settings.
|
||||
"""
|
||||
"""Tests for initialize_plugin_settings action handler."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app_with_persistence(self):
|
||||
"""Create mock app with persistence manager."""
|
||||
def app(self):
|
||||
mock_app = Mock()
|
||||
mock_app.persistence_mgr = Mock()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock()
|
||||
@@ -47,273 +39,202 @@ class TestInitializePluginSettings:
|
||||
return mock_app
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_new_setting_when_not_exists(self, mock_app_with_persistence):
|
||||
"""Test that new setting is created when plugin setting doesn't exist."""
|
||||
handler_module = get_handler_module()
|
||||
persistence_plugin = get_persistence_plugin_module()
|
||||
async def test_creates_new_setting_when_not_exists(self, app):
|
||||
"""New plugin settings use default enabled, priority and config values."""
|
||||
runtime_handler = make_handler(app)
|
||||
app.persistence_mgr.execute_async.side_effect = [
|
||||
make_result(),
|
||||
Mock(),
|
||||
]
|
||||
|
||||
# Mock select result - no existing setting
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=None)
|
||||
mock_app_with_persistence.persistence_mgr.execute_async.return_value = mock_result
|
||||
|
||||
# Create handler instance with mock connection
|
||||
from langbot_plugin.runtime.io.connection import Connection
|
||||
mock_connection = Mock(spec=Connection)
|
||||
|
||||
handler = handler_module.RuntimeConnectionHandler(
|
||||
mock_connection,
|
||||
AsyncMock(return_value=True),
|
||||
mock_app_with_persistence
|
||||
)
|
||||
|
||||
# Get the initialize_plugin_settings action handler
|
||||
# Action handlers are registered via @self.action decorator
|
||||
# We test by calling the persistence operations directly
|
||||
data = {
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value]({
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
'install_source': 'local',
|
||||
'install_info': {'path': '/test'},
|
||||
})
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 2
|
||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
||||
assert insert_params == {
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
'install_source': 'local',
|
||||
'install_info': {'path': '/test'},
|
||||
'enabled': True,
|
||||
'priority': 0,
|
||||
'config': {},
|
||||
}
|
||||
|
||||
# Simulate the action handler logic
|
||||
result = await mock_app_with_persistence.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_plugin.PluginSetting)
|
||||
.where(persistence_plugin.PluginSetting.plugin_author == data['plugin_author'])
|
||||
.where(persistence_plugin.PluginSetting.plugin_name == data['plugin_name'])
|
||||
@pytest.mark.asyncio
|
||||
async def test_inherits_values_from_existing_setting(self, app):
|
||||
"""Existing settings are replaced while preserving user-controlled values."""
|
||||
runtime_handler = make_handler(app)
|
||||
existing_setting = SimpleNamespace(
|
||||
enabled=False,
|
||||
priority=5,
|
||||
config={'key': 'value'},
|
||||
)
|
||||
app.persistence_mgr.execute_async.side_effect = [
|
||||
make_result(existing_setting),
|
||||
Mock(),
|
||||
Mock(),
|
||||
]
|
||||
|
||||
# Verify select was called
|
||||
assert mock_app_with_persistence.persistence_mgr.execute_async.called
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value]({
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
'install_source': 'github',
|
||||
'install_info': {'repo': 'author/name'},
|
||||
})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inherits_enabled_from_existing_setting(self, mock_app_with_persistence):
|
||||
"""Test that enabled status is inherited from existing setting."""
|
||||
handler_module = get_handler_module()
|
||||
persistence_plugin = get_persistence_plugin_module()
|
||||
|
||||
# Mock existing setting with enabled=False
|
||||
mock_existing_setting = Mock()
|
||||
mock_existing_setting.enabled = False
|
||||
mock_existing_setting.priority = 5
|
||||
mock_existing_setting.config = {'key': 'value'}
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=mock_existing_setting)
|
||||
mock_app_with_persistence.persistence_mgr.execute_async.return_value = mock_result
|
||||
|
||||
# Simulate inheritance logic
|
||||
# When existing setting exists, delete old and create new with inherited values
|
||||
setting = mock_result.first()
|
||||
inherited_enabled = setting.enabled if setting is not None else True
|
||||
inherited_priority = setting.priority if setting is not None else 0
|
||||
inherited_config = setting.config if setting is not None else {}
|
||||
|
||||
assert inherited_enabled is False
|
||||
assert inherited_priority == 5
|
||||
assert inherited_config == {'key': 'value'}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_defaults_enabled_true_when_no_existing(self, mock_app_with_persistence):
|
||||
"""Test that enabled defaults to True when no existing setting."""
|
||||
# No existing setting
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=None)
|
||||
mock_app_with_persistence.persistence_mgr.execute_async.return_value = mock_result
|
||||
|
||||
setting = mock_result.first()
|
||||
default_enabled = setting.enabled if setting is not None else True
|
||||
|
||||
assert default_enabled is True
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 3
|
||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||
assert insert_params['enabled'] is False
|
||||
assert insert_params['priority'] == 5
|
||||
assert insert_params['config'] == {'key': 'value'}
|
||||
assert insert_params['install_source'] == 'github'
|
||||
assert insert_params['install_info'] == {'repo': 'author/name'}
|
||||
|
||||
|
||||
class TestSetBinaryStorage:
|
||||
"""Tests for set_binary_storage action handler with size limit validation.
|
||||
|
||||
IMPORTANT: This tests security-critical size limit validation.
|
||||
"""
|
||||
"""Tests for set_binary_storage action handler with size limit validation."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app_with_size_limit(self):
|
||||
"""Create mock app with plugin binary storage size limit."""
|
||||
def app(self):
|
||||
mock_app = Mock()
|
||||
mock_app.instance_config = Mock()
|
||||
mock_app.instance_config.data = {
|
||||
'plugin': {
|
||||
'binary_storage': {
|
||||
'max_value_bytes': 1024, # 1KB limit for testing
|
||||
}
|
||||
}
|
||||
'max_value_bytes': 1024,
|
||||
},
|
||||
},
|
||||
}
|
||||
mock_app.persistence_mgr = Mock()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
|
||||
mock_app.logger = Mock()
|
||||
return mock_app
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app_no_limit(self):
|
||||
"""Create mock app without explicit size limit (uses default)."""
|
||||
mock_app = Mock()
|
||||
mock_app.instance_config = Mock()
|
||||
mock_app.instance_config.data = {
|
||||
'plugin': {}
|
||||
}
|
||||
mock_app.persistence_mgr = Mock()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock()
|
||||
mock_app.logger = Mock()
|
||||
return mock_app
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_value_exceeding_limit(self, mock_app_with_size_limit):
|
||||
"""Test that values exceeding max_value_bytes are rejected."""
|
||||
handler_module = get_handler_module()
|
||||
|
||||
# Value larger than 1024 bytes
|
||||
large_value = b'x' * 2048
|
||||
value_base64 = base64.b64encode(large_value).decode('utf-8')
|
||||
|
||||
data = {
|
||||
@staticmethod
|
||||
def payload(value: bytes):
|
||||
return {
|
||||
'key': 'test-key',
|
||||
'owner_type': 'plugin',
|
||||
'owner': 'test-owner',
|
||||
'value_base64': value_base64,
|
||||
'value_base64': base64.b64encode(value).decode('utf-8'),
|
||||
}
|
||||
|
||||
# Simulate size limit check logic from handler
|
||||
value = base64.b64decode(data['value_base64'])
|
||||
max_value_bytes = (
|
||||
mock_app_with_size_limit.instance_config.data
|
||||
.get('plugin', {})
|
||||
.get('binary_storage', {})
|
||||
.get('max_value_bytes', 10 * 1024 * 1024)
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_value_exceeding_limit(self, app):
|
||||
"""Values larger than max_value_bytes are rejected before persistence writes."""
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
|
||||
self.payload(b'x' * 2048)
|
||||
)
|
||||
|
||||
if max_value_bytes >= 0 and len(value) > max_value_bytes:
|
||||
error_message = f'Binary storage value exceeds limit ({len(value)} > {max_value_bytes} bytes)'
|
||||
# Should return error response
|
||||
assert len(value) > max_value_bytes
|
||||
assert error_message is not None
|
||||
assert response.code != 0
|
||||
assert '2048 > 1024 bytes' in response.message
|
||||
app.persistence_mgr.execute_async.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accepts_value_within_limit(self, mock_app_with_size_limit):
|
||||
"""Test that values within limit are accepted."""
|
||||
# Value smaller than 1024 bytes
|
||||
small_value = b'x' * 512
|
||||
value_base64 = base64.b64encode(small_value).decode('utf-8')
|
||||
async def test_accepts_value_within_limit_and_inserts_storage(self, app):
|
||||
"""A new small value is inserted into binary storage."""
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
data = {
|
||||
'key': 'test-key',
|
||||
'owner_type': 'plugin',
|
||||
'owner': 'test-owner',
|
||||
'value_base64': value_base64,
|
||||
}
|
||||
|
||||
value = base64.b64decode(data['value_base64'])
|
||||
max_value_bytes = 1024
|
||||
|
||||
assert len(value) <= max_value_bytes
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_invalid_max_value_bytes(self, mock_app_with_size_limit):
|
||||
"""Test that invalid max_value_bytes falls back to default."""
|
||||
# Invalid config value
|
||||
mock_app_with_size_limit.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = 'invalid'
|
||||
|
||||
max_value_bytes = (
|
||||
mock_app_with_size_limit.instance_config.data
|
||||
.get('plugin', {})
|
||||
.get('binary_storage', {})
|
||||
.get('max_value_bytes', 10 * 1024 * 1024)
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
|
||||
self.payload(b'x' * 512)
|
||||
)
|
||||
|
||||
try:
|
||||
max_value_bytes = int(max_value_bytes)
|
||||
except (TypeError, ValueError):
|
||||
max_value_bytes = 10 * 1024 * 1024 # Default 10MB
|
||||
|
||||
assert max_value_bytes == 10 * 1024 * 1024
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 2
|
||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
||||
assert insert_params['unique_key'] == 'plugin:test-owner:test-key'
|
||||
assert insert_params['value'] == b'x' * 512
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_limit_disables_check(self, mock_app_with_size_limit):
|
||||
"""Test that negative max_value_bytes disables size check."""
|
||||
mock_app_with_size_limit.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = -1
|
||||
async def test_updates_existing_storage(self, app):
|
||||
"""An existing binary storage row is updated instead of inserted."""
|
||||
runtime_handler = make_handler(app)
|
||||
app.persistence_mgr.execute_async.return_value = make_result(SimpleNamespace(value=b'old'))
|
||||
|
||||
# Large value
|
||||
large_value = b'x' * 20 * 1024 * 1024 # 20MB
|
||||
value_base64 = base64.b64encode(large_value).decode('utf-8')
|
||||
|
||||
max_value_bytes = (
|
||||
mock_app_with_size_limit.instance_config.data
|
||||
.get('plugin', {})
|
||||
.get('binary_storage', {})
|
||||
.get('max_value_bytes', 10 * 1024 * 1024)
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
|
||||
self.payload(b'new')
|
||||
)
|
||||
|
||||
try:
|
||||
max_value_bytes = int(max_value_bytes)
|
||||
except (TypeError, ValueError):
|
||||
max_value_bytes = 10 * 1024 * 1024
|
||||
|
||||
# When max_value_bytes < 0, size check is disabled (condition: max_value_bytes >= 0)
|
||||
if max_value_bytes >= 0 and len(large_value) > max_value_bytes:
|
||||
should_reject = True
|
||||
else:
|
||||
should_reject = False
|
||||
|
||||
assert should_reject is False # Negative limit disables check
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 2
|
||||
update_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
||||
assert update_params['value'] == b'new'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_limit_is_10mb(self, mock_app_no_limit):
|
||||
"""Test that default limit is 10MB when not configured."""
|
||||
max_value_bytes = (
|
||||
mock_app_no_limit.instance_config.data
|
||||
.get('plugin', {})
|
||||
.get('binary_storage', {})
|
||||
.get('max_value_bytes', 10 * 1024 * 1024)
|
||||
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
|
||||
"""Invalid max_value_bytes uses the 10MB default limit."""
|
||||
runtime_handler = make_handler(app)
|
||||
app.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = 'invalid'
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
|
||||
self.payload(b'x' * (10 * 1024 * 1024 + 1))
|
||||
)
|
||||
|
||||
assert max_value_bytes == 10 * 1024 * 1024
|
||||
assert response.code != 0
|
||||
assert '10485761 > 10485760 bytes' in response.message
|
||||
app.persistence_mgr.execute_async.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_limit_rejects_all_values(self, mock_app_with_size_limit):
|
||||
"""Test that zero limit rejects all non-empty values."""
|
||||
mock_app_with_size_limit.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = 0
|
||||
async def test_negative_limit_disables_size_check(self, app):
|
||||
"""Negative max_value_bytes allows values larger than the normal default."""
|
||||
runtime_handler = make_handler(app)
|
||||
app.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = -1
|
||||
|
||||
small_value = b'x' # Just 1 byte
|
||||
max_value_bytes = 0
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
|
||||
self.payload(b'x' * 2048)
|
||||
)
|
||||
|
||||
if max_value_bytes >= 0 and len(small_value) > max_value_bytes:
|
||||
should_reject = True
|
||||
else:
|
||||
should_reject = False
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 2
|
||||
|
||||
assert should_reject is True
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_limit_rejects_non_empty_values(self, app):
|
||||
"""A zero byte limit rejects non-empty values."""
|
||||
runtime_handler = make_handler(app)
|
||||
app.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = 0
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
|
||||
self.payload(b'x')
|
||||
)
|
||||
|
||||
assert response.code != 0
|
||||
assert '1 > 0 bytes' in response.message
|
||||
app.persistence_mgr.execute_async.assert_not_awaited()
|
||||
|
||||
|
||||
class TestGetPluginSettings:
|
||||
"""Tests for get_plugin_settings action handler with defaults."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
"""Create mock app."""
|
||||
def app(self):
|
||||
mock_app = Mock()
|
||||
mock_app.persistence_mgr = Mock()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock()
|
||||
return mock_app
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_defaults_when_setting_not_found(self, mock_app):
|
||||
"""Test that default values are returned when setting doesn't exist."""
|
||||
persistence_plugin = get_persistence_plugin_module()
|
||||
async def test_returns_defaults_when_setting_not_found(self, app):
|
||||
"""Default plugin settings are returned when no persisted row exists."""
|
||||
runtime_handler = make_handler(app)
|
||||
app.persistence_mgr.execute_async.return_value = make_result()
|
||||
|
||||
# Mock no existing setting
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=None)
|
||||
mock_app.persistence_mgr.execute_async.return_value = mock_result
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value]({
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
})
|
||||
|
||||
# Simulate get_plugin_settings logic
|
||||
default_data = {
|
||||
assert response.code == 0
|
||||
assert response.data == {
|
||||
'enabled': True,
|
||||
'priority': 0,
|
||||
'plugin_config': {},
|
||||
@@ -321,107 +242,82 @@ class TestGetPluginSettings:
|
||||
'install_info': {},
|
||||
}
|
||||
|
||||
setting = mock_result.first()
|
||||
if setting is None:
|
||||
result_data = default_data
|
||||
|
||||
assert result_data['enabled'] is True
|
||||
assert result_data['priority'] == 0
|
||||
assert result_data['plugin_config'] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_actual_values_when_setting_exists(self, mock_app):
|
||||
"""Test that actual setting values are returned when setting exists."""
|
||||
persistence_plugin = get_persistence_plugin_module()
|
||||
async def test_returns_actual_values_when_setting_exists(self, app):
|
||||
"""Persisted plugin setting values override defaults."""
|
||||
runtime_handler = make_handler(app)
|
||||
setting = SimpleNamespace(
|
||||
enabled=False,
|
||||
priority=10,
|
||||
config={'custom': 'config'},
|
||||
install_source='github',
|
||||
install_info={'repo': 'test/repo'},
|
||||
)
|
||||
app.persistence_mgr.execute_async.return_value = make_result(setting)
|
||||
|
||||
# Mock existing setting
|
||||
mock_setting = Mock()
|
||||
mock_setting.enabled = False
|
||||
mock_setting.priority = 10
|
||||
mock_setting.config = {'custom': 'config'}
|
||||
mock_setting.install_source = 'github'
|
||||
mock_setting.install_info = {'repo': 'test/repo'}
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value]({
|
||||
'plugin_author': 'test-author',
|
||||
'plugin_name': 'test-plugin',
|
||||
})
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=mock_setting)
|
||||
mock_app.persistence_mgr.execute_async.return_value = mock_result
|
||||
|
||||
# Simulate get_plugin_settings logic
|
||||
data = {
|
||||
'enabled': True,
|
||||
'priority': 0,
|
||||
'plugin_config': {},
|
||||
'install_source': 'local',
|
||||
'install_info': {},
|
||||
assert response.code == 0
|
||||
assert response.data == {
|
||||
'enabled': False,
|
||||
'priority': 10,
|
||||
'plugin_config': {'custom': 'config'},
|
||||
'install_source': 'github',
|
||||
'install_info': {'repo': 'test/repo'},
|
||||
}
|
||||
|
||||
setting = mock_result.first()
|
||||
if setting is not None:
|
||||
data['enabled'] = setting.enabled
|
||||
data['priority'] = setting.priority
|
||||
data['plugin_config'] = setting.config
|
||||
data['install_source'] = setting.install_source
|
||||
data['install_info'] = setting.install_info
|
||||
|
||||
assert data['enabled'] is False
|
||||
assert data['priority'] == 10
|
||||
assert data['plugin_config'] == {'custom': 'config'}
|
||||
assert data['install_source'] == 'github'
|
||||
|
||||
|
||||
class TestGetBinaryStorage:
|
||||
"""Tests for get_binary_storage action handler."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
"""Create mock app."""
|
||||
def app(self):
|
||||
mock_app = Mock()
|
||||
mock_app.persistence_mgr = Mock()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock()
|
||||
return mock_app
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_base64_encoded_value(self, mock_app):
|
||||
"""Test that returned value is base64 encoded."""
|
||||
persistence_bstorage = get_persistence_bstorage_module()
|
||||
async def test_returns_base64_encoded_value(self, app):
|
||||
"""Stored bytes are returned as base64."""
|
||||
runtime_handler = make_handler(app)
|
||||
app.persistence_mgr.execute_async.return_value = make_result(SimpleNamespace(value=b'test binary content'))
|
||||
|
||||
# Mock existing storage
|
||||
test_value = b'test binary content'
|
||||
mock_storage = Mock()
|
||||
mock_storage.value = test_value
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value]({
|
||||
'key': 'test-key',
|
||||
'owner_type': 'plugin',
|
||||
'owner': 'test-owner',
|
||||
})
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=mock_storage)
|
||||
mock_app.persistence_mgr.execute_async.return_value = mock_result
|
||||
|
||||
storage = mock_result.first()
|
||||
if storage is not None:
|
||||
value_base64 = base64.b64encode(storage.value).decode('utf-8')
|
||||
|
||||
assert value_base64 == base64.b64encode(test_value).decode('utf-8')
|
||||
assert response.code == 0
|
||||
assert response.data == {
|
||||
'value_base64': base64.b64encode(b'test binary content').decode('utf-8'),
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_error_when_not_found(self, mock_app):
|
||||
"""Test that error is returned when storage not found."""
|
||||
persistence_bstorage = get_persistence_bstorage_module()
|
||||
async def test_returns_error_when_not_found(self, app):
|
||||
"""Missing binary storage rows return an error response."""
|
||||
runtime_handler = make_handler(app)
|
||||
app.persistence_mgr.execute_async.return_value = make_result()
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=None)
|
||||
mock_app.persistence_mgr.execute_async.return_value = mock_result
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value]({
|
||||
'key': 'test-key',
|
||||
'owner_type': 'plugin',
|
||||
'owner': 'test-owner',
|
||||
})
|
||||
|
||||
storage = mock_result.first()
|
||||
if storage is None:
|
||||
key = 'test-key'
|
||||
error_message = f'Storage with key {key} not found'
|
||||
assert error_message is not None
|
||||
assert response.code != 0
|
||||
assert 'Storage with key test-key not found' in response.message
|
||||
|
||||
|
||||
class TestHandlerQueryLookup:
|
||||
"""Tests for query lookup in cached_queries."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app_with_query_pool(self):
|
||||
"""Create mock app with query pool."""
|
||||
def app(self):
|
||||
mock_app = Mock()
|
||||
mock_app.query_pool = Mock()
|
||||
mock_app.query_pool.cached_queries = {}
|
||||
@@ -429,26 +325,27 @@ class TestHandlerQueryLookup:
|
||||
return mock_app
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_not_found_returns_error(self, mock_app_with_query_pool):
|
||||
"""Test that operations return error when query_id not found."""
|
||||
query_id = 'nonexistent-query'
|
||||
async def test_query_not_found_returns_error(self, app):
|
||||
"""Query-bound actions return error when query_id is not cached."""
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
if query_id not in mock_app_with_query_pool.query_pool.cached_queries:
|
||||
error_message = f'Query with query_id {query_id} not found'
|
||||
# Should return error response
|
||||
assert error_message is not None
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.GET_BOT_UUID.value]({
|
||||
'query_id': 'nonexistent-query',
|
||||
})
|
||||
|
||||
assert response.code != 0
|
||||
assert 'nonexistent-query' in response.message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_found_returns_success(self, mock_app_with_query_pool):
|
||||
"""Test that operations succeed when query exists."""
|
||||
mock_query = Mock()
|
||||
mock_query.variables = {}
|
||||
mock_query.bot_uuid = 'test-bot-uuid'
|
||||
async def test_query_found_returns_success(self, app):
|
||||
"""Query-bound actions read data from the cached query object."""
|
||||
runtime_handler = make_handler(app)
|
||||
query = SimpleNamespace(variables={}, bot_uuid='test-bot-uuid')
|
||||
app.query_pool.cached_queries['existing-query'] = query
|
||||
|
||||
query_id = 'existing-query'
|
||||
mock_app_with_query_pool.query_pool.cached_queries[query_id] = mock_query
|
||||
response = await runtime_handler.actions[PluginToRuntimeAction.GET_BOT_UUID.value]({
|
||||
'query_id': 'existing-query',
|
||||
})
|
||||
|
||||
if query_id in mock_app_with_query_pool.query_pool.cached_queries:
|
||||
query = mock_app_with_query_pool.query_pool.cached_queries[query_id]
|
||||
# Operations can proceed
|
||||
assert query is mock_query
|
||||
assert response.code == 0
|
||||
assert response.data == {'bot_uuid': 'test-bot-uuid'}
|
||||
|
||||
@@ -7,7 +7,7 @@ import pytest
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_filter_by_component_kinds():
|
||||
"""Test that plugins can be filtered by component kinds."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Mock the application
|
||||
mock_app = MagicMock()
|
||||
@@ -113,7 +113,7 @@ async def test_plugin_list_filter_by_component_kinds():
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_filter_no_filter():
|
||||
"""Test that all plugins are returned when no filter is specified."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Mock the application
|
||||
mock_app = MagicMock()
|
||||
@@ -174,7 +174,7 @@ async def test_plugin_list_filter_no_filter():
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_filter_empty_result():
|
||||
"""Test that empty list is returned when no plugins match the filter."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Mock the application
|
||||
mock_app = MagicMock()
|
||||
@@ -220,7 +220,7 @@ async def test_plugin_list_filter_empty_result():
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_filter_plugin_without_components():
|
||||
"""Test that plugins without components are excluded when filtering."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Mock the application
|
||||
mock_app = MagicMock()
|
||||
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_sorting_debug_first():
|
||||
"""Test that debug plugins appear before non-debug plugins."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Mock the application
|
||||
mock_app = MagicMock()
|
||||
@@ -110,7 +110,7 @@ async def test_plugin_list_sorting_debug_first():
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_sorting_by_installation_time():
|
||||
"""Test that non-debug plugins are sorted by installation time (newest first)."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Mock the application
|
||||
mock_app = MagicMock()
|
||||
@@ -207,7 +207,7 @@ async def test_plugin_list_sorting_by_installation_time():
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_empty():
|
||||
"""Test that empty plugin list is handled correctly."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Mock the application
|
||||
mock_app = MagicMock()
|
||||
|
||||
Reference in New Issue
Block a user