mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
test: tighten phase 1 coverage contracts
This commit is contained in:
@@ -12,8 +12,6 @@ Run: uv run pytest tests/e2e/test_startup.py -v -m e2e
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
@@ -64,9 +62,8 @@ class TestStartupFlow:
|
||||
def test_chroma_directory_created(self, e2e_tmpdir):
|
||||
"""Verify Chroma vector database directory was created."""
|
||||
chroma_path = e2e_tmpdir / 'chroma'
|
||||
# Chroma should create its storage on startup or when first used
|
||||
# This test just verifies the directory exists (created by config factory)
|
||||
assert chroma_path.exists() or True # May not be created until first use
|
||||
# Created by the E2E config factory before startup.
|
||||
assert chroma_path.exists()
|
||||
|
||||
def test_pipelines_endpoint(self, e2e_client):
|
||||
"""Test /api/v1/pipelines endpoint (requires auth)."""
|
||||
@@ -86,8 +83,7 @@ class TestStartupFlow:
|
||||
# - 200 if auth succeeds
|
||||
# - 400 if credentials wrong
|
||||
# - 401 if user not initialized
|
||||
# - 500 if internal error (e.g., user service not initialized)
|
||||
assert response.status_code in [200, 400, 401, 500]
|
||||
assert response.status_code in [200, 400, 401]
|
||||
|
||||
|
||||
class TestStartupStages:
|
||||
@@ -127,8 +123,8 @@ class TestStartupStages:
|
||||
|
||||
for endpoint in endpoints:
|
||||
response = e2e_client.get(endpoint)
|
||||
# Should get valid response (even if 401 unauthorized)
|
||||
assert response.status_code < 500, f'{endpoint} should not return 5xx'
|
||||
# Should get a real route response, even if auth is required.
|
||||
assert response.status_code in [200, 401, 403], f'{endpoint} should be registered'
|
||||
|
||||
|
||||
class TestMinimalStartupNoLLM:
|
||||
@@ -143,4 +139,4 @@ class TestMinimalStartupNoLLM:
|
||||
"""Pipeline metadata endpoint should work without LLM."""
|
||||
# Requires auth, but endpoint should exist
|
||||
response = e2e_client.get('/api/v1/pipelines/_/metadata')
|
||||
assert response.status_code in [200, 401] # Not 404 or 500
|
||||
assert response.status_code in [200, 401] # Not 404 or 500
|
||||
|
||||
@@ -9,7 +9,6 @@ import subprocess
|
||||
import time
|
||||
import signal
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
@@ -64,7 +64,8 @@ def fake_bot_app():
|
||||
# Auth services
|
||||
app.user_service = Mock()
|
||||
app.user_service.is_initialized = AsyncMock(return_value=True)
|
||||
app.user_service.verify_jwt_token = AsyncMock(return_value={'email': 'test@example.com'})
|
||||
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
|
||||
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
|
||||
app.apikey_service = Mock()
|
||||
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
|
||||
|
||||
@@ -251,4 +252,4 @@ class TestBotSendMessageEndpoint:
|
||||
|
||||
assert response.status_code == 400
|
||||
data = await response.get_json()
|
||||
assert data['code'] == -1
|
||||
assert data['code'] == -1
|
||||
|
||||
@@ -65,7 +65,8 @@ def fake_knowledge_app():
|
||||
# Auth services
|
||||
app.user_service = Mock()
|
||||
app.user_service.is_initialized = AsyncMock(return_value=True)
|
||||
app.user_service.verify_jwt_token = AsyncMock(return_value={'email': 'test@example.com'})
|
||||
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
|
||||
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
|
||||
app.apikey_service = Mock()
|
||||
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
|
||||
|
||||
@@ -257,4 +258,4 @@ class TestKnowledgeBaseRetrieveEndpoint:
|
||||
|
||||
assert response.status_code == 400
|
||||
data = await response.get_json()
|
||||
assert data['code'] == -1
|
||||
assert data['code'] == -1
|
||||
|
||||
@@ -72,7 +72,8 @@ def fake_pipeline_app():
|
||||
# Auth services
|
||||
app.user_service = Mock()
|
||||
app.user_service.is_initialized = AsyncMock(return_value=True)
|
||||
app.user_service.verify_jwt_token = AsyncMock(return_value={'email': 'test@example.com'})
|
||||
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
|
||||
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
|
||||
app.apikey_service = Mock()
|
||||
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
|
||||
|
||||
@@ -271,4 +272,4 @@ class TestPipelineExtensionsEndpoint:
|
||||
# Should return 200 if pipeline found
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert data['code'] == 0
|
||||
|
||||
@@ -65,7 +65,8 @@ def fake_provider_app():
|
||||
# Auth services
|
||||
app.user_service = Mock()
|
||||
app.user_service.is_initialized = AsyncMock(return_value=True)
|
||||
app.user_service.verify_jwt_token = AsyncMock(return_value={'email': 'test@example.com'})
|
||||
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
|
||||
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
|
||||
app.apikey_service = Mock()
|
||||
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
|
||||
|
||||
@@ -345,4 +346,4 @@ class TestRerankModelEndpoints:
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert 'uuid' in data['data']
|
||||
assert 'uuid' in data['data']
|
||||
|
||||
@@ -9,7 +9,7 @@ Source: src/langbot/pkg/api/http/service/apikey.py
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from types import SimpleNamespace
|
||||
|
||||
from langbot.pkg.api.http.service.apikey import ApiKeyService
|
||||
@@ -101,43 +101,42 @@ class TestApiKeyServiceCreateApiKey:
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
|
||||
# Mock insert result
|
||||
insert_result = Mock()
|
||||
insert_result.all = Mock(return_value=[])
|
||||
|
||||
# Mock select result for retrieving created key
|
||||
created_key = Mock(spec=ApiKey)
|
||||
created_key.id = 1
|
||||
created_key.name = 'New Key'
|
||||
created_key.key = 'lbk_generated_key'
|
||||
created_key.key = 'lbk_fixed-token'
|
||||
created_key.description = 'Test description'
|
||||
select_result = Mock()
|
||||
select_result.first = Mock(return_value=created_key)
|
||||
insert_params = []
|
||||
|
||||
# execute_async returns different results for insert vs select
|
||||
async def mock_execute(query):
|
||||
# First call is insert, second is select
|
||||
if hasattr(query, 'values'):
|
||||
return insert_result
|
||||
params = query.compile().params
|
||||
if {'name', 'key', 'description'}.issubset(params):
|
||||
insert_params.append(params)
|
||||
return Mock()
|
||||
return select_result
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={
|
||||
side_effect=lambda model_cls, entity: {
|
||||
'id': 1,
|
||||
'name': 'New Key',
|
||||
'key': 'lbk_generated_key',
|
||||
'description': 'Test description',
|
||||
'name': entity.name,
|
||||
'key': entity.key,
|
||||
'description': entity.description,
|
||||
}
|
||||
)
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service.create_api_key('New Key', 'Test description')
|
||||
with patch('langbot.pkg.api.http.service.apikey.secrets.token_urlsafe', return_value='fixed-token'):
|
||||
result = await service.create_api_key('New Key', 'Test description')
|
||||
|
||||
# Verify key format
|
||||
assert insert_params == [
|
||||
{'name': 'New Key', 'key': 'lbk_fixed-token', 'description': 'Test description'}
|
||||
]
|
||||
assert result['key'].startswith('lbk_')
|
||||
assert result['key'] == 'lbk_fixed-token'
|
||||
assert result['name'] == 'New Key'
|
||||
assert result['description'] == 'Test description'
|
||||
|
||||
@@ -313,8 +312,8 @@ class TestApiKeyServiceVerifyApiKey:
|
||||
# Verify
|
||||
assert result is False
|
||||
|
||||
async def test_verify_api_key_wrong_prefix(self):
|
||||
"""Returns False for key without correct prefix."""
|
||||
async def test_verify_api_key_unknown_key(self):
|
||||
"""Returns False when the key is not present in persistence."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
@@ -326,7 +325,7 @@ class TestApiKeyServiceVerifyApiKey:
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service.verify_api_key('invalid_prefix_key')
|
||||
result = await service.verify_api_key('unknown_key')
|
||||
|
||||
# Verify
|
||||
assert result is False
|
||||
@@ -427,4 +426,4 @@ class TestApiKeyServiceUpdateApiKey:
|
||||
await service.update_api_key(1)
|
||||
|
||||
# Verify - no execute call since no update_data
|
||||
ap.persistence_mgr.execute_async.assert_not_called()
|
||||
ap.persistence_mgr.execute_async.assert_not_called()
|
||||
|
||||
@@ -432,7 +432,7 @@ class TestBotServiceUpdateBot:
|
||||
"""Tests for update_bot method."""
|
||||
|
||||
async def test_update_bot_removes_uuid_from_data(self):
|
||||
"""Removes uuid field from update data."""
|
||||
"""Does not persist caller-provided uuid in update payload."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
@@ -456,9 +456,9 @@ class TestBotServiceUpdateBot:
|
||||
update_data = {'uuid': 'should-be-removed', 'name': 'Updated Name'}
|
||||
await service.update_bot('test-uuid', update_data)
|
||||
|
||||
# Verify - uuid was removed from bot_data dict
|
||||
assert 'uuid' not in update_data
|
||||
assert 'name' in update_data
|
||||
update_params = ap.persistence_mgr.execute_async.await_args_list[0].args[0].compile().params
|
||||
assert update_params['name'] == 'Updated Name'
|
||||
assert 'should-be-removed' not in update_params.values()
|
||||
|
||||
async def test_update_bot_pipeline_not_found_raises(self):
|
||||
"""Raises Exception when updating with nonexistent pipeline UUID."""
|
||||
@@ -513,7 +513,9 @@ class TestBotServiceUpdateBot:
|
||||
# Execute
|
||||
await service.update_bot('test-uuid', {'use_pipeline_uuid': 'pipeline-uuid'})
|
||||
|
||||
# Verify - pipeline name was captured
|
||||
update_params = ap.persistence_mgr.execute_async.await_args_list[1].args[0].compile().params
|
||||
assert update_params['use_pipeline_uuid'] == 'pipeline-uuid'
|
||||
assert update_params['use_pipeline_name'] == 'Updated Pipeline'
|
||||
|
||||
|
||||
class TestBotServiceDeleteBot:
|
||||
@@ -657,4 +659,4 @@ class TestBotServiceSendMessage:
|
||||
await service.send_message('bot-uuid', 'group', '123', message_chain_data)
|
||||
|
||||
# Verify adapter.send_message was called
|
||||
runtime_bot.adapter.send_message.assert_called_once_with('group', '123', mock_chain)
|
||||
runtime_bot.adapter.send_message.assert_called_once_with('group', '123', mock_chain)
|
||||
|
||||
@@ -153,7 +153,7 @@ class TestCreateKnowledgeBase:
|
||||
|
||||
service = knowledge_module.KnowledgeService(mock_app)
|
||||
|
||||
result = await service.create_knowledge_base({
|
||||
await service.create_knowledge_base({
|
||||
'knowledge_engine_plugin_id': 'author/engine'
|
||||
})
|
||||
|
||||
|
||||
@@ -318,24 +318,22 @@ class TestPipelineServiceCreatePipeline:
|
||||
service.get_pipelines = AsyncMock(return_value=[])
|
||||
service.get_pipeline = AsyncMock(return_value={
|
||||
'uuid': 'new-uuid',
|
||||
'extensions_preferences': {
|
||||
'enable_all_plugins': True,
|
||||
'enable_all_mcp_servers': True,
|
||||
'plugins': [],
|
||||
'mcp_servers': [],
|
||||
}
|
||||
'extensions_preferences': {},
|
||||
})
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock()
|
||||
insert_params = []
|
||||
|
||||
async def mock_execute(query):
|
||||
params = query.compile().params
|
||||
if 'extensions_preferences' in params:
|
||||
insert_params.append(params)
|
||||
return Mock()
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={
|
||||
'uuid': 'new-uuid',
|
||||
'extensions_preferences': {
|
||||
'enable_all_plugins': True,
|
||||
'enable_all_mcp_servers': True,
|
||||
'plugins': [],
|
||||
'mcp_servers': [],
|
||||
}
|
||||
'extensions_preferences': {},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -344,8 +342,13 @@ class TestPipelineServiceCreatePipeline:
|
||||
with patch('langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'):
|
||||
await service.create_pipeline({'name': 'New Pipeline'})
|
||||
|
||||
# Verify - extensions_preferences should have been set
|
||||
ap.persistence_mgr.execute_async.assert_called()
|
||||
assert len(insert_params) == 1
|
||||
assert insert_params[0]['extensions_preferences'] == {
|
||||
'enable_all_plugins': True,
|
||||
'enable_all_mcp_servers': True,
|
||||
'plugins': [],
|
||||
'mcp_servers': [],
|
||||
}
|
||||
|
||||
|
||||
class _MockResultWithBots:
|
||||
@@ -364,7 +367,7 @@ class TestPipelineServiceUpdatePipeline:
|
||||
"""Tests for update_pipeline method."""
|
||||
|
||||
async def test_update_pipeline_removes_protected_fields(self):
|
||||
"""Removes uuid, for_version, stages, is_default from update data."""
|
||||
"""Does not persist protected fields from update data."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
@@ -390,12 +393,11 @@ class TestPipelineServiceUpdatePipeline:
|
||||
}
|
||||
await service.update_pipeline('test-uuid', pipeline_data)
|
||||
|
||||
# Verify - protected fields removed
|
||||
assert 'uuid' not in pipeline_data
|
||||
assert 'for_version' not in pipeline_data
|
||||
assert 'stages' not in pipeline_data
|
||||
assert 'is_default' not in pipeline_data
|
||||
assert 'description' in pipeline_data
|
||||
update_params = ap.persistence_mgr.execute_async.await_args_list[0].args[0].compile().params
|
||||
assert update_params['description'] == 'New description'
|
||||
assert 'should-be-removed' not in update_params.values()
|
||||
assert ['should-be-removed'] not in update_params.values()
|
||||
assert not any(value is True for value in update_params.values())
|
||||
|
||||
async def test_update_pipeline_syncs_bot_names(self):
|
||||
"""Updates bot use_pipeline_name when pipeline name changes."""
|
||||
@@ -826,4 +828,4 @@ class TestDefaultStageOrder:
|
||||
def test_default_stage_order_contains_key_stages(self):
|
||||
"""Default stage order contains key processing stages."""
|
||||
assert 'MessageProcessor' in default_stage_order
|
||||
assert 'SendResponseBackStage' in default_stage_order
|
||||
assert 'SendResponseBackStage' in default_stage_order
|
||||
|
||||
@@ -6,7 +6,6 @@ Tests cover:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
@@ -102,36 +102,33 @@ class TestPrecheckPluginDeps:
|
||||
|
||||
def test_precheck_plugin_deps_no_plugins_dir(self):
|
||||
"""precheck_plugin_deps skips when plugins dir doesn't exist."""
|
||||
mocks = self._make_deps_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
with patch('os.path.exists', return_value=False):
|
||||
from langbot.pkg.core.bootutils.deps import precheck_plugin_deps
|
||||
from langbot.pkg.core.bootutils.deps import precheck_plugin_deps
|
||||
|
||||
with patch('os.path.exists', return_value=False):
|
||||
with patch('langbot.pkg.core.bootutils.deps.pkgmgr.install_requirements') as mock_install:
|
||||
import asyncio
|
||||
asyncio.get_event_loop().run_until_complete(precheck_plugin_deps())
|
||||
|
||||
# Should not raise, just skip
|
||||
mock_install.assert_not_called()
|
||||
|
||||
def test_precheck_plugin_deps_with_plugins_dir(self):
|
||||
"""precheck_plugin_deps checks plugins subdirectories."""
|
||||
mocks = self._make_deps_import_mocks()
|
||||
mock_pkgmgr = MagicMock()
|
||||
mocks['langbot.pkg.utils.pkgmgr'].install_requirements = mock_pkgmgr
|
||||
from langbot.pkg.core.bootutils.deps import precheck_plugin_deps
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.core.bootutils.deps import precheck_plugin_deps
|
||||
def mock_listdir(path):
|
||||
if path == 'plugins':
|
||||
return ['plugin1', 'plugin2']
|
||||
if path == 'plugins/plugin1':
|
||||
return ['requirements.txt', 'main.py']
|
||||
if path == 'plugins/plugin2':
|
||||
return ['main.py']
|
||||
return []
|
||||
|
||||
# Mock os functions
|
||||
with patch('os.path.exists', return_value=True):
|
||||
with patch('os.listdir', return_value=['plugin1', 'plugin2']):
|
||||
with patch('os.path.isdir', return_value=True):
|
||||
# plugin1 has requirements.txt, plugin2 doesn't
|
||||
def mock_listdir_subdir(path):
|
||||
if 'plugin1' in path:
|
||||
return ['requirements.txt', 'main.py']
|
||||
return ['main.py']
|
||||
with patch('os.path.exists', return_value=True):
|
||||
with patch('os.path.isdir', return_value=True):
|
||||
with patch('os.listdir', side_effect=mock_listdir):
|
||||
with patch('langbot.pkg.core.bootutils.deps.pkgmgr.install_requirements') as mock_install:
|
||||
import asyncio
|
||||
asyncio.get_event_loop().run_until_complete(precheck_plugin_deps())
|
||||
|
||||
with patch('os.listdir', side_effect=lambda p: mock_listdir_subdir(p) if 'plugin' in p else ['plugin1', 'plugin2']):
|
||||
import asyncio
|
||||
asyncio.get_event_loop().run_until_complete(precheck_plugin_deps())
|
||||
mock_install.assert_called_once_with('plugins/plugin1/requirements.txt', extra_params=[])
|
||||
|
||||
@@ -6,7 +6,6 @@ Tests I18nString, Metadata, and Component utilities.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.discover.engine import I18nString, Metadata, Component
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class TestExecuteAsync:
|
||||
mgr.db = mock_db
|
||||
|
||||
# Execute a simple select
|
||||
result = await mgr.execute_async(sqlalchemy.select(1))
|
||||
await mgr.execute_async(sqlalchemy.select(1))
|
||||
|
||||
mock_conn.execute.assert_called_once()
|
||||
mock_conn.commit.assert_called_once()
|
||||
|
||||
@@ -8,6 +8,8 @@ Tests cover:
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from importlib import import_module
|
||||
@@ -124,7 +126,3 @@ class TestSerializeModel:
|
||||
assert 'id' not in result
|
||||
assert 'name' not in result
|
||||
assert 'created_at' in result
|
||||
|
||||
|
||||
# Import Mock for type annotations
|
||||
from unittest.mock import Mock
|
||||
@@ -91,9 +91,7 @@ class TestContentFilterStageInit:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
assert stage.filter_chain is not None
|
||||
# Should have at least 'content-ignore' filter
|
||||
assert len(stage.filter_chain) >= 1
|
||||
assert [filter_impl.name for filter_impl in stage.filter_chain] == ['content-ignore']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_with_sensitive_words(self):
|
||||
@@ -121,8 +119,10 @@ class TestContentFilterStageInit:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
# Should have content-ignore and ban-word-filter
|
||||
assert len(stage.filter_chain) >= 2
|
||||
assert [filter_impl.name for filter_impl in stage.filter_chain] == [
|
||||
'ban-word-filter',
|
||||
'content-ignore',
|
||||
]
|
||||
|
||||
|
||||
class TestPreContentFilter:
|
||||
@@ -511,4 +511,4 @@ class TestContentIgnoreFilterDirect:
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
|
||||
assert result.result_type == cntfilter.entities.ResultType.CONTINUE
|
||||
assert result.result_type == cntfilter.entities.ResultType.CONTINUE
|
||||
|
||||
@@ -160,8 +160,11 @@ class TestLongTextProcessStageProcess:
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
# Should not transform short text
|
||||
assert result.new_query.resp_message_chain is not None
|
||||
assert len(result.new_query.resp_message_chain) == 1
|
||||
components = list(result.new_query.resp_message_chain[0])
|
||||
assert len(components) == 1
|
||||
assert isinstance(components[0], platform_message.Plain)
|
||||
assert components[0].text == 'short response'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_plain_component_skips(self):
|
||||
@@ -189,35 +192,13 @@ class TestLongTextProcessStageProcess:
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
# Should skip due to non-Plain component
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_resp_message_chain(self):
|
||||
"""Empty resp_message_chain should be handled gracefully."""
|
||||
longtext = get_longtext_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
|
||||
pipeline_config = make_longtext_config(strategy='forward')
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
# Should handle gracefully (may raise or return CONTINUE)
|
||||
# This tests the defensive behavior
|
||||
try:
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
# If it returns, should be CONTINUE
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
except (IndexError, AttributeError):
|
||||
# Expected if resp_message_chain is empty
|
||||
pass
|
||||
|
||||
components = list(result.new_query.resp_message_chain[0])
|
||||
assert [type(component) for component in components] == [
|
||||
platform_message.Plain,
|
||||
platform_message.Image,
|
||||
]
|
||||
assert components[0].text == 'short'
|
||||
assert components[1].url == 'https://example.com/img.png'
|
||||
|
||||
class TestForwardStrategy:
|
||||
"""Tests for ForwardComponentStrategy."""
|
||||
@@ -253,8 +234,9 @@ class TestForwardStrategy:
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
# Check that message chain was transformed
|
||||
assert result.new_query.resp_message_chain is not None
|
||||
components = list(result.new_query.resp_message_chain[0])
|
||||
assert len(components) == 1
|
||||
assert isinstance(components[0], platform_message.Forward)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_strategy_direct_process(self):
|
||||
@@ -288,36 +270,6 @@ class TestForwardStrategy:
|
||||
class TestLongTextThreshold:
|
||||
"""Tests for threshold boundary handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_threshold_continues(self):
|
||||
"""Text exactly at threshold should trigger processing."""
|
||||
longtext = get_longtext_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
|
||||
threshold = 50
|
||||
pipeline_config = make_longtext_config(strategy='forward', threshold=threshold)
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
mock_adapter = Mock()
|
||||
mock_adapter.bot_account_id = '12345'
|
||||
query.adapter = mock_adapter
|
||||
|
||||
# Text exactly at threshold
|
||||
exact_text = "x" * threshold
|
||||
query.resp_message_chain = [
|
||||
platform_message.MessageChain([platform_message.Plain(text=exact_text)])
|
||||
]
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_below_threshold_not_processed(self):
|
||||
"""Text below threshold should not be transformed."""
|
||||
@@ -344,7 +296,10 @@ class TestLongTextThreshold:
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
# Original chain should remain unchanged
|
||||
components = list(result.new_query.resp_message_chain[0])
|
||||
assert len(components) == 1
|
||||
assert isinstance(components[0], platform_message.Plain)
|
||||
assert components[0].text == short_text
|
||||
|
||||
|
||||
class TestLongTextProcessStageImageStrategy:
|
||||
@@ -367,4 +322,4 @@ class TestLongTextProcessStageImageStrategy:
|
||||
|
||||
# Should have initialized (possibly with fallback strategy)
|
||||
if stage.strategy_impl is not None:
|
||||
assert isinstance(stage.strategy_impl, strategy.LongTextStrategy)
|
||||
assert isinstance(stage.strategy_impl, strategy.LongTextStrategy)
|
||||
|
||||
@@ -254,13 +254,12 @@ class TestRoundTruncatorProcess:
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
|
||||
# Check order is preserved (user2 -> asst2 -> user3)
|
||||
messages = result.new_query.messages
|
||||
if len(messages) >= 3:
|
||||
assert messages[0].role == 'user'
|
||||
assert messages[0].content == 'user2'
|
||||
assert messages[1].role == 'assistant'
|
||||
assert messages[1].content == 'asst2'
|
||||
assert [(msg.role, msg.content) for msg in messages] == [
|
||||
('user', 'user2'),
|
||||
('assistant', 'asst2'),
|
||||
('user', 'user3'),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncate_max_round_one(self):
|
||||
@@ -286,10 +285,8 @@ class TestRoundTruncatorProcess:
|
||||
result = await stage.process(query, 'ConversationMessageTruncator')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
# Only last round (user + assistant pair) should remain
|
||||
messages = result.new_query.messages
|
||||
# At most 2 messages (user + assistant before current)
|
||||
assert len(messages) <= 2
|
||||
assert [(msg.role, msg.content) for msg in messages] == [('user', 'current')]
|
||||
|
||||
|
||||
class TestRoundTruncatorDirect:
|
||||
@@ -321,4 +318,4 @@ class TestRoundTruncatorDirect:
|
||||
result = await trun.truncate(query)
|
||||
|
||||
assert result is not None
|
||||
assert hasattr(result, 'messages')
|
||||
assert hasattr(result, 'messages')
|
||||
|
||||
@@ -19,14 +19,23 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
_mock_runner = MagicMock()
|
||||
_mock_runner.runner_class = lambda name: (lambda cls: cls) # no-op decorator
|
||||
_mock_runner.RequestRunner = object
|
||||
sys.modules.setdefault('langbot.pkg.provider.runner', _mock_runner)
|
||||
sys.modules.setdefault('langbot.pkg.core.app', MagicMock())
|
||||
sys.modules.setdefault('langbot.pkg.utils.httpclient', MagicMock())
|
||||
_mocked_imports = {
|
||||
'langbot.pkg.provider.runner': _mock_runner,
|
||||
'langbot.pkg.core.app': MagicMock(),
|
||||
}
|
||||
_original_imports = {name: sys.modules.get(name) for name in _mocked_imports}
|
||||
sys.modules.update(_mocked_imports)
|
||||
|
||||
import pytest # noqa: E402
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message # noqa: E402
|
||||
from langbot.pkg.provider.runners.n8nsvapi import N8nServiceAPIRunner # noqa: E402
|
||||
|
||||
for _name, _original in _original_imports.items():
|
||||
if _original is None:
|
||||
sys.modules.pop(_name, None)
|
||||
else:
|
||||
sys.modules[_name] = _original
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -82,10 +91,10 @@ async def test_stream_format_single_item():
|
||||
|
||||
chunks = await collect_chunks(runner, [data])
|
||||
|
||||
assert len(chunks) >= 1
|
||||
final = chunks[-1]
|
||||
assert final.is_final is True
|
||||
assert final.content == 'hello'
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].is_final is True
|
||||
assert chunks[0].content == 'hello'
|
||||
assert chunks[0].msg_sequence == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -100,9 +109,10 @@ async def test_stream_format_multi_item_accumulates():
|
||||
|
||||
chunks = await collect_chunks(runner, chunks_data)
|
||||
|
||||
final = chunks[-1]
|
||||
assert final.is_final is True
|
||||
assert final.content == 'foobar'
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].is_final is True
|
||||
assert chunks[0].content == 'foobar'
|
||||
assert chunks[0].msg_sequence == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -115,9 +125,13 @@ async def test_stream_format_batches_every_8_items():
|
||||
|
||||
chunks = await collect_chunks(runner, [data])
|
||||
|
||||
# At least the batch yield at chunk_idx==8 + final yield
|
||||
assert len(chunks) >= 2
|
||||
assert chunks[-1].is_final is True
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0].is_final is False
|
||||
assert chunks[0].content == '01234567'
|
||||
assert chunks[0].msg_sequence == 1
|
||||
assert chunks[1].is_final is True
|
||||
assert chunks[1].content == '01234567'
|
||||
assert chunks[1].msg_sequence == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -129,9 +143,9 @@ async def test_stream_format_split_across_network_chunks():
|
||||
|
||||
chunks = await collect_chunks(runner, [part1, part2])
|
||||
|
||||
final = chunks[-1]
|
||||
assert final.is_final is True
|
||||
assert final.content == 'world'
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].is_final is True
|
||||
assert chunks[0].content == 'world'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -143,10 +157,8 @@ async def test_stream_format_no_spurious_empty_yield():
|
||||
|
||||
chunks = await collect_chunks(runner, [data])
|
||||
|
||||
# No chunk should have empty content before the real content arrives
|
||||
non_final = [c for c in chunks if not c.is_final]
|
||||
for c in non_final:
|
||||
assert c.content # must be non-empty
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].content == 'x'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,7 +7,7 @@ Tests query management, ID generation, and async context handling.
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from langbot.pkg.pipeline.pool import QueryPool
|
||||
|
||||
@@ -37,8 +37,8 @@ class TestQueryPoolInit:
|
||||
class TestQueryPoolAddQuery:
|
||||
"""Tests for add_query method."""
|
||||
|
||||
async def test_add_query_returns_query_with_id(self):
|
||||
"""add_query creates a Query with correct ID."""
|
||||
async def test_add_query_adds_query_with_id(self):
|
||||
"""add_query creates, stores, and caches a Query with the correct ID."""
|
||||
pool = QueryPool()
|
||||
|
||||
# Mock Query creation
|
||||
@@ -134,7 +134,7 @@ class TestQueryPoolAddQuery:
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
|
||||
MockQuery.return_value = mock_query
|
||||
|
||||
query = await pool.add_query(
|
||||
await pool.add_query(
|
||||
bot_uuid='bot1',
|
||||
launcher_type=Mock(),
|
||||
launcher_id=1,
|
||||
@@ -158,7 +158,7 @@ class TestQueryPoolAddQuery:
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
|
||||
MockQuery.return_value = mock_query
|
||||
|
||||
query = await pool.add_query(
|
||||
await pool.add_query(
|
||||
bot_uuid='bot1',
|
||||
launcher_type=Mock(),
|
||||
launcher_id=1,
|
||||
@@ -184,7 +184,7 @@ class TestQueryPoolAddQuery:
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
|
||||
MockQuery.return_value = mock_query
|
||||
|
||||
query = await pool.add_query(
|
||||
await pool.add_query(
|
||||
bot_uuid='bot1',
|
||||
launcher_type=Mock(),
|
||||
launcher_id=1,
|
||||
|
||||
@@ -155,11 +155,10 @@ class TestPreProcessorEmptyMessage:
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
# Empty message should still continue (behavior depends on code)
|
||||
# Empty message should still continue with an empty provider content list.
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert result.new_query.user_message is not None
|
||||
# Empty content list
|
||||
assert result.new_query.user_message.content == [] or result.new_query.user_message.content is None
|
||||
assert result.new_query.user_message.content == []
|
||||
|
||||
|
||||
class TestPreProcessorImageSegment:
|
||||
@@ -428,4 +427,4 @@ class TestPreProcessorVariables:
|
||||
|
||||
variables = result.new_query.variables
|
||||
assert 'group_name' in variables
|
||||
assert 'sender_name' in variables
|
||||
assert 'sender_name' in variables
|
||||
|
||||
@@ -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
|
||||
@@ -238,9 +238,9 @@ class TestResponseWrapperAssistant:
|
||||
async def test_assistant_empty_content(self):
|
||||
"""Assistant with empty content should not emit event."""
|
||||
wrapper = get_wrapper_module()
|
||||
get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
app.plugin_connector.emit_event = AsyncMock()
|
||||
stage = wrapper.ResponseWrapper(app)
|
||||
|
||||
pipeline_config = make_wrapper_config()
|
||||
@@ -262,8 +262,9 @@ class TestResponseWrapperAssistant:
|
||||
async for result in stage.process(query, 'ResponseWrapper'):
|
||||
results.append(result)
|
||||
|
||||
# Should have at least one result (for empty content case)
|
||||
assert len(results) >= 0
|
||||
assert results == []
|
||||
assert query.resp_message_chain == []
|
||||
app.plugin_connector.emit_event.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assistant_tool_calls(self):
|
||||
@@ -313,10 +314,10 @@ class TestResponseWrapperAssistant:
|
||||
async for result in stage.process(query, 'ResponseWrapper'):
|
||||
results.append(result)
|
||||
|
||||
# Should have results for content and tool_calls
|
||||
assert len(results) >= 1
|
||||
assert len(results) == 2
|
||||
for result in results:
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert app.plugin_connector.emit_event.await_count == 2
|
||||
|
||||
|
||||
class TestResponseWrapperInterrupt:
|
||||
@@ -472,4 +473,4 @@ class TestResponseWrapperVariables:
|
||||
|
||||
# Check that bound_plugins was passed
|
||||
emit_call = app.plugin_connector.emit_event.call_args
|
||||
assert emit_call[0][1] == ['plugin1', 'plugin2'] # Second argument is bound_plugins
|
||||
assert emit_call[0][1] == ['plugin1', 'plugin2'] # Second argument is bound_plugins
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -11,6 +11,8 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
import openai # Import real openai package
|
||||
|
||||
from langbot.pkg.provider.modelmgr.errors import RequesterError
|
||||
|
||||
|
||||
class TestInvokeLLMErrorHandling:
|
||||
"""Tests for invoke_llm error handling branches."""
|
||||
@@ -66,7 +68,7 @@ class TestInvokeLLMErrorHandling:
|
||||
side_effect=asyncio.TimeoutError()
|
||||
)
|
||||
|
||||
with pytest.raises(Exception) as exc:
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_llm(
|
||||
query=None,
|
||||
model=mock_model,
|
||||
@@ -87,7 +89,7 @@ class TestInvokeLLMErrorHandling:
|
||||
side_effect=error
|
||||
)
|
||||
|
||||
with pytest.raises(Exception) as exc:
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_llm(
|
||||
query=None,
|
||||
model=mock_model,
|
||||
@@ -108,7 +110,7 @@ class TestInvokeLLMErrorHandling:
|
||||
side_effect=error
|
||||
)
|
||||
|
||||
with pytest.raises(Exception) as exc:
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_llm(
|
||||
query=None,
|
||||
model=mock_model,
|
||||
@@ -129,7 +131,7 @@ class TestInvokeLLMErrorHandling:
|
||||
side_effect=error
|
||||
)
|
||||
|
||||
with pytest.raises(Exception) as exc:
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_llm(
|
||||
query=None,
|
||||
model=mock_model,
|
||||
@@ -175,7 +177,7 @@ class TestInvokeEmbeddingErrorHandling:
|
||||
side_effect=asyncio.TimeoutError()
|
||||
)
|
||||
|
||||
with pytest.raises(Exception) as exc:
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_embedding(
|
||||
model=mock_embedding_model,
|
||||
input_text=['test'],
|
||||
@@ -195,7 +197,7 @@ class TestInvokeEmbeddingErrorHandling:
|
||||
side_effect=error
|
||||
)
|
||||
|
||||
with pytest.raises(Exception) as exc:
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_embedding(
|
||||
model=mock_embedding_model,
|
||||
input_text=['test'],
|
||||
@@ -242,4 +244,4 @@ class TestDefaultConfig:
|
||||
})
|
||||
|
||||
assert req.requester_cfg['base_url'] == 'https://custom.com/v1'
|
||||
assert req.requester_cfg['timeout'] == 60
|
||||
assert req.requester_cfg['timeout'] == 60
|
||||
|
||||
@@ -141,25 +141,21 @@ class TestNormalizeModalities:
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._normalize_modalities('text,image')
|
||||
assert 'text' in result
|
||||
assert 'image' in result
|
||||
assert result == ['text', 'image']
|
||||
|
||||
def test_normalize_list_modalities(self):
|
||||
"""Normalize list of modalities."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._normalize_modalities(['text', 'image', 'audio'])
|
||||
assert 'text' in result
|
||||
assert 'image' in result
|
||||
assert 'audio' in result
|
||||
assert result == ['text', 'image', 'audio']
|
||||
|
||||
def test_normalize_dict_modalities(self):
|
||||
"""Normalize dict with nested modalities."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._normalize_modalities({'input': ['text'], 'output': ['text', 'image']})
|
||||
assert 'text' in result
|
||||
assert 'image' in result
|
||||
assert result == ['text', 'image']
|
||||
|
||||
def test_normalize_none(self):
|
||||
"""Handle None input."""
|
||||
@@ -173,8 +169,7 @@ class TestNormalizeModalities:
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._normalize_modalities('text->image')
|
||||
assert 'text' in result
|
||||
assert 'image' in result
|
||||
assert result == ['text', 'image']
|
||||
|
||||
|
||||
class TestParseRerankResponse:
|
||||
@@ -192,9 +187,10 @@ class TestParseRerankResponse:
|
||||
}
|
||||
|
||||
result = OpenAIChatCompletions._parse_rerank_response(data)
|
||||
assert len(result) == 2
|
||||
assert result[0]['index'] == 0
|
||||
assert result[0]['relevance_score'] == 0.95
|
||||
assert result == [
|
||||
{'index': 0, 'relevance_score': 0.95},
|
||||
{'index': 1, 'relevance_score': 0.80},
|
||||
]
|
||||
|
||||
def test_parse_voyage_format(self):
|
||||
"""Parse Voyage AI format."""
|
||||
@@ -208,8 +204,10 @@ class TestParseRerankResponse:
|
||||
}
|
||||
|
||||
result = OpenAIChatCompletions._parse_rerank_response(data)
|
||||
assert len(result) == 2
|
||||
assert result[0]['index'] == 0
|
||||
assert result == [
|
||||
{'index': 0, 'relevance_score': 0.90},
|
||||
{'index': 2, 'relevance_score': 0.75},
|
||||
]
|
||||
|
||||
def test_parse_dashscope_format(self):
|
||||
"""Parse DashScope format."""
|
||||
@@ -224,8 +222,7 @@ class TestParseRerankResponse:
|
||||
}
|
||||
|
||||
result = OpenAIChatCompletions._parse_rerank_response(data)
|
||||
assert len(result) == 1
|
||||
assert result[0]['index'] == 0
|
||||
assert result == [{'index': 0, 'relevance_score': 0.85}]
|
||||
|
||||
def test_parse_unknown_format(self):
|
||||
"""Handle unknown format returns empty list."""
|
||||
@@ -340,4 +337,4 @@ class TestExtractScanMetadata:
|
||||
|
||||
result = requester._extract_scan_metadata(item, 'gpt-4')
|
||||
|
||||
assert result['display_name'] is None
|
||||
assert result['display_name'] is None
|
||||
|
||||
@@ -9,6 +9,8 @@ import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.provider.modelmgr.errors import RequesterError
|
||||
|
||||
|
||||
class TestOllamaRequesterConfig:
|
||||
"""Tests for default config."""
|
||||
@@ -228,7 +230,7 @@ class TestOllamaErrorHandling:
|
||||
"""TimeoutError is converted to RequesterError."""
|
||||
requester_with_mocked_client.client.chat = AsyncMock(side_effect=asyncio.TimeoutError())
|
||||
|
||||
with pytest.raises(Exception) as exc:
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_llm(
|
||||
query=None,
|
||||
model=mock_model,
|
||||
@@ -259,4 +261,4 @@ class TestOllamaScanModels:
|
||||
"""REQUESTER_NAME constant exists."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.ollamachat import REQUESTER_NAME
|
||||
|
||||
assert REQUESTER_NAME == 'ollama-chat'
|
||||
assert REQUESTER_NAME == 'ollama-chat'
|
||||
|
||||
@@ -13,7 +13,6 @@ from unittest.mock import Mock
|
||||
from importlib import import_module
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
|
||||
|
||||
|
||||
@@ -1,410 +1,190 @@
|
||||
"""Unit tests for RuntimeKnowledgeBase file storage and ZIP processing.
|
||||
"""Unit tests for RuntimeKnowledgeBase file storage behavior."""
|
||||
|
||||
Tests cover:
|
||||
- store_file entry point
|
||||
- _store_file_task background processing
|
||||
- _store_zip_file ZIP extraction
|
||||
- File status management (pending -> processing -> completed/failed)
|
||||
- MIME type detection
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import io
|
||||
import zipfile
|
||||
import tempfile
|
||||
import os
|
||||
from unittest.mock import Mock, AsyncMock, patch, MagicMock
|
||||
from importlib import import_module
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.rag.knowledge.kbmgr import RuntimeKnowledgeBase
|
||||
|
||||
|
||||
def get_kbmgr_module():
|
||||
"""Lazy import to avoid circular import issues."""
|
||||
return import_module('langbot.pkg.rag.knowledge.kbmgr')
|
||||
def _make_zip_bytes(entries: dict[str, bytes]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, 'w') as zf:
|
||||
for name, content in entries.items():
|
||||
zf.writestr(name, content)
|
||||
zf.mkdir('emptydir')
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _make_app() -> Mock:
|
||||
app = Mock()
|
||||
app.logger = Mock()
|
||||
app.task_mgr = Mock()
|
||||
app.storage_mgr = Mock()
|
||||
app.storage_mgr.storage_provider = Mock()
|
||||
app.storage_mgr.storage_provider.exists = AsyncMock(return_value=True)
|
||||
app.storage_mgr.storage_provider.load = AsyncMock()
|
||||
app.storage_mgr.storage_provider.save = AsyncMock()
|
||||
app.storage_mgr.storage_provider.size = AsyncMock(return_value=123)
|
||||
app.storage_mgr.storage_provider.delete = AsyncMock()
|
||||
app.persistence_mgr = Mock()
|
||||
app.persistence_mgr.execute_async = AsyncMock()
|
||||
app.plugin_connector = Mock()
|
||||
return app
|
||||
|
||||
|
||||
def _make_kb(plugin_id: str | None = 'author/engine') -> RuntimeKnowledgeBase:
|
||||
kb_entity = Mock()
|
||||
kb_entity.uuid = 'test-kb-uuid'
|
||||
kb_entity.collection_id = 'test-collection'
|
||||
kb_entity.creation_settings = {}
|
||||
kb_entity.knowledge_engine_plugin_id = plugin_id
|
||||
return RuntimeKnowledgeBase(_make_app(), kb_entity)
|
||||
|
||||
|
||||
class TestStoreFile:
|
||||
"""Tests for store_file method - entry point for file storage."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_file_creates_pending_record_and_user_task(self):
|
||||
kb = _make_kb()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_kb(self):
|
||||
"""Create mock RuntimeKnowledgeBase."""
|
||||
kbmgr = get_kbmgr_module()
|
||||
def create_user_task(coro, **kwargs):
|
||||
coro.close()
|
||||
return SimpleNamespace(id='task-1', kwargs=kwargs)
|
||||
|
||||
mock_app = Mock()
|
||||
mock_app.logger = Mock()
|
||||
mock_app.task_mgr = Mock()
|
||||
mock_app.task_mgr.create_user_task = Mock(return_value=Mock(id=1))
|
||||
mock_app.storage_mgr = Mock()
|
||||
mock_app.storage_mgr.storage_provider = Mock()
|
||||
mock_app.storage_mgr.storage_provider.exists = AsyncMock(return_value=True)
|
||||
mock_app.persistence_mgr = Mock()
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock()
|
||||
kb.ap.task_mgr.create_user_task = Mock(side_effect=create_user_task)
|
||||
|
||||
mock_kb_entity = Mock()
|
||||
mock_kb_entity.uuid = 'test-kb-uuid'
|
||||
task_id = await kb.store_file('documents/test.pdf')
|
||||
|
||||
kb = kbmgr.RuntimeKnowledgeBase(mock_app, mock_kb_entity)
|
||||
kb._on_kb_create = AsyncMock()
|
||||
return kb
|
||||
assert task_id == 'task-1'
|
||||
kb.ap.storage_mgr.storage_provider.exists.assert_awaited_once_with('documents/test.pdf')
|
||||
kb.ap.persistence_mgr.execute_async.assert_awaited_once()
|
||||
call_kwargs = kb.ap.task_mgr.create_user_task.call_args.kwargs
|
||||
assert call_kwargs['kind'] == 'knowledge-operation'
|
||||
assert call_kwargs['name'] == 'knowledge-store-file-documents/test.pdf'
|
||||
assert call_kwargs['label'] == 'Store file documents/test.pdf'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_pending_file_record(self, mock_kb):
|
||||
"""Test that store_file creates a pending file record."""
|
||||
# Mock persistence for file record creation
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=None)
|
||||
mock_kb.ap.persistence_mgr.execute_async.return_value = mock_result
|
||||
async def test_store_file_raises_when_source_file_missing(self):
|
||||
kb = _make_kb()
|
||||
kb.ap.storage_mgr.storage_provider.exists = AsyncMock(return_value=False)
|
||||
|
||||
# Mock file exists in storage
|
||||
mock_kb.ap.storage_mgr.storage_provider.exists = AsyncMock(return_value=True)
|
||||
with pytest.raises(Exception, match='File missing.pdf not found'):
|
||||
await kb.store_file('missing.pdf')
|
||||
|
||||
# We can't directly test store_file without full setup
|
||||
# But we verify the expected behavior pattern
|
||||
file_name = 'test.pdf'
|
||||
storage_path = 'kb/test-kb-uuid/test.pdf'
|
||||
mime_type = 'application/pdf'
|
||||
|
||||
# Verify storage provider would be called
|
||||
assert mock_kb.ap.storage_mgr.storage_provider is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_early_when_file_not_exists(self, mock_kb):
|
||||
"""Test that store_file returns early when file doesn't exist in storage."""
|
||||
mock_kb.ap.storage_mgr.storage_provider.exists = AsyncMock(return_value=False)
|
||||
|
||||
storage_path = 'kb/test-kb-uuid/nonexistent.pdf'
|
||||
|
||||
# Should check existence before proceeding
|
||||
exists = await mock_kb.ap.storage_mgr.storage_provider.exists(storage_path)
|
||||
assert exists is False
|
||||
kb.ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
kb.ap.task_mgr.create_user_task.assert_not_called()
|
||||
|
||||
|
||||
class TestStoreZipFile:
|
||||
"""Tests for _store_zip_file method - ZIP extraction and processing."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_zip_file_extracts_supported_files_and_skips_noise(self):
|
||||
kb = _make_kb()
|
||||
kb.ap.storage_mgr.storage_provider.load = AsyncMock(
|
||||
return_value=_make_zip_bytes(
|
||||
{
|
||||
'doc1.pdf': b'pdf',
|
||||
'doc2.txt': b'text',
|
||||
'subdir/doc3.md': b'markdown',
|
||||
'page.html': b'html',
|
||||
'image.png': b'png',
|
||||
'.hidden': b'hidden',
|
||||
'__MACOSX/doc1.pdf': b'metadata',
|
||||
}
|
||||
)
|
||||
)
|
||||
kb.store_file = AsyncMock(side_effect=['task-pdf', 'task-txt', 'task-md', 'task-html'])
|
||||
|
||||
@pytest.fixture
|
||||
def temp_zip_with_files(self):
|
||||
"""Create a temporary ZIP file with multiple supported files."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
|
||||
with zipfile.ZipFile(tmp, 'w') as zf:
|
||||
# Add supported files
|
||||
zf.writestr('doc1.pdf', b'PDF content 1')
|
||||
zf.writestr('doc2.txt', b'Text content')
|
||||
zf.writestr('subdir/doc3.md', b'Markdown content')
|
||||
# Add unsupported file
|
||||
zf.writestr('image.png', b'PNG binary')
|
||||
# Add hidden file (should be skipped)
|
||||
zf.writestr('.hidden', b'hidden content')
|
||||
# Add __MACOSX file (should be skipped)
|
||||
zf.writestr('__MACOSX/doc1.pdf', b'macos metadata')
|
||||
# Add directory entry
|
||||
zf.mkdir('emptydir')
|
||||
yield tmp.name
|
||||
os.unlink(tmp.name)
|
||||
task_id = await kb._store_zip_file('archive.zip', parser_plugin_id='parser/plugin')
|
||||
|
||||
@pytest.fixture
|
||||
def temp_zip_with_no_supported(self):
|
||||
"""Create a ZIP with no supported file types."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
|
||||
with zipfile.ZipFile(tmp, 'w') as zf:
|
||||
zf.writestr('image.jpg', b'JPEG content')
|
||||
zf.writestr('video.mp4', b'video content')
|
||||
yield tmp.name
|
||||
os.unlink(tmp.name)
|
||||
|
||||
@pytest.fixture
|
||||
def temp_empty_zip(self):
|
||||
"""Create an empty ZIP file."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
|
||||
with zipfile.ZipFile(tmp, 'w') as zf:
|
||||
pass # Empty
|
||||
yield tmp.name
|
||||
os.unlink(tmp.name)
|
||||
|
||||
def test_zip_extraction_identifies_supported_files(self, temp_zip_with_files):
|
||||
"""Test that ZIP extraction identifies supported file types."""
|
||||
# Supported extensions based on source code
|
||||
supported_extensions = ['.pdf', '.txt', '.md', '.doc', '.docx']
|
||||
|
||||
with zipfile.ZipFile(temp_zip_with_files, 'r') as zf:
|
||||
supported_files = []
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
name = info.filename
|
||||
# Skip hidden files
|
||||
if name.startswith('.') or '/.' in name:
|
||||
continue
|
||||
# Skip __MACOSX
|
||||
if '__MACOSX' in name:
|
||||
continue
|
||||
# Check extension
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
if ext in supported_extensions:
|
||||
supported_files.append(name)
|
||||
|
||||
assert 'doc1.pdf' in supported_files
|
||||
assert 'doc2.txt' in supported_files
|
||||
assert 'subdir/doc3.md' in supported_files
|
||||
assert 'image.png' not in supported_files
|
||||
assert '.hidden' not in supported_files
|
||||
assert '__MACOSX/doc1.pdf' not in supported_files
|
||||
|
||||
def test_skips_directory_entries(self, temp_zip_with_files):
|
||||
"""Test that directory entries are skipped."""
|
||||
with zipfile.ZipFile(temp_zip_with_files, 'r') as zf:
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
# Directory should be skipped - ZIP directories have trailing slash
|
||||
assert info.filename.rstrip('/') == 'emptydir'
|
||||
|
||||
def test_skips_hidden_files(self, temp_zip_with_files):
|
||||
"""Test that hidden files (starting with .) are skipped."""
|
||||
with zipfile.ZipFile(temp_zip_with_files, 'r') as zf:
|
||||
hidden_files = []
|
||||
for info in zf.infolist():
|
||||
if not info.is_dir():
|
||||
name = info.filename
|
||||
if name.startswith('.') or '/.' in name:
|
||||
hidden_files.append(name)
|
||||
|
||||
# Hidden files exist in ZIP but should be filtered
|
||||
assert '.hidden' in hidden_files
|
||||
|
||||
def test_skips_macos_metadata(self, temp_zip_with_files):
|
||||
"""Test that __MACOSX files are skipped."""
|
||||
with zipfile.ZipFile(temp_zip_with_files, 'r') as zf:
|
||||
macos_files = []
|
||||
for info in zf.infolist():
|
||||
if not info.is_dir():
|
||||
if '__MACOSX' in info.filename:
|
||||
macos_files.append(info.filename)
|
||||
|
||||
assert '__MACOSX/doc1.pdf' in macos_files
|
||||
|
||||
def test_raises_when_no_supported_files(self, temp_zip_with_no_supported):
|
||||
"""Test that ValueError is raised when no supported files found."""
|
||||
supported_extensions = ['.pdf', '.txt', '.md', '.doc', '.docx']
|
||||
|
||||
with zipfile.ZipFile(temp_zip_with_no_supported, 'r') as zf:
|
||||
supported_files = []
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
ext = os.path.splitext(info.filename)[1].lower()
|
||||
if ext in supported_extensions:
|
||||
supported_files.append(info.filename)
|
||||
|
||||
assert len(supported_files) == 0
|
||||
# Source code raises ValueError in this case
|
||||
|
||||
def test_handles_empty_zip(self, temp_empty_zip):
|
||||
"""Test handling of empty ZIP file."""
|
||||
with zipfile.ZipFile(temp_empty_zip, 'r') as zf:
|
||||
files = [info for info in zf.infolist() if not info.is_dir()]
|
||||
assert len(files) == 0
|
||||
|
||||
|
||||
class TestFileStatusManagement:
|
||||
"""Tests for file status transitions during storage."""
|
||||
assert task_id == 'task-pdf'
|
||||
assert kb.ap.storage_mgr.storage_provider.save.await_count == 4
|
||||
saved_names = [call.args[0] for call in kb.ap.storage_mgr.storage_provider.save.await_args_list]
|
||||
assert any(name.startswith('doc1_') and name.endswith('.pdf') for name in saved_names)
|
||||
assert any(name.startswith('doc2_') and name.endswith('.txt') for name in saved_names)
|
||||
assert any(name.startswith('subdir_doc3_') and name.endswith('.md') for name in saved_names)
|
||||
assert any(name.startswith('page_') and name.endswith('.html') for name in saved_names)
|
||||
assert not any('image' in name for name in saved_names)
|
||||
assert not any('hidden' in name for name in saved_names)
|
||||
assert not any('__MACOSX' in name for name in saved_names)
|
||||
kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with('archive.zip')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_transitions_to_processing(self):
|
||||
"""Test that file status transitions from pending to processing."""
|
||||
# Status values from source code
|
||||
STATUS_PENDING = 'pending'
|
||||
STATUS_PROCESSING = 'processing'
|
||||
STATUS_COMPLETED = 'completed'
|
||||
STATUS_FAILED = 'failed'
|
||||
async def test_store_zip_file_raises_when_no_supported_files(self):
|
||||
kb = _make_kb()
|
||||
kb.ap.storage_mgr.storage_provider.load = AsyncMock(
|
||||
return_value=_make_zip_bytes({'image.png': b'png', 'video.mp4': b'video'})
|
||||
)
|
||||
kb.store_file = AsyncMock()
|
||||
|
||||
# Simulate status transitions
|
||||
initial_status = STATUS_PENDING
|
||||
after_process_start = STATUS_PROCESSING
|
||||
after_success = STATUS_COMPLETED
|
||||
with pytest.raises(Exception, match='No supported files found'):
|
||||
await kb._store_zip_file('archive.zip')
|
||||
|
||||
assert initial_status == 'pending'
|
||||
assert after_process_start == 'processing'
|
||||
assert after_success == 'completed'
|
||||
kb.store_file.assert_not_awaited()
|
||||
kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with('archive.zip')
|
||||
|
||||
|
||||
class TestStoreFileTask:
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_file_task_marks_completed_and_cleans_storage(self):
|
||||
kb = _make_kb()
|
||||
kb._ingest_document = AsyncMock(return_value={'status': 'completed'})
|
||||
file_obj = SimpleNamespace(uuid='file-uuid', file_name='test.pdf', extension='pdf')
|
||||
task_context = Mock()
|
||||
|
||||
await kb._store_file_task(file_obj, task_context)
|
||||
|
||||
task_context.set_current_action.assert_called_once_with('Processing file')
|
||||
kb.ap.storage_mgr.storage_provider.size.assert_awaited_once_with('test.pdf')
|
||||
kb._ingest_document.assert_awaited_once()
|
||||
assert kb.ap.persistence_mgr.execute_async.await_count == 2
|
||||
kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with('test.pdf')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_transitions_to_failed_on_error(self):
|
||||
"""Test that file status transitions to failed on exception."""
|
||||
STATUS_PENDING = 'pending'
|
||||
STATUS_PROCESSING = 'processing'
|
||||
STATUS_FAILED = 'failed'
|
||||
async def test_store_file_task_marks_failed_and_cleans_storage(self):
|
||||
kb = _make_kb()
|
||||
kb._ingest_document = AsyncMock(return_value={'status': 'failed', 'error_message': 'parser failed'})
|
||||
file_obj = SimpleNamespace(uuid='file-uuid', file_name='bad.pdf', extension='pdf')
|
||||
task_context = Mock()
|
||||
|
||||
# Simulate error scenario
|
||||
initial_status = STATUS_PENDING
|
||||
after_error = STATUS_FAILED
|
||||
with pytest.raises(Exception, match='parser failed'):
|
||||
await kb._store_file_task(file_obj, task_context)
|
||||
|
||||
assert initial_status == 'pending'
|
||||
assert after_error == 'failed'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_status_preserves_error_info(self):
|
||||
"""Test that failed status includes error information for debugging."""
|
||||
# File record should have error field populated on failure
|
||||
mock_file_record = Mock()
|
||||
mock_file_record.status = 'failed'
|
||||
mock_file_record.error = 'ParserError: invalid format'
|
||||
|
||||
assert mock_file_record.status == 'failed'
|
||||
assert 'ParserError' in mock_file_record.error
|
||||
|
||||
|
||||
class TestMimeTypeDetection:
|
||||
"""Tests for MIME type detection in file storage."""
|
||||
|
||||
def test_pdf_mime_type(self):
|
||||
"""Test PDF MIME type detection."""
|
||||
filename = 'document.pdf'
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
expected_mime = 'application/pdf'
|
||||
assert ext == '.pdf'
|
||||
|
||||
def test_text_mime_type(self):
|
||||
"""Test text MIME type detection."""
|
||||
filename = 'notes.txt'
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
expected_mime = 'text/plain'
|
||||
assert ext == '.txt'
|
||||
|
||||
def test_markdown_mime_type(self):
|
||||
"""Test markdown MIME type detection."""
|
||||
filename = 'readme.md'
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
expected_mime = 'text/markdown'
|
||||
assert ext == '.md'
|
||||
|
||||
def test_doc_mime_type(self):
|
||||
"""Test DOC MIME type detection."""
|
||||
filename = 'report.doc'
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
expected_mime = 'application/msword'
|
||||
assert ext == '.doc'
|
||||
|
||||
def test_docx_mime_type(self):
|
||||
"""Test DOCX MIME type detection."""
|
||||
filename = 'report.docx'
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
expected_mime = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
assert ext == '.docx'
|
||||
|
||||
|
||||
class TestStoreFileTaskCleanup:
|
||||
"""Tests for cleanup behavior in _store_file_task."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_storage_on_success(self):
|
||||
"""Test that storage is cleaned up after successful processing."""
|
||||
mock_storage_provider = Mock()
|
||||
mock_storage_provider.delete = AsyncMock()
|
||||
|
||||
storage_path = 'kb/test/file.pdf'
|
||||
should_cleanup = True # Based on source code finally block
|
||||
|
||||
if should_cleanup:
|
||||
await mock_storage_provider.delete(storage_path)
|
||||
|
||||
mock_storage_provider.delete.assert_called_once_with(storage_path)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_storage_on_failure(self):
|
||||
"""Test that storage is cleaned up even when processing fails."""
|
||||
mock_storage_provider = Mock()
|
||||
mock_storage_provider.delete = AsyncMock()
|
||||
|
||||
storage_path = 'kb/test/file.pdf'
|
||||
|
||||
# Simulate processing failure and cleanup
|
||||
try:
|
||||
raise Exception("Processing failed")
|
||||
except Exception:
|
||||
pass # Error handled
|
||||
|
||||
# Cleanup should still happen in finally block
|
||||
await mock_storage_provider.delete(storage_path)
|
||||
mock_storage_provider.delete.assert_called_once()
|
||||
assert kb.ap.persistence_mgr.execute_async.await_count == 2
|
||||
kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with('bad.pdf')
|
||||
|
||||
|
||||
class TestDeleteDocument:
|
||||
"""Tests for _delete_document method."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_document_returns_false_when_no_plugin_id(self):
|
||||
kb = _make_kb(plugin_id=None)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_kb_with_plugin(self):
|
||||
"""Create mock KB with plugin ID."""
|
||||
kbmgr = get_kbmgr_module()
|
||||
result = await kb._delete_document('doc-id')
|
||||
|
||||
mock_app = Mock()
|
||||
mock_app.logger = Mock()
|
||||
mock_app.plugin_connector = Mock()
|
||||
mock_app.plugin_connector.rag_delete_document = AsyncMock(return_value={'success': True})
|
||||
|
||||
mock_kb_entity = Mock()
|
||||
mock_kb_entity.uuid = 'test-kb-uuid'
|
||||
mock_kb_entity.knowledge_engine_plugin_id = 'author/engine'
|
||||
|
||||
kb = kbmgr.RuntimeKnowledgeBase(mock_app, mock_kb_entity)
|
||||
return kb
|
||||
|
||||
@pytest.fixture
|
||||
def mock_kb_without_plugin(self):
|
||||
"""Create mock KB without plugin ID."""
|
||||
kbmgr = get_kbmgr_module()
|
||||
|
||||
mock_app = Mock()
|
||||
mock_app.logger = Mock()
|
||||
|
||||
mock_kb_entity = Mock()
|
||||
mock_kb_entity.uuid = 'test-kb-uuid'
|
||||
mock_kb_entity.knowledge_engine_plugin_id = None
|
||||
|
||||
kb = kbmgr.RuntimeKnowledgeBase(mock_app, mock_kb_entity)
|
||||
return kb
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_when_no_plugin_id(self, mock_kb_without_plugin):
|
||||
"""Test that _delete_document returns False when no plugin ID."""
|
||||
kb_entity = mock_kb_without_plugin.knowledge_base_entity
|
||||
async def test_delete_document_calls_configured_rag_plugin(self):
|
||||
kb = _make_kb()
|
||||
kb.ap.plugin_connector.call_rag_delete_document = AsyncMock(return_value=True)
|
||||
|
||||
if kb_entity.knowledge_engine_plugin_id is None:
|
||||
# Source code returns False early
|
||||
expected_result = False
|
||||
assert expected_result is False
|
||||
result = await kb._delete_document('doc-id')
|
||||
|
||||
assert result is True
|
||||
kb.ap.plugin_connector.call_rag_delete_document.assert_awaited_once_with(
|
||||
'author/engine', 'doc-id', 'test-kb-uuid'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_true_on_success(self, mock_kb_with_plugin):
|
||||
"""Test that _delete_document returns True on successful delete."""
|
||||
kb_entity = mock_kb_with_plugin.knowledge_base_entity
|
||||
plugin_id = kb_entity.knowledge_engine_plugin_id
|
||||
async def test_delete_document_returns_false_on_plugin_error(self):
|
||||
kb = _make_kb()
|
||||
kb.ap.plugin_connector.call_rag_delete_document = AsyncMock(side_effect=Exception('plugin error'))
|
||||
|
||||
if plugin_id is not None:
|
||||
# Simulate successful plugin call
|
||||
mock_kb_with_plugin.ap.plugin_connector.rag_delete_document = AsyncMock(
|
||||
return_value={'success': True}
|
||||
)
|
||||
result = await mock_kb_with_plugin.ap.plugin_connector.rag_delete_document(
|
||||
plugin_id.split('/'), 'test-doc-id', kb_entity.uuid
|
||||
)
|
||||
assert result.get('success') is True
|
||||
result = await kb._delete_document('doc-id')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_on_plugin_error(self, mock_kb_with_plugin):
|
||||
"""Test that _delete_document returns False on plugin error."""
|
||||
kb_entity = mock_kb_with_plugin.knowledge_base_entity
|
||||
plugin_id = kb_entity.knowledge_engine_plugin_id
|
||||
|
||||
if plugin_id is not None:
|
||||
# Simulate plugin error
|
||||
mock_kb_with_plugin.ap.plugin_connector.rag_delete_document = AsyncMock(
|
||||
side_effect=Exception("Plugin error")
|
||||
)
|
||||
try:
|
||||
await mock_kb_with_plugin.ap.plugin_connector.rag_delete_document(
|
||||
plugin_id.split('/'), 'test-doc-id', kb_entity.uuid
|
||||
)
|
||||
result = True
|
||||
except Exception:
|
||||
result = False # Source code catches and returns False
|
||||
|
||||
assert result is False
|
||||
assert result is False
|
||||
kb.ap.logger.error.assert_called_once()
|
||||
|
||||
@@ -38,31 +38,6 @@ class TestTelemetryManagerInit:
|
||||
manager = telemetry.TelemetryManager(mock_app)
|
||||
assert manager.telemetry_config == {}
|
||||
|
||||
def test_send_tasks_is_instance_variable(self):
|
||||
"""Test that send_tasks is an instance variable (not class variable).
|
||||
|
||||
NOTE: This test documents a known bug - send_tasks is currently
|
||||
a class variable which causes state pollution between instances.
|
||||
The source code should be fixed to make it an instance variable.
|
||||
"""
|
||||
telemetry = get_telemetry_module()
|
||||
mock_app1 = Mock()
|
||||
mock_app2 = Mock()
|
||||
|
||||
manager1 = telemetry.TelemetryManager(mock_app1)
|
||||
manager2 = telemetry.TelemetryManager(mock_app2)
|
||||
|
||||
# Current behavior (bug): send_tasks is shared across instances
|
||||
# This test will FAIL after source bug is fixed
|
||||
# After fix: manager1.send_tasks should be independent from manager2.send_tasks
|
||||
assert manager1.send_tasks is manager2.send_tasks # BUG - they share same list
|
||||
|
||||
# Expected behavior after fix:
|
||||
# assert manager1.send_tasks is not manager2.send_tasks
|
||||
# assert manager1.send_tasks == []
|
||||
# assert manager2.send_tasks == []
|
||||
|
||||
|
||||
class TestTelemetryManagerInitialize:
|
||||
"""Tests for initialize() method."""
|
||||
|
||||
@@ -644,4 +619,4 @@ class TestStartSendTask:
|
||||
for task in manager.send_tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
manager.send_tasks.clear()
|
||||
manager.send_tasks.clear()
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
|
||||
from langbot.pkg.utils import httpclient
|
||||
|
||||
@@ -108,19 +109,30 @@ class TestSessionPoolIntegration:
|
||||
"""Integration tests for session pool behavior."""
|
||||
|
||||
async def test_session_can_make_request(self):
|
||||
"""Session can be used for actual HTTP requests."""
|
||||
"""Session can be used for HTTP requests without relying on external network."""
|
||||
app = web.Application()
|
||||
|
||||
async def handle_get(request):
|
||||
return web.json_response({'ok': True})
|
||||
|
||||
app.router.add_get('/get', handle_get)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, '127.0.0.1', 0)
|
||||
await site.start()
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
session = httpclient.get_session()
|
||||
|
||||
# Make a simple request (using httpbin or similar)
|
||||
# This is a basic smoke test
|
||||
try:
|
||||
async with session.get('https://httpbin.org/get', timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
||||
async with session.get(
|
||||
f'http://127.0.0.1:{port}/get',
|
||||
timeout=aiohttp.ClientTimeout(total=5),
|
||||
) as resp:
|
||||
assert resp.status == 200
|
||||
except Exception:
|
||||
# Network may be unavailable in CI, just verify session is usable
|
||||
pass
|
||||
|
||||
await httpclient.close_all()
|
||||
assert await resp.json() == {'ok': True}
|
||||
finally:
|
||||
await httpclient.close_all()
|
||||
await runner.cleanup()
|
||||
|
||||
async def test_multiple_requests_same_session(self):
|
||||
"""Multiple requests can use the same session."""
|
||||
|
||||
@@ -137,11 +137,9 @@ class TestReadResourceFile:
|
||||
"""Should read content from a resource file."""
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
try:
|
||||
content = importutil.read_resource_file("templates/config.yaml")
|
||||
assert isinstance(content, str)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
content = importutil.read_resource_file("templates/config.yaml")
|
||||
assert "admins:" in content
|
||||
assert "edition: community" in content
|
||||
|
||||
def test_raises_for_nonexistent_file(self):
|
||||
"""Should raise exception for non-existent resource file."""
|
||||
@@ -158,11 +156,9 @@ class TestReadResourceFileBytes:
|
||||
"""Should read content as bytes from a resource file."""
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
try:
|
||||
content = importutil.read_resource_file_bytes("templates/config.yaml")
|
||||
assert isinstance(content, bytes)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
content = importutil.read_resource_file_bytes("templates/config.yaml")
|
||||
assert b"admins:" in content
|
||||
assert b"edition: community" in content
|
||||
|
||||
def test_raises_for_nonexistent_file_bytes(self):
|
||||
"""Should raise exception for non-existent resource file."""
|
||||
@@ -179,13 +175,10 @@ class TestListResourceFiles:
|
||||
"""Should list files in a resource directory."""
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
try:
|
||||
files = importutil.list_resource_files("templates")
|
||||
assert isinstance(files, list)
|
||||
for f in files:
|
||||
assert isinstance(f, str)
|
||||
except (FileNotFoundError, Exception):
|
||||
pass
|
||||
files = importutil.list_resource_files("templates")
|
||||
assert "config.yaml" in files
|
||||
assert "default-pipeline-config.json" in files
|
||||
assert all(isinstance(file, str) for file in files)
|
||||
|
||||
def test_raises_for_nonexistent_directory(self):
|
||||
"""Should raise exception for non-existent directory."""
|
||||
@@ -196,4 +189,4 @@ class TestListResourceFiles:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
@@ -6,7 +6,6 @@ Tests log page management and pointer-based retrieval.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.utils.logcache import LogPage, LogCache, LOG_PAGE_SIZE, MAX_CACHED_PAGES
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@ Tests pip command generation without actual installation.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, Mock
|
||||
from unittest.mock import patch
|
||||
|
||||
from langbot.pkg.utils import pkgmgr
|
||||
|
||||
|
||||
@@ -87,15 +87,6 @@ class TestGetRunnerCategory:
|
||||
assert get_runner_category("test", "https://example.com") == RunnerCategory.CLOUD
|
||||
assert get_runner_category("test", "https://myserver.example.org") == RunnerCategory.CLOUD
|
||||
|
||||
def test_invalid_url_returns_unknown(self):
|
||||
"""Invalid URL that causes parsing error should return UNKNOWN."""
|
||||
# URLs that cause exceptions during parsing return UNKNOWN
|
||||
# Note: "not a valid url" is actually parseable by urlparse, it just has no scheme
|
||||
# Use a URL that genuinely causes an exception
|
||||
result = get_runner_category("test", "://invalid")
|
||||
# urlparse may handle this differently, but exceptions return UNKNOWN
|
||||
assert result in (RunnerCategory.UNKNOWN, RunnerCategory.CLOUD)
|
||||
|
||||
def test_urlparse_exception_returns_unknown(self):
|
||||
"""Exception during URL parsing should return UNKNOWN."""
|
||||
# Test by mocking urlparse to raise an exception
|
||||
@@ -108,14 +99,6 @@ class TestGetRunnerCategory:
|
||||
result = runner.get_runner_category("test", "http://example.com")
|
||||
assert result == RunnerCategory.UNKNOWN
|
||||
|
||||
def test_url_without_scheme(self):
|
||||
"""URL without scheme should still be parseable."""
|
||||
# urlparse can parse this, hostname might be None
|
||||
result = get_runner_category("test", "example.com")
|
||||
# Without scheme, urlparse treats it as path, so hostname is None
|
||||
# This should return UNKNOWN or CLOUD depending on implementation
|
||||
assert result in (RunnerCategory.UNKNOWN, RunnerCategory.CLOUD)
|
||||
|
||||
|
||||
class TestIsCloudRunner:
|
||||
"""Test is_cloud_runner helper function."""
|
||||
@@ -295,4 +278,4 @@ class TestConstants:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
@@ -6,7 +6,6 @@ Tests version comparison logic without network calls.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
|
||||
from langbot.pkg.utils.version import VersionManager
|
||||
|
||||
@@ -7,8 +7,6 @@ Tests cover:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user